image/svg+xml $ $ ing$ ing$ ces$ ces$ Res Res ea ea Res->ea ou ou Res->ou r r ea->r ch ch ea->ch r->ces$ r->ch ch->$ ch->ing$ T T T->ea ou->r

Object-relational mapping (ORM)

Présentation d'Hibernate

Deux manières de configurer le mapping entre objets et tables de BDD existent avec Hibernate :

Classe Bean entité

Avec JPA, on manipule des classes de type bean entité : ces classes suivent un certain nombre de contraintes afin d'être facilement introspectables et que leurs objets puissent être plus facilement être rendus persistants.

Contraintes imposées sur une classe bean entité :

Création rapide d'un Bean entité sous Eclipse

La plupart des IDE permettent une création rapide d'un JavaBean entité :

  1. Tout d'abord, on créé la classe avec le nom souhaité (qui peut hériter de Objet ou d'une autre classe) ; la classe implante l'interface marqueur Serializable pour indiquer que ses instances peut être rendue persistantes
  2. On ajoute tous les champs de la classe en visibilité private
  3. On génère automatiquement les getters et setters pour tous les champs (sous Eclipse : menu Source/Generate Getters and Setters)
  4. On créé automatiquement les méthodes equals et hashCode basées sur les champs de la classe (sous Eclipse : menu source/Generate hashCode() and equals())

Exemple : une page de wiki

Une page de wiki possède un titre (String), un contenu (String), une date de modification (Date), des étiquettes qui sont des mots-clé pour indexer la page ainsi qu'un ensemble de liens vers d'autres pages de wiki. Voici comment on pourrait écrire le bean entité correspondant :

@Entity
public class WikiPage implements Serializable
{
	private String title;
	private String content;
	private Date modificationDate;
	private Set<String> tags = new HashSet<>();
	private Set<WikiPage> links = new HashSet<>();
	
	/** Optional, should be implicitely autogenerated by the compiler */
	public WikiPage()
	{
		super();
	}
	
	public String getTitle() { return title; }
	public void setTitle(String title) { this.title = title; }
	
	public String getContent() { return content; }
	public void setContent(String content) { this.content = content; }
	
	public Date getModificationDate() { return modificationDate; }
	public void setModificationDate(Date date) { this.modificationDate = modificationDate; }
	
	public Set<String> getTags() { return tags; }
	public void setTags(Set<String> tags) { this.tags = tags; }
	
	public Set<WikiPage> getLinks() { return links; }
	public void setLinks(Set<WikiPage> links) { this.links = links; }
	
	public int hashCode()
	{
		return title.hashCode() 
			+ content.hashCode() 
			+ modificationDate.hashCode()
			+ tags.hashCode();
	}
	
	public boolean equals(Object o)
	{
		if (o == null || ! getClass().equals(o.getClass())) return false;
		WikiPage wp = (WikiPage)o;
		return wp.title.equals(o.title) 
			&& wp.content.equals(o.content) 
			&& wp.modificationDate.equals(o.modificationDate)
			&& wp.tags.equals(o.tags);
	}
}

Installation et configuration d'Hibernate

Bibliothèques pour Hibernate

Des versions compilées d'Hibernate peuvent être récupérées sous la forme de fichiers jar. Ces fichiers doivent être référencés pour les projets utilisant Hibernate (présents dans le classpath d'exécution ou indiqués auprès de l'environnement de développement).
Hibernate est basé sur l'API JPA2 qui doit être également présente ainsi que la bibliothèque de logging SLF4J.

L'installation d'Hibernate peut également être facilitée en utilisant un dépôt Maven.

Configuration d'Hibernate

Pour fonctionner, Hibernate doit se connecter à une base de données. Il faut donc lui indiquer les coordonnées de cette base avec les informations d'authentification. Cette tâche est réalisée par l'intermédiaire d'un fichier de configuration nommé persistence.xml placé dans un répertoire META-INF/ accessible depuis le classpath :

