graphql-cypher
v0.7.5
Published
A simple but powerful translation layer between GraphQL and Cypher
Downloads
28
Maintainers
Readme
graphql-cypher
A simple but powerful translation layer between GraphQL and Cypher.
Note This library is currently coupled to Neo4j as a backing database, but I'd be happy to accept contributions to decrease that coupling if there is another graph database which uses Cypher that someone would like to support.
Note In its current form, certain features of this library require APOC to run. See Limitations
Table of Contents
- graphql-cypher
- Table of Contents
- Key Features
- Goals
- Documentation
- Limitations
- Inspiration
- Local Development
Key Features
🔨 Simple setup
Attach Cypher resolution directives to an field in your schema and they'll be resolved accordingly, no matter where they are in the query.
🌎 Helpful Cypher globals
Important data is added automatically to your Cypher queries for you to reference. In addition to $args
(the field arguments), you get parent
(the parent node) and $context
(special values you can add to your GraphQL context to give to every query).
type User {
posts(offset: Int = 10): [Post!]!
@cypherCustom(
statement: "MATCH (parent)-[:HAS_POST]->(p:Post) RETURN p SKIP $args.offset LIMIT $context.globalPageSize"
)
}
🔗 Multi-data-source friendly
"Skip" fields which are resolved from data sources external from your Cypher-powered database easily. graphql-cypher
will plan all the queries and inter-dependencies for you.
Externally-powered fields are even woven back into Cypher via the parent
variable, just like any normal Cypher query, so you can reference external data just as easily as graph-native data.
type User {
# this field is resolved from an external source...
settings: UserSettings! @cypherSkip
}
# suppose UserSettings is fetched from an external DB or API and
# has a shape like {accountId: String!, userId: String!}
type UserSettings {
account: Account!
@cypher(match: "(a:Account{id: parent.accountId})", return: "a")
}
type Query {
user(id: ID!): User! @cypher(match: "(u:User{id:$args.id})", return: "u")
}
🔑 Authorization friendly
graphql-cypher
doesn't require any resolvers by default, but it gives you the option to optionally omit fields based on logic you define in a regular old GraphQL resolver. This means it can better support custom authorization or pre/post-query logic.
const resolvers = {
Query: {
secret: (parent, args, ctx) => {
if (!ctx.isAdmin) {
return null;
}
// the field on the parent will be passed as a function you can choose
// to call, or omit
return parent.secret();
},
},
};
There are limitations to this functionality, please see Limitations
Goals
- Support application-focused use cases, giving users as much control as possible over their GraphQL query and mutation contracts
- Make it dead simple for a user familiar with Cypher to start resolving complex GraphQL queries with next to no business logic
- Support multi-data-source apps, letting users choose which data store is the right fit for each part of their GraphQL schema and stitch them together with ease
Documentation
Setup
Install the library and its dependencies* (if you don't have them already):
npm i -s graphql-cypher graphql graphql-middleware neo4j-driver
* graphql-middleware
is not required if you're using graphql-yoga
or another GraphQL server that supports middleware natively
The first step to get started is to add the middleware to your schema.
GraphQL Yoga Server
import { middleware as cypherMiddleware } from 'graphql-cypher';
import { GraphQLServer } from 'graphql-yoga';
const server = new GraphQLServer({
// ... typedefs, etc
middlewares: [cypherMiddleware],
});
Other GraphQL Server
import { middleware as cypherMiddleware } from 'graphql-cypher';
import { applyMiddleware } from 'graphql-middleware';
import schema from './my-schema';
const schemaWithMiddleware = applyMiddleware(schema, cypherMiddleware);
Providing directive typeDefs
Some GraphQL implementations want you to specify typeDefs for all your directives. You can do that by importing directiveTypeDefs
from graphql-cypher
. It's a function. Call it with no arguments, and it will generate the default typeDefs, which you can then add to your schema string. Or, pass in custom directive names if you have changed those, and it will use the names you provide (see Renaming the directives below for the names).
import { gql } from 'apollo-server';
import { makeExecutableSchema } from 'graphql-tools';
import { directiveTypeDefs } from 'graphql-cypher';
const myOtherTypeDefs = gql`
type Query {
foo: Boolean
}
`;
const fullTypeDefs = [
gql`
${directiveTypeDefs()}
`,
myOtherTypeDefs,
];
const schema = makeExecutableSchema({
typeDefs,
});
Add your Neo4j Driver to Context
Finally, assign your Neo4j JS driver instance to a neo4jDriver
field on your GraphQL context object for each request.
// Apollo example
const neo4jDriver = v1.driver('bolt://localhost:7687');
const apollo = new ApolloServer({
context: ({ req }) => ({
neo4jDriver,
}),
});
Only Neo4j is supported by the library at the moment. Adding support for other Cypher-powered databases is welcome!
Now you're ready to begin adding directives to your schema. Find a field you want to resolve with Cypher and create your directive.
type Query {
user(id: ID!): User
@cypher(match: "(user:User {id: $args.id})", return: "user")
}
Try a query! If all went well (and data is present in your database to match the query), you should get your node back! No resolvers necessary!
Renaming the directives: if the default directive names don't work for you (for instance, if you're still using
neo4j-graphql-js
@cypherCustom
directive for part of your schema), you can rename them. Just assign the directives to different properties inschemaDirectives
, and then create a configured middleware by importingcreateMiddleware
fromgraphql-cypher
and providing a config object.Example:
const customMiddleware = createMiddleware({ directiveNames: { cypher: 'myCypher', cypherSkip: 'myCypherSkip', cypherCustom: 'myCypherCustom', cypherNode: 'myCypherNode', cypherRelationship: 'myCypherRelationship', generateId: 'myGenerateId', } });
Queries
Basic querying (@cypher
, @cypherNode
)
The basic directives in this library will help you establish which parts of your schema are resolved via Cypher, and how the data is queried.
@cypher
: Cypher entry point
The @cypher
directive is the starting point for any Cypher-based query. Attach it to a root field, or to a field which has a non-Cypher-resolved parent.
It has a variety of arguments, all of which should feel familiar; they correspond with clauses in Cypher.
match
(String
): The contents will be added to aMATCH
clause. If you have aWHERE
statement, it should be included in the string as well. If you want to match multiple paths, you can separate them with commas as usual.optionalMatch
(String
): Similar tomatch
, but forOPTIONAL MATCH
.create
(String
): Contents will be added to aCREATE
clause.createMany
([String!]
): If you have multipleCREATE
clauses, pass them as a list to this argument instead.merge
/mergeMany
: Similar tocreate
/createMany
, but forMERGE
clauses.set
/setMany
: Similar tocreate
/createMany
, but forSET
clauses.delete
/deleteMany
: Similar tocreate
/createMany
, but forDELETE
clauses.detachDelete
/detachDeleteMany
: Similar tocreate
/createMany
, but forDETACH DELETE
clauses.remove
/removeMany
: Similar tocreate
/createMany
, but forREMOVE
clauses.orderBy
(String
): Contents will be added to anORDER BY
clause.skip
(String
): Contents will be added to aSKIP
clause.limit
(String
): Contents will be added to aLIMIT
clause.return
(String!
): required The name of the binding which you want to return from the query. Do not add custom property selections; the libray will handle these.
Using all these arguments, you can do almost any basic Cypher query. @cypher
will be used for both queries and mutations. For queries, use it to find the node or nodes which the field returns. For mutations, use it to make changes to a node by referencing $args
.
Example
type Query {
user(id: ID!): User @cypher(match: "(user:User{id:$args.id})", return: "user")
users(first: Int = 10, offset: Int = 0)
@cypher(
match: "(user:User)"
skip: "$args.offset"
limit: "$args.first"
return: "user"
)
}
type Mutation {
createUser(input: UserCreateInput!): User!
@cypher(
create: "(user:User)"
set: "user += $args.input"
return: "user"
)
updateUser(input: UserUpdateInput!): User
@cypher(
match: "(user:User{id: $args.input.id})"
setMany: [
"user.name = $args.input.name",
"user.age = $args.input.age"
]
return: "user"
)
}
@cypherNode
: Traverse a relationship to another node
We're working with graphs, so obviously one of the biggest things to do is traverse a relationship and add another node to our query. @cypherNode
lets us define these connections.
@cypherNode
should be added to a field which represents another node or list of nodes in the graph relative to the parent type. It supports the following arguments:
relationship
(String!
): required The type of the connecting relationship (like "HAS_POST
")direction
(RelationshipDirection!
): required The direction of the relationship (IN
orOUT
). This is an enum, so no quotes are required.label
(String
): The library will attempt to infer the label to use for the target node based on its GraphQL type name. If your type name does not match your graph node's label, supply one manually to this argument.where
(String
): Add aWHERE
clause to your node connection to filter the results. Use the presetnode
andrelationship
bindings to create predicates based on the matched node or relationship:where: "node.age > $args.ageLimit"
Example
type User {
posts: [Post!]! @cypherNode(relationship: "HAS_POST", direction: OUT)
}
@cypherRelationship
: Represent a relationship with a type
If you're utilizing properties on relationships in your graph, you can also represent those using the cypherRelationship
directive. Use it instead of @cypherNode
on a field which represents a relationship. It accepts the following arguments:
type
(String!
): required The type of the relationship (like "HAS_POST
")direction
(RelationshipDirection!
): required The direction of the relationship (IN
orOUT
). This is an enum, so no quotes are required.nodeLabel
(String
): We need to know the label of the target node of the relationship to make a good query, so we will try to infer it from your schema. If we can't (or if the target node label is different from your GraphQL type name), you can manually supply one here.where
(String
): Add aWHERE
clause to your node connection to filter the results. Use the presetnode
andrelationship
bindings to create predicates based on the matched node or relationship:where: "node.age > $args.ageLimit"
Once you've added a @cypherRelationship
directive to a field, you should then add a @cypherNode
directive to the node field on your relationship type!
Example
type User {
friends: [UserFriendship!]!
@cypherRelationship(type: "HAS_FRIEND", direction: "OUT")
}
type UserFriendship {
type: String
friend: User! @cypherNode(relationshipType: "HAS_FRIEND", direction: "OUT")
}
@cypherLinkedNodes
: Represent a linked list
Linked lists are a powerful concept to utilize for a series of data points in a graph. The @cypherLinkedNodes
directive can help traverse a linked list with support for basic pagination. It accepts the following arguments:
relationship
(String!
): required The type of the relationship between linked nodeswhere
(String
): Allows adding aWHERE
clause to the pattern. This is potentially useful to introduce cursor-based pagination. The valuenode
will be automatically bound to the ending node of the current linked list segment, so you can use(node)
in yourWHERE
clause as you see fit.direction
(RelationshipDirection
): The direction of the relationship from the source node (IN
orOUT
). This defaults toOUT
.label
(String
): We need to know the label of the list nodes to make a good query, so we will try to infer it from your schema. If we can't (or if the target node label is different from your GraphQL type name), you can manually supply one here.skip
(String
): Provide a field parameter path toskip
to specify how many nodes you want to skip in the list (ex:$args.input.offset
)limit
(String
): Provide a field parameter path tolimit
to specify the maximum number of nodes you want to return in the list (ex:$args.input.first
)
Example
type User {
posts(first: Int = 10, offset: Int = 0): [Post!]!
@cypherLinkedNodes(
relationship: "HAS_NEXT_POST"
skip: "$args.first"
limit: "$args.offset"
)
# cursor (experimental idea)
# it may be possible to utilize the "node" binding in the where argument
# to enforce that all returned paths come after a particular node,
# selected by ID or some other cursor value.
postsWithCursor(cursor: String, count: Int = 10): [Post!]!
@cypherLinkedNodes(
relationship: "HAS_NEXT_POST"
limit: "$args.count"
where: "(:Post {id:$args.cursor})-[:HAS_NEXT_POST*]->(node)"
)
}
Tip: If your linked list uses multiple types of relationships, you can supply a multi-type matcher to
relationship
like "HAS_FIRST_POST|HAS_NEXT_POST
".
@cypherVirtual
: Add extra layers to the structure of returned data
Sometimes you may want to introduce a new, intermediate layer between two parts of your GraphQL schema while continuing to fetch all the data from a single connected query. The @cypherVirtual
directive is a GraphQL Type directive which indicates that a particular named type is "virtual": it only exists in your GraphQL schema, and is "invisible" to your Cypher query (mostly).
If it's still not making sense, here's an example. Suppose you've got a relationship between two nodes in your graph database like so:
(:User)-[:HAS_FRIEND]->(:Friend)
but you wanted your GraphQL schema to look like this:
type User {
friendshipsConnection(type: String = "any"): FriendshipsConnection!
}
type FriendshipsConnection {
edges: [FriendshipEdge!]!
}
type FriendshipEdge {
node: User!
}
This can be accomplished by adding a cypherVirtual
directive to the virual FriendshipsConnection
type.
type User {
friendshipsConnection(type: String = "any"): FriendshipsConnection!
}
type FriendshipsConnection @cypherVirtual {
edges: [FriendshipEdge!]!
@cypherRelationship(
type: "HAS_FRIEND"
direction: "OUT"
where: "$virtual.type = 'any' OR relationship.type = $virtual.type"
)
}
type FriendshipEdge {
node: User! @cypherNode(relationship: "HAS_FRIEND", direction: "OUT")
}
There are a few new things to notice when you use virtual nodes. The first is the use of $virtual
within the Cypher statement on the edges
field. When you mark a type as Virtual, the parameters which were passed to the field which returned that type will be "copied" onward to child fields as the $virtual
parameter. This is important, as it allows us to add arguments to our friendshipsConnection
field which will then be used by the Cypher query to resolve our edges
field within FriendshipsConnection
. The library uses $virtual
for this so that you can still pass in parameters directly to the edges
field if you want, and they can all be easily differentiated and used in your final query.
@cypherComputed
: Compute aggregated or altered field values
The @cypherComputed
directive can help derive data from nodes in your graph to expose through your schema. For instance, suppose you stored firstName
and lastName
properties on a :User
node in your graph, but wanted your GraphQL schema to have a fullName
field. You can use @cypherComputed(value: "parent.firstName + ' ' + parent.lastName")
to do this.
Computed fields only support one directive argument, value
, which is a free-form Cypher string you may supply. Use the parent
variable as usual to reference the parent node as you write your query. Whatever you supply to value
must only include basic Cypher operator-based computation or user functions. You may not invoke custom procedures or use any Cypher clauses, or the generated query will be invalid.
You may also use $args
or $context
as usual when writing your value string. $args
will reference arguments supplied to the field which you annotated with @cypherComputed
.
@cypherCustom
: Custom Cypher queries (only supported with APOC)
The @cypherCustom
directive gives you full control over your Cypher query from start to finish, at the cost of performance. This feature uses APOC's custom Cypher functions under the hood, which can make queries hard for Neo4j to plan effectively. But, for complex queries that require advanced logic, it can help bridge the gap.
@cypherCustom
can be used as a 'starting point' directive like @cypher
.
There are a few ways you can write your Cypher statements:
Simple mode: one statement per directive, which will be run every time.
type Query {
user(id: ID!): User
@cypherCustom(
statement: """
MATCH (user:User {id: $args.id}) RETURN user
"""
)
}
Conditional mode: multiple statements with conditions; the one that matches will be run.
type Query {
posts(filter: PostFilter): [Post!]!
@cypherCustom(
statements: [
{
when: "$args.filter"
statement: """
MATCH (post:Post)
WHERE post.title =~ $args.filter.titleMatch
RETURN post
"""
}
{
statement: """
MATCH (post:Post)
RETURN post
"""
}
]
)
}
In conditional mode, always write your last statement without a condition.
Currently, conditional mode only supports existential conditions: supply an arg name to when
, and it will choose that statement if that arg exists. This supports deep paths.
@cypherCustom
arguments:
statement
(String
): A single Cypher statement to runstatements
([ConditionalCypherStatement
!]): A list of conditional statements to test and run, in order.- A conditional statement has the form:
{ when: String, statement: String! }
- A conditional statement has the form:
returnsRelationship
(Boolean
): Indicate if the custom Cypher statement will return a relationship or relationships instead of nodes. This is required if your statement returns relationships, or@cypherNode
and@cypherRelationship
directives on nested fields will generate invalid Cypher.
Mutations
Like @cypher
, @cypherCustom
works just fine as a starting point for a mutation as well. Just add it to a root field in your Mutation
type and write your Cypher query.
Rules for @cypherCustom
directives
- Don't specify property selections on the returned node ("
{.id, .name}
", etc). These will be managed by the library.
@cypher
globals
We've already seen the $args
parameter, which is available to our all our Cypher statements and fragments. There are a few other parameters as well:
$args
: All the arguments provided to your GraphQL field. This includes defaulted values.parent
: (notice: no$
) This is the parent of the current field; analagous to theparent
field in a GraphQL resolver.parent
works even if the parent of your GraphQL field wasn't resolved from Cypher! Example usage:MATCH (parent)-[:LIKES]->(post:Post)
(for Cypher parents) orMATCH (user:User{id: parent.userId})
(for non-Cypher parents)$context
: Pass values via a specialcypherContext
property on your GraphQL context, and they will be populated in this parameter. This is great for context-centric values like the ID or permissions of the current API user.$generated
: Any values generated bygraphql-cypher
will be available as properties on this parameter (see: Generated Values)$virtual
: Only used in the fields of a type marked@cypherVirtual
. This parameter is an object that holds the$args
which were passed to the field which returned the parent@cypherVirtual
type so they can be used by its child field queries.
Custom Resolver Logic
You can add your own custom resolver logic into the Cypher execution flow to control user access to data or call out to external services before returning the result.
Simply add your own resolver to a Cypher-powered field, and then be sure to call await parent.myFieldName()
(where "myFieldName" is the name of the field you're resolving) to retrieve the field's data from your graph database when you're ready for it.
Example: Authorization
const resolvers = {
Query: {
adminData: (parent, args, context) => {
if (!context.isAdmin) {
return null;
}
return parent.adminData();
},
},
};
Example: External Service
const resolvers = {
Query: {
monitoredData: async (parent, args, context) => {
const data = await parent.monitoredData();
context.notifier.record('The user fetched the data');
return data;
},
},
};
There are limitations to custom resolvers. Currently, there is no way to modify incoming arguments to a Cypher-powered field. This is a limitation of the design of this library and GraphQL itself; by the time your resolver is called, the Cypher query is likely to have already been sent to your database. With the exception of root fields, there is no way for us to modify arguments before making that initial Cypher query, and still retain the benefit of batching up all field Cypher statements into one larger query (which is the whole benefit of the library!) I have some ideas about possible ways to approach this problem, but they are a bit heavy and I'm waiting to see a bit more of how the usage pans out. In the meantime, if there's logic to do, it basically has to be done in Cypher.
Generated Values
This library ships with utility directive support for generating some values as extra arguments to your Cypher statements. All generated values are available on the $generated
parameter in Cypher.
@generateId
: Use it as a directive on a field to generate an ID which will be supplied as an extra argument to that field. This is useful for create mutations.
type Mutation {
createPerson(input: PersonCreateInput!): Person!
@generateId(argName: "personId")
@cypher(
create: "(person:Person{id: $generated.personId})"
set: "person += $args.input"
return: "person"
)
}
@generateId
takes one optional argument, argName
, which can be used to change the property it gets assigned to on $generated
. That's why the value is available on $generated.personId
above. The default is $generated.id
.
Limitations
@cypherCustom
directives rely on the popular APOC library for Neo4j. Chances are if you're running Neo4j, you already have it installed.- Using
parent.myField()
to selectively fetch data only prevents the data from being queried if the field is the root field in your operation or the direct descendant of a non-Cypher-powered field. Otherwise, the data will still be fetched, but by omitting the call toparent.myField()
you will just not return it.- This could probably be changed, but not within the current middleware model. A new traversal to just resolve the Cypher queries before the main resolvers are called would probably need to be introduced.
@cypherCustom
custom queries are not going to be as performant as@cypher
and the other directives, because they run Cypher fragments in user functions, which basically means they get an entirely separate query planning phase. I haven't really profiled it much, but I see a pretty significant performance increase by sticking with the standard directives whenever possible.
Inspiration
Obviously the official neo4j-graphql-js
was a huge inspiration for this library. I learned a lot by reading over their output queries, without which I would probably have struggled for far longer trying to understand how to craft the underlying Cypher queries for this library.
neo4j-graphql-js
still has a lot of great features which I don't intend to bring to graphql-cypher
, namely things like automated generation with convention-based parameters. I hope that graphql-cypher
will become a tool geared toward a specific audience of people like me who want full control over their app, and neo4j-graphql-js
can continue to evolve in the direction of auto-generated schemas and turnkey prototyping solutions.
This project was bootstrapped with TSDX.
Local Development
Below is a list of commands you will probably find useful.
npm start
or yarn start
Runs the project in development/watch mode. Your project will be rebuilt upon changes. TSDX has a special logger for you convenience. Error messages are pretty printed and formatted for compatibility VS Code's Problems tab.
Your library will be rebuilt if you make edits.
npm run build
or yarn build
Bundles the package to the dist
folder.
The package is optimized and bundled with Rollup into multiple formats (CommonJS, UMD, and ES Module).
npm test
or yarn test
Runs the test watcher (Jest) in an interactive mode. By default, runs tests related to files changed since the last commit.
npm run test:integration
This library has a full integration tests suite against a real Neo4j database. To run it, you need to have Docker started. It will manage creating Neo4j containers and removing them as needed.