A training records app using Neo4J

Neo4J's graphic of what a graph-based database stores data
Neo4J’s domain model

Neo4J is possibly the most well-known graph-based databases. That means it’s not your traditional tables, columns and rows – instead, it stores things as nodes and relationships amongst them. Having watched a presentation about it at QCon London 2015, I decided to take a further look.

To make it realistic, I have decided to create a very simplified Training Records application. The target audience are small companies who might be using an Excel spreadsheet to track their employee’s training, or a small training supplier who wants to track what training their customers undertook (eg so you can invite them for a refresher course if training is about to expire).

I started with a very simple domain model – that a Person has Training.

For a person, I need to identify them uniquely enough, so I settled on capturing a name and a date of birth. I also capture an email and a mobile number, so we can contact them.

For a course, I only need to capture the course name, and a short description to provide a bit of context.

And the relationship between the person and a course is that a person has undertaken the training, and gained it on a specific day and it expires on a specific day.

This is my simple domain model in graphical form, with examples underneath.

Domain model
Domain model

I’m basing the code on my skeleton DropWizard app, and I’m using Neo4J in embedded mode rather than client-server.

I start by defining labels for Person and Course nodes. This is a way of grouping nodes together – you can query for nodes by label for instance.

public enum NodeLabel implements Label {
    PERSON,
    COURSE
}

I start by adding a DropWizard command, so I can import some simple data.

public class ImportCommand extends ConfiguredCommand {
    DateTimeFormatter inputFormat = DateTimeFormat.forPattern("dd-MM-yyyy");
 
    public Import() {
        super("import", "Wipes the database and recreates it");
    }
 
    @Override
    protected void run(Bootstrap bootstrap, Namespace namespace, TrainingRecordsConfiguration trainingRecordsConfiguration) throws Exception {
        final String dbPath = "./trainingdb";
 
        // Delete the path if it exists
        File f = new File(dbPath);
        if (f.isDirectory()) {
            FileUtils.deleteDirectory(f);
        }
 
        GraphDatabaseService graphDb = null;
        try {
            graphDb = new GraphDatabaseFactory().newEmbeddedDatabase(dbPath);
            Runtime.getRuntime().addShutdownHook(new Thread() {
                @Override
                public void run() {
                    System.out.println("Shutting down GraphDB");
                    graphDb.shutdown();
                }
            });
 
            importPeople(graphDb);
            importCourses(graphDb);
        } catch (Exception e) {
            e.printStackTrace();
        }
 
        // Close any outstanding resources
        if (graphDb != null) {
            graphDb.shutdown();
        }
    }
 
    private void importPeople(final GraphDatabaseService graphDb) {
        final String name = "Alice";
        final String dateOfBirth = "01-01-1970";
        final String email = "[email protected]";
        final String mobile = "07700 123456";
 
        // Neo4J doesn't do composite keys, so I've created my own here
        final String personRef = name + dateOfBirth;
 
        // Enforce unique personRefs
        try (Transaction tx = graphDb.beginTx()) {
            graphDb.schema().constraintFor(NodeLabel.PERSON).assertPropertyIsUnique("personRef").create();
            tx.success();
        }
 
        // Create a user
        try (Transaction tx = graphDb.beginTx()) {
            Node personNode = graphDb.createNode();
            personNode.addLabel(NodeLabel.PERSON);
            personNode.setProperty("name", name);
            personNode.setProperty("dateOfBirth", DateTime.parse(dateOfBirth, inputFormat).getMillis());
            personNode.setProperty("personRef", personRef);
            personNode.setProperty("email", email);
            personNode.setProperty("mobile", mobile);
            tx.success();
        }
    }
 
    private void importCourses(final GraphDatabaseService graphDb) {
        final String name = "First Aider";
        final String description = "First Aid certified";
 
        // Enforce unique course names
        try (Transaction tx = graphDb.beginTx()) {
            graphDb.schema().constraintFor(NodeLabel.COURSE).assertPropertyIsUnique("name").create();
            tx.success();
        }
 
        try (Transaction tx = graphDb.beginTx()) {
            Node courseNode = graphDb.createNode();
            courseNode.addLabel(NodeLabel.COURSE);
            courseNode.setProperty("name", name);
            courseNode.setProperty("description", description);
            tx.success();
        }
    }
}

