Présentation de Spring Data Neo4j


Dans un précédant article je vous ai présenté la base de données Neo4j ainsi que son API standard. Vous avez pu remarquer qu’il n’est pas facile d’implémenter un dao sur cette base dans vos projets. Exemple : les nœuds et les relations ne sont pas typés. Mais la communauté Spring a sorti il y a plus d’an un autre module pour son framework Spring Data. Il va nous permettre, dans le même principe que JPA, de mapper nos entités avec la base de données Neo4j.

Présentation

Le mapping des entités ce fait grâce à des annotations spécifique à Neo4j. Si on reprend l’exemple de mon précédant article nous pouvons modéliser nos utilisateur avec un simple POJO annoté :

@NodeEntity
public class User {
   @GraphId
   Long id;
   private String name;
   Set<User> friends = new HashSet<User>();
 
   // Getter - Setter
}

Ici l’annotation NodeEntity indique au framework que cette classe est une entité Neo4j. Chaque nœud doit avoir un id unique. Il est donc obligatoire, comme pour JPA d’annoter un attribut qui représentera cet id avec l’annotation @GraphId.

Grâce au template Neo4j fourni, nous allons tester l’insertion de nœud dans la base de données. Mais avant cela il faut configurer Spring pour lui indiquer ou sera stockés les fichiers de la base de données. Voici le fichier xml de configuration :

<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
      xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
      xmlns:neo4j="http://www.springframework.org/schema/data/neo4j"
      xsi:schemaLocation="http://www.springframework.org/schema/beans
       http://www.springframework.org/schema/beans/spring-beans.xsd
       http://www.springframework.org/schema/data/neo4j
       http://www.springframework.org/schema/data/neo4j/spring-neo4j-2.0.xsd">
 
   <neo4j:config storeDirectory="target/neo4j-db"/>
</beans>

Maintenant nous pouvons écrire notre premier test :

@ContextConfiguration(locations = "classpath*:spring/socialContext.xml")
@RunWith(SpringJUnit4ClassRunner.class)
public class TestEntity {
 
   @Autowired
   private Neo4jTemplate template;
   @Rollback(false)
   @BeforeTransaction
   public void clearDatabase() {
       Neo4jHelper.cleanDb(template);
   }
   @Test
   @Transactional
   public void testPersistUser() {
       User martin = template.save(new User("Martin"));
       User retrieveMartin = template.findOne(martin.getId(), User.class);
       assertEquals(martin, retrieveMartin);
   }
}

Une autre classe bien pratique qui s’appelle Neo4jHelper nous propose de supprimer le contenu de notre base de données. Dans l’exemple elle sert à supprimer le contenu de la base avant chaque transaction. D’ailleurs ces transactions sont obligatoires pour persister dans la base de données.

Pour récupérer facilement nos utilisateurs en fonction de leur nom par exemple, nous allons indexer l’attribut « name » avec l’annotation Index :

@Indexed
private String name;

Et de même voici le test :

@Test
@Transactional
public void testIndex() {
    String name = "Martin";
    User martin = template.save(new User(name));
    GraphRepository<User> userRepository = template.repositoryFor(User.class);
    User retrieveMartin = userRepository.findByPropertyValue("name", name);
    assertEquals(martin, retrieveMartin);
}

Ici on récupère un repository fourni par Spring qui propose des méthodes de type DAO pour manipuler nos entités. Et grâce à la méthode findByPropertyValue qui prend en paramètre le nom de l’attribut annoté par un index et la valeur à rechercher, nous pouvons récupérer facilement nos objets.

Maintenant nous allons mettre en place les relations entre nos utilisateurs. Elles sont représentées par une collection d’utilisateurs. Il existe deux manières de déclarer cette collection : si vous en voulez une en lecture seule alors il faut utiliser un objet de type Iterable ou sinon il faut la déclarer en tant qu’objet de type Set. Ensuite il suffit de l’annoter avec @RelatedTo qui prend en option le type de la relation, le type de la classe ciblée, et la direction. Voici l’exemple :

@RelatedTo(type = "Friend", direction = Direction.OUTGOING)
Set<User> friends = new HashSet<User>();

Et son test associé :

@Test
@Transactional
public void testRelationship() {
    User martin = template.save(new User("Martin"));
    User simon = template.save(new User("Simon"));
    User pierre = template.save(new User("Pierre"));
    martin.addFriend(simon);
    martin.addFriend(pierre);
    GraphRepository<User> userRepository = template.repositoryFor(User.class);
    User retrieveMartin = userRepository.findOne(martin.getId());
    Set<User> martinFriends = template.fetch(retrieveMartin.getFriends());
    for (User friends : martinFriends) {
        assertThat(friends, Is.is(AnyOf.anyOf(equalTo(simon), equalTo(pierre))));
    }
}

Par default, comme pour JPA, la collection n’est pas chargée lors de la récupération puisque c’est du mode Lazy. C’est pour ça qu’il faut la charger avec le template.

Le repository GraphRepository nous fournit pas mal de méthodes pour manipuler nos entités, mais nous allons l’étendre, ou plutôt le compléter en lui ajoutant d’autres méthodes. Par exemple on pourrait ajouter le service de mon précédant article, qui permettait à l’utilisateur de savoir quels amis ont au moins deux amis en commun avec lui.

