Entity Framework - Stratégies d'héritage
Entity Framework Core 7 est déjà sorti il y a quelque temps maintenant (le 8 Novembre 2022 si vous n’avez pas cliqué sur le lien…), mais je souhaitais revenir sur l’arrivée de la stratégie TPC (Table Per Concrete), et au passage, en profiter pour présenter les différentes possibilités pour gérer l’héritage avec EF.
Entity Framework Core
Entity Framework Core est un ORM (aka. object-relational mapping) dédié au monde .NET. Il succède à Entity Framework, et vient lui apporter légèreté et extensibilité ainsi que le support du multiplateforme et la diffusion du code en open source.
Il permet de se connecter à de nombreux fournisseurs de base de données : SQL Server bien sûr, mais aussi SQLite, PostgreSQL, MySQL, Oracle et bien d’autres.
EF Core supporte 2 approches : code-first, dans lequel la base de données est créée à partir des classes C# via un mécanisme de migration, et database-first, où le modèle et les classes sont générés en s’appuyant sur une base de données existante. Avec EF Core, c’est l’approche code-first qui est privilégiée.
Cet article n’étant pas un article sur EF Core, je vous invite à consulter la documentation de Microsoft pour en apprendre plus sur le sujet ;)
L’héritage
En programmation objet, il est courant d’avoir une hiérarchie d’héritage entre des classes (pour spécialiser des informations tout en conservant une abstraction). Ce type de représentation est commun en langage objet mais pas en SQL. Notre ORM a donc pour charge de transposer notre modèle en quelque chose de compréhensible pour SQL, et pour cela il dispose de plusieurs stratégies.
Prenons un exemple simple, qui va nous suivre tout au long de la suite de cet article.
public class Publication
{
public Guid Id;
public string Name;
public string Author;
}
public class Post : Publication
{
public DateTime PublicationDate;
public string Category;
public string Thumbnail;
public string Url;
}
public class Book : Publication
{
public int PageNumber;
public string FourthCover;
public string ISBN;
}
TPH - Table Per Hierarchy
C’est le mode par défaut dans Entity Framework. EF va mapper l’ensemble des classes dans une seule table, en ajoutant une colonne discriminante pour distinguer le type réel de l’objet.
La colonne discriminante n’est pas une propriété réelle (aka. il n’est pas nécessaire de l’ajouter dans vos classes C#), elle est utilisée en interne par EF Core. Par défaut, elle s’appelle Discriminator et est de type string. Elle contient le nom de la classe fille, dans notre cas Post ou Book.
Il est possible de configurer cela via la méthode OnModelCreating
:
protected override void OnModelCreating(ModelBuild modelBuilder)
{
modelBuilder.Entity<Publication>()
.HasDiscriminator<string>("PublicationType")
.HasValue<Post>("Post")
.HasValue<Book>("Book");
}
Le principal problème de cette stratégie est qu’il est nécessaire que l’ensemble des propriétés des sous-classes doivent être nullable côté base de données (dans notre exemple, category, url, pageNumber…). Cela peut-être gênant concernant l’intégrité des données.
TPT - Table Per Type
Avec EF 5, on a vu apparaitre la stratégie TPT (aka. Table-Per-Type), qui permet d’avoir chaque type .NET dans sa propre table.
Les tables représentant les feuilles (Posts et Books pour notre exemple) devront donc référencer la table mère (Publications) via une clé étrangère pour pouvoir accéder aux propriétés communes.
modelBuilder.Entity<Publication>().UseTptMappingStrategy();
Le principal problème de cette stratégie est qu’il est nécessaire de souvent devoir utiliser des jointures pour récupérer l’ensemble des données d’un élément. Attention donc aux performances.
TPC - Table Per Concrete
Et désormais, suite à l’arrivée d’EF7 Preview 5, la dernière stratégie arrive avec TPC (Table-Per-Concrete).
Celle-ci ressemble à TPT, mais cette fois, on créé une table uniquement pour les types finaux (aka. les feuilles ou les types concrets). Chaque table contiendra donc les propriétés de l’objet ainsi que ceux de la classe mère.
modelBuilder.Entity<Publication>().UseTpcMappingStrategy();
Contrairement à la stratégie TPT, toutes les informations d’un objet sont contenues dans une seule table. On limite donc l’utilisation des jointures.
De plus, vis-à-vis de TPH, on peut réserver l’utilisation de la nullité pour les colonnes qui le nécessite vraiment.
Attention à la gestion de la valeur des clés primaires.
Dans EF Core, chaque entité d’une hiérarchie doit avoir un identifiant unique (aka. un article ne pourrait pas avoir le même identifiant qu’un livre).
Etant donné que l’on n’a pas de table commune, il est nécessaire de passer par une séquence (c’est la méthode utilisée par défaut pour les clés primaires de type int) ou une clé primaire de type GUID
.
Et les performances dans tout ça ?
Attention, le choix de la stratégie d’héritance peut avoir des impacts importants sur les performances.
Les différents benchmarks montrent que le TPT est, dans la plupart des cas, la stratégie la moins performante. Contrairement à TPH, où toutes les données sont une seule et unique table, les requêtes TPT doivent faire plusieurs jointures (et la jointure est l’une des principales sources de problèmes de performances dans les bases de données…). Le fait d’avoir des colonnes vides n’est pas forcément gênant, c’est généralement assez bien géré par les moteurs de bases de données (par exemple, la fonctionnalité de colonnes éparses dans SQL Server).
Côté TPC, on est assez proche de TPH en matière de performances. C’est légèrement plus lent lors d’une requête multi-types (car plusieurs tables sont impliquées) mais plus efficace lors d’une requête mono-type (dans ce cas, on travaille sur une seule table sur laquelle on n’a pas besoin de faire de filtrage).
Pour aller plus loin, je vous invite à jeter un oeil du côté de cette classe proposée par l’équipe EF, qui donne une base de code permettant de comparer les performances entre les différentes stratégies d’héritage en l’appliquant à votre modèle : https://github.com/dotnet/EntityFramework.Docs/blob/main/samples/core/Benchmarks/Inheritance.cs.