Adding Custom Logic

Lesson Overview

In the previous lesson we explored building a GraphQL API using the Neo4j GraphQL Library that implemented basic create, read, update, and delete (CRUD) functionality. In this lesson we explore adding custom logic to our GraphQL API using the power of the Cypher query language.

Codesandbox Setup

To begin we’ll clear out our Neo4j database, open a new Codesandbox, and run a GraphQL mutation to add some initial data.

First, open Neo4j Browser for your Neo4j Sandbox instance and run the following Cypher query to delete all the data in the database

Make sure you’re running this command in the correct database as this will delete all data in the Neo4j database!

MATCH (a) DETACH DELETE a

Next, open this Codesandbox and edit the .env file, adding the connection credentials for your Neo4j Sandbox. Refer to the setup instructions in the previous lesson if necessary.

In GraphQL Playground running in your Codesandbox, paste and execute the following GraphQL mutation to load the initial data we’ll be working with:

mutation {
  createBooks(
    input: [
      {
        isbn: "1492047686"
        title: "Graph Algorithms"
        price: 37.48
        description: "Practical Examples in Apache Spark and Neo4j"
        subjects: { create: [{ name: "Graph theory" }, { name: "Neo4j" }] }
        authors: {
          create: [{ name: "Mark Needham" }, { name: "Amy E. Hodler" }]
        }
      }
      {
        isbn: "1119387507"
        title: "Inspired"
        price: 21.38
        description: "How to Create Tech Products Customers Love"
        subjects: {
          create: [{ name: "Product management" }, { name: "Design" }]
        }
        authors: { create: { name: "Marty Cagan" } }
      }
      {
        isbn: "190962151X"
        title: "Ross Poldark"
        price: 15.52
        description: "Ross Poldark is the first novel in Winston Graham's sweeping saga of Cornish life in the eighteenth century."
        subjects: {
          create: [{ name: "Historical fiction" }, { name: "Cornwall" }]
        }
        authors: { create: { name: "Winston Graham" } }
      }
    ]
  ) {
    books {
      title
    }
  }

  createCustomers(
    input: [
      {
        username: "EmilEifrem7474"
        reviews: {
          create: {
            rating: 5
            text: "Best overview of graph data science!"
            book: { connect: { where: { isbn: "1492047686" } } }
          }
        }
        orders: {
          create: {
            books: { connect: { where: { title: "Graph Algorithms" } } }
            shipTo: {
              create: {
                address: "111 E 5th Ave, San Mateo, CA 94401"
                location: {
                  latitude: 37.5635980790
                  longitude: -122.322243272725
                }
              }
            }
          }
        }
      }
      {
        username: "BookLover123"
        reviews: {
          create: [
            {
              rating: 4
              text: "Beautiful depiction of Cornwall."
              book: { connect: { where: { isbn: "190962151X" } } }
            }
          ]
        }
        orders: {
          create: {
            books: {
              connect: [
                { where: { title: "Ross Poldark" } }
                { where: { isbn: "1119387507" } }
                { where: { isbn: "1492047686" } }
              ]
            }
            shipTo: {
              create: {
                address: "Nordenskiöldsgatan 24, 211 19 Malmö, Sweden"
                location: { latitude: 55.6122270502, longitude: 12.99481772774 }
              }
            }
          }
        }
      }
    ]
  ) {
    customers {
      username
    }
  }
}

With our initial data loaded let’s dive into adding custom logic to our GraphQL API using Cypher!

The @cypher GraphQL Schema Directive

Schema directives are GraphQL’s built-in extension mechanism and indicate that some custom logic will occur on the server. Schema directives are not exposed through GraphQL introspection and are therefore invisible to the client. The @cypher GraphQL schema directive allows for defining custom logic using Cypher in the GraphQL schema. Using the @cypher schema directive overrides field resolution and will execute the attached Cypher query to resolve the GraphQL field. Refer to the @cypher directive documentation for more information.

Computed Scalar Field

Let’s look at an example of using the @cypher directive to define a computed scalar field in our GraphQL schema. Since each order can contain multiple books we need to compute the order "subtotal" or the sum of the price of each book in the order. To calculate the subtotal for an order with orderID "123" in Cypher we would write a query like this:

MATCH (o:Order {orderID: "123"})-[:CONTAINS]->(b:Book)
RETURN sum(b.price) AS subTotal

With the @cypher schema directive in the Neo4j GraphQL Library we can add a field subTotal to our Order type that includes the logic for traversing to the associated Book nodes and summing the price property value of each book. Here we use the extend type syntax of GraphQL SDL but we could also add this field directly to the Order type definition as well.

