Designing Your GraphQL Schema

The goal of this guide is to explain how to design your GraphQL schema for use with GRANDstack and the Neo4j GraphQL Library. Refer to the Neo4j GraphQL Library documentation for more information.

Three Principles of the Neo4j GraphQL Library

It's important to understand the goals and advantages of using Neo4j GraphQL.

  1. GraphQL type definitions drive the Neo4j database schema.
  2. A single Cypher database query is generated for each GraphQL request.
  3. Custom logic can be added with @cypher GraphQL schema directives to extend the functionality of GraphQL beyond CRUD.

GraphQL Type Definitions Drive The Database

GraphQL type definitions

A major benefit of using the Neo4j GraphQL Library is the ability to define both the database schema and the GraphQL schema with the same type definitions. The Neo4j GraphQL Library can also automatically create Query and Mutation types from your type definitions that provide instant CRUD functionality. This GraphQL CRUD API can be configured and extended.

A Single Cypher database query is generated for each GraphQL Request

Cypher query generation

Since GraphQL type definitions are used to define the database schema, the Neo4j GraphQL Library can generate a single Cypher query to resolve any arbitrary GraphQL request. This also means our resolvers are automatically implemented for us. No need to write boilerplate data fetching code, just inject a Neo4j driver instance into the request context and the Neo4j GraphQL Library will take care of generating the database query and handling the database call. Additionally, since a single Cypher query is generated, this results in a huge performance boost and eliminates the n+1 query problem.

Defining Custom Logic With @cypher Schema Directives

Cypher directive example

Since GraphQL is an API query language and not a database query language it lacks semantics for operations such as aggregations, projections, and more complex graph traversals. We can extend the power of GraphQL through the use of @cypher GraphQL schema directives to bind the results of a Cypher query to a GraphQL field. This allows for the expression of complex logic using Cypher and can be used on Query and Mutation fields to define custom data fetching or mutation logic.

For more on how the Neo4j GraphQL Library aims to solve some of the common problems that come up when building GraphQL APIs see this post.

What is a GraphQL schema?

A GraphQL schema defines the types and the fields available on each type, which can include references to other types. A GraphQL schema includes two special types Query and Mutation, the fields of which define the entry points for the schema. In typical GraphQL implementations the Query and Mutation types must be defined by the developer, however in our case the Neo4j GraphQL Library will handle generating CRUD queries and mutations based on our type definitions. We do however have the ability to define custom queries and mutations by either using a @cypher schema directive in our type definitions, effectively annotating the schema with a Cypher query, or implementing the resolver in code. See below for examples of each of those approaches.

Graph Thinking

The official guide to GraphQL makes the observation that your application data is a graph. GraphQL allows you to model your business domain as a graph, which in the client presents an object-oriented like approach to data where objects reference other types, creating a graph. However, as GraphQL is data layer agnostic, the guide says developers are free to implement the backend however they wish. When GraphQL is used with a graph database such as Neo4j, we don't need a mapping and translation layer to translate our datamodel, instead the GraphQL type definitions can drive the database data model. The graph model of GraphQL translates easily to the labeled property graph model used by Neo4j and other graph database systems.

Nodes, relationships, and properties.

In a graph database nodes are the entities in the graph and relationships connect them. We can store arbitrary key-value pairs as properties on both nodes and relationships. This is the labeled property graph model.

Graph data model

In GraphQL we define types and the fields available on each type, which may reference other types. For example:

type TypeName {
fieldName: FieldType
referenceField: TypeName
listReferenceField: [TypeName]
}

How then do we map the GraphQL type system to the labeled property graph? By applying these basic rules:

  1. GraphQL type names become node labels
  2. Fields become node properties
  3. Reference fields become relationships (we discuss the case of modeling relationship types below)

The GraphQL Schema Definition Language

In GraphQL we use the Schema Definition Language (SDL) to define our types in a language agnostic way.

Type Definitions

We use SDL to define object types for our domain and the fields available on each type, which may be scalar types or reference other object types. For example:

type Movie {
movieId: ID!
title: String
description: String
year: Int
}

This type definition is defining a node with the label Movie and the properties movieId, title, description, and year:

Graph data model

Our type definitions can reference other object types as well:

type Actor {
actorId: ID!
name: String
movies: [Movie]
}
type Movie {
movieId: ID!
title: String
description: String
year: Int
actors: [Actor]
}

Arguments

Fields can include arguments. For example, we may want to limit the number of actors we return for movie:

