-
Notifications
You must be signed in to change notification settings - Fork 1.2k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
* Add a virtual fields guide * Update docs/pages/guides/virtual-fields.mdx Co-authored-by: Jed Watson <[email protected]>
- Loading branch information
Showing
6 changed files
with
378 additions
and
50 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
--- | ||
'@keystone-next/example-virtual-field': minor | ||
--- | ||
|
||
Updated virtual fields to match those in the guide. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,8 +1,318 @@ | ||
import { Markdown } from '../../components/Page'; | ||
import { ComingSoon } from '../../components/ComingSoon'; | ||
|
||
# How To Use Virtual Fields | ||
# Virtual Fields | ||
|
||
<ComingSoon/> | ||
Keystone lets you define your data model in terms of `lists`, which have `fields`. | ||
Most lists will have some [scalar fields](../apis/fields#scalar-types), such as `text` and `integer` fields, which are stored in your database. | ||
|
||
It can also be helpful to have read-only fields which are computed on the fly when you query them. | ||
Keystone lets you do this with the [`virtual`](../apis/fields#virtual-type) field type. | ||
|
||
Virtual fields provide a powerful way to extend your GraphQL API. | ||
In this guide we'll introduce the syntax for adding virtual fields, and show how to build up from a simple to a complex example. | ||
|
||
## Hello world | ||
|
||
We'll start with a list called `Example` and create a virtual field called `hello`. | ||
|
||
```typescript | ||
import { config, createSchema, list } from '@keystone-next/keystone/schema'; | ||
import { virtual } from '@keystone-next/fields'; | ||
|
||
export default config({ | ||
lists: createSchema({ | ||
Example: list({ | ||
fields: { | ||
hello: virtual({ | ||
field: schema.field({ | ||
type: schema.String, | ||
resolve() { | ||
return "Hello, world!"; | ||
}, | ||
}), | ||
}), | ||
}, | ||
}), | ||
}), | ||
}); | ||
``` | ||
|
||
We can now run a GraphQL query and request the `hello` field on one of our `Example` items, | ||
|
||
```graphql | ||
{ | ||
Example(where: { id: "1" }) { | ||
id | ||
hello | ||
} | ||
} | ||
``` | ||
|
||
which gives the response: | ||
|
||
```javascript | ||
{ Example: { id: "1", hello: "Hello, world! } } | ||
``` | ||
The value of `hello` is generated from the `resolve` function, which returns the string `"Hello, world!"`. | ||
## The schema API | ||
The `virtual` field is configured using functions from the `schema` API in the `@keystone-next/types` package. | ||
This API provides the interface required to create type-safe extensions to the Keystone GraphQL schema. | ||
The schema API is based on the [`@graphql-ts/schema`](https://github.com/Thinkmill/graphql-ts) package. | ||
The `virtual` field accepts a configuration option called `field`, which is a `schema.field()` object. | ||
In our example we passed in two required options to `schema.field()`. | ||
The option `type: schema.String` specifies the GraphQL type of our virtual field, and `resolve() { ... }` defines the [GraphQL resolver](https://graphql.org/learn/execution/#root-fields-resolvers) to be executed when this field is queried. | ||
The `schema` API provides support for the built in GraphQL scalar types `Int`, `Float`, `String`, `Boolean`, and `ID`, as well as the Keystone custom scalars `Upload` and `JSON`. | ||
## Resolver arguments | ||
The `resolve` function accepts arguments which let you write more sophisticated virtual fields. | ||
The arguments are `(item, args, context, info)`. | ||
The `item` argument is the **internal item** representing the list item being queried. | ||
Refer to the [internal items guide](../guides/internal-items) for details on how to work with internal items in Keystone. | ||
The `args` argument represents the arguments passed to the field itself in the query. | ||
The `context` argument is a [`KeystoneContext`](../apis/context) object. | ||
The `info` argument holds field-specific information relevant to the current query as well as the schema details. | ||
We can use the `item` and `context` arguments to query data in our Keystone system. | ||
For example, if have a blog with `Author` and `Post` lists, it might be convenient to have an `authorName` field on the `Post` list. | ||
We can do this with a `virtual` field which queries for the related `author` and returns their name. | ||
```typescript | ||
export default config({ | ||
lists: createSchema({ | ||
Post: list({ | ||
fields: { | ||
content: text(), | ||
author: relationship({ ref: 'Author', many: false }), | ||
authorName: virtual({ | ||
type: schema.String, | ||
field: schema.field({ | ||
async resolve(item, args, context) { | ||
const { author } = await context.lists.Post.findOne({ | ||
where: { id: item.id }, | ||
query: 'author { name }', | ||
}); | ||
return author && author.name; | ||
}, | ||
}), | ||
}), | ||
}, | ||
}), | ||
Author: list({ | ||
fields: { | ||
name: text({ isRequired: true }), | ||
}, | ||
}), | ||
}), | ||
}); | ||
``` | ||
## GraphQL arguments | ||
Continuing with our blog example, we may want to extract an excerpt of each blog post for display on the home page. | ||
We could query the full `Post.content` field for each post and then slice it in the client, but it would be nicer if we could ask for just the slice that we want from the GraphQL API. | ||
To do this we can use a `virtual` field which takes a `length` argument and then performs the `.slice()` operation as part of resolver function. | ||
This gives control of the size of the excerpt to the frontend while getting the backend to do the actual work. | ||
We use the `args` option to define the GraphQL field arguments we want to support. | ||
```typescript | ||
export default config({ | ||
lists: createSchema({ | ||
Post: list({ | ||
fields: { | ||
content: text(), | ||
excerpt: virtual({ | ||
field: schema.field({ | ||
type: schema.String, | ||
args: { | ||
length: schema.arg({ | ||
type: schema.nonNull(schema.Int), | ||
defaultValue: 200 | ||
}), | ||
}, | ||
resolve(item, { length }) { | ||
if (!item.content) { | ||
return null; | ||
} | ||
const content = item.content as string; | ||
if (content.length <= length) { | ||
return content; | ||
} else { | ||
return content.slice(0, length - 3) + '...'; | ||
} | ||
}, | ||
}), | ||
graphQLReturnFragment: '(length: 500)', | ||
}), | ||
}, | ||
}), | ||
}), | ||
}); | ||
``` | ||
This will generate the following GraphQL type: | ||
```graphql | ||
type Post { | ||
id: ID! | ||
content: String | ||
excerpt(length: Int! = 200): String | ||
} | ||
``` | ||
We can now perform the following query to get all the excerpts without over-fetching on the client. | ||
``` | ||
{ | ||
allPosts { | ||
id | ||
excerpt(length: 100) | ||
} | ||
} | ||
``` | ||
As well as passing in the `field` definition, we have also passed in `graphQLReturnFragment: '(length: 500)'`. | ||
This is the value used when displaying the field in the Admin UI, where we want to have a different length the default of `200`. | ||
Had we not specified `defaultValue` in our field, the `graphQLReturnFragment` argument would be **required**, as the Admin UI would not be able to query this field without it. | ||
## GraphQL objects | ||
The examples above returned a scalar `String` value. Virtual fields can also be configured to return a GraphQL object. | ||
In our blog example we might want to provide some statistics on each blog post, such as the number of words, sentences, and paragraphs in the post. | ||
We can set up a GraphQL type called `PostCounts` to represent this data using the `schema.object()` function. | ||
```typescript | ||
export default config({ | ||
lists: createSchema({ | ||
Post: list({ | ||
fields: { | ||
content: text(), | ||
counts: virtual({ | ||
field: schema.field({ | ||
type: schema.object<{ | ||
words: number; | ||
sentences: number; | ||
paragraphs: number; | ||
}>()({ | ||
name: 'PostCounts', | ||
fields: { | ||
words: schema.field({ type: schema.Int }), | ||
sentences: schema.field({ type: schema.Int }), | ||
paragraphs: schema.field({ type: schema.Int }), | ||
}, | ||
}), | ||
resolve(item: any) { | ||
const content = item.content || ''; | ||
return { | ||
words: content.split(' ').length, | ||
sentences: content.split('.').length, | ||
paragraphs: content.split('\n\n').length, | ||
}; | ||
}, | ||
}), | ||
graphQLReturnFragment: '{ words sentences paragraphs }', | ||
}), | ||
}, | ||
}), | ||
}), | ||
}); | ||
``` | ||
This example is written in TypeScript, so we need to specify the type of the root value expected by the `PostCounts` type. | ||
This type must correspond to the return type of the `resolve` function. | ||
Because our `virtual` field has an object type, we also need to provide a value for the option `graphQLReturnFragment`. | ||
This fragment tells the Keystone Admin UI which values to show in the item page for this field. | ||
#### Self-referencing objects | ||
!> This information is specifically for TypeScript users of the `schema.object()` function with a self-referential GraphQL type. | ||
GraphQL types will often contain references to themselves and to make TypeScript allow that, you need have an explicit type annotation of `schema.ObjectType<RootVal>` along with making `fields` a function that returns the object. | ||
```ts | ||
type PersonRootVal = { name: string; friends: PersonRootVal[] }; | ||
const Person: schema.ObjectType<PersonRootVal> = schema.object<PersonRootVal>()({ | ||
name: "Person", | ||
fields: () => ({ | ||
name: schema.field({ type: schema.String }), | ||
friends: schema.field({ type: schema.list(Person) }), | ||
}), | ||
}); | ||
``` | ||
## Keystone types | ||
Rather than returning a custom GraphQL object, we might want to have a virtual field which returns one of the GraphQL types generated by Keystone itself. | ||
For example, for each `Author` we might want to return their `latestPost` as a `Post` object. | ||
To achieve this, rather than passing in `schema.field({ ... })` as the `field` option, we pass in a function `lists => schema.field({ ... })`. | ||
The argument `lists` contains the type information for all of the Keystone lists. | ||
In our case, we want the output type of the `Post` list, so we specify `type: lists.Post.types.output`. | ||
``` | ||
export const lists = createSchema({ | ||
Post: list({ | ||
fields: { | ||
title: text(), | ||
content: text(), | ||
publishDate: timestamp(), | ||
author: relationship({ ref: 'Author.posts', many: false }), | ||
}, | ||
}), | ||
Author: list({ | ||
fields: { | ||
name: text({ isRequired: true }), | ||
email: text({ isRequired: true, isUnique: true }), | ||
posts: relationship({ ref: 'Post.author', many: true }), | ||
latestPost: virtual({ | ||
field: lists => | ||
schema.field({ | ||
type: lists.Post.types.output, | ||
async resolve(item, args, context) { | ||
const { posts } = await context.lists.Author.findOne({ | ||
where: { id: item.id }, | ||
query: `posts( | ||
orderBy: { publishDate: desc } | ||
first: 1 | ||
) { id }`, | ||
}); | ||
if (posts.length > 0) { | ||
return context.db.lists.Post.findOne({ | ||
where: { id: posts[0].id } | ||
}); | ||
} | ||
}, | ||
}), | ||
graphQLReturnFragment: '{ title publishDate }', | ||
}), | ||
}, | ||
}), | ||
}); | ||
``` | ||
Once again we need to specify `graphQLReturnFragment` on this virtual field to specify which fields of the `Post` to display in the Admin UI. | ||
## Working with virtual fields | ||
Virtual fields provide a powerful way to extend your GraphQL API, however there are some considerations to keep in mind when using them. | ||
The virtual field executes its resolver every time the field is requested. | ||
For trivial calculations this isn't a problem, but for more complex calculations this can lead to performance issues. | ||
In this case you can consider memoizing the value to avoid recalculating it for each query. | ||
Another way to address this is to use a [scalar field](../apis/fields#scalar-types) and to populate its value each time the item is updated using a [hook](./hooks). | ||
The other main consideration is that it is not possible to filter on a virtual field, as each item calcutes its value dynamically, rather than having it stored in the database. | ||
Using a pre-calculated scalar field is the best solution to use if you need filtering for your field. | ||
export default ({ children }) => <Markdown>{children}</Markdown>; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
084c4e3
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Successfully deployed to the following URLs: