Interacting with Neo4j in NodeJS using the Neode Object Mapper
I’d like to take some time to introduce you to Neode, an OGM designed to take care of the CRUD boilerplate required to set up a Neo4j project with NodeJS.
Background
When I first started taking NodeJS seriously, there wasn’t a great deal of support for Neo4j. Where libraries did exist, the projects either seemed stale or required a large amount of boilerplate before you could get started.
With Neo4j version 3.0, the company announced a set of official drivers and the binary Bolt protocol. Any old libraries still utilised Neo4j’s REST API and therefore didn’t take advantage of the improved speed and security.
As far as I could see, it would have taken a lot of work to update these existing libraries to utilise Bolt.
Through a side project, I put together a set of generic services on top of the official drivers to take care of the mundane CRUD operations. It made sense to extract these out into a single package that could be used by the wider community.
Inspiration
When I think of MongoDB, I instantly think about Mongoose. In a few lines of code, you can establish a database connection, define a model and start interacting with the database. I wanted to create something that was just as quick to set up with the ambition that it would become as synonymous with Neo4j as Mongoose is for MongoDB. Only time will tell on that one.
I also took a lot of inspiration from Multicolour, an open source REST API generator built by Dave Mackintosh. Multicolour allows you to place model definitions into a content/blueprints
folder, which was then picked up and loaded into a Waterline ORM.
This appealed to me a lot — the native JavaScript drivers require a lot of boilerplate to get going. The barrier to entry should be as low as possible, and an opinionated framework can go a long way towards that.
Getting Started
The first step is to create an instance of Neode — this can be done by importing the dependency and then instantiating a new instance with a server connection string, username and password.
// index.js
import Neode from 'neode';
const instance = new Neode('bolt://localhost:7687',
'username', 'password');
Configuring using .env
Many frameworks use .env files to manage configuration. This is something that I’d found useful when building web apps using Laravel. It made sense to me to include this from the outset. First, create a .env
file with the Neo4j connection details:
# .env
NEO4J_PROTOCOL=bolt
NEO4J_HOST=localhost
NEO4J_USERNAME=neo4j
NEO4J_PASSWORD=neo4j
NEO4J_PORT=7687
Then call the static .fromEnv()
function. The framework will format the connection string and auth token for you.
const instance = Neode.fromEnv();
Enterprise Features
Neode supports a number of enterprise features out of the box. For example, when installing the schema, Neode will check for enterprise mode and attempt to create the exists constraints only available in Neo4j Enterprise Edition.
When instantiating Neode, you can pass through true
as the fourth argument or call the setEnterprise()
function to turn on Enterprise mode.
instance.setEnterprise(true)
Defining Models
Neode revolves around the notion of Definitions, or Models. Each Node definition is identified by a name
and have a schema
containing properties and relationships.
For example, we can create a Person model with a person_id as a unique identifier, a payroll number, name and age. You can define a long form property definition using an object or use the simple definition by supplying just the data type as a string.
instance.model('Person', {
person_id: {
primary: true,
type: 'uuid',
// Creates an Exists Constraint in Enterprise mode
required: true,
},
payroll: {
type: 'number',
unique: 'true', // Creates a Unique Constraint
},
name: {
type: 'name',
indexed: true, // Creates an Index
},
age: 'number' // Simple schema definition of property : type
});
In the example above, I have explicitly called the .model()
function on the Neode instance to define a single node. You can define multiple definitions using the with()
function, or using withDirectory()
to load an entire directory of models.
// Define Multiple Definitions
instance.with({
Movie: require('./models/Movie'),
Person: require('./models/Person')
});// Load all definitions from a Directory
instance.withDirectory(__dirname+'/models');
Relationships
Relationships are defined against the models themselves. This allows you to give a logical name to a definition. For example, an Director may have directed
one or more movies, but that movie will only have one director
. The underlying graph may have a structure of (:Director)-[:DIRECTED]->(:Movie)
but the terminology used in the application layer makes sense to the context of the domain entity itself.
instance.model('Director').relationship('directed', 'DIRECTED', 'out', 'Movie', {
since: {
type: 'number',
required: true,
}
});instance.model('Movie').relationship('director', 'DIRECTED', 'in', 'Director', {
since: {
type: 'number',
required: true,
}
});
Relationships can be defined against a model through the instance, or included in the model definition.
{
directed: {
type: "relationship",
target: "Movie",
relationship: "DIRECTED",
direction: "out",
properties: {
name: "string"
},
}
}
Writing to the Graph
Once you’ve defined your models, the next step is to create some nodes. Neode supports creates and merges. Each write function will return a Promise, and resolve to either an instance of a Node
or a NodeCollection
of Node instances.
Create
You can call the create function with the name of the node definition and a map of properties.
instance.create('Person', {
name: 'Adam'
})
.then(adam => {
console.log(adam.get('name')); // 'Adam'
});
Merge
When you pass through a map of properties, Neode will use the schema to work out which fields to merge on. These are typically indexed values; primary keys and unique values.
instance.merge('Person', {
person_id: 1,
name: 'Adam'
})
.then(adam => {
console.log(adam.get('name')); // 'Adam'
});
Merge On
Alternatively, if you know the specific fields that you would like to merge on, you can use the mergeOn
function.
instance.mergeOn(
'Person',
{
person_id: 1 // Merge on person_id
},
{
name: 'Adam' // Set the name property
}
})
.then(adam => {
console.log(adam.get('name')); // 'Adam'
});
Delete
You can delete any Node instance by calling the delete() function.
instance.find('Person', {person_id: 1})
.then(adam => adam.delete());
When deleting a Node, Neode will check for relationships in the schema with a cascade property set. Where appropriate, it will either cascade delete the node or by default it will detach the node, removing the relationship but leaving the node at the end of the relationship. This can be useful for cascade deleting the orders for a customer, or inversely, keeping the customer record when deleting an order.
Reading from the Graph
Neode has a number of helper functions for reading from the graph.
Get all
Nodes
To get all nodes for a particular label, you can call the all()
function with the definition name as the first parameter. This will return a NodeCollection
. You can optionally pass through
- a map of filters as the second argument,
- order properties as the third argument and
- limit and skip as the fourth and fifth.
instance.all('Person', {name: 'Adam'},{name: 'ASC', id: 'DESC'},1,0)
.then(collection => {
console.log(collection.length); // 1
console.log(collection.get(0).get('name')); // 'Adam'
})
Finding a Single Node
You can use the find()
function to quickly find a model by the primary key that is defined in it’s schema. This is great for API calls where you quickly want to find a record based on the ID passed in the url
instance.find('Person', 1)
.then(res => {...});
The first()
function provides you with a little more flexibility, returning the first node based on the input arguments. This can either be based on a single property, or a map of properties.
// Single Property with key and value
instance.first('Person', 'name', 'Adam')
.then(adam => {...})// Multiple Properties as a map
instance.first('Person', {name: 'Adam', age: 29})
.then(adam => {...})
Raw Cypher
If that isn’t good enough, the cypher()
function will run a straight cypher query directly through the driver.
instance.cypher(
'MATCH (p:Person {name: {name}}) RETURN p', {name: "Adam"})
.then(res => {
console.log(res.records.length);
})
By default, this will return raw Results from the driver. If you need Node or Relationship instances, you can use the hydrate()
function to convert the raw nodes into the appropriate model. By default, Neode will use the labels to try and identify a direct match inside the set of definition. You can also force the nodes to a particular definition.
For a single result, you can use the hydrateFirst()
method instead. This will take the record from the first row by default.
Links
The source code for Neode is on GitHub at github.com/adam-cowley/neode. There is also an example application based on the movie graph at github.com/adam-cowley/neode-example.
Give it a try and let me know how you get on. You can find me on Twitter as @adamcowley. If you have any problems, feel free to create an issue. Pull Requests are also more than welcome.
If you get stuck with anything or with the Neo4j JavaScript drivers in general, you can always head to the #help-javascript channel on the Neo4j Users Slack.