Add this extension to the schema.graphql file:

# schema.graphql

extend type Order {
  subTotal: Float @cypher(statement:"MATCH (this)-[:CONTAINS]->(b:Book) RETURN sum(b.price)")
}

The @cypher directive takes a single argument statement which is the Cypher statement to be executed to resolve the field. This Cypher statement can reference the this variable which is the currently resolved node, in this case the currently resolved Order node.

We can now include this subTotal field in our GraphQL queries:

{
  orders {
    books {
      title
      price
    }
    subTotal
  }
}
{
  "data": {
    "orders": [
      {
        "books": [
          {
            "title": "Graph Algorithms",
            "price": 37.48
          }
        ],
        "subTotal": 37.48
      },
      {
        "books": [
          {
            "title": "Graph Algorithms",
            "price": 37.48
          },
          {
            "title": "Inspired",
            "price": 21.38
          },
          {
            "title": "Ross Poldark",
            "price": 15.52
          }
        ],
        "subTotal": 74.38
      }
    ]
  }
}

The @cypher directive gives us all the power of Cypher, with the ability to express complex traversals, pattern matching, even leveraging Cypher procedures like APOC. Let’s add a slightly more complex @cypher directive field to see what is possible. Let’s say that the policy for computing the shipping cost of orders is to charge $0.01 per km from our distribution warehouse. We can define this logic in Cypher, adding a shippingCost field to the Order type.

Add this extension to the schema.graphql file:

# schema.graphql

extend type Order {
  shippingCost: Float @cypher(statement: """
  MATCH (this)-[:SHIPS_TO]->(a:Address)
  RETURN round(0.01 * distance(a.location, Point({latitude: 40.7128, longitude: -74.0060})) / 1000, 2)
  """)
}

Node And Object Fields

In addition to scalar fields we can also use @cypher directive fields on object and object array fields with Cypher queries that return nodes or objects. Let’s add a recommended field to the Customer type, returning books the customer might be interested in purchasing based on their order history and the order history of other customers in the graph.

Add this extension to the schema.graphql file:

# schema.graphql

extend type Customer {
    recommended: [Book] @cypher(statement: """
    MATCH (this)-[:PLACED]->(:Order)-[:CONTAINS]->(:Book)<-[:CONTAINS]-(:Order)<-[:PLACED]-(c:Customer)
    MATCH (c)-[:PLACED]->(:Order)-[:CONTAINS]->(rec:Book)
    WHERE NOT EXISTS((this)-[:PLACED]->(:Order)-[:CONTAINS]->(rec))
    RETURN rec
    """)
}

Now we can use this recommended field on the Customer type. Since recommended is an array of Book objects we need to select the nested fields we want to be returned - in this case the title field.

{
  customers {
    username
    recommended {
      title
    }
  }
}
{
  "data": {
    "customers": [
      {
        "username": "EmilEifrem7474",
        "recommended": [
          {
            "title": "Inspired"
          },
          {
            "title": "Ross Poldark"
          }
        ]
      },
      {
        "username": "BookLover123",
        "recommended": []
      }
    ]
  }
}

In this case we recommend two books to Emil that he hasn’t purchased, however since BookLover123 has already purchased every book in our inventory we don’t have any recommendations for them!

Any field arguments declared on a GraphQL field with a Cypher directive are passed through to the Cypher query as Cypher parameters. Let’s say we want the client to be able to specify the number of recommendations returned. We’ll add a field argument limit to the recommended field and reference that in our Cypher query as a Cypher parameter.

Modify this extension in the schema.graphql file:

# schema.graphql

extend type Customer {
    recommended(limit: Int = 3): [Book] @cypher(statement: """
    MATCH (this)-[:PLACED]->(:Order)-[:CONTAINS]->(:Book)<-[:CONTAINS]-(:Order)<-[:PLACED]-(c:Customer)
    MATCH (c)-[:PLACED]->(:Order)-[:CONTAINS]->(rec:Book)
    WHERE NOT EXISTS((this)-[:PLACED]->(:Order)-[:CONTAINS]->(rec))
    RETURN rec LIMIT $limit
    """)
}

We set a default value of 3 for this limit argument so that if the value isn’t specified the limit Cypher parameter will still be passed to the Cypher query with a value of 3. The client can now specify the number of recommended books to return:

{
  customers {
    username
    recommended(limit:1) {
      title
    }
  }
}

We can also return a map from our Cypher query when using the @cypher directive on an object or object array GraphQL field. This is useful when we have multiple computed values we want to return or for returning data from an external data layer. Let’s add weather data for the order addresses so our delivery drivers know what sort of conditions to expect. We’ll query an external API to fetch this data using the apoc.load.json procedure.

