Using Neogma to build a type-safe Node.js app with a Neo4j graph database
This is an introduction to Neogma, an open source Object-Graph-Mapper for Node.js which utilizes TypeScript. It achieves a fast, easy and safe way to interact with a Neo4j database.
Overview
This article will demonstrate how to Create, Read, Update, and Delete nodes and relationships of a Neo4j graph database by using the Neogma framework for the Node.js runtime environment for JavaScript. We’ll also build a simple app to find common movie interests between the users.
Traditionally, you may use the Neo4j JavaScript driver, with the Cypher query language, by way of string statements to interact with the database. For example, in order to create a node with the label Movie and the name Inception, you need to run the statement CREATE (:Movie { name: $name })
, with a parameter of { name: "Inception" }
However, with Neogma, you just need to call a simple method:
Movies.create({ name: 'Inception' });
and Neogma takes care of generating the intended Cypher, by using the label you’ve defined and automatically generates and runs the query by setting the correct parameters!
By defining Models, you can benefit from the automated methods of Creating, Reading, Updating, and Deleting nodes and relationships. By using the Query Builder, you easily create and run flexible queries to suit your needs.
Using TypeScript is recommended as Neogma has built-in types which are used in every part of it. However, it can always be used with plain JavaScript.
You can find the final source code of this article here.
In case you want to build something on your own, you can always refer to the detailed Neogma Documentation.
Getting Started
You need to have the following installed:
- Neo4j. After you install it, set it up so a graph database is running. Take note of the connection information (url, username, password).
Alternatively, a Neo4j sandbox can be used out of the box without having to install anything. - Node.js
- (optional) Git, for version control.
- (optional) Yarn, to be used instead of npm. However, every yarn command can be replaced by the equivalent npm command.
Next up we need to create and configure the project. If you are already familiar with TypeScript, you can use your own configuration.
Otherwise, you should use this template I’ve created where everything is pre-configured. You can either click on the Use this template button to create a new repository, or you can just download the code.
Then, navigate to the project root and run yarn
. To make sure it is set-up correctly, edit the src/app.ts
file to add a simple
console.log('hello, neogma!');
You can run the app by running yarn build
followed by yarn start
at the root project directory.
We’re now ready to start building our app, starting by creating the Models, populating data, and querying our graph!
Creating the Models
The first thing we need to do is add Neogma to our project.
While on the root project directory, run yarn add neogma
. This installs neogma from the npm registry and adds it as a project dependency.
Next up, we start using Neogma by creating an instance of it.
Add the following to src/app.ts
.
You need to replace the url
, username
, password
fields with your own connection. This will also enables logging the queries to the console, so we can see them in action. If you cannot connect like this because your connection is encrypted, you need to change the corresponding field.
Now, let’s think about how our models will be like.
In our application, there are Users and Movies. A User can indicate that they like a Movie. In our graph, we need User nodes, Movie nodes, and a relationship about a User liking a Movie. Our simple database will consist of the following nodes and relationships:
Now let’s define our first Model!
Create a directory models
in src
, and a file Movies.ts
inside it. Let’s start editing the src/models/Movies.ts
file:
1. First of all we need to import the Neogma instance we have created:
import { neogma } from '../app';
2. Next up, we need to create some interfaces so we can have type-safety with TypeScript:
import { ModelFactory, NeogmaInstance } from 'neogma';export type MoviesPropertiesI = {
name: string;
year: number;
};export interface MoviesRelatedNodesI {}export type MoviesInstance = NeogmaInstance<
MoviesPropertiesI,
MoviesRelatedNodesI,
>;
MoviesPropertiesI
defines what properties each Movie node has. Here we define a string property called name and a number property called year.MoviesRelatedNodesI
defines what relationships this Model has. We can leave it empty as we’ll define the relationship between Users and Movies at the Users Model.- Finally, we define the interface of the Movie instance. This corresponds to a node in the database and provides access to its properties, useful methods for editing its data etc.
3. Now, we can create our Movies
class, which is the last part of our src/models/Movies.ts
file as a whole:
As we can see, the Model class is created by calling ModelFactory
and using the interfaces we declared above. Also, we provide:
- The label that the nodes of this Model have.
- Optionally a field which will be considered a (unique) primary key. This is needed by Neogma only for calling some methods of the Instances of Movies. An index can be created on this field for performance benefits, in case many Movie nodes exist in the database.
- Our schema, consisting of the properties of each node and a validation for them.
Now let’s define the Users Model (src/models/Users.ts
).
It’s very similar to the Movies Model, but we additionally include the information for the User-Likes-Movie relationship.
- We need to import
ModelRelatedNodesI
, a helper type we’ll use. We also need to import theMovies
Model and theMoviesInstance
interface.
import { ModelFactory, NeogmaInstance, ModelRelatedNodesI } from 'neogma';import { Movies, MoviesInstance } from './Movies';
2. We need to define the type for the relationships, for type-safety benefits.
export interface UsersRelatedNodesI {
LikesMovie: ModelRelatedNodesI<typeof Movies, MoviesInstance>;
}
We define a relationship configuration with the LikesMovie
alias, and the related Model which is Movies
.
3. When creating the Users
class, we also need to provide some information about the relationship. In the first parameter of the ModelFactory
call, we also include a relationships
field:
relationships: {
LikesMovie: {
model: Movies,
direction: 'out',
name: 'LIKES',
},
},
The relationships
field has the relationship alias as its key. As its value, the relationship information is provided, including the related Model (Movies
), the name of the relationship (LIKES
) and the direction of it (from Users to Movies, so outwards).
The complete code for src/models/Users.ts
is:
Will all this information, our Model classes are ready to be used for Creating, Reading, Updating, and Deleting nodes and relationships, with proper types for safety and ease of use. Let’s see them in action!
Populating Data
Now that our models are ready, we can start using them by creating some nodes and relationships.
Let’s go back to our src/app.ts
file. We need to import our models and some of their interfaces. This needs to happen after the neogma
variable declaration. We additionally need a function for populating data and a main function which calls it. Finally, we need to call our main function:
import { Users, UsersPropertiesI } from './models/Users';
import { Movies, MoviesPropertiesI } from './models/Movies';const populateData = async () => {
};const main = async () => {
// populate the data
await populateData();
};main();
It’s time to create our first node in the database! Inside the populateData
function, add the following:
await Users.createOne({
name: 'Barry',
age: 36,
});
The above function creates a node with the given name and age properties. They are validated, and a runtime error is thrown if they are invalid. Additionally, a TypeScript error is thrown on compilation time if a field is missing or is of the wrong type.
The created node has the label that’s given at the Users
Model definition (in this case, User
).
Let’s now create some Movies. Add:
await Movies.createMany([
{
name: 'The Dark Knight',
year: 2008,
},
{
name: 'Inception',
year: 2010,
},
]);
This acts in a similar fashion with createOne
, while creating multiple nodes at the same time.
An alternative way to save a node into a database is to build an Instance of the Model. To demonstrate this, let’s build an Instance of the Users
Model:
const cynthia = Users.build({
name: 'Cynthia',
age: 21,
});
This Instance is of the UsersInstance
type we defined earlier. It has a lot of useful methods we can use.
At this stage, this Instance isn’t saved in the database yet. For it to persist, we need to call its save
method:
await cynthia.save();
And just like that, our Instance is now a node in our graph!
Now let’s relate this Instance to a Movie. We can use the Instance’s relateTo
method:
await cynthia.relateTo({
alias: 'LikesMovie',
where: {
name: 'Inception',
},
});
As we can see, this needs:
1. The alias of the relationship. We provided it in our Users
Model definition. With this, the relationship name, direction, and the related Model will be used automatically.
2. A where statement, to match the nodes of the related Model.
The above method relates the cynthia node with a node which has the name Inception. The target node has the information provided by the LikesMovie
alias, which means: Its Model is Movies
and its label is Movie
. The relationship name is LIKES
, with a direction from the User towards the Movie.
Now let’s try something a bit more complex.
We will create a User node and at the same time relate it with an existing Movie node. We’ll also create a Movie node on the fly and relate our User with it.
We’ll use the createOne
method, and the relationship alias key to indicate that this node will be related to a Movie.
A where
field indicates that the User we’re creating will be related to the existing Movie with the name The Dark Knight.
A properties
field indicates that a new Movie will be created with the given properties and will be related to the User.
await Users.createOne({
name: 'Jason',
age: 26,
LikesMovie: {
where: {
params: {
name: 'The Dark Knight',
},
},
properties: [
{
name: 'Interstellar',
year: 2014,
},
],
},
});
Let us now find a User from the database. The simplest way is by using the findOne
static of the Users
class:
const barry = await Users.findOne({
where: {
name: 'Barry',
},
});
The found user, who has the name Barry, is represented by an Instance of the Users model. This means that we can easily access its properties and use its methods.
By adding the following:
console.log(`Barry's age is ${barry.age}`);
We see that Barry’s age is 36
.
We can easily edit Barry’s age by setting the age
field and saving it to the database.
barry.age = 37;
await barry.save();
Since Barry already exists in the database, the last line will just update it, instead of create it like in Cynthia’s example. That way, by just saving an Instance, its data will always be correctly reflected in the database!
Now, let’s have Barry like every movie by leaving the where
statement empty:
await barry.relateTo({
alias: 'LikesMovie',
where: {},
});
As a last step, let’s have Cynthia like Interstellar, which was created during Jason’s createOne
call.
await cynthia.relateTo({
alias: 'LikesMovie',
where: {
name: 'Interstellar',
},
});
Tip: If you’ve ran this populator more than once, you’ll see that nodes with the same names are created again. To solve this, we could have used a MERGE clause instead of a CREATE one, by using a merge: true
parameter. However, for simplicity, in this example we can just delete all existing data (nodes and relationships) when the populator starts. Add the following line to the beginning of the populateData
function to run it as a raw query:
await neogma.queryRunner.run('MATCH (n) DETACH DELETE n');
The final code for our populateData
function inside src/app.ts
is:
By running the function, you can see in the console every query that was generated and ran by Neogma. Also, our graph has the following data:
And we are ready to start querying it!
Querying our Graph
Now that our nodes and relationships are in place, it’s time to perform some queries on them. As we’ve already seen, we can find nodes by using the findOne
or findMany
static of a Model. However, for more flexible and complex queries, we can use the QueryBuilder
class so we can construct a custom one directly from JavaScript objects.
Let’s create a queryData
function below our populateData
function.
const queryData = async () => {
};
Now let’s call it. Replace the main
function with:
const main = async () => {
await populateData();
await queryData();
};
Additionally, we need to import the QueryBuilder
class from Neogma. Replace the existing ‘neogma’ import with:
import { Neogma, QueryBuilder, QueryRunner } from 'neogma';
We’ll now see 3 different examples of querying our data. In each, we add the given code to our queryData
function.
Finding a specific User and a specific Movie
A great introductory example is just finding a specific User and a specific Movie, regardless of their relationship.
First we need to create an instance of QueryBuilder
. On this instance we can call the methods which correspond to the clauses we want to use.
Since each of them returns the instance, we can chain them to construct our query.
The code which runs a query for matching a User and a Movie, and returning both of them is the following:
const specificUserAndMovieResult = await new QueryBuilder()
.match({
model: Users,
where: {
name: 'Jason',
},
identifier: 'user',
})
.match({
model: Movies,
where: {
name: 'Inception',
},
identifier: 'movie',
})
.return(['user', 'movie'])
.run(neogma.queryRunner);
In the first match parameter, we provide the Model we want to use. So, the node's label will be User
. We also provide a where parameter, so the matched node’s name needs to equal Jason
. Lastly, we specify an identifier to be used in the query (user
).
The second match parameter is very similar to the first one, with the difference that we now match the Movie Inception
and we use the movie
identifier for it.
The final part of the query construction is the return clause. We just return the identifiers we used earlier, user
and movie
.
To run this QueryBuilder
instance, we just need to call its run
method by passing a QueryRunner
instance. We can use the one that our Neogma
instance has (neogma.queryRunner
). The return value of this is the QueryResult
of the Neo4j driver.
The generated Cypher is:MATCH (user:`User` { name: $name }) MATCH (movie:`Movie` { name: $name__aaaa }) RETURN user, movie
with the parameters:{ name: 'Jason', name__aaaa: 'Inception' }
The return value is not typed since it’s arbitrary and not a result of a Model operation. We can use the getResultProperties
static of the QueryRunner
to get the properties from our result:
const jason = QueryRunner.getResultProperties<UsersPropertiesI>(
specificUserAndMovieResult,
'user',
)[0];const inception = QueryRunner.getResultProperties<MoviesPropertiesI>(
specificUserAndMovieResult,
'movie',
)[0];console.log(`${jason.name} is ${jason.age} years old`);console.log(`${inception.name} came out in ${inception.year}`);
For example, for jason
: We use the UsersPropertiesI
interface because we know that a User node has the properties that we defined. We pass the query result (specificUserAndMovieResult
) and the identifier (user
) as parameters. The results could consist of many records but we only care about the first (array element [0]
). Then, we can access the node properties directly from the variables.
When we run it, we get the following result:
Jason is 26 years old
Inception came out in 2010
Finding the common liked Movies between two Users
Let’s find the common liked movies between Jason and Barry. We need to match a node for Jason, a node for Barry, and all the Movie nodes that both of them are related to.
Thus, we’ll use the related
field of a match
parameter, which is an array of objects. Its first and third elements correspond to information about which nodes to match. Its second element represents information about the relationship between them (direction, and optionally a name and an identifier). This can continue indefinitely to relate more than 2 nodes. Each even entry describes a node, and each odd entry describes a relationship.
In this case: User Jason(entry 0) is related (entry 1) to any Movie (entry 2) which is related (entry 3) to User Barry (entry 4).
The query to find the common liked Movies between Jason and Barry is:
const commonMoviesBetweenJasonAndBarryResult = await new QueryBuilder()
.match({
related: [
{
model: Users,
where: {
name: 'Jason',
},
},
Users.getRelationshipByAlias('LikesMovie'),
{
model: Movies,
identifier: 'movie',
},
{
direction: 'in',
},
{
model: Users,
where: {
name: 'Barry',
},
},
],
})
.return('movie')
.run(neogma.queryRunner);
- In our
related
field inmatch
we first match the UserJason
. - Then, we specify that the relationship configuration (name, label, direction) is the one we have defined in our Model and has the alias
LikesMovie
. To extract this information, we use thegetRelationshipByAlias
static of our ModelUsers
.
This relationship is between the node above this object (Jason) and the one below it (movie). - We match a Movie and we give it the identifier
movie
. - A relationship is matched between the node above (movie) and the one below (Barry). For its configuration, we just specify that its direction will be inwards (from Barry to movie).
- We match the User
Barry
.
Finally, we return the nodes associated with the identifier movie
and we run the query.
The generated query is:MATCH (:`User` { name: $name })-[:LIKES]->(movie:`Movie`)<-[]-(:`User` { name: $name__aaaa }) RETURN movie
with the parameters:{ name: 'Jason', name__aaaa: 'Barry' }
To parse the result, we’ll use QueryRunner.getResultProperties
again. This time we want all the records, not just the first one. We’ll map each record to just its name, and then join all those names with a comma before logging them.
const commonMovieNames = QueryRunner.getResultProperties<MoviesPropertiesI>(
commonMoviesBetweenJasonAndBarryResult,
'movie',
)
.map((result) => result.name)
.join(', ');console.log(
`Common liked movies between Jason and Barry: ${commonMovieNames}`,
);
The above code logs:
Common liked movies between Jason and Barry: Interstellar, The Dark Knight
Finding the most liked movie
We’ll find which Movie is liked by most Users, how many of them like it, and their average age.
Again, we’ll use a match
parameter with a related
field.
We’ll match any User, any Movie, and the relationship between them.
We’ll return the Movie, the count of the relationships between any User and that Movie, and the average User age.
Finally, we’ll order by the total likes, descending.
const mostLikedMovieResult = await new QueryBuilder()
.match({
related: [
{
model: Users,
identifier: 'user',
},
{
...Users.getRelationshipByAlias('LikesMovie'),
identifier: 'likesMovie',
},
{
model: Movies,
identifier: 'movie',
},
],
})
.return([
'movie',
'count (likesMovie) as totalLikes',
'avg(user.age) as averageAge',
])
.orderBy([['totalLikes', 'DESC']])
.run(neogma.queryRunner);
In our related array, we first match a User while giving it an identifier. Then we use an object which spreads the return value of getRelationshipByAlias
of the Users
Model. We additionally give an identifier to the relationship match (likesMovie
). We also match a Movie and give it an identifier.
We return:
1. The movie identifier, which represents the Movie node we’re getting info about.
2. A count of the relationships between any user and that Movie. We assign the totalLikes
identifier to it.
3. The average age of the Users who like that Movie. We assign the averageAge
identifier to it.
We order by the totalLikes
identifier, descending. Then, we run the query.
The following Cypher gets generated, without any parameters:
MATCH (user:`User`)-[likesMovie:LIKES]->(movie:`Movie`) RETURN movie, count (likesMovie) as totalLikes, avg(user.age) as averageAge ORDER BY totalLikes DESC
We can get the properties of the movie
identifier with the QueryRunner.getResultProperties
function. The totalLikes
and averageAge
identifiers are number, so we’ll get their values directly from the result.
const movie = QueryRunner.getResultProperties<MoviesPropertiesI>(
mostLikedMovieResult,
'movie',
)[0];const totalLikes = mostLikedMovieResult.records[0].get('totalLikes');const averageAge = mostLikedMovieResult.records[0].get('averageAge');console.log(
`The most liked movie is ${movie.name}, as ${totalLikes} users like it! Their average age is ${averageAge}`,
);
By running it, we get the following output:
The most liked movie is Interstellar, as 3 users like it! Their average age is 28
Altogether, the entire queryData
function is the following:
And that’s all! As a reminder, you can find the complete code of this application here.
Conclusion
In this article we’ve seen Neogma in action and how it gets rid of the need of manually creating Cypher statements and assigning parameters. It uses the Model definitions for validations and to automatically use the needed data (labels, relationship information, etc.) in queries.
It also includes a powerful Query Builder, in case more custom queries are needed.
All these are performed with type-safety, making development easier thanks to the built-in Neogma types.
The simple application we created can extended to a complete application.
With adequate data, we can offer each User personalized recommendations about which movies might be of interest (i.e. with Collaborative Filtering).
Feel free to check out Neogma at GitHub, or get in contact with me for any question, recommendation etc.
Improvements
This example application can be improved in order to better reflect a real-world application. The improvements branch implements the following:
- Remove the
logger
, so the generated queries don’t flood the console. - Use a
.env
file for the connection configuration, instead of being directly in the code. - Move the query functions to Model methods and statics. The
app.ts
file shouldn’t create and run queries, but just run methods or statics of our Models. - Add an
id
property to each Model, which will also be the primary key field in its definition. - We additionally define the User-Movie relationship at the Movies Model (using the
reverseRelationshipConfiguration
static of the Users Model). That way, the relationship information can also be obtained from the Movie’s perspective. It’s used in a query. - The data populator should be a script which runs regardless of the main application. To achieve this more easily, moving the creation of the
neogma
instance to a new file is useful (init/neogma.ts
).
Reastically, the data will arrive externally, i.e. from a front-end or another back-end service.