Using Neogma to build a type-safe Node.js app with a Neo4j graph database

Jason Athanasoglou
Neo4j Developer Blog
15 min readJan 11, 2021

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.

Calls “Users.createOne” with some properties. Calls “.relateTo” on the instance. Logs the age property of the instance.
A simple example of Neogma usage.

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:

A User and a Movie node (circles of different colors) and a relationship between them (an arrow from the User to the Movie).
Each node is labelled, and is represented by circle. A relationship has a name and a direction, and is represented by an arrow between the two nodes it relates. All of them belong in the same graph.

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.

  1. We need to import ModelRelatedNodesI, a helper type we’ll use. We also need to import the Movies Model and the MoviesInstance 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:

Various User and Movie nodes, and relationships between them
Users, Movies, and relationships that a User likes a Movie

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);
  1. In our related field in match we first match the User Jason.
  2. 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 the getRelationshipByAlias static of our Model Users.
    This relationship is between the node above this object (Jason) and the one below it (movie).
  3. We match a Movie and we give it the identifier movie.
  4. 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).
  5. 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.

--

--

Jason Athanasoglou
Neo4j Developer Blog

Full-stack software engineer. I love creating projects related to my interests and sharing with the community!