Création de graphes Neo4j dans un script Groovy, avec sucres syntaxiques

Découvrant les bases de données de type graphe avec la base de données Neo4j, il était naturel pour moi de chercher à en découvrir l'API au moyen de scripts Groovy. Les scripts présentés dans cet article utilisent le système GRAPE qui permet de ramener toutes les dépendances dont on a besoin, et ainsi d'être assez vite au coeur de l'action.

Je vous présenterai également quelques sucres syntaxiques (en Groovy) pour augmenter la clarté du code pour la création de graphes Neo4j.

Pour commencer, voici un script Groovy s'inspirant de l'exemple Hello World du manuel de Neo4j :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
// See also http://docs.neo4j.org/chunked/stable/tutorials-java-embedded-hello-world.html

@Grab('org.neo4j:neo4j:2.0.0-M05')
import org.neo4j.graphdb.factory.GraphDatabaseFactory
import org.neo4j.graphdb.*
import org.neo4j.cypher.javacompat.ExecutionEngine


enum RelTypes implements RelationshipType
{
    KNOWS
}

def DB_PATH = "D:/Tmp/Neo4j/"

def db = new GraphDatabaseFactory().newEmbeddedDatabase(DB_PATH)

def tx = db.beginTx()
try
{
    def firstNode = db.createNode()
    firstNode.setProperty('message', 'Hello')

    def secondNode = db.createNode()
    secondNode.setProperty('message', 'World!')

    def relationship = firstNode.createRelationshipTo secondNode, RelTypes.KNOWS
    relationship.setProperty('message', 'brave Neo4j')
    
    tx.success()
}
finally
{
    tx.finish()
}

def engine = new ExecutionEngine(db)
def result = engine.execute('''\
MATCH (n)-[r:KNOWS]->(m)
RETURN n, r, m
''')

println result.dumpToString()

db.shutdown()

Ce script crée une base de données Neo4j de type embedded si elle n'existait pas auparavant dans le répertoire indiqué, ajoute deux noeuds liés par une relation au sein d'une transaction, puis enfin affiche le résultat de l'exécution d'une requête Cypher sur la base de données.

Notez que dans ce script, on ajoute une propriété message sur chacun des noeuds, ainsi que sur la relation qui les lie, avec un appel de méthode setProperty. La relation est identifiée par la valeur d'énumération RelTypes.KNOWS.
Pour la requête Cypher, qui est plutôt simple, on recherche les noeuds liés entre eux par une relation dirigée libellée "KNOWS" ; à la suite de l'exécution du script, vous devriez voir s'afficher ceci (pour la toute première exécution) :

+-----------------------------------------------------------------------------------------+
| n                        | r                                | m                         |
+-----------------------------------------------------------------------------------------+
| Node[1]{message:"Hello"} | :KNOWS[0]{message:"brave Neo4j"} | Node[2]{message:"World!"} |
+-----------------------------------------------------------------------------------------+
1 row

Le premier sucre syntaxique que nous allons appliquer à notre script est celui décrit par le billet intitulé Basic Neo4j through Groovy. L'idée est de faciliter la définition et la lecture des propriétés que l'on peut avoir aussi bien sur les noeuds que sur les relations, grâce à un zeste de méta programmation Groovy :

1
2
3
ExpandoMetaClass.enableGlobally()
PropertyContainer.metaClass.getProperty = { name -> delegate.getProperty(name) }
PropertyContainer.metaClass.setProperty = { name, val -> delegate.setProperty(name, val) }

Sans entrer dans le détail, on indique à l'interface PropertyContainer (qui est l'interface parent des interfaces Node et de Relationship) comment se définit et se lit une propriété que l'on utiliserait avec l'opérateur dot sur un objet de ce type.

Ainsi, plutôt que d'écrire node.setProperty('message', 'Hello'), on est en droit d'écrire :

1
node.message = 'Hello'

C'est comme si message était une propriété à part entière de l'objet Node.

De même, pour la lecture, au lieu d'écrire node.getProperty('message'), on peut simplement utiliser node.message.

On peut ainsi rendre le code plus lisible !

Voici à nouveau notre script, avec l'application de ce que nous venons juste de voir :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
// See also http://www.soderstrom.se/?p=81

@Grab('org.neo4j:neo4j:2.0.0-M05')
import org.neo4j.graphdb.factory.GraphDatabaseFactory
import org.neo4j.graphdb.*
import org.neo4j.cypher.javacompat.ExecutionEngine

// Syntactic sugars stuff