First, we’ll add a type to the GraphQL type definitions to represent this object (Weather), then we’ll use the apoc.load.json procedure to fetch data from an external API and return the current conditions, returning a map from our Cypher query that matches the shape of the Weather type.

Add these types and extensions to the schema.graphql file:

# schema.graphql

type Weather {
  temperature: Int
  windSpeed: Int
  windDirection: Int
  precipitation: String
  summary: String
}

extend type Address {
  currentWeather: Weather @cypher(statement:"""
  WITH 'https://www.7timer.info/bin/civil.php' AS baseURL, this
  CALL apoc.load.json(
      baseURL + '?lon=' + this.location.longitude + '&lat=' + this.location.latitude + '&ac=0&unit=metric&output=json')
      YIELD value WITH value.dataseries[0] as weather
      RETURN {
          temperature: weather.temp2m,
          windSpeed: weather.wind10m.speed,
          windDirection: weather.wind10m.direction,
          precipitation: weather.prec_type,
          summary: weather.weather} AS conditions
      """)
}

Now we can include the currentWeather field on the Address type in our GraphQL queries:

{
  orders {
    shipTo {
      address
      currentWeather {
        temperature
        precipitation
        windSpeed
        windDirection
        summary
      }
    }
  }
}
{
  "data": {
    "orders": [
      {
        "shipTo": {
          "address": "111 E 5th Ave, San Mateo, CA 94401",
          "currentWeather": {
            "temperature": 9,
            "precipitation": "none",
            "windSpeed": 2,
            "windDirection": "S",
            "summary": "cloudyday"
          }
        }
      },
      {
        "shipTo": {
          "address": "Nordenskiöldsgatan 24, 211 19 Malmö, Sweden",
          "currentWeather": {
            "temperature": 6,
            "precipitation": "none",
            "windSpeed": 4,
            "windDirection": "NW",
            "summary": "clearday"
          }
        }
      }
    ]
  }
}

Custom Query Field

We can use the @cypher directive on Query fields to compliment the auto-generated Query fields provided by the Neo4j GraphQL Library. Perhaps we want to leverage a full-text index for fuzzy matching for book searches?

First, in Neo4j Browser, create the full-text index:

CALL db.index.fulltext.createNodeIndex("bookIndex", ["Book"],["title", "description"])

To search this full-text index we use the db.index.fulltext.queryNodes procedure:

CALL db.index.fulltext.queryNodes("bookIndex", "garph~")

Neo4j full-text indexes use Apache Lucene query syntax - the ~ indicates we want to use "fuzzy matching" taking into account slight misspellings.

Next, add a bookSearch field to the Query type in our GraphQL type definitions which requires a searchString argument that becomes the full-text search term:

# schema.graphql

type Query {
    bookSearch(searchString: String!): [Book] @cypher(statement: """
    CALL db.index.fulltext.queryNodes('bookIndex', $searchString+'~')
    YIELD node RETURN node
    """)
}

And we now have a new entry-point to our GraphQL API allowing for full-text search of book titles and descriptions:

{
  bookSearch(searchString: "garph") {
    title
    description
  }
}
{
  "data": {
    "bookSearch": [
      {
        "title": "Graph Algorithms",
        "description": "Practical Examples in Apache Spark and Neo4j"
      }
    ]
  }
}

Custom Mutation Field

Similar to adding Query fields, we can use @cypher schema directives to add new Mutation fields. This is useful in cases where we have specific logic we’d like to take into account when creating or updating data. Here we make use of the MERGE Cypher clause to avoid creating duplicate Subject nodes and connecting them to books.

# schema.graphql

type Mutation {
  mergeBookSubjects(subject: String!, bookTitles: [String!]!): Subject @cypher(statement: """
  MERGE (s:Subject {name: $subject})
  WITH s
  UNWIND $bookTitles AS bookTitle
  MATCH (t:Book {title: bookTitle})
  MERGE (t)-[:ABOUT]->(s)
  RETURN s
  """)
}

Now perform the update to the graph:

mutation {
  mergeBookSubjects(
    subject: "Non-fiction"
    bookTitles: ["Graph Algorithms", "Inspired"]
  ) {
    name
  }
}

Custom Resolvers

Combining the power of Cypher and GraphQL is extremely powerful, however there are bound to be some cases where we want to add custom logic using code by implementing resolver functions. This might be where we want to fetch data from another database, API, or system. Let’s consider a contrived example where we compute an estimated delivery date using a custom resolver function.

