Stratégies d’héritage composées dans Hibernate

Baptiste Autin, le 27 septembre 2011

Le concept d’héritage n’existe pas dans le modèle entité-relation en tant que tel.
Dans une base de données relationnelle, on peut donc représenter une relation d’héritage :

  • soit avec deux tables et une association intermédiaire (joined strategy en JPA)
  • soit avec une seule table dans laquelle on regroupe les deux entités, et à laquelle on adjoint une colonne pour identifier le nom de la classe (single table strategy)

Une variante de la seconde stratégie, dite table per class, existe aussi, mais je l’ignore pour la simplicité de mon exposé (elle consiste à définir une table par classe concrète, en regroupant les attributs de la classe concrète et ceux de toutes ses classes parentes).

Chacune de ces deux techniques présente des avantages et des inconvénients, en terme de performance, de simplicité d’utilisation, d’évolution, de respect/non-respect des formes normales, etc.

Hibernate prend en charge les deux techniques, et permet même de les mixer.
C’est cette technique de mixage des stratégies d’héritage que je voudrais vous présenter maintenant.

Cas d’utilisation

Je me suis librement inspiré de cet article de bioinformatique.
L’auteur y propose une ontologie des termes cliniques associés aux néoplasmes.
Je ne m’étendrai ni sur les ontologies, ni sur les néoplasmes, il s’agit juste pour moi de partir d’un cas concret.

Du schéma de classification générale fourni par l’auteur de ce blog, j’en ai tiré le diagramme de classe suivant :

Nous supposerons que ces classes possèdent des attributs et des méthodes, que je n’ai pas indiqués.

Nous supposerons aussi que les classes de niveau 1 (Neoplasm) et 2 (NeuralCrest, GermCell, Mesoderm, Trophectoderm, Neuroectoderm et EndodermEctoderm) sont abstraites.
Et les autres concrètes.

Imaginons maintenant que nous ayons besoin d’enregistrer des instances de classes concrètes dans une base de données relationnelle.
Comment allons-nous faire ?
Et d’abord, comment allons-nous mapper ce modèle objet vers un modèle physique de tables ?

Pour commencer, il est probable que certaines de ces classes partageront des attributs communs (un nom latin, une famille, une catégorie biologique, un ensemble de vocabulaires associés, etc.)
D’autres attributs seront au contraire spécifiques aux classes de bas niveau.

Par exemple :

Créer une seule table Néoplasm regroupant tous les attributs de toutes les classes, accompagnée d’un champ “type” pour identifier le type de la classe n’est pas très élégant… même si cette solution serait vraisemblablement gagnante en terme de performances (pas de jointure à effectuer). Je dis vraisemblablement car tout dépendra de la manière dont la base de données gérera l’accroissement du nombre de colonnes avec des valeurs à NULL. L’ajout d’index pourrait également avoir un impact négatif en ce que cette indexation portera sur toute la table, et donc, potentiellement, sur des enregistrements qui ne seraient pas concernés.

Créer une table par classe, avec une association par relation d’héritage, est une solution classique… Mais avec 26 classes, il faudra prévoir 26 tables.
De plus, si les classes de bas niveau ont peu d’attributs propres, est-il vraiment nécessaire de leur consacrer une table ?
Ne pourrions-nous pas limiter un peu le nombre de tables en regroupant localement certaines classes ?

C’est là que le mixage des solutions de persistance d’Hibernate vient à notre secours.

Je vous propose la configuration suivante :
– les classes de niveau 1 et 2 seront chacune mappées vers une table individuelle
– les classes de niveau 3 sont regroupées dans la classe de niveau 2 dont elles héritent

Par exemple, les classes Molar et Tropoblast seront regroupées dans la table Tropectoderm, les classes NeuroectodermPrimitive et NeuralTube dans la table Neuroectoderm, etc.

La définition de la table Trophectoderm devient alors la suivante : Trophectoderm(id, a, b, c, d, e, f, type)
Le champ type est l’identifiant de la classe (il le faut bien, puisque nous avons regroupé certaines classes). Hibernate renseignera automatiquement ce champ avec le nom simple de la classe Java.

Mapping

Le mapping Hibernate consiste à définir au niveau de la classe Neoplasm une stratégie de “table unique” :

@Inheritance(strategy = InheritanceType.SINGLE_TABLE)

Chaque classe de niveau 2 déclare une table complémentaire :

@SecondaryTable(name="NeuralCrest", pkJoinColumns = @PrimaryKeyJoinColumn(name="id"))

Les classes de niveau 3 utilisent à la fois la table secondaire ET la table globale (celle de Neoplasm).