public Import() {
super("import", "Wipes the database and recreates it");
}

@Override
protected void run(Bootstrap bootstrap, Namespace namespace, TrainingRecordsConfiguration trainingRecordsConfiguration) throws Exception {
final String dbPath = "./trainingdb";

// Delete the path if it exists
File f = new File(dbPath);
if (f.isDirectory()) {
FileUtils.deleteDirectory(f);
}

GraphDatabaseService graphDb = null;
try {
graphDb = new GraphDatabaseFactory().newEmbeddedDatabase(dbPath);
Runtime.getRuntime().addShutdownHook(new Thread() {
@Override
public void run() {
System.out.println("Shutting down GraphDB");
graphDb.shutdown();
}
});

importPeople(graphDb);
importCourses(graphDb);
} catch (Exception e) {
e.printStackTrace();
}

// Close any outstanding resources
if (graphDb != null) {
graphDb.shutdown();
}
}

private void importPeople(final GraphDatabaseService graphDb) {
final String name = "Alice";
final String dateOfBirth = "01-01-1970";
final String email = "[email protected]";
final String mobile = "07700 123456";

// Neo4J doesn’t do composite keys, so I’ve created my own here
final String personRef = name + dateOfBirth;

// Enforce unique personRefs
try (Transaction tx = graphDb.beginTx()) {
graphDb.schema().constraintFor(NodeLabel.PERSON).assertPropertyIsUnique("personRef").create();
tx.success();
}

// Create a user
try (Transaction tx = graphDb.beginTx()) {
Node personNode = graphDb.createNode();
personNode.addLabel(NodeLabel.PERSON);
personNode.setProperty("name", name);
personNode.setProperty("dateOfBirth", DateTime.parse(dateOfBirth, inputFormat).getMillis());
personNode.setProperty("personRef", personRef);
personNode.setProperty("email", email);
personNode.setProperty("mobile", mobile);
tx.success();
}
}

private void importCourses(final GraphDatabaseService graphDb) {
final String name = "First Aider";
final String description = "First Aid certified";

// Enforce unique course names
try (Transaction tx = graphDb.beginTx()) {
graphDb.schema().constraintFor(NodeLabel.COURSE).assertPropertyIsUnique("name").create();
tx.success();
}

try (Transaction tx = graphDb.beginTx()) {
Node courseNode = graphDb.createNode();
courseNode.addLabel(NodeLabel.COURSE);
courseNode.setProperty("name", name);
courseNode.setProperty("description", description);
tx.success();
}
}
}

I can then register this command in my application class so it becomes an available command line command (eg instead of java -jar server config.yml, I can do java -jar import config.yml to run my class).

public class TrainingRecordsApplication extends Application<TrainingRecordsConfiguration> {
 
    // ... snipped irrelevant code ...
 
    @Override
    public void initialize(Bootstrap<TrainingRecordsConfiguration> bootstrap) {
        bootstrap.addCommand(new ImportCommand());
    }
 
    // ... snipped irrelevant code ...
}

// … snipped irrelevant code …

@Override
public void initialize(Bootstrap<TrainingRecordsConfiguration> bootstrap) {
bootstrap.addCommand(new ImportCommand());
}

// … snipped irrelevant code …
}

To create a relationship between Alice and the First Aider course, you need to define the relationship types:-

public enum RelType implements RelationshipType {
    HAS
}

and you create a relationship

    private void importTraining(final GraphDatabaseService graphDb) {
        final String personRef = "Alice" + "01-01-1970";
        final String courseName = "First Aider";
        final String gained = "01-01-2015";
        final String expires = "01-01-2020";
 
        // Find the person
        Node personNode = null;
        try (Transaction ignored = graphDb.beginTx()) {
            personNode = graphDb.findNode(NodeLabel.PERSON, "personRef", personRef);
        }
        if(personNode == null) {
            throw new RuntimeException("Person is missing");
        }
 
        // Find the course
        Node courseNode = null;
        try (Transaction ignored = graphDb.beginTx()) {
            courseNode = graphDb.findNode(NodeLabel.COURSE, "name", courseName);
        }
        if(courseNode == null) {
            throw new RuntimeException("Course is missing");
        }
 
        // Create the relationship
        try (Transaction tx = graphDb.beginTx()) {
            Relationship r = personNode.createRelationshipTo(courseNode, RelType.HAS);
            r.setProperty("gained", DateTime.parse(gained, inputFormat).getMillis());
            r.setProperty("expires", DateTime.parse(expires, inputFormat).getMillis());
            tx.success();
        }
    }