First, we add an estimatedDelivery field to the Order type, including the @ignore directive which indicates we plan to resolve this field manually and it will not be included in the generated database queries.

# schema.graphql

extend type Order {
    estimatedDelivery: DateTime @ignore
}

Now it’s time to implement our Order.estimatedDelivery resolver function. Our function simply calculates a random date - but the point is that this can be any custom logic we choose to define.

Add this function to beginning of index.js:

// index.js

const resolvers = {
  Order: {
    estimatedDelivery: (obj, args, context, info) => {
      const options = [1, 5, 10, 15, 30, 45];
      const estDate = new Date();
      estDate.setDate(
        estDate.getDate() + options[Math.floor(Math.random() * options.length)]
      );
      return estDate;
    }
  }
};

Next, we include the resolvers object when instantiating Neo4jGraphQL.

Modify these objects in index.js:

// index.js

const neoSchema = new Neo4jGraphQL({
  typeDefs,
  resolvers,
  debug: true
});

const server = new ApolloServer({
  context: { driver },
  schema: neoSchema.schema,
  introspection: true,
  playground: true
});

And now we can reference the estimatedDelivery field in our GraphQL queries. When this field is included in the selection instead of trying to fetch this field from the database, our custom resolver will be executed.

{
  orders {
    shipTo {
      address
    }
    estimatedDelivery
  }
}
{
  "data": {
    "orders": [
      {
        "shipTo": {
          "address": "111 E 5th Ave, San Mateo, CA 94401"
        },
        "estimatedDelivery": "2021-05-09T23:43:05.970Z"
      },
      {
        "shipTo": {
          "address": "Nordenskiöldsgatan 24, 211 19 Malmö, Sweden"
        },
        "estimatedDelivery": "2021-04-29T23:43:05.970Z"
      }
    ]
  }
}

Exercise: Exploring The @cypher Directive

We saw how powerful the Cypher directive can be for adding custom logic to our GraphQL API. For this exercise first be sure to follow along with the steps above to make use of the @cypher schema directive in your GraphQL type definitions. If you run into any issues you can refer to this Codesandbox with all the code we added in this lesson.

For this exercise you will be adding a similar field to the Book type which will return similar books. How you determine similarity is up to you. Perhaps consider order co-occurence (books purchased together) or user reviews? First, think about how to query for similar books using Cypher, testing in Neo4j Browser. Then add the query as a @cypher directive field to the Book type. Advanced users may wish to explore the Graph Data Science Library to leverage graph algorithms. For example, here’s how we can use the Jaccard Similarity function to find similar books using book subjects:

# schema.graphql

extend type Book {
  similar: [Book] @cypher(statement: """
  MATCH (this)-[:ABOUT]->(s:Subject)
  WITH this, COLLECT(id(s)) AS s1
  MATCH (b:Book)-[:ABOUT]->(s:Subject) WHERE b <> this
  WITH this, b, s1, COLLECT(id(s)) AS s2
  WITH b, gds.alpha.similarity.jaccard(s2, s2) AS jaccard
  ORDER BY jaccard DESC
  RETURN b LIMIT 1
  """)
}

Your solution will enable clients of the GraphQL API to include the similar field in the selection set and view similar books:

{
  books(where: { title: "Graph Algorithms" }) {
    title
    similar {
      title
    }
  }
}
{
  "data": {
    "books": [
      {
        "title": "Graph Algorithms",
        "similar": [
          {
            "title": "Inspired"
          }
        ]
      }
    ]
  }
}

Check Your Understanding

Question 1

Schema directives are used in GraphQL type definitions to indicate custom server-side logic. In the Neo4j GraphQL Library which GraphQL schema directive is used to define custom logic using the Cypher query language?

Select the correct answer.

  • @gql

  • @cypher

  • @gds

  • @ignore

  • @relationship

Question 2

Which of the following GraphQL SDL snippets show the proper usage of the @cypher schema directive to compute the subtotal for an order?

Select the correct answer.

  • subTotal: Float @cypher(statement:"MATCH (this)-[:CONTAINS]→(b:Book) RETURN sum(b.price)")

  • MATCH (o:Order)-[:CONTAINS]→(b:Book) RETURN b ORDER BY p.price DESC

  • { orders { subTotal } }

Question 3

The Cypher query used in a @cypher schema directive cannot use Cypher procedures such as APOC, the Graph Data Science Library, or other built-in procedures.

Is this statement true or false?

  • True

  • False

Summary

In this lesson, we explored two methods for adding custom logic to our GraphQL API: the @cypher schema directive and custom resolvers. In the next lesson we address adding authorization rules to our API using the @auth directive and JSON Web Tokens (JWTs).