Neo4j

De afgelopen jaren zagen we de productie van data steeds meer toenemen. Elk jaar produceerden we weer een beetje meer data dan het jaar ervoor. Deze stijging in data zorgde voor problemen in de databases, die de data moesten opleveren en oplossingen hiervoor rezen de pan uit. Onder de noemer NoSQL (Not only SQL) ontwikkelden technologieën als Hadoop, MongoDB, Cassandra en Neo4j zich snel. Waarin onderscheidt Neo4j zich en hoe kan je het gebruiken? In dit artikel zullen we dat verder uitleggen.

Wat is Neo4j?

Neo4j is een graph database. Dat wil zeggen een database waarin je de gegevens opslaat in een graph model. De data slaan we dan op in Nodes in plaats van in tabellen. Deze Nodes worden met elkaar verbonden door middel van relaties. Relaties worden opgeslagen als pointers naar Nodes, waardoor het erg efficiënt is om deze relaties te behandelen, ook wel traversen genoemd. Het is het onderscheidende onderdeel ten opzichte van andere NoSQL oplossingen of relationele databases.

 

Node en Relationship

Neo4j kent een aantal elementen waarmee je data kunt opslaan en structureren. In het afbeelding 1 zie je deze weergegeven.

De cirkels in afbeelding 1 noemen we nodes. Deze representeren "dingen". Je kunt nodes goed vergelijken met Classes in Java. De meeste voorbeelden uit object-georiënteerd modelleren komen sterk overeen met Graph-modeling. Een node kan één of meerdere labels bevatten om deze te typeren. Dit kan helpen om structuur te geven aan je database. Om de relaties tussen de nodes aan te geven, hebben ze relationships gekregen. Relationships zijn altijd getypeerd en hebben een richting. Zowel nodes als relationships kunnen properties bevatten om data in op te slaan. Alle standaard Java-primitieven kunnen opgeslagen worden. De key is altijd een String. Vergelijk het dus met een soort HashMap.

Om uit te leggen hoe je eenvoudig de eerste stappen van modelleren leert van een graph database, is het handig om de vergelijking te maken met de relationele database. In afbeelding 2 zie je aan de linkerkant een tabel met klanten.

In het rood zie je het record van een klant: Alice. Aan de rechterkant zie je een tabel met bankrekeningen en in het blauw zie je de records die de drie rekeningen van Alice representeren. Om deze twee tabellen aan elkaar te verbinden is er een koppeltabel nodig om te ondersteunen dat mensen meerdere rekeningen hebben en dat rekeningen eigendom kunnen zijn van meerdere mensen. Daarom zie je in de middelste tabel in het geel de records met daarin de koppelingen tussen Alice en haar rekeningen.

 

In afbeelding 3 zie je hoe deze structuur er uit ziet in een graph. De tabellen zijn verdwenen. Een graph kent namelijk geen tabellen. Alle records in het rood en blauw zijn nodes geworden in de database. De koppeltabel is omgezet naar relaties, die de twee typen nodes met elkaar verbinden.

Het WK voetbal

Op het moment van schrijven is het WK voetbal volop in gang. Nederland staat in de halve finale, dus reden genoeg om het WK als onderwerp te nemen om te laten zien hoe je in Neo4j een model maakt, hoe je hier data in opslaat en hoe je het vervolgens kunt opvragen.

Om met de voorbeelden uit dit artikel mee te doen, ga je naar neo4j.com en download en start je Neo4j. Na het starten kun je met je browser naar http://localhost:7474. Via deze webconsole kun je eenvoudig cypher queries uitvoeren.

Afbeelding 4 laat zien welk model we gaan gebruiken voor deze data.

Centraal staan de wedstrijden (Match) en daaraan gekoppeld zijn de landen die daarin spelen (Country). De wedstrijd is eveneens gekoppeld aan een editie van het WK, zodat we informatie kunnen opslaan over meerdere kampioenschappen.

 

ASCII art

Van relationele databases kennen we SQL. Neo4j heeft een vergelijkbare taal: Cypher. Met Cypher kun je net zoals SQL data invoegen, updaten en verwijderen. De kracht van Cypher zit echter vooral in het feit dat je definieert wát je wilt queryen in plaats van hóe je dat wilt queryen. Het is daarmee een erg expressieve taal. Één van de belangrijkste onderdelen van Cypher is pattern matching.

De notatie van deze patterns komt sterk overeen met de plaatjes, die je kunt tekenen van een graph. Daarom refereren we er ook vaak aan als ASCII art. In afbeelding 5 zie je bijvoorbeeld een Match node met een relatie van het type "HOME_TEAM" naar een Country node.

Als we hier een match pattern van willen maken met ASCII-karakters, dan ziet dat er zo uit:


(:Match)-[:HOME_TEAM]→(:Country)

 