<persistence xmlns="http://java.sun.com/xml/ns/persistence"
             xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
             xsi:schemaLocation="http://java.sun.com/xml/ns/persistence http://java.sun.com/xml/ns/persistence/persistence_2_0.xsd"
             version="2.0">

    <persistence-unit name="sampleconfig">
        <provider>org.hibernate.ejb.HibernatePersistence</provider>
        <class>org.sample.entities.Entity</class>

        <properties>
            <property name="dialect" value="org.hibernate.dialect.SQLiteDialect" />
            <property name="javax.persistence.jdbc.driver" value="org.sqlite.JDBC" />
            <property name="javax.persistence.jdbc.url" value="jdbc:sqlite:/path/to/file.db" />
            <property name="javax.persistence.jdbc.user" value="" />
            <property name="javax.persistence.jdbc.password" value="" />
            <property name="hibernate.show_sql" value="true" />
            <property name="format_sql" value="true" />
            <property name="hibernate.connection.charSet" value="UTF-8" />
            <property name="hibernate.hbm2ddl.auto" value="create" />
        </properties>
    </persistence-unit>
</persistence>

On configure ainsi les différentes propriétés de la EntityManager chargée d'ouvrir une session Hibernate. D'autres propriétés existent pour un contrôle plus fin des EntityManager (on pourra consulter la documentation à cet effet).

Parmi les propriétés utilisée, hibernate.hbdm2ddl.auto avec pour valeur fixée à create permet de créer automatiquement les tables de la base de données selon la définition des classes que nous utilisons (ce qui est particulièrement pratique).

Obtention d'un EntityManager

Avant de pouvoir manipuler des objets persistants avec Hibernate, il faut ouvrir un EntityManager. Pour cela, il est usuel de créer une classe HibernateUtils.java utilisant un pattern singleton chargée de ce travail :

public class HibernateUtil {
	
	private static final EntityManagerFactory emFactory;
	
	static {
		try {
			emFactory = Persistence.createEntityManagerFactory("sampleconfg");
			
		} catch (Throwable ex) {
			System.err.println("Initial SessionFactory creation failed." + ex);
			throw new ExceptionInInitializerError(ex);
		}
	}

	public static EntityManagerFactory getEntityManagerFactory() {
		return emFactory;
	}
}

Ainsi lorsque l'on a besoin d'un EntityManager, nous pouvons le construire en utilisant la factory :
EntityManager em = entityManagerFactory.createEntityManager();

Notons qu'il est possible de gérer plusieurs configurations simultanément et donc d'obtenir différentes factory de EntityManager selon chacune de ces configurations (ici nous utilisons la configuration sampleconfig).

L'EntityManager est utilisé pour réaliser toutes les opérations de persistance sur les entités (sauvegarde, mise à jour et récupération d'entités sauvées).

Un Enterprise JavaBean peut également utiliser le système d'injection de ressources du conteneur pour obtenir un EntityManager :

@Stateless
public class MyEJB
{
	@PersistenceContext(unitName="sampleconfig") 
	protected EntityManager entityManager;
	
	...
}

Annotations avec JPA

Annotation de classe JavaBean

Nous annotons chaque classe JavaBean :

Annotation de champs

Chaque champ peut être annoté pour indiquer des propriétés particulières le concernant :

Relations entre entités

Un champ peut référencer un objet d'une entité externe voire une collection d'objets d'entité externe. Il est utile dans cette situation de réaliser des jointures de table. Il faut annoter le champ référençant un objet ou une collection d'objets afin d'indiquer le type de relation. Nous passons en revue les trois types de relations : OneToOne, ManyToOne (ou OneToMany dans le sens inverse) et ManyToMany.

