GRANDstack tips and tricks

· 11 min read

GRANDstack tips and tricks

Using GRANDstack can rapidly accelerate the development of applications. The neo4j-graphql-js library provides the ability to translate GraphQL queries from the frontend to Cypher queries. This is achieved by defining the GraphQL schema and annotating it with a few extra directives. If you want to get familiar with the GRANDstack you can visit their documentation.

GRANDstack logo

This post will present some more advanced tips and tricks for using the neo4j-graphql-js library that we found useful for real world applications. It will focus on overcoming some of its limitations or adding missing features. It will show you how to unset Date properties, explain how to reuse resolvers generated by the library, provide an example of implementing the Union type and at the end, we will explore ways of writing automated tests for the application.

Unsetting Date properties

If you use data type Date as optional and set its value, it can not be unset. GraphQL schema definition:

type Foo {
  uuid: ID!
  date: DateTime
  note: String
}

We can create a new object with an empty date and set only the note:

mutation {
  CreateFoo(note: "something") {
    ...
    date {
      formatted
    }
  }
}

and it returns following fragment:

"date": {
  "formatted": null
}

Everything is fine, full of rainbows and unicorns. Now we set the date:

mutation {
  UpdateFoo(uuid: "cooluuid", date: { year: 2014 }) {
    ...
  }
}

It works and I can read the date from the object. I can delete the note from the object by setting it to null. But when I try to remove the date from the object:

mutation {
  UpdateFoo(uuid:"cooluuid", date: null){
    ...
  }
}

It throws an error saying Cannot read property 'formatted' of null.

Normally, the library generates mutations to remove nested objects from the object; the mutations are called something like RemoveBarFromFoo. This is done because nested objects are normally nodes in the graph and deleting the relationship removes them from the object. However, for DateTime fields, there is no method to unset it.

What you can do to allow unsetting the DateTime properties is to define FooInput input type:

input FooInput {
  note: String
  date: DateTime
}

and create custom mutation: UpdateFoo(uuid: ID!, input: FooInput!): Foo!. The initial idea is to use a custom @cypher directive for the mutation with a Cypher query similar to this:

MATCH (foo:Foo)
WHERE foo.uuid=$uuid
SET foo += $input
RETURN foo

But what happens if you want to change the date? You send FooInput from the front end with the following structure:

{
  date: {
    year: 2014
  }
}

It throws an error Failed to invoke procedure 'apoc.cypher.doIt': Caused by: org.neo4j.cypher.internal.v3_5.util.CypherTypeException: Property values can only be of primitive types or arrays thereof. So it cannot accept nested structures. What are our other options? We could send the date as a DateTime string and change the Cypher too (we don’t mind that foo.date is set to the string initially with the first SET command, as it is overridden on the next line). Naive query implementation:

MATCH (foo:Foo)
WHERE foo.uuid=$uuid
SET foo += $input
SET foo.date = datetime($input.date)
RETURN foo

Wow, setting the date and unsetting the date works correctly! Are we done? Unfortunately not yet! If you change only note, the date is unset. This is because $input.date is resolved to null if it is undefined.

What are our options? We have two. The first is to write a custom JavaScript resolver that would handle the differences between null and undefined. The other is to update our Cypher query to:

MATCH (foo:Foo)
WHERE foo.uuid=$uuid
SET foo += $input
FOREACH (y IN CASE WHEN $input.date IS NULL THEN [] ELSE [1] END | SET foo.date = datetime($input.date))
RETURN foo

How does this query work? The FOREACH line works like an if statement. It sets the date only if the $input.date is not null. If there is $input.date key set to null, the previous SET line sets it to null. If the $input.date is not set at all, the date field is not touched at all.

Reusing generated resolvers

Out of the box, the neo4j-graphql-js library supports either using generated resolvers or writing custom ones. But there are use cases where we need to create custom resolvers that will reuse resolvers generated by the neo4j-graphql-js library. Those cases include business logic that cannot be expressed by Cypher or generating a query based on communication with external services.

If we write a custom resolver and return the whole entity as a result, it will work for simple fields. But what if the entity has some complex types? It will not cascade. So it forces us to write our custom resolvers for the given nested type too. Reusing the generated resolvers helps with resolving all of the return fields and handles the cascading resolvers calls and saves us of the burden of rewriting resolvers for every type.

A naive implementation would be to parse the incoming query and extract the fields from the query and then call: graphql(resolveInfo.schema, selectQuery, resolveInfo.rootValue, ctx), where selectQuery is extracted from the fields definition in the original query and wrapped into the new query. This may work for simple use cases, but the limitations become apparent when you use aliases, templates and other features of the GraphQL language. You might handle all of this manually, but this functionality is already implemented in GraphQL tools provided by Apollo.