Notez au passage que les annotations JPA ne sont pas automatiquement héritées, et qu’il faut donc explicitement annoter les classes de bas niveau.
Notez également qu’il est indispensable de repréciser le nom de la table secondaire dans toutes les annotations @Column qui se rapportent à une table secondaire :

@Column(table = TABLE_NAME)
private String d;

C’est pour cette raison que mon code fait usage d’une constante publique TABLE_NAME, pour concentrer en une seul endroit le nom de la table.

On prendra soin de créer une contrainte d’intégrité référentielle sur la clef primaire de chaque table vers la clef primaire de Neoplasm (autrement dit Trophectoderm.id -> Neoplasm.id, NeuralCrest.id -> Neoplasm.id, etc.). On s’assurera ainsi qu’on ne pourra supprimer un enregistrement d’une des tables secondaires sans supprimer l’enregistrement en assocation dans la table Neoplasm).

Utilisation

On peut ensuite manipuler des sous-classes de Neoplasm. Par exemple :

NeuralCrestPrimitive ncp = new NeuralCrestPrimitive();
ncp.setLatinName("Pia mater");
ncp.setA("Example #1");
ncp.setB("Example #2");
session.persist(ncp);

Hibernate exécutera alors les 2 opérations suivantes :
insert into neoplasm (latinName, type) values ('Pia mater', 'NeuralCrestPrimitive')
insert into neural_crest (a, b, id) values (Example #1, Example #2, [last inserted id])

Conclusion
Nous avons mappé notre modèle vers une base contenant 7 tables, au lieu de 26 tables avec une stratégie de mapping joined.
Une opération de lecture d’une instance de bas niveau ne nécessite qu’une jointure, une opération d’écriture 2 INSERTS/UPDATES.
C’est une solution moyenne, qui est d’autant plus intéressante :
– que la hauteur de l’arbre hiérarchique est grande (au moins 3 niveaux)
– que la structure des classes de bas-niveau diffère peu d’une classe à l’autre au sein d’une même branche
– que la structure des classes diffère beaucoup d’une branche à l’autre

Classes Java

// *********
// Top class
// *********

@Entity
@Table(name = Neoplasm.TABLE_NAME)
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
@DiscriminatorColumn(name = "type", discriminatorType = DiscriminatorType.STRING)
abstract public class Neoplasm {

	public static final String TABLE_NAME = "neoplasm";

	@Id @GeneratedValue
	private int id;
	
	@Column(name = "latinName")
	private String latinName;
	
	
	public int getId() {
		return id;
	}
	public void setId(int id) {
		this.id = id;
	}
	public String getLatinName() {
		return latinName;
	}
	public void setLatinName(String latinName) {
		this.latinName = latinName;
	}
}

// **************
// Middle classes
// **************

@Entity
@SecondaryTable(name=NeuralCrest.TABLE_NAME, pkJoinColumns = @PrimaryKeyJoinColumn(name="id"))
abstract public class NeuralCrest extends Neoplasm {
	
	public static final String TABLE_NAME = "neural_crest";
	
	@Column(table=TABLE_NAME)
	private String a;
	
	@Column(table=TABLE_NAME)
	private String b;
	
	public String getA() {
		return a;
	}
	public void setA(String a) {
		this.a = a;
	}
	public String getB() {
		return b;
	}
	public void setB(String b) {
		this.b = b;
	}
}

@Entity
@SecondaryTable(name = GermCell.TABLE_NAME, pkJoinColumns = @PrimaryKeyJoinColumn(name="id"))
abstract public class GermCell extends Neoplasm {
	
	public static final String TABLE_NAME = "germ_cell";
}

@Entity
@SecondaryTable(name = Mesoderm.TABLE_NAME, pkJoinColumns = @PrimaryKeyJoinColumn(name="id"))
abstract public class Mesoderm extends Neoplasm {
	
	public static final String TABLE_NAME = "mesoderm";
}

@Entity
@SecondaryTable(name = Neuroectoderm.TABLE_NAME, pkJoinColumns = @PrimaryKeyJoinColumn(name="id"))
abstract public class Neuroectoderm extends Neoplasm {
	
	public static final String TABLE_NAME = "neuroectoderm";
}

@Entity
@SecondaryTable(name = EndodermEctoderm.TABLE_NAME, pkJoinColumns = @PrimaryKeyJoinColumn(name="id"))
abstract public class EndodermEctoderm extends Neoplasm {
	
	public static final String TABLE_NAME = "endoderm_ectoderm";
}

@Entity
@SecondaryTable(name = Trophectoderm.TABLE_NAME, pkJoinColumns = @PrimaryKeyJoinColumn(name="id"))
abstract public class Trophectoderm extends Neoplasm {
	
	public static final String TABLE_NAME = "trophectoderm";
}

// **************
// Bottom classes
// **************

@Entity
@SecondaryTable(name = NeuralCrest.TABLE_NAME, pkJoinColumns = @PrimaryKeyJoinColumn(name="id"))
public class Ectomesenchymal extends NeuralCrest {
	
	@Column(table=TABLE_NAME)
	private String d;
	
	public void setD(String d) {
		this.d = d;
	}

	public String getD() {
		return d;
	}
}

@Entity
@SecondaryTable(name = NeuralCrest.TABLE_NAME, pkJoinColumns = @PrimaryKeyJoinColumn(name="id"))
public class NeuralCrestEndocrine extends NeuralCrest {
	
	@Column(table=TABLE_NAME)
	private String d;
	
	@Column(table=TABLE_NAME)
	private String e;

	public void setD(String d) {
		this.d = d;
	}
	public String getD() {
		return d;
	}
	public void setE(String e) {
		this.e = e;
	}
	public String getE() {
		return e;
	}
}

@Entity
@SecondaryTable(name = NeuralCrest.TABLE_NAME, pkJoinColumns = @PrimaryKeyJoinColumn(name="id"))
public class NeuralCrestPrimitive extends NeuralCrest {
		
	@Column(table = TABLE_NAME)
	private String d;

	public void setD(String d) {
		this.d = d;
	}

	public String getD() {
		return d;
	}
}

@Entity
@SecondaryTable(name = NeuralCrest.TABLE_NAME, pkJoinColumns = @PrimaryKeyJoinColumn(name="id"))
public class PeripheralNervousSystem extends NeuralCrest {
	
	@Column(table=TABLE_NAME)
	private String c;
	
	@Column(table=TABLE_NAME)
	private String f;
	
	@Column(table=TABLE_NAME)
	private String g;
	
	public String getC() {
		return c;
	}
	public void setC(String c) {
		this.c = c;
	}
	public String getF() {
		return f;
	}
	public void setF(String f) {
		this.f = f;
	}
	public String getG() {
		return g;
	}
	public void setG(String g) {
		this.g = g;
	}
}


@Entity
@SecondaryTable(name = Mesoderm.TABLE_NAME, pkJoinColumns = @PrimaryKeyJoinColumn(name="id"))
public class Coelomic extends Mesoderm {
}

@Entity
@SecondaryTable(name = GermCell.TABLE_NAME, pkJoinColumns = @PrimaryKeyJoinColumn(name="id"))
public class Differentiated extends GermCell {
}

@Entity
@SecondaryTable(name = EndodermEctoderm.TABLE_NAME, pkJoinColumns = @PrimaryKeyJoinColumn(name="id"))
public class EndodermEctodermEndocrine extends EndodermEctoderm {
}

@Entity
@SecondaryTable(name = EndodermEctoderm.TABLE_NAME, pkJoinColumns = @PrimaryKeyJoinColumn(name="id"))
public class EndodermEctodermPrimitive extends EndodermEctoderm {
}

@Entity
@SecondaryTable(name = Mesoderm.TABLE_NAME, pkJoinColumns = @PrimaryKeyJoinColumn(name="id"))
public class Mesenchymal extends Mesoderm {
}

@Entity
@SecondaryTable(name = Mesoderm.TABLE_NAME, pkJoinColumns = @PrimaryKeyJoinColumn(name="id"))
public class MesodermPrimitive extends Mesoderm {
}

@Entity
@SecondaryTable(name = Trophectoderm.TABLE_NAME, pkJoinColumns = @PrimaryKeyJoinColumn(name="id"))
public class Molar extends Trophectoderm {
}


@Entity
@SecondaryTable(name = Neuroectoderm.TABLE_NAME, pkJoinColumns = @PrimaryKeyJoinColumn(name="id"))
public class NeuralTube extends Neuroectoderm {
}

@Entity
@SecondaryTable(name = Neuroectoderm.TABLE_NAME, pkJoinColumns = @PrimaryKeyJoinColumn(name="id"))
public class NeuroectodermPrimitive extends Neuroectoderm {
}

@Entity
@SecondaryTable(name = EndodermEctoderm.TABLE_NAME, pkJoinColumns = @PrimaryKeyJoinColumn(name="id"))
public class Odontogenic extends EndodermEctoderm {
}

@Entity
@SecondaryTable(name = EndodermEctoderm.TABLE_NAME, pkJoinColumns = @PrimaryKeyJoinColumn(name="id"))
public class Parenchymal extends EndodermEctoderm {
}

@Entity
@SecondaryTable(name = GermCell.TABLE_NAME, pkJoinColumns = @PrimaryKeyJoinColumn(name="id"))
public class Primordial extends GermCell {
}

@Entity
@SecondaryTable(name = Mesoderm.TABLE_NAME, pkJoinColumns = @PrimaryKeyJoinColumn(name="id"))
public class Subelomic extends Mesoderm {
}

@Entity
@SecondaryTable(name = EndodermEctoderm.TABLE_NAME, pkJoinColumns = @PrimaryKeyJoinColumn(name="id"))
public class Surface extends EndodermEctoderm {
}

@Entity
@SecondaryTable(name = Trophectoderm.TABLE_NAME, pkJoinColumns = @PrimaryKeyJoinColumn(name="id"))
public class Trophoblast extends Trophectoderm {
}

Variante
Plutôt que de laisser Hibernate enregistrer textuellement le nom de la classe dans le champ type de la table Neoplasm, nous pourrions définir une entité NeoplasmType(id, name), et insérer dans Neoplasm une clef étrangère vers cette entité.
Il faudra alors penser à indiquer l’identifiant numérique correspondant dans chaque classe concrète à l’aide de l’annotation @DiscriminatorValue.

Le mapping de la classe Trophectoderm deviendrait par exemple :

@Entity
@SecondaryTable(name="trophectoderm", pkJoinColumns=@PrimaryKeyJoinColumn(name="id"))
@DiscriminatorValue("3")	// si "3" est l'identifiant numérique correspondant à "Trophectoderm" dans la table NeoplasmType 
abstract class Trophectoderm extends Neoplasm {
	(...)
}

Il faudrait aussi préciser à Hibernate qu’il ne doit pas se baser sur le nom de la classe pour identifier le type, mais sur un identifiant numérique et une table annexe :

@Entity
@Table(name = Neoplasm.TABLE_NAME)
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
@DiscriminatorColumn(name = "id_type", discriminatorType = DiscriminatorType.INTEGER)
abstract public class Neoplasm {
	(...)
	@ManyToOne
	@JoinColumn(name="id_type", nullable=false, insertable=false, updatable=false)
	private NeoplasmType type;
	(...)
}

4 commentaires sur “Stratégies d’héritage composées dans Hibernate”

  1. Andrey dit :

    Is it possible to express that mapping on XML?

  2. Baptiste Autin dit :

    I think you could do something like (though not tested):

    <hibernate-mapping>
        <class name=”Neoplasm” table=”neoplasm”>
            <id name=”id” column=”id” type=”int”>
                <generator class=”native”/>
            </id>
            <discriminator column=”type” type=”string”/>
            <subclass name=”NeuralCrest”>
                <join table=”neural_crest”>
                    <key column=”id”/>
                    <property name=”a” column=”a”/>
                </join> 
                <subclass name=”Ectomesenchymal”>
                </subclass>
                <subclass name=”NeuralCrestPrimitive”>
                    <property name=”d” column=”d”/>
                    <property name=”e” column=”e”/>
                </subclass>
                <subclass name=”PeripheralNervousSystem”>
                    <property name=”c” column=”c”/>
                    <property name=”f” column=”f”/>
                    <property name=”g” column=”g”/>
                </subclass>
            </subclass>
            <subclass name=”GermCell”>
                <join table=”germ_cell”>
                    <key column=”id”/>
                    (…)
                </join>
            </subclass>
            <subclass name=”Mesoderm”>
                <join table=”neuroectoderm”>
                    <key column=”id”/>
                    (…)
                </join>
            </subclass>
        </class>
    </hibernate-mapping>

  3. Sergio dit :

    Hi, I have tried this map pattern for my three level hierarchy case. When I save a bottom object I get an error (PK constraint) because hibernate does three inserts: 1 insert into table A, 1 insert into table B and 1 insert into table B again with differents properties set (specific for bottom object). I have revised annotations and class definitions and I don’t see any difference with your example. Do you have any idea what may be happening? Thank you!!

  4. Pierrick dit :

    Bonjour,
    merci pour cet exemple simple et détaillé.
    Avez-vous essayé avec un niveau supplémentaire (4). Dans mon cas j’ai une classe abtraite supplémentaire, et je me trouve confronté à un problème de contrainte de clé unique.
    Merci pour votre réponse
    Cdlt, Pierrick

Laisser une réponse

«     »