Deze ASCII art heeft een aantal simpele tekenregels:

  • met () tekenen we een node
  • een relationship tekenen we met een -→
  • als we binnen een relationship properties of types willen definiëren, gebruiken we een [] binnen de pijl van de relationship
  • om het type van een relationship of het label van een node aan te geven, gebruiken we een :. :Match uit het voorbeeld geeft dus aan dat we een node willen vinden met het label „Match"

 

Creëren van Nodes

Nu we weten hoe we eenvoudige patronen kunnen beschrijven, kunnen we deze ook gebruiken om data aan te maken in de database. Laten we nu eens proberen om een node aan te maken voor het model waar we mee willen werken. Een node voor Nederland, zodat we daar later wedstrijden aan kunnen koppelen.

 


CREATE (n:Country {name: "Netherlands"}) RETURN n
+-----------------------------+
| n                           |
+-----------------------------+
| Node[9]{name:"Netherlands"} |
+-----------------------------+

 

Hiermee hebben we een node gemaakt met het label :Country. Binnen de {} kunnen we een lijst van properties opgeven. Een alternatieve query is:

 


MERGE (n:Country {name: "Netherlands"}) RETURN n

 

Het verschil tussen beiden is dat bij MERGE een nieuwe node wordt gecreëerd als deze nog niet aanwezig is in de database. Is de node al wel aanwezig, dan retourneren we die.

Laten we nu eens kijken hoe we een wedstrijd kunnen koppelen aan deze Nederland node. In 1 statement kan je meerdere nodes en relaties aanmaken:

 


MERGE (netherlands:Country {name: "Netherlands"})
MERGE (spain:Country {name: "Spain"})
MERGE (m:Match {h_score: 1, a_score: 5})
MERGE (spain)<-[:HOME_TEAM]-(m)-[:AWAY_TEAM]->(netherlands)
RETURN m,netherlands

 

Omdat we in deze query MERGE gebruiken, zal er geen nieuwe node aangemaakt worden voor Nederland. We koppelen de nieuwe :Match node met een relatie van het type AWAY_TEAM aan Nederland.

Nu we weten hoe we nodes en relaties kunnen aanmaken, kunnen we deze kennis gebruiken om de hele dataset aan te maken. Aan het einde van dit artikel staat een link waar alle code uit dit artikel te vinden is, inclusief het laden van de hele dataset.

 

Queries

Nu we geleerd hebben hoe we nodes en relaties kunnen aanmaken is het tijd om informatie uit de graph te halen. Om eenvoudig te beginnen: tegen welke landen heeft Nederland gespeeld tijdens WK’s?

De eerste stap hiervoor is het nadenken over het patroon dat we hiervoor nodig hebben. We starten met de Node voor Nederland (netherlands:Country {name: "Netherlands"}). Vervolgens koppelen we deze aan een node met het Label :Match en deze koppelen we aan een Node met het label :Country.

De gehele query ziet er dan als volgt uit:

 


MATCH (netherlands:Country {name: "Netherlands"})--(match:Match)--(country:Country) return netherlands,match,country

Dit ziet er natuurlijk erg mooi uit als een plaatje. Als je het echter wilt gebruiken in een applicatie heb je natuurlijk de data zelf nodig. Bijvoorbeeld door in de return alleen de landnamen te returnen.

 


MATCH (netherlands:Country {name: "Netherlands"})--(match:Match)--(country:Country)
return country.name as opponent
order by opponent
limit 10
+-------------+
| opponent    |
+-------------+
| "Argentina" |
| "Argentina" |
| "Argentina" |
| "Argentina" |
| "Austria"   |
| "Belgium"   |
| "Belgium"   |
| "Brazil"    |
| "Brazil"    |
| "Brazil"    |
+-------------+

 

Dit geeft ons een lijst van alle landen. Hier zitten dubbelen in, doordat we tegen sommige landen meerdere wedstrijden hebben gespeeld. Laten we daarom eens kijken hoeveel wedstrijden we tegen elk land hebben gespeeld? In Cypher werkt het groeperen van data iets eenvoudiger dan in SQL. We hoeven namelijk niet meer te definiëren hoe we willen groeperen om vervolgens een count te kunnen doen. We kunnen de query daarom als volgt schrijven.


MATCH (netherlands:Country {name: "Netherlands"})--(match:Match)--(country:Country)
return country.name as opponent, count(’atch) as cnt
order by cnt desc
limit 10
+-----------------------------+
| opponent              | cnt |
+-----------------------------+
| "Brazil"              | 4   |
| "Argentina"           | 4   |
| "Germany FR"          | 3   |
| "Belgium"             | 2   |
| "Uruguay"             | 2   |
| "Republic of Ireland" | 2   |
| "Saudi Arabia"        | 1   |
| "Egypt"               | 1   |
| "Austria"             | 1   |
| "Switzerland"         | 1   |
+-----------------------------+

 

Naast het draaien van Cypher statementes in de webconsole kun je deze natuurlijk ook opnemen in je applicaties. Het bovenstaande Cypher statement kunnen we natuurlijk ook draaien vanuit Java.

 


