Skip to content

Commit

Permalink
Virtual field docs (#5906)
Browse files Browse the repository at this point in the history
* Add a virtual fields guide

* Update docs/pages/guides/virtual-fields.mdx

Co-authored-by: Jed Watson <[email protected]>
  • Loading branch information
emmatown and JedWatson authored Jun 18, 2021
1 parent 40a44d2 commit 084c4e3
Show file tree
Hide file tree
Showing 6 changed files with 378 additions and 50 deletions.
5 changes: 5 additions & 0 deletions .changeset/brown-forks-promise.md
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.
6 changes: 2 additions & 4 deletions docs/components/Navigation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -75,8 +75,9 @@ export function Navigation() {
<NavItem href="/guides/cli">Command Line</NavItem>
<NavItem href="/guides/relationships">Relationships</NavItem>
<NavItem href="/guides/filters">Query Filters</NavItem>
<NavItem href="/guides/document-fields">Document Fields</NavItem>
<NavItem href="/guides/hooks">Hooks</NavItem>
<NavItem href="/guides/document-fields">Document Fields</NavItem>
<NavItem href="/guides/virtual-fields">Virtual Fields</NavItem>
<NavItem href="/guides/access-control" isPlaceholder>
Access Control
</NavItem>
Expand All @@ -86,9 +87,6 @@ export function Navigation() {
<NavItem href="/guides/schema-extension" isPlaceholder>
Schema Extension
</NavItem>
<NavItem href="/guides/virtual-fields" isPlaceholder>
Virtual Fields
</NavItem>
<NavItem href="/guides/internal-items" isPlaceholder>
Internal Items
</NavItem>
Expand Down
8 changes: 3 additions & 5 deletions docs/pages/apis/fields.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -554,13 +554,12 @@ export default config({
### virtual

A `virtual` field represents a value which is computed a read time, rather than stored in the database.

(coming soon)
See the [virtual fields guide](../guides/virtual-fields) for details on how to use virtual fields.

Options:

- `field` (required):
- `graphQLReturnFragment` (default: `undefined` ):
- `field` (required): The GraphQL field that defines the type, resolver and arguments.
- `graphQLReturnFragment` (default: `''` ): The sub-fields that should be fetched by the Admin UI when displaying this field.

```typescript
import { config, createSchema, list } from '@keystone-next/keystone/schema';
Expand All @@ -579,7 +578,6 @@ export default config({

}
})
graphQLReturnFragment: '...',
}),
/* ... */
},
Expand Down
314 changes: 312 additions & 2 deletions docs/pages/guides/virtual-fields.mdx
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>;
3 changes: 2 additions & 1 deletion examples/virtual-field/schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@ type Post {
content: String
counts: PostCounts
excerpt(length: Int! = 200): String
relatedPosts: [Post!]
publishDate: String
author: Author
authorName: String
}

enum PostStatusType {
Expand Down Expand Up @@ -155,6 +155,7 @@ type Author {
reason: "This query will be removed in a future version. Please use postsCount instead."
)
postsCount(where: PostWhereInput! = {}): Int
latestPost: Post
}

type _QueryMeta {
Expand Down
Loading

1 comment on commit 084c4e3

@vercel
Copy link

@vercel vercel bot commented on 084c4e3 Jun 18, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please sign in to comment.