// For node and relationship properties
ExpandoMetaClass.enableGlobally()
PropertyContainer.metaClass.getProperty = { name -> delegate.getProperty(name) }
PropertyContainer.metaClass.setProperty = { name, val -> delegate.setProperty(name, val) }


enum RelTypes implements RelationshipType
{
    KNOWS
}

def DB_PATH = "D:/Tmp/Neo4j/"

def db = new GraphDatabaseFactory().newEmbeddedDatabase(DB_PATH)

def tx = db.beginTx()
try
{
    def firstNode = db.createNode()
    firstNode.message = 'Hello'

    def secondNode = db.createNode()
    secondNode.message = 'World!'

    def relationship = firstNode.createRelationshipTo secondNode, RelTypes.KNOWS
    relationship.message = 'brave Neo4j'
    
    tx.success()
}
finally
{
    tx.finish()
}

def engine = new ExecutionEngine(db)
def result = engine.execute('''\
MATCH (n)-[r:KNOWS]->(m)
RETURN n.message, r.message as relationship, m.message
''')

println result.dumpToString()

db.shutdown()

Dans notre script, la création d'un noeud s'effectue en deux étapes : une instance de Node est créée à partir de l'objet db, qui est de type GraphDatabaseService, puis ensuite on positionne une ou plusieurs propriétés, à raison d'une écriture par propriété. Ne serait-il pas intéressant de pouvoir le faire en une seule fois, sachant que l'interface GraphDatabaseService ne dispose pas d'une telle méthode ?

C'est là qu'intervient à nouveau la magie de la méta programmation ; ajoutons ce comportement, de cette manière :

1
2
3
4
5
6
7
GraphDatabaseService.metaClass.createNode = { Map properties ->
    def node = delegate.createNode()
    properties.each { k, v ->
        node."$k" = v
    }
    node
}

Ici, nous ajoutons une nouvelle méthode, createNode, prenant une map comme paramètre afin de passer les propriétés à ajouter à un noeud, créé en interne par la méthode createNode() sans argument. Dans le code de la nouvelle méthode, notez aussi de quelle manière nous ajoutons les propriétés en utilisant la forme node."$k" = v, où k est le nom de la propriété et v sa valeur.

Du coup, nous pouvons créer un noeud, en transmettant les différentes propriétés à lui ajouter, comme ceci :

1
def secondNode = db.createNode(message: 'World!', other: 'Salut !')

Avec ce second sucre syntaxique en action, voici la nouvelle version du script :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
@Grab('org.neo4j:neo4j:2.0.0-M05')
import org.neo4j.graphdb.factory.GraphDatabaseFactory
import org.neo4j.graphdb.*
import org.neo4j.cypher.javacompat.ExecutionEngine

// Syntactic sugars stuff

// For node and relationship properties
ExpandoMetaClass.enableGlobally()
PropertyContainer.metaClass.getProperty = { name -> delegate.getProperty(name) }
PropertyContainer.metaClass.setProperty = { name, val -> delegate.setProperty(name, val) }

// Add a new method on GraphDatabaseService interface 
GraphDatabaseService.metaClass.createNode = { Map properties ->
    def node = delegate.createNode()
    properties.each { k, v ->
        node."$k" = v
    }
    node
}


enum RelTypes implements RelationshipType
{
    KNOWS
}

def DB_PATH = "D:/Tmp/Neo4j/"

def db = new GraphDatabaseFactory().newEmbeddedDatabase(DB_PATH)

def tx = db.beginTx()
try
{
    def firstNode = db.createNode(message: 'Hello')

    def secondNode = db.createNode(message: 'World!', other: 'Salut !')

    def relationship = firstNode.createRelationshipTo secondNode, RelTypes.KNOWS
    relationship.message = 'brave Neo4j'
    
    tx.success()
}
finally
{
    tx.finish()
}

def engine = new ExecutionEngine(db)
def result = engine.execute('''\
MATCH (n)-[r:KNOWS]->(m)
RETURN n.message, r.message as relationship, m.message
''')

println result.dumpToString()

Portons maintenant notre attention sur la manière de créer une relation entre deux noeuds, pour la rendre plus expressive.

Pour cela, je vais utiliser une redéfinition de l'opérateur rightShift (>>) pour que :

1
def relationship = firstNode.createRelationshipTo secondNode, RelTypes.KNOWS

puisse aussi s'écrire :

1
def relationship = firstNode >> RelTypes.KNOWS >> secondNode

Je donne ci-après le code qui permet de réaliser cela :