GraphDatabaseService db = new GraphDatabaseFactory().newEmbeddedDatabase("/tmp/testGraph"); ExecutionEngine engine = new ExecutionEngine(db);
try (Transaction tx = db.beginTx();) {
  HashMap<String,Object> params = new HashMap<String,Object>();
  params.put("countryName", "Netherlands");
  String query = "MATCH (netherlands:Country {name: {countryName}})—
  (match:Match)--(country:Country)\n" +                  "return
  country.name as opponent, count(match) as cnt\n" +                 
  "order by cnt desc\n" +
  "limit 100";
  ExecutionResult result = engine.execute(query, params);
  for (Map<String, Object> row : result) {
    System.out.println(row.get("opponent") + ": " +
    row.get("cnt"));
  }
}

 

Op de eerste regel maken we een nieuwe EmbeddedDatabase aan, het aangeven van een locatie waar de database staat is voldoende. Vervolgens kunnen we met de ExecutionEngine de query uitvoeren. Hierin kun je een lijst met parameters meegeven die je kunt gebruiken in de query.

 

In welke wedstrijd zijn de meeste doelpunten gescoord? En wanneer was dat?

Hiervoor moeten we kijken naar de Nodes met het label :Match. Via de WorldCup kunnen we bij het Year komen. Als dit een relationele database was geweest, hadden we hier 2 join operaties moeten doen. In Cypher is dit een eenvoudige one-liner. In de return definiëren we het totaal als de optelling van uit- en thuisdoelpunten en we geven een limiet op van 1.


match (m:Match)<-[:CONTAINS_MATCH]-(:WorldCup)-[:IN_YEAR]->(y:Year)
return y.year, m.description, m.h_score, m.a_score,
toInt(m.h_score) + toInt(m.a_score) as total
order by total desc
limit 1
+--------------------------------------------------------------------+
| y.year | m.description             | m.h_score | m.a_score | total |
+--------------------------------------------------------------------+
| 1954   | "Austria vs. Switzerland" | "7"       | "5"       | 12    |
+--------------------------------------------------------------------+

 

Welke landen wonnen de wereldbeker in eigen land?

Laten we het iets ingewikkelder maken. Voor deze vraag moeten we kijken naar de WorldCup en het land waar deze gespeeld is. Vervolgens willen we de wedstrijden zien die dit land gespeeld heeft in het dezelfde WK. Door in het match patern gebruik te maken van dezelfde alias voor de host en het land van de wedstrijd, dwingen we af dat deze hetzelfde zijn. De match voor deze query bestaat nog uit een tweede deel. Na de komma definiëren we dat we alleen de finale wedstrijden willen hebben.

In de where clause controleren we aan de hand van het type van de relatie of we de thuis- of uitspelende ploeg zijn en vergelijken de bijbehorende score.

De gehele query ziet er dan als volgt uit.

 


match (host:Country)<-[mr]-(m)<-[:CONTAINS_MATCH]-(wc:WorldCup)-[:HOSTED_BY]-(host), (m)--(:Phase {name: 'Final'})
where (type(mr) = 'HOME_TEAM' AND m.h_score > m.a_score)
OR (type(mr) = 'AWAY_TEAM' AND m.a_score > m.h_score)
return host.name,wc.name order by wc.name
+-------------------------------------------------+
| host.name   | wc.name                           |
+-------------------------------------------------+
| "Uruguay"   | "1930 FIFA World Cup Uruguay ™"   |
| "Italy"     | "1934 FIFA World Cup Italy ™"     |
| "England"   | "1966 FIFA World Cup England ™"   |
| "Argentina" | "1978 FIFA World Cup Argentina ™" |
| "France"    | "1998 FIFA World Cup France ™"    |
+-------------------------------------------------+

 

Welke uitslag willen we liever veranderen?

Er zijn van die uitslagen die we liever anders zouden zien. Ik herinner me de teen van Casillas nog van de finale tegen Spanje in 2010. Laten we voor deze oefening eens kijken hoe we een update doen in de database. Wat nou als die teen er niet was en het 1-1 was geworden?

 


match (netherlands:Country {name: "Netherlands"})<--(m:Match)
-[:IN_PHASE]->(:Phase {name: 'Final'}),
(m)-->(spain:Country {name: "Spain"})
set m.h_score = 1
return m.description, m.h_score, m.a_score
+-------------------------------------------------+
| m.description           | m.h_score | m.a_score |
+-------------------------------------------------+
| "Netherlands vs. Spain" | 1         | "1"       |
+-------------------------------------------------+

 

Tot slot, voor de mensen die deze wedstrijd liever helemaal vergeten, kunnen we hem natuurlijk ook verwijderen uit de database. Hiervoor moet je binnen Neo4j wel iets meer doen dan alleen de node verwijderen. Relationships hebben een verplicht start- en eindpunt. Alleen de node verwijderen gaat dus niet lukken, want we moeten dan ook alle relationships verwijderen. Gelukkig kan dit in één statement.


match (m:Match {description: "Netherlands vs. Spain"})-[relation]-()
delete m,relation