Skip to content
KeystoneJS LogoKeystoneJS

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.

/lists/Page.js
JS
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:

GraphQL
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:

GraphQL
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:

  1. A custom type which will be returned by our mutation.
  2. A custom mutation which will increment the page views.
  3. 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.

GraphQL
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:

/lists/Page.js
JS
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:

GraphQL
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 which to increment. Finally, the mutation returns an object of our custom IncrementPageViewsOutput type.

Now, to wire it in:

/lists/Page.js
diff
 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 the resolver function's signature.

Let's define the resolver function:

/lists/Page.js
JS
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 be undefined initially, so before we increment it we make sure to convert any falsey values to 0.

Our custom mutation is now available to the client! It can be utilized like so:

GraphQL
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.

GraphQL
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.

/lists/Page.js
diff
 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:

JS
const pageViewsOver = async (_, { id, threshold }, _, _, { query }) => {
  const {
    data: { views },
  } = await 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.

On this page

  • The problem
  • Why is this bad?
  • The solution
  • Custom type
  • Custom mutation
  • Custom query
  • Conclusion
Edit on GitHub