Dans une association, deux classes sont impliquées, l'une d'elle est considérée comme propriétaire de la relation : c'est sa table qui pourra contenir une colonne considérée comme clé étrangère vers l'autre entité (il est inutile de dupliquer des données définissant la relation dans l'autre table impliquée).

Relation OneToOne

La relation OneToOne est une relation bijective associant une entité à une autre. Prenons l'exemple d'un utilisateur d'un site web que l'on associerait avec des informations de contact.

@Entity
public class User implements Serializable
{
	@Id
	@GeneratedValue
	private long id;
	
	@Column(nullable=false)
	private String username;
	
	@OneToOne(cascade={CascadeType.ALL})
    @JoinColumn(name="contact_id", referencedColumnName="id")
	private ContactDetails contact;
	
	// Do not forget to generate the getters and setters
}

@Entity
public class ContactDetails implements Serializable
{
	@Id
	@GeneratedValue
	private long id;
	
	@Column(nullable=false)
	private String email;
	
	@Column(nullable=false)
	private String phone;
	
	// Do not forget to generate the getters and setters
}

On remarque la présence de la propriété cascade={CascadeType.ALL} pour l'annotation OneToOne. Cela détermine le comportement à adopter lorsqu'un utilisateur est ajouté avec un contact non présent dans la base : le contact est automatiquement sauvegardé. De même si un utilisateur est supprimé, son contact lié est supprimé automatiquement. D'autres types de cascade peuvent être employés si l'on souhaite ne réagir que pour la sauvegarde (CascadeType.PERSIST) ou la suppression (CascadeType.REMOVE).

Notons que nous avons réalisé une relation unidirectionnelle entre User et ContactDetails. Il aurait été également possible de rajouter un champ à ContactDetails afin de récupérer le User auquel il est associé. Dans cette situation, nous aurions rajouté à ContactDetails :

@OneToOne(mappedBy="contact")
private User user;

Objet embarqué
Eclatement sur plusieurs tables

Ce concept peut être considéré comme le contraire de celui d'objet embarqué. Il s'agit de distribuer les colonnes d'un objet sur plusieurs tables. Pour cela :

@Entity
@SecondaryTables ({
	@SecondaryTable (name = "ElectronicAddress"),
	@SecondaryTable (name = "PostalAddress")
public class ContactDetails implements Serializable
{
	@Id
	@GeneratedValue
	private long id;
	
	@Column(nullable=false, table="electronic_address")
	private String email;
	
	@Column(nullable=true)
	private String phone;
	
	@Column(nullable=false, table="postal_address")
	private String streetNumber;
	
	@Column(nullable=false, table="postal_address")
	private String street;
	
	@Column(nullable=true, table="postal_address")
	private String zipCode;
	
	@Column(nullable=false, table="postal_address")
	private String city;
	
	@Column(nullable=false, table="postal_address")
	@Enumerated(EnumType.STRING)
	private Country country;
}

Ici, les objets sont distribués sur trois tables :

  1. La table principale ContactDetails (qui ne contient que le numéro de téléphone)
  2. La table secondaire ElectronicAddress contenant l'adresse email
  3. La table secondaire PostalAddress avec les colonnes de l'adresse postale

On notera que le country est de type enum ; on pourra ainsi déclarer le type Country sous cette forme :

enum Country {
	AFGHANISTAN,
	ALBANIA,
	ALGERIA,
	...;
}

L'utilisation d'un enum présente l'inconvénient de nécessiter une recompilation du code pour intégrer un nouveau pays. L'annotation @Enumerated(EnumType.STRING) indique que le pays est stocké dans la colonne sous la forme du nom de l'enum. L'utilisation @Enumerated(EnumType.ORDINAL) est à proscrire car utilisant l'ordre des valeurs de l'enum (qui peut changer si un nouvel élément est inséré).

Relation OneToMany

La relation OneToMany permet de lier une entité à plusieurs autres entités.

En reprenant l'exemple précédent, nous pourrions désormais considérer qu'un utilisateur peut posséder plusieurs détails de contact au cours du temps. Nous pouvons donc réécrire ContactDetails en ajoutant un champ spécifiant la date de création des informations de contact.

@Entity
public class ContactDetails implements Serializable
{
	...
	
	@Column(nullable=false)
	private Date creationDate;
}

La classe User doit être modifiée pour intégrer la relation OneToMany :

@Entity
public class User implements Serializable
{
	@OneToMany(cascade={CascadeType.ALL})
    @OrderBy("id")
	private List<ContactDetails> contacts = new ArrayList<>();
}

Cela permet d'obtenir la liste des contacts liés à utilisateur triés par identificateur ; étant donné que les identificateurs sont attribués séquentiellement, cela équivaut à un tri chronologique des adresses de contact. La mise en oeuvre d'une relation OneToMany utilise par défaut une nouvelle table pour réaliser la jointure : ici il s'agirait d'une table nommée user_contact_details avec deux colonnes : user_id et contact_details_id. Ce comportement par défaut est modifiable en utilisant les annotations appropriées.

Relation ManyToMany

Le relation ManyToMany permet de lier de représenter des relations de type n..n où des entités peuvent être liées à plusieurs autres entités et vice-versa. Pour l'exemple précédent, même si ce n'est pas particulièrement pertinent, on pourrait imaginer qu'un utilisateur possède plusieurs informations de contact et qu'une information de contact puisse être partagé entre plusieurs utilisateurs.

Un exemple plus réaliste serait celui de livres liés à des auteurs : un livre peut être co-écrit par plusieurs auteurs et un auteur peut écrire plusieurs livres. On se retrouverait ainsi avec le modèle suivant :

@Entity
public class Book implements Serializable
{
	@Id @GeneratedKey
	private long id;
	
	@Column("title", nullable=false)
	private String title;
	
	@ManyToMany(cascade={CascadeType.ALL})
	@OrderBy("name")
	private List<Author> authors = new ArrayList<>();
	
	// do not forget getters and setters...
}

@Entity
public class Author implements Serialiazable
{
	@Id @GeneratedKey
	private long id;
	
	@ManyToMany(mappedBy="authors")
	private Set<Book> books = new HashSet<>();
	
	// do not forget getters and setters
}

La relation est ici bidirectionnelle : nous pouvons donc interroger un livre pour connaître ses auteurs et nous pouvons également à partir d'un auteur connaître tous les livres qu'il a écrits.

Stratégie de récupération

Lorsqu'une relation est définie, deux stratégies de récupération peuvent être utilisées ; la stratégie est précisée dans l'annotation @{One,Many}To{One,Many} avec la propriété fetch qui peut prendre les valeurs :

Validation des beans entité

Contrainte (annotation) Explication
@AssertFalse, @AssertTrue Indique qu'un champ booléen doit toujours être faux ou vrai (cela rend plus ou moins le champ inutile)
@Min(0), @Min(100) Permet d'indiquer une valeur minimale ou maximale pour un nombre entier (existe aussi en version @DecimalMin et @DecimalMax pour les décimaux)
@Past, @Future Pour un champ de type Date indique que sa valeur doit être dans le passé ou le futur
@Null, @NotNull Indique que le champ doit être nul ou non-nul
@Pattern(regexp="0[1-9][0-9]{8}") Permet de vérifier que le champ (un String) respecte l'expression régulière (ici un numéro de téléphone français)
@Size(min=10, max=20) Permet d'indiquer des bornes pour la taille d'un String, d'une Collection ou d'une Map
@Entity
public class Address
{
	@Pattern(regexp="[0-9]{5]", message="The supplied French zip code is not valid")
	private String zip;
}

Il est plus adéquat d'indiquer des clés de ResourceBundle dans le message : par défaut les valeurs sont récupérées dans le fichier ValidationMessage.properties à la racine du projet. On peut ainsi indiquer @Pattern(@Pattern(regexp="[0-9]{5}", message="{error.invalidFrenchZipCode}") et ajouter l'entrée error.invalidFrenchZipCode = The supplied French zip code is not valid dans ValidationMessage.properties. Ceci permet d'internationaliser les message en proposant différentes versions du fichier (ValidationMessage_fr.properties pour des messages en français...).

Une annotation peut concerner un champ mais également une méthode. Cela peut être utile notamment pour intégrer une méthode de validation directement dans le bean. Avec l'exemple suivant, nous testons la concordance entre le code postal et la ville :

@Entity
public class Address
{
	@Pattern(regexp="[0-9]{5}", message="{error.invalidFrenchZipCode}")
	private String zip;
	
	@NotNull
	private String city;
	
	@AssertTrue
	public boolean isZipMatchingCity()
	{
		List<String> cities = CityDatabase.getCities(zip);
		return cities.contains(city));
	}

Exemple : page de Wiki

Nous annotons ici l'entité représentant une page de wiki. Nous notons que nous utilisons une relation ManyToMany récursive pour représenter les liens entre différentes pages. L'annotation @ElementCollection permet de représenter ici une relation ManyToMany sur un type élémentaire String (nous n'avons pas besoin de créer une entité pour String) : une table spécifique sera créé avec une colonne spécifiant wiki_page_id et une autre colonne avec le tag.

@Entity
public class WikiPage implements Serializable
{
	@Id @GeneratedKey
	private long id;
	
	@Column(name="title", nullable=false, length=64)
	private String title;
	
	@Column(nullable=false)
	private String content;
	
	@Column(nullable=false)
	private Date modificationDate;
	
	@ElementCollection
	@Column(name="tags")
	private Set<String> tags = new HashSet<>();
	
	@ManyToMany
	private Set<WikiPage> links;
	
	@ManyToMany(mappedBy="links")
	private Set<WikiPage> reverseLinks = new HashSet<>();
	
	// do not forget getters and setters
}

☞ Attention, mettre une longueur maximale de titre à 64 n'est pas toujours une bonne idée pour le titre d'une page de wiki.

Héritage entre classes

Il est possible aussi d'intégrer les champs d'une classe ancêtre dans une entité qui en hérite sans que la classe ancêtre soit-elle même une entité. La classe ancêtre est en quelque sorte embarquée dans la classe dérivée. On utilise pour cela l'annotation @MappedSuperclass sur la classe ancêtre.

Manipulation d'objets : requête, ajout, mise à jour et suppression

Nous pouvons manipuler des objets avec Hibernate typiquement en utilisant ce squelette de code :

EntityManager em = HibernateUtil.getEntityManagerFactory().createEntityManager();
Transaction transaction = null;
try {
	transaction = em.getTransaction();
	...
	transaction.commit();
} catch (HibernateException e) {
	transaction.rollback();
	e.printStackTrace();
} finally {
	em.close();
}

Nous devons obtenir une EntityManager à l'aide de l'EntityManagerFactory puis commencer une transaction. Une transaction est appliquée atomiquement selon un mode tout ou rien : les actions réalisées sont toutes appliquées (opération commit) ou alors toutes annulées (opération rollback). Si une exception survient, il est plus prudent de tout annuler en demandant un rollback.

Les opérations rélisées peuvent être de différentes natures.

Modification

Le classe EntityManager possède des méthodes permettant de sauvegarder ou modifier des entités :

En cas de problème (violation de contraintes lors de l'insertion d'un enregistrement dans une table notamment) une exception pourra être levée.

Il est possible d'implanter des listeners appelés lors d'événements de persistance de l'entité. Les listeners sont des méthodes de l'entité annotées par :

Les listeners sont utiles pour vérifier des contraintes sur les entités (respect de certains critères pour les champs), pour journaliser des informations pour le débuggage. @PostLoad peut aussi permettre d'initialiser certains champs de l'objet après son chargement (en particulier les champs transitoires). Les listeners sont aussi externalisables dans des classes extérieures à l'entité, comme décrit ici.

Requête

Nous pouvons demander la récupération d'un objet présentant une certaine valeur de clé primaire. Pour cela EntityManager dispose d'une méthodes T find(Class className, Object id). Il est nécessaire d'indiquer la classe de l'entité en tant que premier paramètre. Le second paramètre est la clé primaire de l'objet à récupérer.

La méthode find peut retourner null si aucun objet n'existe avec la clé primaire donnée. Sinon un objet est retourné (un seul car la clé primaire est censée être unique).

Java Persistence Query Language (JPQL)

Quelques exemples de requêtes JPQL

Pour obtenir tous les livres référencés :

Query query1 = em.createQuery("Select b FROM Book b", Book.class);
List<Book> result1 = query1.getResultList();

Si l'on souhaite n'obtenir que les livres dont le titre commence par un préfixe, nous pouvons utiliser une clause WHERE avec l'opérateur LIKE :

Query query2 = em.createQuery("Select b FROM Book b WHERE b.title LIKE :title", Book.class);
query2.setParameter("title", "Arsène Lupin%");
List<Book> result2 = query2.getResultList();

Maintenant cherchons tous les livres dont l'auteur est Maurice Leblanc :

Query query3 = em.createQuery("Select b from Book b JOIN e.authors a WHERE a.name = ?", Book.class);
query3.setParameter(1, "Maurice Leblanc");
List<Book> result3 = query3.getResultList();

Quels sont les auteurs les plus prolixes (qui ont écrit plus de N livres) ?

Query query4 = em.createQuery("Select a, SUM(b) from Book b JOIN b.authors a GROUP BY a.name HAVING SUM(b) > ? ORDER BY SUM(b) DESC", Object[].class);
query4.setParameter(1, n);
List<Object[]> result4 = query4.getResultList();
for (Object[] r: result4)
{
	Author author = (Author)r[0];
	int writtenBooks = (Integer)r[1];
	System.out.println(r[0].getName() + " has written " + writtenBooks + " books");
}

Tout comme avec le langage SQL, il est possible de mettre à jour des enregistrements. Par exemple, si l'on veut modifier le titre de livres dans la base :

Query queryUpdate = em.createQuery("UPDATE Book b SET e.title = :newTitle WHERE b.title LIKE :title");
queryUpdate.setParameter("newTitle", "Tim");
queryUpdate.setParameter("title", "Tintin %");
int updatedRecords = query.executeUpdate(); // return the number of updated records

On peut également supprimer les livres présentant certains critères :

Query queryDelete = em.createQuery("DELETE Book b WHERE b.year < :boundaryYear");
queryUpdate.setParameter("boundaryYear", 1900);
int deletedRecords = query.executeUpdate(); // return the number of deleted records