A training records app using Neo4J
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.
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
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.