// Find the person
Node personNode = null;
try (Transaction ignored = graphDb.beginTx()) {
personNode = graphDb.findNode(NodeLabel.PERSON, "personRef", personRef);
}
if(personNode == null) {
throw new RuntimeException("Person is missing");
}

// Find the course
Node courseNode = null;
try (Transaction ignored = graphDb.beginTx()) {
courseNode = graphDb.findNode(NodeLabel.COURSE, "name", courseName);
}
if(courseNode == null) {
throw new RuntimeException("Course is missing");
}

// Create the relationship
try (Transaction tx = graphDb.beginTx()) {
Relationship r = personNode.createRelationshipTo(courseNode, RelType.HAS);
r.setProperty("gained", DateTime.parse(gained, inputFormat).getMillis());
r.setProperty("expires", DateTime.parse(expires, inputFormat).getMillis());
tx.success();
}
}

Some example CQL to return all the PERSON nodes:-

MATCH (p:PERSON) return p;

or the courses

MATCH (c:COURSE) return c;

or the training records for Alice

MATCH (p:PERSON {personRef: "Alice01-011970"}) -[r:HAS]-> (c:COURSE) return p,r,c;

In Java, the equivalent code snippets are:-

    public void getPersons() {
        try (Transaction ignored = db.beginTx()) {
            ResourceIterator<Node> ri = db.findNodes(NodeLabel.PERSON);
            while (ri.hasNext()) {
                Node currentNode = ri.next();
                System.out.println("Person: " + (String) currentNode.getProperty("name"));
            }
        }
    }
 
    public void getCourses() {
        try (Transaction ignored = db.beginTx()) {
            ResourceIterator<Node> ri = db.findNodes(NodeLabel.COURSE);
            while (ri.hasNext()) {
                Node currentNode = ri.next();
                System.out.println("Course: " + (String) currentNode.getProperty("name"));
            }
        }
    }
 
    public void getPersonsTrainingRecords(final String personRef) {
        try (Transaction ignored = db.beginTx()) {
            Node person = db.findNode(NodeLabel.PERSON, "personRef", personRef);
            if (person == null) {
                throw new RuntimeException("Failed to find person");
            }
            System.out.println("Person: " + (String) person.getProperty("name"));
 
            for (Relationship singleRelationship : person.getRelationships(RelType.HAS)) {
                Node course = singleRelationship.getEndNode();
                System.out.println("Course: " + (String) course.getProperty("name"));
                DateTime gained = new DateTime(singleRelationship.getProperty("gained"));        
                System.out.println("Gained: " + DateTimeFormat.forPattern("dd-MM-yyyy").print(gained));
            }
        }
    }

public void getCourses() {
try (Transaction ignored = db.beginTx()) {
ResourceIterator<Node> ri = db.findNodes(NodeLabel.COURSE);
while (ri.hasNext()) {
Node currentNode = ri.next();
System.out.println("Course: " + (String) currentNode.getProperty("name"));
}
}
}

public void getPersonsTrainingRecords(final String personRef) {
try (Transaction ignored = db.beginTx()) {
Node person = db.findNode(NodeLabel.PERSON, "personRef", personRef);
if (person == null) {
throw new RuntimeException("Failed to find person");
}
System.out.println("Person: " + (String) person.getProperty("name"));

for (Relationship singleRelationship : person.getRelationships(RelType.HAS)) {
Node course = singleRelationship.getEndNode();
System.out.println("Course: " + (String) course.getProperty("name"));
DateTime gained = new DateTime(singleRelationship.getProperty("gained"));
System.out.println("Gained: " + DateTimeFormat.forPattern("dd-MM-yyyy").print(gained));
}
}
}

I’ve got a full working copy available for you to tinker with at https://bitbucket.org/AndrewGorton/trainingrecords – see the Readme file on how to build or run the application. It’s based on the above code, and uses DropWizard with Mustache to create simple webpages to surface the data. It also allows full import and export of data to CSV files.

Screengrab of the Training Records application
Training Records application

This is my personal blog - all views are my own.