Introduction to graph databases using Neo4j – Part 2

In part one, we have talked about Neo4j and how it differs from a traditional RDBMS. Now, I’ll show you how to build a simple Spring Boot REST service on top of Neo4j.

We’ll be developing against the same sample movie recommendation dataset as before. You’ll find it in Neo4j’s online sandbox environment.

Getting started

There is a Neo4j community Spring Data module along with a starter project to help us get going quickly. We can use the Spring Initializr to bootstrap our application. Minimum required dependencies are Spring Web and Spring Data Neo4j.

Because a good programmer is a lazy programmer, I also added Lombok to minimize boiler plate code. Everything else can be left default.

After generating the project, let’s proceed and start modelling our domain.

Domain model

Spring Data Neo4j uses the Neo4j-OGM framework under the hood. OGM is short for Object Graph Mapping. You can think of Neo4j-OGM as Hibernate for the Graph database world.

Like we’re used to with Hibernate/JPA, we start by annotating our domain entities. Let’s start with the movie entity:

@NodeEntity(label = “movie”)
public class Movie {
    @Id
    @Property(“movieId”)
    private String id;

    @Required
    private String title;

    @RelationShip(type = “IN_GENRE”, direction = RelationShip.OUTGOING)
    private List<Genre> genres;
}

 

Remember that in Neo4j, entities are represented by nodes. We use the @NodeEntity annotation to map movie nodes to objects. The label property defaults to the entity’s simple class name and in this case could’ve been omitted.

Next, we have some members, starting with the id property. The @Id annotation tells OGM that this property is the primary key of the node. Note that in Neo4j the property is actually called “movieId” rather than “id”. We therefore use the @Property annotation to map its value. The title member is required, because it will never be null.

Finally, a movie is placed in one or more genres. This relationship is defined by the @RelationShip annotation. The type represents the name of the relationship in the database, which in our case is “IN_GENRE”. From the perspective of the movie entity, the direction of the relationship is outgoing. This is the default direction and can be omitted, but I left it here for clarity of the example.

In the same way, we define the entity User:

@NodeEntity
public class User {
    @Id
    @Property(“userId”)
    private String id;

    @Required
    private String name;
}

 

When we need to store additional properties on a relationship, we model this relationship in its own class. An example of this is the MovieRating class, which represents a user’s rating of a movie:

@RelationShipEntity(type = “RATED”)
public class MovieRating {
    @StartNode
    private User user;

    @EndNode
    private Movie movie;

    @Required
    private double rating;
}

 

First, we need the @RelationShipEntity annotation to declare that this class represents a relationship, rather than a node. Again, type corresponds to the relationship’s name in our database.

Because it is impossible for Neo4j-OGM to infer the start and end node of the relation, it is mandatory to provide these using @StartNode and @EndNode.

By adding additional members, we can add as many properties to the relationship as we’d like. In this case there is only one additional property: the rating of the movie.

Adding repositories

The spring-boot-starter-data-neo4j package comes with the Neo4jRepository. It’s an extension of the PagingAndSortingRepository, which not only enables us to easily create CRUD repositories for our graph nodes, but provides us with paging and sorting capabilities as well.

Here’s a first version of our MovieRepository for looking up movies by id and searching by title:

public interface MovieRepository extends Neo4jRepository<Movie, String> {
    Optional<Movie> findById(String movieId);
    List<Movie> findByTitleContainingIgnoreCase(String title);
}

 

Neo4j repositories follow the query method conventions of Spring Data. We use these conventions to apply criteria on node and relationship properties.

However, to fully leverage the capabilities of the graph model, we need to resort to writing custom Cypher queries. We bind these to our functions using the @Query annotation. For example, this function in our MovieRepository gives us an alphabetically sorted list of all actors in a particular movie:

@Query("MATCH (:Movie { movieId: {0} })<-[:ACTED_IN]-(a:Actor) RETURN a ORDER BY a.name ASC")
List<Actor> getActors(String movieId);

 

Unfortunately, writing Cypher queries as strings is the only option we have. The OGM doesn’t come with some syntax and type checked graph criteria API.

Now that we have a first repository, it’s time to wire it up to a RestController and expose some data.

Adding REST controllers

We can connect our REST controllers like we’re used to. This is the implementation of our MovieController:

@RestController
@RequestMapping(“/movies”)
@RequiredArgsConstructor
public class MovieController {
    private final MovieRepository movieRepository;

    public ResponseEntity<Movie> getById(@PathVariable String id) {
        return movieRepository.findById(id)
                        .map(ResponseEntity::ok)
                        .orElse(ResponseEntity.notfound().build());
    }

    @GetMapping(“/{id}/actors”)
    public ResponseEntity<List<Actor>> getMovieActors(@PathVariable String id) {
        return ResponseEntity.ok(movieRepository.getActors(id));
    }
}

 