The functionality is called schema delegation. One of its main benefits is that it can delegate the schema to it. The delegation also supports schema transformers that you can use to modify the original query or the resulting query. An example of a simple delegation:

delegateToSchema({
  schema: resolveInfo.schema,
  operation: 'query',
  fieldName: 'User',
  context: ctx,
  info: resolveInfo,
})
  .then(res => {
    return res[0];
  })

There is one catch in this solution and you need to be aware of it - delegated requests will run in a separate transaction.

Union types

Neo4j-graphql-js offers (limited) support for interfaces types. However, sometimes union type is preferred type and the lack of its support can limit developers. We can apply the resolver reusing described above and therefore supporting unions in your application is fairly easy.

A good example of union type usage is for search functionality - we want to perform a search across multiple types and return the ordered results (and possibly other information, like a score). The schema definition for this use case is simple:

union SearchResult = Person | Company | Product

type Query {
    fullTextSearch(query: String!): [SearchResult!]!
}

An example of a query:

{
  fullTextSearch(query: "Becke~"){
    __typename
    ... on Person{
      firstName
      lastName
    }
    ... on Company{
      name
      address{
        street
        town
      }
    }
    ... on Product{
      name
    }
  }
}

We create a fulltext index in the database and from the resolver function we call:

CALL db.index.fulltext.queryNodes("fullTextSearch", "query~") yield node, score
RETURN labels(node)[0] AS type, node.uuid AS uuid, score order by score desc

Easy, right? We got all the results we want. Now we just need to query the specific types to get required fields for them. We will use the trick with using existing resolvers that was described above. We construct a query for each type, reuse a generated filter for uuid_in and add three transformers:

  1. Strip aliases - we need to strip aliases from the query as they would not get correctly matched by the top-level return mechanism.
  2. Add a uuid field - we need to have uuids to match the fulltext results with the results returned from subsequent queries. For example, to sort results or to limit them.
  3. Transform the query - that is the place where the interesting stuff happens. The request consists of the body of the initial GraphQL query. So we need to get the inline fragment for the type, extract its fields, and move it one level higher.

Example of delegateToSchema call:

delegateToSchema({
  schema: resolveInfo.schema,
  operation: 'query',
  fieldName: 'Person',
  args: {
    filter: {
      uuid_in: uuids,
    },
  },
  context: ctx,
  info: resolveInfo,
  transforms: [new Aliases(), new AddUUID("Person"), new UnionTypeDef("Person")],
})

There is one last catch. Neo4j-graphql-js library overrides the resolveType method to use only the FRAGMENT_TYPE field. We override it again to use __typename and FRAGMENT_TYPE field. This is added to the GraphQLSchema definition:

const schema: GraphQLSchema = makeAugmentedSchema({ ... });

schema.getTypeMap().Location['resolveType'] = obj => {
  return obj['__typename'] ? obj['__typename'] : obj['FRAGMENT_TYPE'];
};

Testing the application

Testing the generated schema and the logic of all of the @cypher statements embedded into the schema definition can be tricky. However, we can adapt Testcontainers with Neo4j solution for the neo4j-graphql-js. We will use NodeJS clone for running our docker images.

The database image itself can be started pretty easily. Just don’t forget to mount your plugins and enable them. Starting the container with Neo4j:

new GenericContainer('neo4j', '3.5.12-enterprise')
      .withEnv('NEO4J_ACCEPT_LICENSE_AGREEMENT', 'yes')
      .withEnv('NEO4J_AUTH', 'neo4j/Neo4jpassword')
      .withEnv('NEO4J_dbms_security_procedures_unrestricted', 'apoc.\\\\\\*')
      .withBindMount(path.resolve(__dirname, '../neo4jplugins'), '/plugins', 'ro')
      .withExposedPorts(7687, 7474)
      .start()

With the database up and running you just instantiate your Apollo server with the driver pointing to the testing DB. Then you can start testing the application using the Apollo Client. You may choose whether to populate the DB using the Apollo client, Cypher scripts or a combination of both.

There is a difference between the Java version and NodeJS version of the Testcontainers. In our case, we must not forget to shut down the container correctly, as the library would leave it up and running. This is the case for failed tests or even when an exception happens during the execution of the test.

Conclusion

Neo4j-graphql-js library is not an officially supported Neo4j library. Hhowever, we found it very useful for prototyping or PoC types of projects. With the tricks described above you can create more complex features that are currently missing in the library. We hope you find them useful!

Michal Trnka