type Actor {
actorId: ID!
name: String
movies: [Movie]
}
type Movie {
movieId: ID!
title: String
description: String
year: Int
actors(limit: Int = 10): [Actor]
}

Here limit is an integer argument with default value 10. This argument can be specified at query time.

Schema Directives

Schema directives are GraphQL's built-in extension mechanism. We can use them to annotate fields or object type definitions.

@relationship Schema Directive

To be able to fully specify the labeled property graph equivalent we make use of the @relationship GraphQL schema directive to declare the relationship type and direction:

type Actor {
actorId: ID!
name: String
movies: [Movie] @relationship(type: "ACTED_IN", direction: OUT)
}
type Movie {
movieId: ID!
title: String
description: String
year: Int
actors(limit: Int = 10): [Actor] @relationship(type: "ACTED_IN", direction: IN)
}

Here direction is an enum added to our type definitions by the Neo4j GraphQL Library with possible values IN, OUT.

These type definitions then map to this labeled property graph model in Neo4j:

Graph data model

@cypher Schema Directive

The Neo4j GraphQL Library introduces a @cypher GraphQL schema directive that can be used to bind a GraphQL field to the results of a Cypher query. For example, let's add a field similarMovies to our Movie which is bound to a Cypher query to find other movies with an overlap of actors:

type Actor {
actorId: ID!
name: String
movies: [Movie] @relationship(type: "ACTED_IN", direction: OUT)
}
type Movie {
movieId: ID!
title: String
description: String
year: Int
actors(limit: Int = 10): [Actor] @relationship(name: "ACTED_IN", direction: IN)
similarMovies(limit: Int = 10): [Movie] @cypher(statement: """
MATCH (this)<-[:ACTED_IN]-(:Actor)-[:ACTED_IN]->(rec:Movie)
WITH rec, COUNT(*) AS score ORDER BY score DESC
RETURN rec LIMIT $limit
""")
}

In the context of the Cypher query used in a @cypher directive field, this is bound to the currently resolved object, similar to the object parameter passed to the GraphQL resolver, in this example this becomes the currently resolved movie.

GraphQL field arguments for the @cypher directive field are passed to the Cypher query as Cypher parameters (In this case $limit)

@cypher directives can be used to implement authorization logic as well. We can include values from the request context, such as those added by authorization middleware as Cypher parameters. See the auth documentation for more information.

API Generation

In typical GraphQL server implementations we would now define a Query and Mutation object type, the fields of each becoming the entry points for our API. With the Neo4j GraphQL Library we don't need to define a Query or Mutation type as queries and mutations with full CRUD operations will be generated for us from our type defintions.

We also don't need to define resolvers - the functions that contain the logic for resolving the actual data from the data layer. Since the Neo4j GraphQL Library handles database query generation and data fetching our resolvers are generated for us. You can read more about the API generation process in the Schema section of the documentation.

Custom Queries And Mutations

The auto-generated API gives us basic CRUD functionality, but what if we wanted to implement our own custom logic? We have two options for implementing custom logic with the Neo4j GraphQL Library:

  1. Using the@cypher schema directive
  2. Implementing custom resolvers

Using @cypher

To implement a custom Query field, simply include a Query type in your type definitions and include your custom logic in a @cypher directive annotated field. Here we use a fulltext index to perform a fuzzy text match for searching for movies:

type Query {
moviesByFuzzyMatch(search: String, first: Int = 10, offset: Int = 0): [Movie] @cypher(statement: """
CALL db.index.fulltext.queryNodes('movieIndex', $search) YIELD node
RETURN node SKIP $offset LIMIT $first
""")
}

This approach can be used to define custom logic for Mutation fields as well. See the documentation for more examples.

Implementing Custom Resolvers

Resolvers are functions that define how to actually fetch the data from the data layer. Because the Neo4j GraphQL Library autogenerates CRUD resolvers for queries and mutations, you don't need to implement resolvers yourself, however if you have some custom code beyond which can be defined using an @cypher directive, you can implement your own resolvers and pass those along. Here's a simple example defining a field resolver for fullName.

import { Neo4jGraphQL } from "@neo4j/graphql";
const typeDefs = ```
type User {
userId: ID!
firstName: String
lastName: String
fullName: String
}
```;
const resolvers = {
User: {
fullName(obj, params, ctx, resolveInfo) {
return `${obj.firstName} ${obj.lastName}`;
}
}
};
const neoSchema = new Neo4jGraphQL({
typeDefs,
resolvers,
});