Since the writing of this article we have switched to using express-cassandra
and Facebook's DataLoader
as front end to GraphQL.
Relay, GraphQL and Cassandra are a natural match. The reliance on types, fields and unique IDs in GraphQL translates into tables, columns and UUIDs in Cassandra. Connecting them is fairly straightforward. In this article it is shown based on the Express GraphQL server and a modified version of the Relay To Do example.
It is possible to use some kind of Object-Relational Mapping (ORM) solution for the data mapping. Multiple implementations exist. While they have their benefits, the ORM solutions can result in over-engineered projects. After all, Cassandra is not a relational database and many of the advantages they provide cannot be fully utilized.
In this article we will explore an approach working directly with the Cassandra driver for Node.js. The entire project is available here.
In order to implement the access we will use two packages. The first one is the Cassandra driver. It provides all the baseline functionality for running queries and statements against a Cassandra cluster:
npm install cassandra-driver
We will also use a package called Squel which allows the building of SQL strings using JavaScript syntax. While Cassandra uses Cassandra Query Language instead of the Structured Query Language that Squel is built to produce, we will find out that they are so similar that Squel can actually be used to produce CQL:
npm install squel --save
The dependency on squel
has been removed from IMRSK, however the code using it can be found at an older revision.
We will use a schema with tables User
and ToDo
, closely resembling the To Do example in Relay.
CREATE TABLE "User"(
id uuid PRIMARY KEY,
"username" varchar,
"password" varchar,
"displayName" varchar,
"email" varchar,
);
CREATE INDEX "User_username_ix"
ON "User"( username );
CREATE TABLE "ToDo"(
"id" uuid PRIMARY KEY,
"ToDo_User_id" uuid,
"ToDo_Text" varchar,
"ToDo_Complete" boolean,
);
CREATE INDEX "ToDo_ToDo_User_id_ix"
ON "ToDo"( "ToDo_User_id" );
We will use UUID as primary and logical foreign keys for both tables.
Notice that all table names and field names are in quotes. This is necessary for Cassandra to respect the case. Otherwise Cassandra would default to lower case.
Note: since writing, URB has switched to using express-cassandra, which takes care of setting up the tables.
The GraphQL schema closely follows our database schema:
type Mutation {
ToDo_add(input: ToDo_addInput!): ToDo_addPayload
ToDo_updateStatus(input: ToDo_updateStatusInput!): ToDo_updateStatusPayload
ToDo_delete(input: ToDo_deleteInput!): ToDo_deletePayload
ToDo_updateRename(input: ToDo_updateRenameInput!): ToDo_updateRenamePayload
ToDo_list_updateMarkAll(input: ToDo_list_updateMarkAllInput!): ToDo_list_updateMarkAllPayload
ToDo_list_deleteCompleted(input: ToDo_list_deleteCompletedInput!): ToDo_list_deleteCompletedPayload
}
interface Node {
id: ID!
}
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}
type Query {
node(id: ID!): Node
Viewer: Viewer
}
type ToDo implements Node {
id: ID!
ToDo_Text: String
ToDo_Complete: Boolean
}
input ToDo_addInput {
ToDo_Text: String!
clientMutationId: String!
}
type ToDo_addPayload {
ToDosEdge: ToDosEdge
Viewer: Viewer
clientMutationId: String!
}
input ToDo_deleteInput {
id: ID!
clientMutationId: String!
}
type ToDo_deletePayload {
deletedToDoId: ID
Viewer: Viewer
clientMutationId: String!
}
input ToDo_list_deleteCompletedInput {
clientMutationId: String!
}
type ToDo_list_deleteCompletedPayload {
deletedToDoIds: [String]
Viewer: Viewer
clientMutationId: String!
}
input ToDo_list_updateMarkAllInput {
ToDo_Complete: Boolean!
clientMutationId: String!
}
type ToDo_list_updateMarkAllPayload {
changedToDos: [ToDo]
Viewer: Viewer
clientMutationId: String!
}
input ToDo_updateRenameInput {
id: ID!
text: String!
clientMutationId: String!
}
type ToDo_updateRenamePayload {
ToDo: ToDo
clientMutationId: String!
}
input ToDo_updateStatusInput {
ToDo_Complete: Boolean!
id: ID!
clientMutationId: String!
}
type ToDo_updateStatusPayload {
ToDo: ToDo
Viewer: Viewer
clientMutationId: String!
}
type ToDosConnection {
pageInfo: PageInfo!
edges: [ToDosEdge]
}
type ToDosEdge {
node: ToDo
cursor: String!
}
type Viewer implements Node {
id: ID!
ToDos(status: String = "any", after: String, first: Int, before: String, last: Int): ToDosConnection
ToDo_TotalCount: Int
ToDo_CompletedCount: Int
}
Types for ToDo
and User
are defined, as well as a connection between a user and the multiple ToDos is defined. Mutations for adding, removing and updating ToDos are also provided.
The Cassandra client needs to be provided the connection points and the default keyspace to use. Both values are being retrieved from the environment, or .env
file:
export const client = new cassandraDriver.Client( {
contactPoints: process.env.CASSANDRA_CONNECTION_POINTS.split( ',' ),
keyspace: process.env.CASSANDRA_KEYSPACE
} );
Here is an example from a .env
file configured to use a local machine:
CASSANDRA_CONNECTION_POINTS=localhost
CASSANDRA_KEYSPACE=rebar
The UUID type from Cassandra is being used. It is re-exported in the client in order to be used without a separate export:
export const Uuid = cassandraDriver.types.Uuid;
The Cassandra client code is available in /data/lib/CassandraClient.js.
Squel needs to know how to process UUIDs which is achieved with the following line:
squel.registerValueHandler( Uuid, function( uuid ){ return uuid; } );
it is also exported to be used with the client:
export const sql = squel;
Classes representing each entity will be used to present the information to GraphQL. The field names are named exactly the same as the field names in the database. The constructor accepts a dictionary with values for all the fields.
export default class ToDo
{
constructor( fields )
{
this.id = fields.id;
this.ToDo_User_id = fields.ToDo_User_id;
this.ToDo_Text = fields.ToDo_Text;
this.ToDo_Complete = fields.ToDo_Complete;
}
}
There are several versions of the function to run a query against Cassandra depending on how many results are expected. The most general function is displayed below:
export function runQuery( objectPrototype, qText, qVar )
{
return new Promise( ( resolve, reject ) =>
{
client.execute( qText, qVar, {prepare: true}, ( err, result ) => {
if( ensureNoErrorOrReport( qText, qVar, err, reject ) )
{
const resultAsObjects = [ ];
const rowCount = result.rowLength;
for( let ixRow = 0 ; ixRow < rowCount ; ixRow++ )
{
let row = result.rows[ ixRow ];
resultAsObjects.push( new objectPrototype( row ) );
}
resolve( resultAsObjects );
} } );
} );
}
The function returns a promise. GraphQL accepts promises for all resolve
methods. Inside the promise a query is executed against the Cassandra client. Once it is checked for errors, all rows are being converted to objects as defined earlier. At this point it becomes important to have the exact same naming for the fields, including the case sensitive name in Cassandra.
In the functions for accessing the database, the CQL statements can be defined as constant strings, or assembled as strings on the fly. In the following example a function can perform an update of the text field, the completed field, or both:
export function DA_ToDo_update( id, fields )
{
// We will not update ToDo_User_id since it makes no sense to update it
let cqlText = 'UPDATE "ToDo" SET ';
let cqlParams = [ ];
let followingItem = false;
if( 'ToDo_Text' in fields )
{
cqlText += '"ToDo_Text" = ?';
cqlParams.push( fields.ToDo_Text );
followingItem = true;
}
if( followingItem ) cqlText += ', ';
if( 'ToDo_Complete' in fields )
{
cqlText += '"ToDo_Complete" = ?';
cqlParams.push( fields.ToDo_Complete );
followingItem = true;
}
cqlText += ' WHERE id = ?;';
cqlParams.push( id );
return runQueryNoResult( cqlText, cqlParams );
}
Notice that the parameters are always placed in a parameters array and never encoded into the string directly. This allows the statement to be prepared, and also prevents against SQL exploits. CQL exploits, that is.
Squel, while originally made for generating SQL statements, is fully capable of generating CQL statements. In the query below a user is being found by the provided user name. The toParam
function forced squel to provide the query text and the parameters separately:
export function DA_User_getByUserName( username )
{
const query = sql.select( )
.from( '"User"' )
.where( 'username=?', username )
.toParam( )
;
return runQueryOneResult( User, query.text, query.values );
}
In the project used user authentication is performed using JWT HTTP only cookies. Without going into further detail we'll mention that when the GraphQL Express server is placed in a route, the user id is provided as parameter to all GrapQL resolve methods and mutations:
router.use( '/graphql', graphQLHTTP( request => {
let user_id = ........ retrieve user id from cookie JWT ........
return( {
schema: schema,
rootValue: { user_id: user_id },
pretty: true
} )
} ) );
The user id is later used in the schema objects and passed on to the data access functions:
export default mutationWithClientMutationId( {
name: 'ToDo_delete',
inputFields: {
id: { type: new GraphQLNonNull( GraphQLID ) },
},
outputFields: {
deletedToDoId: {
type: GraphQLID,
resolve: ( {id} ) => id,
},
Viewer: {
type: ViewerType,
resolve: ( parent, args, { rootValue: {user_id} } ) => DA_User_get( user_id )
},
},
mutateAndGetPayload: ( {id}, { rootValue: {user_id} } ) =>
{
var localToDoId = fromGlobalId(id).id;
return DA_ToDo_delete( user_id, localToDoId )
.then( ( ) => ( {id} ) )
;
}
} );
The resolve method is self explanatory and simply returns the promise from the data access method:
resolve: ( parent, args, { rootValue: {user_id} } ) => DA_User_get( user_id )
mutateAndGetPayload
capitalizes on the fact that the data access methods return promises. It takes the promise of the object deletion, and once it is complete returns the id of the deleted To Do:
var localToDoId = fromGlobalId(id).id;
return DA_ToDo_delete( user_id, localToDoId )
.then( ( ) => ( {id} ) )
Special attention needs to be given to the syntax. The code below defines an arrow function, which returns an object {id: id}
:
.then( ( ) => ( {id} ) )
However, the code below:
.then( ( ) => {id} )
would return nothing, since it is interpreted as function () { id }
, which is incorrect.
The data access functions do not guarantee to generate the same object when it is retrieved at different occasions. For instance, when an object is retrieved by id, and as part of an array, the two objects albeit having the same fields, would be different objects in memory. Relay uses ===
to compare objects. This is why, for cursorForObjectInConnection
a helper function is used:
outputFields: {
ToDosEdge: {
type: ToDosConnection.edgeType,
resolve: ( {localToDoId}, args, { rootValue: {user_id} } ) =>
{
let a_ToDo;
return DA_ToDo_get( localToDoId )
.then( ( retrieved_ToDo ) => {
a_ToDo = retrieved_ToDo;
} )
.then( ( ) => DA_ToDo_list_get( user_id ) )
.then( ( arr_ToDo ) => ( {
cursor: cursorForObjectInConnectionWithUuidComparison( arr_ToDo, a_ToDo ),
node: a_ToDo,
} ) )
;
}
},
The helper function scans the array, and if the object is found in the array, the same object is placed at the corresponding position in the array:
export function cursorForObjectInConnectionWithUuidComparison( arr, obj )
{
for( let ix = 0; ix < arr.length; ix++ )
{
if( arr[ ix ].id.equals( obj.id ) )
{
arr[ ix ] = obj;
break;
}
}
let cursor = cursorForObjectInConnection( arr, obj );
return cursor;
}