Object-relational mapping (ORM)
- Surcouche sur une base de données relationnelles permettant de représenter les données sous la forme d'objets liés entre-eux.
- Chaque enregistrement d'une table est représenté comme un objet instance d'une classe
- Les relations d'héritage peuvent être exploitées
- Un champ peut contenir une référence vers un objet (exploitation des relations), vers une collection d'objets
- En Java, on manipule des POJO (Plain Old Java Object) avec des getters et setters
-
En Java, l'API de référence pour les ORM est Java Persistence API (JPA) qui propose des possibilités de persistence pour les Enterprise Java Beans de type entité
- La spécification du mapping entre objets et BDD relationnelle est réalisée grâce à des annotations embarquées dans les classes (possible depuis Java 1.5)
-
Il existe différentes implantations basées sur cette API :
- Hibernate
- OpenJDO
- ...
Présentation d'Hibernate
- Projet lancé par Garvin King au sein de l'entreprise JBoss reprise par la société RedHat
- Version stable la plus récente en janvier 2019 : 5.4
-
Composantes principales d'Hibernate :
- Core qui apporte les fonctionnalités principales d'Hibernate avec la gestion de sessions
- Annotations pour le support des annotations
- EntityManager pour supporter Java Persistence API
- Shards pour le support du sharding ce qui permet de partitionner des enregistrements sur plusieurs bases de données
- Validator pour valider la cohérence des contraintes imposées sur des colonnes d'entité
- Search permet de réaliser des recherches en texte intégral sur des colonnes en utilisant Lucène
- Tools pour faciliter la construction de projets en fournissant des greffons pour Ant et Eclipse
Deux manières de configurer le mapping entre objets et tables de BDD existent avec Hibernate :
- Historiquement en utilisant des fichiers de configuration XML
- Depuis l'introduction de JPA2 avec des annotations Java directement dans les JavaBeans (document de référence pour l'utilisation des annotations)
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é :
- Elle possède un constructeur sans argument qui sera utilisé pour initialiser la classe. Nous pouvons donc ne pas écrire de constructeur : le constructeur implicite sans argument sera automatiquement installé.
- Les différents champs de la classe ont une visibilité réduite (protected ou private).
- Les champs sont lisibles et modifiables par des getters et setters publics basés sur le même nom que les champs
- On évite de placer du code réalisant des traitements avancés dans la classe. La classe n'est utilisée que pour contenir des données : aucun code métier, de récupération de données ou de présentation ne doit normalement être présent.
Création rapide d'un Bean entité sous Eclipse
La plupart des IDE permettent une création rapide d'un JavaBean entité :
- 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
- On ajoute tous les champs de la classe en visibilité private
- On génère automatiquement les getters et setters pour tous les champs (sous Eclipse : menu Source/Generate Getters and Setters)
- 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
- Afin de lier la logique objet à celle d'une base de données relationnelle, on peut utiliser avec JPA des annotations au sein des classes
- Les annotations sont une fonctionnalité intégrée depuis Java 1.5 permettant d'intégrer un concept de programmation déclarative au sein du code source : toutes les annotations commencent par un caractère @ et peuvent comporter des propriétés (couples de clé-valeur) ; il est possible également d'imbriquer des annotations. Les annotations peuvent concerner des classes, des champs, des méthodes ainsi que des paramètres de méthode.
- Les annotations utilisées par Hibernate sont conservées à la compilation. En utilisant l'API d'introspection de Java, Hibernate peut récupérer ces annotations et reconstituer la logique de mapping objet-BDD.
Annotation de classe JavaBean
Nous annotons chaque classe JavaBean :
- @Entity : cette annotation est obligatoire ; elle indique que la classe doit être gérée comme classe persistante par Hibernate : cette classe est considérée comme un bean entité
- @Table(name="mytable") : cette annotation indique que les objets de la classe sont placés comme enregistrements dans la table nommé mytable
- @Index(columnList="column1, column2,...", unique=true)` : cette annotation permet d'installer un index sur la table afin de retrouver plus rapidement des enregistrements ; on spécifie les critères de l'index avec les noms des colonnes concernées, la propriété unique indiquant s'il peut ou non exiter un unique enregistrement possédant des valeurs données pour les colonnes référencées dans l'index
- L'annotation @NamedQueries abrite un tableau de @NamedQuery et permet de spécifier des requêtes spéciales pour interroger la table en utilisant JPQL (Java Persistence Query Language).
Annotation de champs
Chaque champ peut être annoté pour indiquer des propriétés particulières le concernant :
- @Column(name="columnName") : cette annotation permet d'indiquer le nom de la colonne de la table hébergeant le champ ; en cas d'absence de ce nom, la colonne porte le nom du champ par défaut. Par ailleurs diverses contraintes peuvent être spécifiées sur les valeurs autorisées pour le champ : length permet d'indiquer la longueur maximale d'une chaîne de caractères, nullable permet d'indiquer si le champ peut être nul (par défaut à true), unique si la colonne réprésente une clé unique (par défaut à false), updatable est un booléen indiquant si le champ peut être mis à jour...
- Une table doit posséder une clé primaire qui permet d'identifier de façon unique chaque objet référencé. Il faut donc que l'entité objet possède un champ jouant le rôle de cette clé primaire (aucun doublon sur la valeur de ce champ n'est autorisé). Ce champ est annoté avec @Id On peut demander l'autogénération de ce champ ce qui est pertinent si ce champ est un entier incrémenté à chaque enregistrement. On utilisera alors l'annotation @GeneratedValue.
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é
- Un objet peut être embarqué (embedded) dans un autre. Le principe est alors d'intégrer les colonnes de la classe de cet objet dans la même classe que l'objet contenant. On n'a donc qu'une seule table mais avec des données possiblement dupliquées si des objets embarqués sont présents en plusieurs exemplaires (et donc répliqués dans les enregistrements).
- Pour rendre une classe embarquable, on l'annote @Embeddable ; les objets instantiés depuis cette classe ne peuvent pas avoir d'existence indépendante et sont destinés à être "intégrés" dans d'autres objets
- La classe utilisant une classe embarquable annote le champ en question avec @Embedded
- Il est possible de changer le nom des colonnes embarquées (ce qui est obligatoire si la même classe embarquée est référencée plusieurs fois ans dans la classe étudiée).
- Une classe embarquée peut servir de clé primaire si le champ en question utilisant cette classe et annoté par @EmbeddedId
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 :
- Nous déclarons avec l'annotation @SecondaryTables sur la classe le nom des tables secondaires nécessaires
- Nous intégrons dans l'annotation @Column le nom de la table dans laquelle le champ est intégré
@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 :
- La table principale ContactDetails (qui ne contient que le numéro de téléphone)
- La table secondaire ElectronicAddress contenant l'adresse email
- 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 :
- FetchType.EAGER : les objets en relation sont récupérés transitivement lors d'une requête. Cette stratégie évite de réaliser des requêtes ultérieures mais peut s'avérer coûteuse si elle aboutit à la récupération d'un graphe d'objets volumineux.
- FetchType.LAZY : les objets en relation sont récupérés paresseusement ; cela signifie qu'une requête de récupération sera réalisée uniquement lorsqu'on appelera le getter pour récupérer les objets en relation. Cela est rendu possible par l'utilisation de proxies.
Validation des beans entité
- Un bean entité représentant un type d'objet persistant est composé d'attributs qui ne peuvent pas toujours prendre des valeurs arbitraires.
- Pour préciser les valeurs acceptables d'un attribut, la JSR 303 a introduit le concept de validation de bean entité. Il est possible de créer ses propres annotations en les annotant avec javax.validation.Constraint : on peut indiquer par une propriété validatedBy une classe héritant de ContraintValidator réalisant le travail de validation.
- Plus prosaïquement, on peut s'en remettre à l'utilisation de contraintes prédéfinies dans le paquetage javax.validation.constraints ; en voici un tableau récapitulatif :
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 |
- On peut associer un message à chaque contrainte qui pourra être affiché lorsque la contrainte n'est pas vérifiée, tel que pour l'exemple suivant où l'on requiert la fourniture d'un code postal français correct :
@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
- JPA peut manipuler des entités qui héritent entre-elles
-
Plusieurs stratégies peuvent être employées pour représenter sous forme relationnelle le concept d'héritage en POO ; la stratégie à employer peut être indiquée avec l'annotation @Inheritance(strategy = ...) sur la classe de plus haut niveau dans la hiérarchie :
- InheritanceType.TABLE_PER_CLASS : cette stratégie créé une table pour chaque classe. Cela est peu optimal lorsque l'on a à réaliser des requêtes sur une classe ancêtre : il faut alors réaliser l'union de toutes les tables des classes descendantes.
- InheritanceType.SINGLE_TABLE : cette stratégie emploie une seule table pour tous les objets de la hierarchie de classes. La table comporte donc l'union des colonnes de tous les descendants (potentiellement volumineux) avec une colonne spéciale dite de discrimination (DTYPE) indiquant le nom de la classe de l'entité. Cette stratégie est peu flexible, notamment lorsqu'une nouvelle classe doit être ajoutée dans la hiérarchie (il faut modifier le schéma de la table).
- InheritanceType.JOINED : cette stratégie utilise une jointure pour les sous-classes en utilisant par défaut la même clé primaire que la classe ancêtre. Il y a bien une table par classe comme pour la stratégie TABLE_PER_CLASS mais seuls les champs effectivement définis dans la classe sont présents dans la table ; l'obtention des valeurs des champs hérités nécessitera de requêter les tables correspondant aux classes ancêtres.
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 :
- void persist(Object o) pour sauvegarder un objet et donc le rendre persistant ; l'objet ne doit pas être déjà présent (sinon EntityExistsException est levée)
- T merge(Object o) qui sauvegarde un objet ou le met à jour s'il existe déjà un objet avec la même clé primaire dans la base
- void refresh(Object o) pour restaurer l'état de l'objet tel qu'il est actuellement en base (annule les modifications réalisées en RAM)
- void detach(Object o) pour détacher l'instance du contexte de persistance sans la supprimer de la base ; l'objet devient indépendant ; l'opération est réversible avec la méthode merge
- void remove(Objet o) pour supprimer un objet de la base (mais l'objet reste toujours transitoirement dans le tas en mémoire, l'objet n'est juste plus persistant)
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 :
- @PrePersist et @PostPersist : pour exécuter du code avant et après la mise en persistance de l'objet ; en levant une exception dans @PrePersist, il est possible d'empêcher la persistance
- @PreUpdate et @PostUpdate : pour exécuter du code à l'occasion de la mise à jour d'un objet en base (avant et après l'opération) ; là encore une exception levée dans @PreUpdate peut empêcher la mise à jour
- @PreRemove et @PostRemove : pour les méthodes à appeler avant et après la suppression de l'entité de la base
- @PostLoad : annote une méthode appelée le chargement de l'instance depuis la base
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)
- Historiquement, Hibernate a proposé le langage de requêtage Hibernate Query Langage (HSQL) s'inspirant fortement du langage SQL mais plus adapté au paradigme objet. Ceci permet d'exécuter des requêtes complexes qu'il serait difficile de mettre en place uniquement à l'aide d'appel de méthodes sur les objets.
- L'élaboration de JPA a été influencé par le projet Hibernate. Un nouveau langage de requêtage reprenant les fonctionnalités essentielles de HSQL a été créé : Java Persistence Query Langage (JPQL). Une documentation de référence est disponible ici.
- Pour réaliser une requête il est nécessaire d'obtenir une instance de EntityManager
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