What about the Database?
How to Write Efficient Queries for GraphQL Resolvers
Ryan Chenkie
@ryanchenkie
- Full Stack Developer
- Google Developer Expert
- JavaScript Consultant
- React, Angular, Node, GraphQL
Why Do We ❤️ GraphQL?
Discoverability
Client/Server Contracts
Efficiencies
Why Do We ❤️ GraphQL?
Discoverability
Client/Server Contracts
Efficiencies
When you make a query, GraphQL resolves with exactly the data you ask for
But what does the database do?
We might get efficiencies out of GraphQL itself, but we might be losing out at the database layer
How can we use GraphQL to improve our database queries?
- Scoped Selections
- Sorts and Limits from Sub-Selections
- Scoped Queries from Intersecting Auth Info
- Protection from Deeply Nested Queries
Use Cases
MongoDB by example, but applicable to any database
It's all about the AST
(Abstract Syntax Tree)
There's code for humans and then there's code for computers
GraphQL for Humans
query {
books {
title
author
publishDate
}
}
GraphQL for
Computers
{
"fieldName": "books",
"fieldNodes": [
{
"kind": "Field",
"name": {
"kind": "Name",
"value": "books",
"loc": {
"start": 4,
"end": 9
}
},
"arguments": [],
"directives": [],
"selectionSet": {
"kind": "SelectionSet",
"selections": [
{
"kind": "Field",
"name": {
"kind": "Name",
"value": "title",
"loc": {
"start": 16,
"end": 21
}
},
"arguments": [],
"directives": [],
"loc": {
"start": 16,
"end": 21
}
},
{
"kind": "Field",
"name": {
"kind": "Name",
"value": "author",
"loc": {
"start": 26,
"end": 32
}
},
"arguments": [],
"directives": [],
"loc": {
"start": 26,
"end": 32
}
},
{
"kind": "Field",
"name": {
"kind": "Name",
"value": "publishDate",
"loc": {
"start": 37,
"end": 48
}
},
"arguments": [],
"directives": [],
"loc": {
"start": 37,
"end": 48
}
}
],
"loc": {
"start": 10,
"end": 52
}
},
"loc": {
"start": 4,
"end": 52
}
}
],
"returnType": "[Book]",
"parentType": "Query",
"path": {
"key": "books"
},
"schema": {
"__validationErrors": [],
"__allowedLegacyNames": [],
"_queryType": "Query",
"_directives": [
"@cacheControl",
"@skip",
"@include",
"@deprecated"
],
"_typeMap": {
"Query": "Query",
"Book": "Book",
"String": "String",
"Int": "Int",
"Contact": "Contact",
"Address": "Address",
"Secret": "Secret",
"Post": "Post",
"Author": "Author",
"__Schema": "__Schema",
"__Type": "__Type",
"__TypeKind": "__TypeKind",
"Boolean": "Boolean",
"__Field": "__Field",
"__InputValue": "__InputValue",
"__EnumValue": "__EnumValue",
"__Directive": "__Directive",
"__DirectiveLocation": "__DirectiveLocation",
"CacheControlScope": "CacheControlScope",
"Upload": "Upload"
},
"_possibleTypeMap": {},
"_implementations": {},
"_extensionsEnabled": true
},
"fragments": {},
"operation": {
"kind": "OperationDefinition",
"operation": "query",
"variableDefinitions": [],
"directives": [],
"selectionSet": {
"kind": "SelectionSet",
"selections": [
{
"kind": "Field",
"name": {
"kind": "Name",
"value": "books",
"loc": {
"start": 4,
"end": 9
}
},
"arguments": [],
"directives": [],
"selectionSet": {
"kind": "SelectionSet",
"selections": [
{
"kind": "Field",
"name": {
"kind": "Name",
"value": "title",
"loc": {
"start": 16,
"end": 21
}
},
"arguments": [],
"directives": [],
"loc": {
"start": 16,
"end": 21
}
},
{
"kind": "Field",
"name": {
"kind": "Name",
"value": "author",
"loc": {
"start": 26,
"end": 32
}
},
"arguments": [],
"directives": [],
"loc": {
"start": 26,
"end": 32
}
},
{
"kind": "Field",
"name": {
"kind": "Name",
"value": "publishDate",
"loc": {
"start": 37,
"end": 48
}
},
"arguments": [],
"directives": [],
"loc": {
"start": 37,
"end": 48
}
}
],
"loc": {
"start": 10,
"end": 52
}
},
"loc": {
"start": 4,
"end": 52
}
}
],
"loc": {
"start": 0,
"end": 54
}
},
"loc": {
"start": 0,
"end": 54
}
},
"variableValues": {},
"cacheControl": {
"cacheHint": {
"maxAge": 0
}
}
}
A GraphQL query is translated into something the computer can understand
That something takes the form of an Abstract Syntax Tree (AST)
Abstract
Can be anything
Syntax
Has to do with programming syntax
Tree
One big object with many nested objects
ASTs are useful because we can extend GraphQL's capabilities programmatically
How can we interact with the AST?
Query: {
books: (parent, args, context, info) => {}
}
In the resolver function, the info object has the GraphQL AST
Useful pieces of the AST
fieldNode
selectionSet
selections
query {
books {
title
author
publishDate
}
}
{
"fieldName": "books",
"fieldNodes": [
{
"kind": "Field",
"name": {
"kind": "Name",
"value": "books",
"loc": {
"start": 4,
"end": 9
}
},
"arguments": [],
"directives": [],
"selectionSet": {
"kind": "SelectionSet",
"selections": [
{
"kind": "Field",
"name": {
"kind": "Name",
"value": "title",
"loc": {
"start": 16,
"end": 21
}
},
"arguments": [],
"directives": [],
"loc": {
"start": 16,
"end": 21
}
},
{
"kind": "Field",
"name": {
"kind": "Name",
"value": "author",
"loc": {
"start": 26,
"end": 32
}
},
"arguments": [],
"directives": [],
"loc": {
"start": 26,
"end": 32
}
},
{
"kind": "Field",
"name": {
"kind": "Name",
"value": "publishDate",
"loc": {
"start": 37,
"end": 48
}
},
"arguments": [],
"directives": [],
"loc": {
"start": 37,
"end": 48
}
}
],
"loc": {
"start": 10,
"end": 52
}
},
"loc": {
"start": 4,
"end": 52
}
}
],
"returnType": "[Book]",
"parentType": "Query",
"path": {
"key": "books"
},
"schema": {
"__validationErrors": [],
"__allowedLegacyNames": [],
"_queryType": "Query",
"_directives": [
"@cacheControl",
"@skip",
"@include",
"@deprecated"
],
"_typeMap": {
"Query": "Query",
"Book": "Book",
"String": "String",
"Int": "Int",
"Contact": "Contact",
"Address": "Address",
"Secret": "Secret",
"Post": "Post",
"Author": "Author",
"__Schema": "__Schema",
"__Type": "__Type",
"__TypeKind": "__TypeKind",
"Boolean": "Boolean",
"__Field": "__Field",
"__InputValue": "__InputValue",
"__EnumValue": "__EnumValue",
"__Directive": "__Directive",
"__DirectiveLocation": "__DirectiveLocation",
"CacheControlScope": "CacheControlScope",
"Upload": "Upload"
},
"_possibleTypeMap": {},
"_implementations": {},
"_extensionsEnabled": true
},
"fragments": {},
"operation": {
"kind": "OperationDefinition",
"operation": "query",
"variableDefinitions": [],
"directives": [],
"selectionSet": {
"kind": "SelectionSet",
"selections": [
{
"kind": "Field",
"name": {
"kind": "Name",
"value": "books",
"loc": {
"start": 4,
"end": 9
}
},
"arguments": [],
"directives": [],
"selectionSet": {
"kind": "SelectionSet",
"selections": [
{
"kind": "Field",
"name": {
"kind": "Name",
"value": "title",
"loc": {
"start": 16,
"end": 21
}
},
"arguments": [],
"directives": [],
"loc": {
"start": 16,
"end": 21
}
},
{
"kind": "Field",
"name": {
"kind": "Name",
"value": "author",
"loc": {
"start": 26,
"end": 32
}
},
"arguments": [],
"directives": [],
"loc": {
"start": 26,
"end": 32
}
},
{
"kind": "Field",
"name": {
"kind": "Name",
"value": "publishDate",
"loc": {
"start": 37,
"end": 48
}
},
"arguments": [],
"directives": [],
"loc": {
"start": 37,
"end": 48
}
}
],
"loc": {
"start": 10,
"end": 52
}
},
"loc": {
"start": 4,
"end": 52
}
}
],
"loc": {
"start": 0,
"end": 54
}
},
"loc": {
"start": 0,
"end": 54
}
},
"variableValues": {},
"cacheControl": {
"cacheHint": {
"maxAge": 0
}
}
}
Useful Resources on ASTs
bit.ly/gql-ast-1
bit.ly/gql-ast-2
1. Scoped Selections
What GraphQL Gives Back
{
"data": {
"books": [
{
"title": "Harry Potter and the Chamber of Secrets",
"author": "J.K. Rowling",
"publishDate": "1998-07-02"
},
{
"title": "Jurassic Park",
"author": "Michael Crichton",
"publishDate": "1990-11-20"
}
]
}
}
What the DB Gives Back
[
{
"title": "Harry Potter and the Chamber of Secrets",
"author": "J.K. Rowling",
"publishDate": "1998-07-02",
"coverArt": "Cliff Wright",
"country": "United Kingdom",
"mediaType": "Print",
"pages": "251",
"isbn": "0-7475-3849-2",
},
{
"title": "Jurassic Park",
"author": "Michael Crichton",
"publishDate": "1990-11-20",
"coverArt": "Chip Kidd",
"country": "United States",
"mediaType": "Print",
"pages": "400",
"isbn": "0-394-58816-9",
}
]
Let's use the selections in the GraphQL query to inform the database query
We need to turn this
query {
books {
title
author
publishDate
}
}
Into this
Book.find().select('title author publishDate')
const getQuerySelections = ({ fieldNodes }) => {
return fieldNodes
.map(node => node.selectionSet.selections)
.flat()
.map(s => s.name.value)
.join(' ');
};
Query: {
books: async (parent, args, context, info) => {
try {
const selections = getQuerySelections(info);
return await getBooks(selections);
} catch (err) {
throw new Error(err);
}
}
}
const selections = getQuerySelections(info);
console.log(selections); // 'title author publishDate'
2. Sorts and Limits from Sub-Selections
We can get arguments from the GraphQL resolver, but what about arguments for sub-selections?
Query: {
contacts: (parent, args, context, info) => {
console.log(args);
// { LIMIT: 10 }
}
}
{
contacts(LIMIT: 10) {
firstName
lastName
}
}
{
usersWithContacts {
firstName
lastName
contacts(LIMIT: 4) {
firstName
lastName
}
}
}
Query: {
booksWithContacts: (parent, args, context, info) => {
console.log(args);
// {}
}
}
const getQuerySubArguments = ({ fieldNodes }) => {
return fieldNodes
.map(node => node.selectionSet.selections)
.flat()
.filter(s => s.arguments && s.arguments.length)
.map(s => s.arguments)
.flat()
.filter(a => a.kind === 'Argument');
};
const getLimit = rawArgs => {
const limitArg = rawArgs.find(a => a.name.value === 'LIMIT');
return limitArg && limitArg.value ? parseInt(limitArg.value.value) : null;
};
usersWithContacts: async (parent, args, context, info) => {
const subArguments = getQuerySubArguments(info);
const limit = getLimit(subArguments);
return await getUsersWithContacts(selections, limit);
}
3. Intersecting Auth
We don't always need to use the AST
const server = new ApolloServer({
typeDefs,
resolvers,
context: ({ req }) => ({
clientId: getClientId(req.headers.authorization)
})
});
secrets: async (parent, args, context, info) => {
const { STARTS_WITH } = args;
const { clientId } = context;
const rootQuery = {
message: {
$regex: STARTS_WITH
}
}
return await getSecrets(
makeScopedQuery(rootQuery, clientId)
);
}
4. Defending Against Deeply-Nested Queries
You might have circular relationships in your data
post => author => post => author => post => author
{
posts {
author {
posts {
author {
posts {
author {
posts {
author {
posts {
name
}
}
}
}
}
}
}
}
}
}
const getSelectionDepth = (node, currentDepth = 1) => {
return node.map(n => {
if (!n.selectionSet) {
return currentDepth;
}
return Math.max(
...getSelectionDepth(
n.selectionSet.selections,
currentDepth + 1
)
);
});
};
posts: async (parent, args, context, info) => {
const { fieldNodes } = info;
const selectionDepth = getSelectionDepth(fieldNodes)[0];
if (selectionDepth > 5) {
throw new Error('Max selection depth exceeded');
}
}
npm install graphql-depth-limit
Limitations
Coupling to the database layer
You may not have full control over your database
Who is doing this in practice?
@AdamRackis
Some Treats for You
Slides
bit.ly/graphql-ast-slides
Code
bit.ly/graphql-ast-demo
graphcollective.io
@graphcollective
Thanks!
Ryan Chenkie
@ryanchenkie
What About the Database? How to Write Efficient Queries for GraphQL Resolvers
By Ryan Chenkie
What About the Database? How to Write Efficient Queries for GraphQL Resolvers
When writing GraphQL servers, we often place a lot of emphasis on how to craft good resolvers. However, we don't often think about how to create good database queries to furnish the data for those resolvers. Let's look at how it's easy to fall into the trap of writing inefficient database queries and how we can use GraphQL itself to solve this problem.
- 1,257