Custom schema
Out of the box, Keystone provides predictable CRUD (Create, Read, Update, Delete) GraphQL operations for Lists. They are the primary methods for updating list data and should be enough for most applications. However, custom types, queries and mutations may be added if you wish to preform non-CRUD operations.
Tip: See the GraphQL philosophy page for more information on how Keystone implements CRUD operations in GraphQL and when custom schema may be required.
Adding to Keystone's generated schema can be done using the keystone.extendGraphQLSchema
method.
The problem
A common situation where custom schema would be beneficial is incrementing a value stored in the database.
First let's define a Page
list. For the sake of simplicity, we'll give it only two fields: title
and views
.
const { Text, Integer } = require('@keystonejs/fields');
const pageList = keystone.createList('Page', {
fields: {
title: { type: Text },
views: { type: Integer },
},
});
If we had a front-end application that updated the view count every time someone visits the page it would require multiple CRUD operations.
Using CRUD we'd first have to fetch the current view count:
query Page($id: ID!) {
Page(where: { id: $id }) {
views
}
}
Once we have the view count we could increment this value with JavaScript and send an updatePage
mutation to save the new value:
mutation updatePageViews($id: ID!, $views: Int!) {
updatePage(id: $id, data: { views: $views }) {
views
}
}
Why is this bad?
The problem with this approach is that the client can set the view count to any arbitrary value. Moreover, even if there is no untoward manipulation of the count, the GraphQL server is trusting that the update mutation is received from the same client immediately after the view query. On heavily trafficked sites this will not always be the case. Read and Update requests from multiple clients can be received in any order depending on the speed of their internet connections, which means updates could override one another.
The solution
Like any problem, there are multiple solutions. You could implement an incrementing value with Hooks, but in this example we're going to look at how to do this with a custom GraphQL mutation which will perform the incrementation on the server.
For this example, we will be adding three things to Keystone's GraphQL schema:
- A custom type which will be returned by our mutation.
- A custom mutation which will increment the page views.
- A custom query which will check if the current page views are over a certain threshold.
Note: This custom query is somewhat superfluous; you could just query the page directly and check the views client-side. However, it serves to illustrate how custom queries are set up.
Custom type
First, let's define what we want our custom GraphQL type to look like:
Tip: You can learn more about schemas and types on https://graphql.org. You can also find a reference of all the types generated by Keystone in built-in GraphQL Playground's Schema tab, accessible via the Admin UI.
type IncrementPageViewsOutput {
# The new page views after the mutation.
currentViews: Int!
# The time and date when the page was viewed.
timestamp: String!
}
We can register this type with Keystone using the keystone.extendGraphQLSchema()
method, like so. Note the type
key simply takes the definition as a string:
keystone.extendGraphQLSchema({
types: [
{
type: 'type IncrementPageViewsOutput { currentViews: Int!, timestamp: String! }',
},
],
});
Custom mutation
Next, let's define our custom mutation to actually handle the view count incrementation:
incrementPageViews(id: ID!): IncrementPageViewsOutput
This defines a mutation called incrementPageViews
that accepts a single mandatory id
argument of type ID
(an internal GraphQL type). This is the id of the specific Post
whose views you wish to increment. Finally, the mutation returns an object of our custom IncrementPageViewsOutput
type.
Now, to wire it in:
keystone.extendGraphQLSchema({
types: [
{
type: 'type IncrementPageViewsOutput { currentViews: Int!, timestamp: String! }',
},
],
+ mutations: [
+ {
+ schema: 'incrementPageViews(id: ID!): IncrementPageViewsOutput',
+ resolver: incrementPageViews,
+ },
+ ],
});
Custom mutations (and queries) take a schema
and a resolver
key. Like with the custom type's type
key, schema
is simply the custom mutation definition, while resolver
is a function which tells the GraphQL server how to execute this mutation. In our case, it must return an IncrementPageViewsOutput
object.
Note: Refer to the
keystone.extendGraphQLSchema
API documentation for details on theresolver
function's signature.
Let's define the resolver function:
const incrementPageViews = async (_, { id }) => {
// pageList was defined above when we created the Page list
const { adapter } = pageList;
const oldItem = await adapter.findById(id);
const newItem = await adapter.update(id, {
...oldItem,
views: ++(oldItem.views || 0),
});
return {
currentViews: newItem.views,
timestamp: new Date().toUTCString(),
};
};
Note: The value of
views
may beundefined
initially, so before we increment it we make sure to convert anyfalsey
values to0
.
Our custom mutation is now available to the client! It can be utilized like so:
mutation incrementPageViews($id: ID!) {
incrementPageViews(id: $id) {
currentViews
timestamp
}
}
Custom query
Finally, let's define a custom query that checks if a page's views are over a certain threshold.
pageViewsOver(id: ID!, threshold: Integer!): Boolean
This defines a query called pageViewsOver
. It requires two arguments: an id
(just like our mutation) and an integer threshold
. It will true if the Post
with the given id has been viewed more than the specified number of times, false otherwise.
Add the query alongside our custom type and mutation. Note that custom queries take the same schema
and resolver
keys as custom mutations.
keystone.extendGraphQLSchema({
types: [
{
type: 'type IncrementPageViewsOutput { currentViews: Int!, timestamp: String! }',
},
],
mutations: [
{
schema: 'incrementPageViews(id: ID!): IncrementPageViewsOutput',
resolver: incrementPageViews,
},
],
+ queries: [
+ {
+ schema: 'pageViewsOver(id: ID!, threshold: Integer!): Boolean',
+ resolver: pageViewsOver,
+ },
+ ],
});
And finally, define our query's resolver:
const pageViewsOver = async (_, { id, threshold }, context) => {
const {
data: { views },
} = await context.executeGraphql({
query: `
Post(where: { id: "${id}" }) {
views
}
`,
});
return views > threshold;
};
Note: As shown here, resolvers can execute further GraphQL operations of their own.
Conclusion
As you can see, custom schema can be used to augment Keystone's existing GraphQL functionality in various ways. Custom mutations can be used for actions like incrementation that require a single operation that should not rely on data from the client, but they can also be used for operations that have side effects not related to updating lists.
Reminder: We used a custom mutation to increase the reliability of operations like incrementation because client requests can be received out of order.
Whilst a custom mutation is a huge improvement over a client-side query-then-mutate solution, it is not completely transactional in every situation. The incrementPageViews
function is asynchronous. This means it awaits database operations like findById
and update
. Depending on your server environment database operations, just like http requests, can be returned in a different order than executed in JavaScript.
In this example we've reduced the window this can occur in from seconds to milliseconds. It's not likely a problem for simple page views, but you may want to consider implementing database transactions where accuracy is absolutely critical.