An experimental library for 'translating' GraphQL operations into ArangoDB AQL queries which are designed to fetch all requested data in as few queries as possible. Flexibility is another objective; I want to empower the developer to define exactly how they want their GraphQL schema without being forced into a particular schema shape due to their database structure.
I've moved on from the side project which drove me to create this library and I don't actively use it anymore. If there's a feature or bugfix you need, I encourage you to open a PR. If you'd like to take this project in a new direction, you're welcome to fork it!
type Query {
user(id: ID!): User @aqlDocument(collection: "users", key: "$args.id")
}
type User {
friends: [FriendOfEdge!]!
@aqlEdge(
collection: "friendOf"
direction: ANY
sort: { property: "name", sortOn: "$field_node" }
)
}
type FriendOfEdge {
strength: Int
user: User! @aqlEdgeNode
}
type Mutation {
createPost(input: PostCreateInput!): Post!
@aqlSubquery(
query: """
INSERT { title: $args.input.title, body: $args.input.body } INTO posts
"""
return: "NEW"
)
}
For a simple and small example, see the example directory.
For a larger scale app that uses this library, check out my now-defunct startup idea Toast. The toast-core microservice (linked) drove the entire development of this library and almost every feature is utilized in that codebase.
- This library is not designed to run on the Foxx framework. Because Foxx code runs with direct database memory access, the concern of batching up queries is probably not as important, and designing for Foxx posed too many complexities to getting the library working.
- Experimental features, like Relay support, have known shortcomings. See the documentation for those sections.
- Writing database queries inside a GraphQL schema can be error prone! That's just a fact of life for the way this library is implemented.
Start by installing the library
npm i --save graphql-arangodb
You may also need to install peer dependencies if you don't have them:
npm i --save graphql arangojs
To use the directives in this library, you need to add type definitions for them. The library exports pre-built type definitions for all directives, you just need to include them in your type definitions.
import { directiveTypeDefs } from 'graphql-arangodb';
const typeDefs = [directiveTypeDefs, ...allYourAppsOtherTypeDefs];
makeExecutableSchema({ typeDefs });
The easiest way to connect graphql-arangodb
to your ArangoDB database is to instantiate a Database
class from arangojs
and assign it to the arangoDb
field of your GraphQL context
:
const arangoDb = new Database({
url: 'http://localhost:8529',
});
arangoDb.useDatabase('mydb');
arangoDb.useBasicAuth('mysecretuser', 'mysecretpassword');
const context = {
arangoDb,
};
// pass the context into your GraphQL server according to documentation of the server
To start resolving queries using AQL, you need to set up resolvers for fields which will be resolved using those queries. For most use cases, this means all of the top-level fields in the root query and mutation types.
For most people, adding the default aqlResolver
from graphql-arangodb
should be enough:
import aqlResolver from 'graphql-arangodb';
const resolvers = {
Query: {
user: aqlResolver,
users: aqlResolver,
// ...
},
};
However, there are some advanced scenarios where you may want to customize how the resolver works. To do this, you can import createResolver
and create your own version of the default resolver. All config properties are optional.
import { createResolver, plugins as defaultPlugins } from 'graphql-arangodb';
const resolver = createResolver({
// argument resolvers are called like regular resolvers, but they are used only by
// graphql-arangodb to apply custom transformations to field arguments before
// adding them to the AQL query. They are separated from normal resolvers for
// technical reasons related to how queries are extracted and built by the library.
// Whenver possible, prefer to put this logic inside the AQL query itself.
argumentResolvers: {
Query: {
searchUsers: args => ({
...args,
// apply Lucene fuzzy indicator to user's match string before passing it to AQL
match: `${args.match}~`,
}),
},
},
// customize the key in your context which stores data which will be passed down
// into AQL queries via the $context interpolation
contextKey: 'arango_context',
// customize the context property which is used to get your Database instance
contextDbKey: 'arango_db',
// advanced: you can reassign the names of the default directive plugins, or
// create your own plugin here. Plugins aren't documented yet, see source.
plugins: {
...defaultPlugin,
custom: myCustomPlugin,
},
// you can specify a static database instance instead of passing one through context
db: new Database(),
});
Now that the library is configured, you can start adding directives to indicate how to query for your data.
Usage of these directives is fairly similar to writing subqueries directly in AQL. The main thing to know is that you never write the RETURN
statement. This library automatically constructs the correct RETURN
projections based on the selected fields in the GraphQL query.
Before we begin with the directives, this library also ships some enums which will be used in directive parameters. To use an enum, just supply its literal value to the parameter (don't enclose it in "
marks).
AqlEdgeDirection
:OUTBOUND | INBOUND | ANY
AqlSortOrder
:DESC | ASC
AqlRelayConnectionSource
:Default | FullText
Some directives take complex inputs:
input AqlSortInput {
"""
The property to sort on
"""
property: String!
"""
The order to sort in. Defaults ASC
"""
order: AqlSortOrder = ASC
"""
Change the object being sorted. Defaults to $field
"""
sortOn: String
}
input AqlLimitInput {
"""
The upper limit of documents to return
"""
count: String!
"""
The number of documents to skip
"""
skip: String
}
"""
These are the same as the OPTIONS for a regular edge traversal in AQL
"""
input AqlTraversalOptionsInput {
bfs: Boolean
uniqueVertices: String
uniqueEdges: String
}
All directives support the following interpolations in their parameter values:
$parent
: Reference the parent document. If there is no parent (this is a root field in the query), references theparent
from GraphQL, if that exists.$field
: Reference the field itself. In@aql
directives, you must assign something to this binding to be returned as the value of the field. For all other purposes, you can use this to reference the current value (for instance, if you want to do a filter on$field.name
or some other property).$args
: Reference the field args of the GraphQL query. You can use nested arg values. Usages of$args
get turned into bind variables when the query is executed, and all field args are passed in as values.$context
: Reference values from thearangoContext
key in your GraphQL context. Use this for global values across all queries, like the authenticated user ID.
Selects a single or multiple documents (depending on whether the return type of the field is a list) from a specified collection. If a single document is selected, you can supply an key
parameter to select it directly. This key
parameter may be an argument interpolation ($args.id
, etc), or a concrete value. It is passed directly into the DOCUMENT
AQL function as the second parameter. If you do not specify an key
parameter, the first item from the collection will be returned. To select a single item with a filter, use @aql
.
Parameters
collection: String!
: The name of the collection of documentskey: String
: A string value or interpolation that indicates the database key of the document.filter: String
: Adds a filter expression. Applies to key-based single document fetching (the first document will be taken after filter is applied).sort: AqlSortInput
: Adds a sort expression. Applies to key-based single document fetching (the first document will be taken after sort is applied).limit: AqlLimitInput
: Adds a limit expression. Only works whenkey
is not provided.
Example
type Query {
user(id: ID!): User @aqlDocument(collection: "users", key: "$args.id")
}
Traverses a relationship from the parent document to another document across an edge. @aqlNode
skips over the edge and returns the related document as the field value. If you want to utilize properties from the edge, use @aqlEdge/@aqlEdgeNode
instead.
Parameters
edgeCollection: String!
: The name of the collection which the edge belongs todirection: AqlEdgeDirection!
: The direction to traverse. Can beANY
.filter: String
: Adds a filter expression.sort: AqlSortInput
: Adds a sort expression.limit: AqlLimitInput
: Adds a limit expression.options: AqlTraverseOptionsInput
: Modify OPTIONS parameters on the traversal.
Example
type User {
posts: [Post!]! @aqlNode(edgeCollection: "posted", direction: OUTBOUND)
}
@aqlEdge
traverses an edge from the parent document, returning the edge itself as the field value. @aqlEdgeNode
can be used on the type which represents the edge to reference the document at the other end of it. @aqlEdgeNode
should only be used on a field within a type represented by an edge. It has no directive parameters.
Parameters
Only @aqlEdge
takes parameters:
collection: String!
: The name of the collection for the edgedirection: AqlEdgeDirection!
: The direction to traverse. Can beANY
.filter: String
: Adds a filter expression. To filter on the node, you can use$field_node
as an interpolation. DefaultssortOn
to$field
.sort: AqlSortInput
Adds a sort expression.limit: AqlLimitInput
: Adds a limit expression.options: AqlTraverseOptionsInput
: Modify OPTIONS parameters on the traversal.
@aqlEdgeNode
has no parameters.
Example
type User {
friends: [FriendOfEdge!]!
@aqlEdge(
collection: "friendOf"
direction: ANY
sort: { property: "name", sortOn: "$field_node" }
)
}
type FriendOfEdge {
strength: Int
user: User! @aqlEdgeNode
}
Construct a free-form subquery to resolve a field. There are important rules for your subquery:
- Important: You must assign the value you wish to resolve to the
$field
binding. This can be done for a single value usingLET $field = value
, or for a list by ending the subquery withFOR $field IN list
. See the examples. - Do not wrap in
()
. This is done by the library. - Do not include a
RETURN
statement. AllRETURN
projections are constructed by the library for you to match the GraphQL query.
Parameters
query: String!
: Your subquery string, following the rules listed above.return: String
: An optional way to specify the name of a binding to return. By default, in a subquery, you must follow the important rule marked above and assign to$field
. However, if you prefer, you may specify which variable binding you want to return within your subquery, and we will do this for you.
Examples
Resolving a single value
type Query {
userCount: Int!
@aqlSubquery(
query: """
LET $field = LENGTH(users)
"""
)
}
Resolving multiple values
type Query {
"""
Merges the list of public posts with the list of posts the user has posted (even
private) to create a master list of all posts accessible by the user.
"""
authorizedPosts: [Post!]!
@aqlSubquery(
query: """
LET authenticatedUser = DOCUMENT('users', $context.userId)
LET allAuthorizedPosts = UNION_DISTINCT(
(FOR post IN posts FILTER post.public == true RETURN post),
(FOR post in OUTBOUND authenticatedUser posted RETURN post)
)
FOR $field in allAuthorizedPosts
"""
)
}
In the above example, instead of the final line, you could also pass "allAuthorizedPosts"
to the return
parameter:
type Query {
"""
Merges the list of public posts with the list of posts the user has posted (even
private) to create a master list of all posts accessible by the user.
"""
authorizedPosts: [Post!]!
@aqlSubquery(
query: """
LET authenticatedUser = DOCUMENT('users', $context.userId)
LET allAuthorizedPosts = UNION_DISTINCT(
(FOR post IN posts FILTER post.public == true RETURN post),
(FOR post in OUTBOUND authenticatedUser posted RETURN post)
)
"""
return: "allAuthorizedPosts"
)
}
Free-form AQL for resolving individual fields using parent data or arbitrary expressions. Unlike @aqlSubquery
, this should not be used for a full query structure, only for a simple expression.
Parameters
expression: String!
: The expression to evaluate. Use interpolations to access in-scope information, like the$parent
.
Example
type User {
fullName: String!
@aql(expression: "CONCAT($parent.firstName, \" \", $parent.lastName)")
}
Resolve the annotated field with the _key
or _id
of the parent document, respectively. You can just attach these to any field which indicates the type's ID
if you want your GraphQL IDs to be based on the underlying ArangoDB keys or full IDs.
Example
type User {
id: ID @aqlKey # will be "2301" or similar
}
type Post {
id: ID @aqlId # will be "posts/1234" or similar (depending on your collection name)
}
Known limitations
The current Relay directives don't conform entirely to the Relay spec. They only support first
/after
paging; no reverse paging. pageInfo
does not include hasPreviousPage
. They work for basic, forward-looking pagination use cases, but have not been tested with the official Relay client library.
The usage of these directives may change a bit over time, so be sure to check when upgrading the library!
You must use all of the provided directives to properly construct a Relay connection, according to the rules below. The following example provides a full picture of how to create a Relay Connection:
Basic Relay Example
type User {
postsConnection(first: Int = 10, after: String): UserPostsConnection!
@aqlRelayConnection(
edgeCollection: "posted"
edgeDirection: OUTBOUND
cursorExpression: "$node.title"
)
}
type UserPostsConnection {
edges: [UserPostEdge!]! @aqlRelayEdges
pageInfo: UserPostsPageInfo! @aqlRelayPageInfo
}
type UserPostEdge {
cursor: String!
node: Post! @aqlRelayNode
}
type UserPostsPageInfo {
hasNextPage: Boolean!
}
type Post {
id: ID!
title: String!
body: String!
publishedAt: String!
}
Relay Example with filtering
type User {
postsConnection(
first: Int = 10
after: String
filter: PostsFilterInput
): UserPostsConnection!
@aqlRelayConnection(
edgeCollection: "posted"
edgeDirection: OUTBOUND
cursorExpression: "$node.title"
filter: """
($args['filter'] && (
$args['filter'].titleLike == null || LIKE($node.title, CONCAT("%", $args['filter'].titleLike, "%"))
) && (
$args['filter'].publishedAfter == null || $node.publishedAt > $args['filter'].publishedAfter
))
"""
)
}
input PostsFilterInput {
titleLike: String
publishedAfter: String
}
About filtering
- The
filter
parameter must be evaluated as a single boolean expression. Outer parameters should be used to enclose multiple computations. - If your filter parameter is optional, you should guard against it being
null
within your filter statement. - The word
filter
is interpreted in AQL as a newFILTER
statement, so if you use that as a parameter name, you must access it via bracket syntax (['filter']
), not dot syntax (.filter
) - Test that the user has supplied a filterable value before filtering on that value (this is the reason the above example tests that
$args['filter'].titleLike
is not null before asserting that the node title is LIKE that value) - You may use
$node
and$edge
to represent the current node and edge you are filtering against.$edge
is only valid in a true edge connection from a parent node.
All directives can be applied to either the field which is resolved, or the type it resolves to. Applying the directive to the type might be useful if you reuse the connection in multiple places and don't want to apply the directive to each one. However, doing so may make your schema harder to read.
Add this directive to a field or type definition to indicate that it should be resolved as a Relay Connection. The resolved value will have the standard edges
and pageInfo
parameters.
Note: Currently this only supports forward pagination using
after
.
Parameters
edgeCollection: String
: The name of the collection of edges to traverseedgeDirection: AqlEdgeDirection
: The direction to traverse edges. Can beANY
.cursorExpression: String
: An expression used to compute a cursor from a node or edge. Using$node
will refer to the node,$edge
refers to the edge. If omitted, entries will be sorted by_key
.filter: String
: Supply a filter statement to further reduce the edges which will be matched in the connection.$node
,$edge
, and$path
may be used in addition to all standard interpolations, and will correspond to the first, second and third positional bindings in aFOR ... IN
edge traversal statement.source: String
: (Advanced) Supply your own customFOR
expression to source documents from. For example,FOR $node IN FULLTEXT(Posts, "title", $args.searchTerm)
would create a fulltext search connection. Use$node
and$edge
as bindings when traversing documents so that the rest of the query works properly. It's also possible to use subqueries to traverse more advanced collections, likeFOR $node IN (FOR foo IN ...)
. Using a subquery in this way is valid AQL, so you can place any complex traversal logic within it if you wish. Also, if you use$edge
or$path
in yourfilter
orcursorExpression
arg, you should be sure to bind them in yoursource
arg!
Add this directive to a field or type definition to indicate that it should be resolved as a Relay Edge list. Must be used as a child field of a type resolved by @aqlRelayConnection
.
Add this directive to a field or type definition to indicate that it should be resolved as a Relay Page Info object. Must be used as a child field of a type resolved by @aqlRelayConnection
.
Add this directive to a field or type definition to indicate that it should be resolved as the Node of a Relay Edge. Must be used as a child field of a type resolved by @aqlRelayEdge
.
In addition to adding directives to your schema to resolve fields, you can also utilize a function called runCustomQuery
to imperatively execute AQL queries like you would using the standard arangojs
client, but with added support for projected return values based on the GraphQL selection!
If that doesn't make sense, imagine a scenario where you are writing a query to do a full text search and you want to pre-process the user's input to work with Lucene. There's not currently a great place to put that processing logic; all the @aql
directives assume you're just passing in the user's arguments verbatim.
Instead, you can write your own resolver like so:
import aqlResolver from 'graphql-arangodb';
import aql from 'arangojs';
const searchResolver = async (parent, args, context, info) => {
const fullTextSearchString = processSearchString(args.searchString);
return aqlResolver.runCustomQuery({
query: aql`
FOR matchedPost IN FULLTEXT(posts, "title", ${fullTextSearchString})
RETURN matchedPost
`,
parent,
args,
context,
info,
});
};
Here we're using the aqlResolver.runCustomQuery
function, which accepts a custom query string and bind variables. Write your own AQL however you'd like and return the data to resolve the current field (but be aware that your AQL will be run inside a larger query!).
The magic comes in when the result is returned. Because you passed in the parent
, context
, and info
, graphql-arangodb
can extend your query to return the rest of the data the user needs for their GraphQL operation. In other words, if the user made the query:
query Search($searchString: "good") {
search(searchString: $searchString) {
id
title
body
tags {
id
name
}
author {
id
name
}
}
}
... they would still get tags
and author
resolved by your existing @aql
directives on your schema, at no cost to you.
runCustomQuery
is a tool to give you as much power as possible to craft root queries and mutations, while still getting the benefits of your declarative directives to resolve deeply nested data in a single database round-trip.
In addition to crafting your own queries with a literal string, you can still use this library's built-in 'query builders' which power the directives to create your custom query. This enables you to either opt out of using directives entirely (if you prefer not to clutter your schema document) or conditionally trigger different built-in behaviors.
import aqlResolver, { builders } from 'graphql-arangodb';
const conditionalResolver = async (parent, args, context, info) => {
if (args.searchTerm) {
return aqlResolver.runCustomQuery({
queryBuilder: builders.aqlRelayConnection({
// this sets up the relay connection to draw from a search view using the requested search term
source: `FOR $node IN PostSearchView SEARCH PHRASE($node.name, $args.searchTerm, 'text_en')`,
// our 'cursor' will actually be the weight value of the result, allowing proper sorting of results by weight.
cursorExpression: `BM25($node)`,
// because we order by weight, we actually want to start at higher values and go down
sortOrder: 'DESC',
}),
parent,
args,
context,
info,
});
} else {
return aqlResolver.runCustomQuery({
queryBuilder: builders.aqlRelayConnection({
source: `FOR $node IN posts`,
cursorExpression: '$node.createdAt',
}),
parent,
args,
context,
info,
});
}
};
With the custom resolver above, for example, we construct our Relay-style connection based on a search view if the user has supplied a search term argument, or else we simply list all documents in the collection.
Simple mutations are essentially made possible using the same tools as queries, especially @aqlSubquery
:
type Mutation {
createPost(input: PostCreateInput!): Post!
@aqlSubquery(
query: """
INSERT { title: $args.input.title, body: $args.input.body } INTO posts
"""
return: "NEW"
)
}
The user can, of course, make selections on the returned Post
, which will be properly converted into projections and subqueries just like a query operation.
However, there are some limitations to how complex things can get before you want a proper resolver. If there is logic to be done before writing to the database, you can defer calling graphql-arangodb
's resolver until you have done it:
import { resolver } from 'graphql-arangodb';
const resolvers = {
Mutation: {
createPost: async (parent, args, ctx, info) => {
const canCreatePost = await doSomethingElse(args, ctx);
if (!canCreatePost) {
throw new ForbiddenError("Hey, you can't do that!");
}
return resolver(parent, args, ctx, info);
},
},
};
You could also use the same trick to do some logic after.
If you want to modify the arguments before passing them on, or do even more advanced logic, see the section on runCustomQuery
above.
There are notable use cases where you may want to specifically split the overall GraphQL operation into multiple AQL queries. For instance, if you do a write mutation, ArangoDB will not allow you to read from that collection again in the same query. However, it's possible (depending on what you return from your mutation) for the user to create a selection set which re-traverses collections which were affected by the original write. In such a case, you may want to split the initial write AQL query from the subsequent read queries in the remainder of the operation.
You can use the experimental @aqlNewQuery
directive to do this. Simply add it to any field, and that field will start a brand new AQL query, as if it had been a root field.
Important: you must attach the library resolver to any field you annotate with @aqlNewQuery
, so that it can process that field and any sub-selections into the new AQL query.
Important: if you are using this directive to accomplish a read-after-write scenario, you should add the waitForSync
option to your write queries to ensure the data is consistent before the second query is run.
Example:
type Post {
id: ID! @aqlKey
title: String!
body: String!
publishedAt: String!
author: User! @aqlNode(edgeCollection: "posted", direction: INBOUND)
}
type CreatePostPayload {
post: Post!
@aqlNewQuery
@aqlSubquery(
query: """
LET $field = DOCUMENT(posts, $parent._key)
"""
)
}
type Mutation {
createPost: CreatePostPayload!
@aqlSubquery(
query: """
INSERT { title: "Fake post", body: "foo", publishedAt: "2019-05-03" }
INTO posts
OPTIONS { waitForSync: true }
LET $field = {
post: NEW
}
"""
)
}
The example above allows a user to make a query like this:
mutation CreatePost {
createPost {
post {
id
title
author {
id
}
}
}
}
without triggering an "access after data-modification by traversal" error from AQL.
Splitting up queries may also be useful for tuning performance and balancing the overall size of queries.
One interesting property of AQL is that it will interpret a binding parameter which is shaped like a document as a document. This enables you to seamlessly split up fields which traverse edges using @aqlNewQuery
without any further modifications, because the node from the previous query will be passed into the new query as a @parent
bind parameter, and all built-in traversal queries are designed to utilize this. In other words, you can add @aqlNewQuery
to @aqlNode
, @aqlEdge
, and @aqlRelayConnection
without any further changes, and they will function correctly (while splitting into new queries themselves).
In detail: while a typical @aqlNode
query, for instance, might look like this when generated (much of this is scaffolding from the library, but pay attention to the simplePosts field subquery):
LET query = FIRST(
LET createUser = FIRST(
INSERT {_key: @userId, role: @role, name: @name} INTO users
RETURN NEW
)
RETURN {
_id: createUser._id,
_key: createUser._key,
_rev: createUser._rev,
name: createUser.name,
id: createUser._key,
simplePosts: (
FOR createUser_simplePosts IN OUTBOUND createUser posted
RETURN {
_id: createUser_simplePosts._id,
_key: createUser_simplePosts._key,
_rev: createUser_simplePosts._rev,
title: createUser_simplePosts.title,
id: createUser_simplePosts._key
}
)
}
)
RETURN query
... if you were to add @aqlNewQuery
to the simplePosts
field, it would generate two queries:
LET query = FIRST(
LET createUser = FIRST(
INSERT {_key: @userId, role: @role, name: @name} INTO users
RETURN NEW
)
RETURN {
_id: createUser._id,
_key: createUser._key,
_rev: createUser._rev,
name: createUser.name,
id: createUser._key,
}
)
RETURN query
for the rest of the fields, and then:
LET query = FIRST(
FOR createUser_simplePosts IN OUTBOUND @parent posted
RETURN {
_id: createUser_simplePosts._id,
_key: createUser_simplePosts._key,
_rev: createUser_simplePosts._rev,
title: createUser_simplePosts.title,
id: createUser_simplePosts._key
}
)
RETURN query
for the simplePosts
field.
The @parent
bind parameter of the second query will be populated with the returned value from the first query, which includes the needed _id
field (the library ensures this is always present) for AQL to evaluate the @parent
bind variable as a document reference.
If you want to expriment with this behavior on your own, try running an AQL query in your database and passing an object with a valid _id
field as a bind parameter, then traversing edges from it.
This project was bootstrapped with TSDX.
Below is a list of commands you will probably find useful.
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.
Bundles the package to the dist
folder.
The package is optimized and bundled with Rollup into multiple formats (CommonJS, UMD, and ES Module).
Runs the test watcher (Jest) in an interactive mode. By default, runs tests related to files changed since the last commit.