Connecting to the sandbox

The only thing left to do to run our application is connecting to the sandbox project. In the sandbox, navigate to the connection details of the project and add them to the Spring Boot application configuration:

spring:
  data:
    neo4j:
      uri: bolt://<ip>:<port>
      username: neo4j
      password: ********

 

After filling in the connection parameters, let’s launch our application and try some requests.

Requesting data

We’ll be using curl to perform some requests on our application. Those who prefer a graphical user environment might want to use the Postman API Client.

First, we are going to fetch a movie by its id. From part 1, we already know that The Matrix goes by id 2571:

curl http://localhost:8080/movies/2571
{
   "genres" : [ { "name" : “Thriller" }, { "name" : “Sci-Fi" }, { "name" : “Action" } ],
   "id" : "2571",
   "title" : "Matrix, The"
}

Now, let’s request the actors that were in it:

curl http://localhost:8080/movies/2571/actors
[
   {
      "movies" : null,
      "name" : "Carrie-Anne Moss"
   },
   {
      "movies" : null,
      "name" : "Hugo Weaving"
   },
   {
      "movies" : null,
      "name" : "Keanu Reeves"
   },
   {
      "movies" : null,
      "name" : "Laurence Fishburne"
   }
]

 

As expected, the resource returns all actors, sorted by name. But what’s up with the empty movies property? Why doesn’t neo4j-ogm populate this collection?

Let’s have another look at our Cypher query:

MATCH (:Movie { movieId: {0} })<-[:ACTED_IN]-(a:Actor)
RETURN a
ORDER BY a.name ASC

 

By returning only actors, we explicitly tell Neo4j that we’re not interested in the movie-side of the relationship. Therefore, Neo4j won’t return this information and the movies collection remains unpopulated.

To make Neo4j also return the movies actors acted in, we should change our query and return the whole path between from actor to movie:

MATCH (:Movie { movieId: {0} })<-[:ACTED_IN]-(a:Actor)-[r:ACTED_IN]->(m:Movie)
RETURN a, r, m
ORDER BY a.name ASC

 

This will return all information needed to fully populate our Actor objects, for example:

[
   {
      "movies" : [
         {
            "genres" : null,
            "id" : "78218",
            "title" : "Unthinkable"
         },
         {
            "genres" : null,
            "id" : "52458",
            "title" : "Disturbia"
         },
         {
            "genres" : null,
            "id" : "8831",
            "title" : "Suspect Zero"
         },
         {
            "genres" : null,
            "id" : "4226",
            "title" : "Memento"
         },
         {
            "genres" : null,
            "id" : "3981",
            "title" : "Red Planet"
         },
         {
            "genres" : null,
            "id" : "2571",
            "title" : "Matrix, The"
         },
         {
            "genres" : null,
            "id" : "4014",
            "title" : "Chocolat"
         }
      ],
      "name" : "Carrie-Anne Moss"
   },
   
]

 

Note that, although we now have information about the movies Carrie-Anne Moss acted in, Neo4j won’t transitively fetch movie genres. They remain null. Unlike JPA, there is no such thing as lazy collections. This is something we should be aware of.

As an attentive reader, you might have also noticed that the starting point of our query, The Matrix, is absent in the returned movie collection. This is because our query actually reads:

Given the movie “The Matrix”, return its actors along with all other movies they’ve acted in.

We have to be careful about what our query returns and be very explicit:

MATCH (m1:Movie { movieId: {0} })<-[r1:ACTED_IN]-(a:Actor)-[r2:ACTED_IN]->(m2:Movie)
RETURN a, r1, r2, m1, m2
ORDER BY a.name ASC

 

Conclusion and further reading

With spring-boot-starter-data-neo4j, it is fairly easy to develop a Spring Boot application that interfaces with Neo4j. Its Neo4jRepository is a powerful tool for developing searchable and pageable CRUD repositories with little effort.

It is quite similar to developing JPA based applications, however there are some caveats.

Unlike JPA, there is no support for lazy collections. Relations and nodes not explicitly named in our queries will rather lead to uninitialized objects. We have to be careful and not assume null references to imply non existence.

Also, Neo4j-OGM lacks a criteria API. We have to rely on string based Cypher queries and can’t rely on compile time syntax and type checking.

Finally, you can find the source code for our sample application in BitBucket.

Rens Verhage

Rens Verhage is sinds maart 2020 in dienst als Senior Software Engineer bij Profit4Cloud. Rens heeft ruim 14 jaar ervaring met Java en is OCA / OCP gecertificeerd.

Interesse in onze nieuwste blogs? Registreer dan hier

Frequent brengen wij nieuwe blogs uit. Wanneer u zich hier registreert, worden deze per mail automatisch toegestuurd.

Registreer