Pour rajouter ce service nous allons déclarer une interface qui contiendra sa signature avec une autre méthode qui nous facilitera l’ajout d’utilisateur :

public interface MySocialRepository {
   @Transactional
   User addUser(String name);
   Iterable<User> findCommonsFriend(User user, int nbCommonsFriend);
}

Ensuite nous allons créer une autre interface qui représentera nos services personnalisés avec les services de base du framework :

public interface SocialRepository extends MySocialRepository, GraphRepository<User> {
}

Après implémentons nos services (Attention vous devez respecter le pattern nom_de_l’interface + impl au niveau du nom de votre classe sinon l’injection ne fonctionne pas):

public class SocialRepositoryImpl implements MySocialRepository {
 
   @Autowired
   private SocialRepository repository;
 
   @Override
   @Transactional
   public User addUser(String name) {
       User user = findUserByName(name);
       if (user == null) {
           user = new User(name);
           repository.save(user);
       }
       return user;
   }
 
   @Override
   public Iterable<User> findCommonsFriend(User user, int nbCommonsFriend) {
       TraversalDescription description  = Traversal.description().evaluator(new Evaluator() {
           @Override
           public Evaluation evaluate(Path path) {
               if (path.length() == 0) {
                   return Evaluation.EXCLUDE_AND_CONTINUE;
               }
               Node node = path.endNode();
               Iterable<Relationship> relationships = node.getRelationships(withName(RelationshipTypes.FRIEND), Direction.OUTGOING);
               int i = 0;
               for (Relationship relationship : relationships) {
                   i++;
               }
               if (i >= nbCommonsFriend) {
                   return Evaluation.INCLUDE_AND_CONTINUE;
               } else {
                   return Evaluation.EXCLUDE_AND_CONTINUE;
               }
           }
       }).relationships(withName(RelationshipTypes.FRIEND), Direction.OUTGOING);
       return repository.findAllByTraversal(user, description);
   }
}

La méthode findAllByTraversal prend en paramètre le nœud de départ et une description de la traversée. Dans cette description je créé un Evaluator qui indique si l’on inclut ou non le nœud traversé, et je renseigne le type et la direction des relations à traverser. Puis dans cet Evaluator je teste si le nœud qui en train d’être traversé possèdent un nombre de relations supérieures ou égales au nombre passé en paramètre de mon service. Pour finir il ne faut pas oublier d’exclure le premier nœud qui correspond au départ, plus précisément à l’utilisateur passé en paramètre de notre service.

Et pour finir voici les tests de notre repository :

@ContextConfiguration(locations = "classpath:spring/socialContext.xml")
@RunWith(SpringJUnit4ClassRunner.class)
@Transactional
public class TestRepository {
 
   @Autowired
   private SocialRepository repository;
 
   @Autowired
   private Neo4jTemplate template;
 
   @Rollback(false)
   @BeforeTransaction
   public void clearDatabase() {
       Neo4jHelper.cleanDb(template);
   }
 
   @Test
   public void testUserCreation() {
       User user = repository.save(new User("Humain"));
       User userRetrieved = repository.findOne(user.getId());
       assertEquals(user.getName(), userRetrieved.getName());
   }
   @Test
   public void findCommonsFriend() {
       repository.makeSomeUser();
       User rootUser = repository.findUserByName("Martin");
       for (User user : repository.findCommonsFriend(rootUser, 2)) {
           assertThat(user.getName(), Is.is(AnyOf.anyOf(StringContains.containsString("Matthieu"), StringContains.containsString("Romain"))));
       }
   }
}

On peut remarquer que grâce à l’Autowired notre interface SocialRepository nous fournit nos services plus ceux qui sont par default. Le framework route automatiquement la bonne implémentation en fonction de la méthode appelée.

Conclusion

Le concept des bases de données graphe n’est pas facile à prendre en main, surtout pour quelqu’un qui a l’habitude de travailler avec une API comme JPA. Mais Spring Data Neo4j simplifie grandement cette approche grâce aux annotations et aux templates. D’ailleurs vous pouvez coupler l’API JPA avec Spring Data Neo4j pour stoker une partie de vos entités dans une base de données et l’autre dans Neo4j.

Si vous voulez approfondir ce framework, je vous conseille de lire ce livre gratuit disponible à cette adresse : http://spring.neo4j.org/guide, et je vous assure qu’il y a encore plein de fonctionnalités à découvrir…

Code source

Voici le code source de l'exemple avec les dépendances Maven : Example source code


Commentaires

1. Le vendredi 6 avril 2012, 23:00 par Marc D.

Bonjour.
Exemple très sympa et abordable.
Par contre la partie repository en test unitaire ne passe pas dans mon cas.
"Unable to lock store C:\...\target\neo4j-db\neostore, this is usually a result of some other Neo4j kernel running using the same store."

Si quelqu'un trouve la solution, qu'il la poste en commentaire pour le bonheur de tous :)
Merci encore.

Fil des commentaires de ce billet

Ajouter un commentaire

Le code HTML est affiché comme du texte et les adresses web sont automatiquement transformées.