1
2
3
4
5
6
7
8
9
10
11
12
class RelationshipHelper {
    Node node
    RelationshipType relationship

    def rightShift(Node _node) {
        this.node.createRelationshipTo _node, relationship 
    }
}

Node.metaClass.rightShift { RelationshipType relationship ->
    new RelationshipHelper(node: delegate, relationship: relationship)
}

Pour comprendre ce que fait ce code, réécrivons l'affectation de la variable relationship en considérant que l'utilisation de l'opérateur >> correspond à l'appel de méthode rightShift, et en tenant compte de l'ordre d'évaluation :

1
def relationship = firstNode.rightShift(RelTypes.KNOWS).rightShift(secondNode)

Le premier appel à rightShift fait intervenir le redéfinition de cet opérateur sur un type Node, et renvoie une instance de la classe uilitaire RelationshipHelper ; cette classe sert à contenir les références intermédiaires que sont le noeud de départ et la relation. Et le second appel à rightShift, qui est la redéfinition de >> mais cette fois dans la classe RelationshipHelper, finalise la création de la relation : en effet, tous les éléments sont réunis pour créer la relation à partir du noeud de départ vers le noeud cible transmis en paramètre à rightShift.

Notre script, avec l'utilisation de l'opérateur >>, s'écrit maintenant :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
@Grab('org.neo4j:neo4j:2.0.0-M05')
import org.neo4j.graphdb.factory.GraphDatabaseFactory
import org.neo4j.graphdb.*
import org.neo4j.cypher.javacompat.ExecutionEngine

// Syntactic sugars stuff

// For node and relationship properties
ExpandoMetaClass.enableGlobally()
PropertyContainer.metaClass.getProperty = { name -> delegate.getProperty(name) }
PropertyContainer.metaClass.setProperty = { name, val -> delegate.setProperty(name, val) }

// Add a new method on GraphDatabaseService interface 
GraphDatabaseService.metaClass.createNode = { Map properties ->
    def node = delegate.createNode()
    properties.each { k, v ->
        node."$k" = v
    }
    node
}

// For relationship creation

class RelationshipHelper {
    Node node
    RelationshipType relationship

    def rightShift(Node _node) {
        this.node.createRelationshipTo _node, relationship 
    }
}

Node.metaClass.rightShift { String name ->
    new RelationshipHelper(node: delegate, relationship: DynamicRelationshipType.withName(name))
}

Node.metaClass.rightShift { RelationshipType relationship ->
    new RelationshipHelper(node: delegate, relationship: relationship)
}


enum RelTypes implements RelationshipType
{
    KNOWS
}

def DB_PATH = "D:/Tmp/Neo4j/"

def db = new GraphDatabaseFactory().newEmbeddedDatabase(DB_PATH)

def tx = db.beginTx()
try
{
    def firstNode = db.createNode(message: 'Hello')

    def secondNode = db.createNode(message: 'World!', other: 'Salut !')

    def relationship = firstNode >> RelTypes.KNOWS >> secondNode
    relationship.message = 'brave Neo4j'

    def thirdNode = db.createNode(message: 'Salut !', other: 'Hola')
    firstNode >> 'KNOWS' >> thirdNode
    
    tx.success()
}
finally
{
    tx.finish()
}

def engine = new ExecutionEngine(db)
def result = engine.execute('''\
MATCH (n)-[r:KNOWS]->(m)
RETURN n.message, r.message as relationship, m.message
''')

println result.dumpToString()

db.shutdown()

Ce script crée une relation entre les noeuds firstNode et thirdNode en utilisant la chaîne de caractères 'KNOWS' ; cela est rendu possible du fait de la redéfinition de l'opérateur rightShift avec un argument de Closure de type String :

1
2
3
Node.metaClass.rightShift { String name ->
    new RelationshipHelper(node: delegate, relationship: DynamicRelationshipType.withName(name))
}

En utilisant deux redéfinitions, l'une prenant un argument de Closure de type RelationshipType et l'autre un argument de type String, on tire partie du support multi méthodes de Groovy (voir Groovy Goodness: Multiple Overloaded Operator Methods for Nice API).

Dans le cas de l'utilisation d'une chaîne de caractères pour établir une relation, l'objet relation créé est une relation dynamique nommée de type DynamicRelationshipType.

Les sucres syntaxiques que je vous ai présentés dans cet article sont juste quelques possibilités d'utilisation du langage Groovy pour augmenter la lisibilité du code dans le cas de la création de graphes Neo4j, et il est certain que l'on peut explorer d'autres pistes !