Héritage

EF peut mapper une hiérarchie de type .NET à une base de données. Cela vous permet d’écrire normalement vos entités .NET dans le code, à l’aide de types de base et dérivés, tout en permettant et que l'EF crée de manière transparente le schéma de base de données approprié, émette des requêtes, etc. Les détails réels de la façon dont une hiérarchie de types est mappée dépendent du fournisseur ; cette page décrit la prise en charge de l’héritage dans le contexte d’une base de données relationnelle.

Mappage de hiérarchie de type d’entité

Par convention, EF n’analyse pas automatiquement les types de base ou dérivés. Cela signifie que si vous souhaitez qu’un type CLR dans votre hiérarchie soit mappé, vous devez spécifier explicitement ce type sur votre modèle. Par exemple, si vous spécifiez uniquement le type de base d’une hiérarchie, EF Core n’inclut pas implicitement tous ses sous-types.

L’exemple suivant expose un DbSet pour Blog et sa sous-classeRssBlog. Si Blog a une autre sous-classe, elle ne sera pas incluse dans le modèle.

internal class MyContext : DbContext
{
    public DbSet<Blog> Blogs { get; set; }
    public DbSet<RssBlog> RssBlogs { get; set; }
}

public class Blog
{
    public int BlogId { get; set; }
    public string Url { get; set; }
}

public class RssBlog : Blog
{
    public string RssUrl { get; set; }
}

Remarque

Les colonnes de base de données sont automatiquement rendues nullables si nécessaire lors de l’utilisation du mappage TPH. Par exemple, la colonne RssUrl est nullable, car les instances de Blog régulières n’ont pas cette propriété.

Si vous ne souhaitez pas exposer de DbSet pour une ou plusieurs entités de la hiérarchie, vous pouvez également utiliser l’API Fluent pour vous assurer qu’elles sont incluses dans le modèle.

Conseil

Si vous ne vous appuyez pas sur les conventions, vous pouvez spécifier le type de base explicitement à l’aide de HasBaseType. Vous pouvez également utiliser .HasBaseType((Type)null) pour supprimer un type d’entité de la hiérarchie.

Configuration de table par hiérarchie et de discriminateur

Par défaut, EF mappe l’héritage à l’aide du modèle table par hiérarchie (TPH). TPH utilise une table unique pour stocker les données de tous les types de la hiérarchie, et une colonne de discriminateur est utilisée pour identifier le type que représente chaque ligne.

Le modèle ci-dessus est mappé au schéma de base de données suivant (notez la colonne Discriminator créée implicitement, qui identifie le type de Blog stocké dans chaque ligne).

Screenshot of the results of querying the Blog entity hierarchy using table-per-hierarchy pattern

Vous pouvez configurer le nom et le type de la colonne de discriminateur, ainsi que les valeurs utilisées pour identifier chaque type dans la hiérarchie :

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Blog>()
        .HasDiscriminator<string>("blog_type")
        .HasValue<Blog>("blog_base")
        .HasValue<RssBlog>("blog_rss");
}

Dans les exemples ci-dessus, EF a ajouté implicitement le discriminateur en tant que propriété cachée sur l’entité de base de la hiérarchie. Cette propriété peut être configurée comme n’importe quelle autre :

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Blog>()
        .Property("Discriminator")
        .HasMaxLength(200);
}

Enfin, le discriminateur peut également être mappé à une propriété .NET régulière dans votre entité :

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Blog>()
        .HasDiscriminator(b => b.BlogType);

    modelBuilder.Entity<Blog>()
        .Property(e => e.BlogType)
        .HasMaxLength(200)
        .HasColumnName("blog_type");
        
    modelBuilder.Entity<RssBlog>();
}

Lors de l’interrogation d’entités dérivées, qui utilisent le modèle TPH, EF Core ajoute un prédicat sur une colonne de discriminateur dans la requête. Ce filtre garantit que nous n’obtenons pas de lignes supplémentaires pour les types de base ou les types frères et non dans le résultat. Ce prédicat de filtre est ignoré pour le type d’entité de base, car l’interrogation de l’entité de base obtient des résultats pour toutes les entités de la hiérarchie. Lors de la matérialisation des résultats d’une requête, si nous voyons une valeur de discriminateur, qui n’est mappée à aucun type d’entité dans le modèle, nous levons une exception car nous ne savons pas comment matérialiser les résultats. Cette erreur se produit uniquement si votre base de données contient des lignes avec des valeurs de discrimination, qui ne sont pas mappées dans le modèle EF. Si vous disposez de ces données, vous pouvez marquer le mappage de discriminateur dans le modèle EF Core comme incomplet pour indiquer que nous devons toujours ajouter un prédicat de filtre pour interroger n’importe quel type dans la hiérarchie. L’appel IsComplete(false) sur la configuration du discriminateur marque la correspondance comme étant incomplète.

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Blog>()
        .HasDiscriminator()
        .IsComplete(false);
}

Colonnes partagées

Par défaut, lorsque deux types d’entités frères dans la hiérarchie ont une propriété portant le même nom, ils sont mappés à deux colonnes distinctes. Toutefois, si leur type est identique, ils peuvent être mappés à la même colonne de base de données :

public class MyContext : DbContext
{
    public DbSet<BlogBase> Blogs { get; set; }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Blog>()
            .Property(b => b.Url)
            .HasColumnName("Url");

        modelBuilder.Entity<RssBlog>()
            .Property(b => b.Url)
            .HasColumnName("Url");
    }
}

public abstract class BlogBase
{
    public int BlogId { get; set; }
}

public class Blog : BlogBase
{
    public string Url { get; set; }
}

public class RssBlog : BlogBase
{
    public string Url { get; set; }
}

Remarque

Les fournisseurs de bases de données relationnelles, tels que SQL Server, n’utilisent pas automatiquement le prédicat de discriminateur lors de l’interrogation de colonnes partagées lors de l’utilisation d’un cast. La requête Url = (blog as RssBlog).Url retournerait également la valeur Url pour les lignes Blog sœurs. Pour restreindre la requête à RssBlog entités, vous devez ajouter manuellement un filtre sur le discriminateur, tel que Url = blog is RssBlog ? (blog as RssBlog).Url : null.

Configuration table par type

Dans le scénario de mappage TPT, tous les types sont mappés à des tables individuelles. Les propriétés qui appartiennent uniquement à un type de base ou à un type dérivé sont stockées dans une table qui mappe à ce type. Les tables qui mappent aux types dérivés stockent également une clé étrangère qui joint la table dérivée à la table de base.

modelBuilder.Entity<Blog>().ToTable("Blogs");
modelBuilder.Entity<RssBlog>().ToTable("RssBlogs");

Conseil

Au lieu d’appeler ToTable sur chaque type d’entité, vous pouvez appeler modelBuilder.Entity<Blog>().UseTptMappingStrategy() sur chaque type d’entité racine et les noms de tables seront générés par EF.

Conseil

Pour configurer différents noms de colonnes pour les colonnes clés primaires de chaque table, consultez Configuration de facette spécifique à la table.

va créer le schéma de base de données suivant pour le modèle ci-dessus.

CREATE TABLE [Blogs] (
    [BlogId] int NOT NULL IDENTITY,
    [Url] nvarchar(max) NULL,
    CONSTRAINT [PK_Blogs] PRIMARY KEY ([BlogId])
);

CREATE TABLE [RssBlogs] (
    [BlogId] int NOT NULL,
    [RssUrl] nvarchar(max) NULL,
    CONSTRAINT [PK_RssBlogs] PRIMARY KEY ([BlogId]),
    CONSTRAINT [FK_RssBlogs_Blogs_BlogId] FOREIGN KEY ([BlogId]) REFERENCES [Blogs] ([BlogId]) ON DELETE NO ACTION
);

Remarque

Si la contrainte de clé primaire est renommée, le nouveau nom est appliqué à toutes les tables mappées à la hiérarchie, les futures versions EF autorisent le changement de nom de la contrainte uniquement pour une table particulière lorsque le problème 19970 est résolu.

Si vous utilisez la configuration en bloc, vous pouvez récupérer le nom de colonne d’une table spécifique en appelant GetColumnName(IProperty, StoreObjectIdentifier).

foreach (var entityType in modelBuilder.Model.GetEntityTypes())
{
    var tableIdentifier = StoreObjectIdentifier.Create(entityType, StoreObjectType.Table);

    Console.WriteLine($"{entityType.DisplayName()}\t\t{tableIdentifier}");
    Console.WriteLine(" Property\tColumn");

    foreach (var property in entityType.GetProperties())
    {
        var columnName = property.GetColumnName(tableIdentifier.Value);
        Console.WriteLine($" {property.Name,-10}\t{columnName}");
    }

    Console.WriteLine();
}

Avertissement

Dans de nombreux cas, TPT affiche des performances inférieures par rapport à TPH. Pour plus d’informations, consultez la documentation sur les performances.

Avertissement

Les colonnes d’un type dérivé sont mappées à différentes tables. Par conséquent, les contraintes et les index FK composites qui utilisent les propriétés héritées et déclarées ne peuvent pas être créées dans la base de données.

Configuration de table par type concret (TPC)

Remarque

La fonctionnalité de table par type concret (TPC) a été introduite dans EF Core 7.0.

Dans le scénario de mappage TPC, tous les types sont mappés à des tables individuelles. Chaque table contient des colonnes pour toutes les propriétés du type d’entité correspondant. Cela résout certains problèmes de performances courants avec la stratégie TPT.

Conseil

L'équipe EF a démontré et parlé en détail du mappage TPC dans un épisode de .NET Data Community Standup. Comme pour tous les épisodes de Community Standup, vous pouvez regarder l'épisode TPC dès maintenant sur YouTube.

modelBuilder.Entity<Blog>().UseTpcMappingStrategy()
    .ToTable("Blogs");
modelBuilder.Entity<RssBlog>()
    .ToTable("RssBlogs");

Conseil

Au lieu d’appeler ToTable sur chaque type d’entité, il suffit d’appeler modelBuilder.Entity<Blog>().UseTpcMappingStrategy() sur chaque type d’entité racine pour générer et les noms de tables par convention.

Conseil

Pour configurer différents noms de colonnes pour les colonnes clés primaires de chaque table, consultez Configuration de facette spécifique à la table.

va créer le schéma de base de données suivant pour le modèle ci-dessus.

CREATE TABLE [Blogs] (
    [BlogId] int NOT NULL DEFAULT (NEXT VALUE FOR [BlogSequence]),
    [Url] nvarchar(max) NULL,
    CONSTRAINT [PK_Blogs] PRIMARY KEY ([BlogId])
);

CREATE TABLE [RssBlogs] (
    [BlogId] int NOT NULL DEFAULT (NEXT VALUE FOR [BlogSequence]),
    [Url] nvarchar(max) NULL,
    [RssUrl] nvarchar(max) NULL,
    CONSTRAINT [PK_RssBlogs] PRIMARY KEY ([BlogId])
);

Schéma de la base de données TPC

La stratégie TPC est similaire à la stratégie TPT sauf qu'une table différente est créée pour chaque type concret dans la hiérarchie, mais les tables ne sont pas créées pour les types abstraits, d'où le nom « table par type concret ». Comme pour TPT, le tableau lui-même indique le type de l'objet enregistré. Cependant, contrairement au mappage TPT, chaque table contient des colonnes pour chaque propriété du type concret et de ses types de base. Les schémas de base de données TPC sont dénormalisés.

Par exemple, envisagez de mapper cette hiérarchie :

public abstract class Animal
{
    protected Animal(string name)
    {
        Name = name;
    }

    public int Id { get; set; }
    public string Name { get; set; }
    public abstract string Species { get; }

    public Food? Food { get; set; }
}

public abstract class Pet : Animal
{
    protected Pet(string name)
        : base(name)
    {
    }

    public string? Vet { get; set; }

    public ICollection<Human> Humans { get; } = new List<Human>();
}

public class FarmAnimal : Animal
{
    public FarmAnimal(string name, string species)
        : base(name)
    {
        Species = species;
    }

    public override string Species { get; }

    [Precision(18, 2)]
    public decimal Value { get; set; }

    public override string ToString()
        => $"Farm animal '{Name}' ({Species}/{Id}) worth {Value:C} eats {Food?.ToString() ?? "<Unknown>"}";
}

public class Cat : Pet
{
    public Cat(string name, string educationLevel)
        : base(name)
    {
        EducationLevel = educationLevel;
    }

    public string EducationLevel { get; set; }
    public override string Species => "Felis catus";

    public override string ToString()
        => $"Cat '{Name}' ({Species}/{Id}) with education '{EducationLevel}' eats {Food?.ToString() ?? "<Unknown>"}";
}

public class Dog : Pet
{
    public Dog(string name, string favoriteToy)
        : base(name)
    {
        FavoriteToy = favoriteToy;
    }

    public string FavoriteToy { get; set; }
    public override string Species => "Canis familiaris";

    public override string ToString()
        => $"Dog '{Name}' ({Species}/{Id}) with favorite toy '{FavoriteToy}' eats {Food?.ToString() ?? "<Unknown>"}";
}

public class Human : Animal
{
    public Human(string name)
        : base(name)
    {
    }

    public override string Species => "Homo sapiens";

    public Animal? FavoriteAnimal { get; set; }
    public ICollection<Pet> Pets { get; } = new List<Pet>();

    public override string ToString()
        => $"Human '{Name}' ({Species}/{Id}) with favorite animal '{FavoriteAnimal?.Name ?? "<Unknown>"}'" +
           $" eats {Food?.ToString() ?? "<Unknown>"}";
}

Lors de l'utilisation de SQL Server, les tables créées pour cette hiérarchie sont :

CREATE TABLE [Cats] (
    [Id] int NOT NULL DEFAULT (NEXT VALUE FOR [AnimalSequence]),
    [Name] nvarchar(max) NOT NULL,
    [FoodId] uniqueidentifier NULL,
    [Vet] nvarchar(max) NULL,
    [EducationLevel] nvarchar(max) NOT NULL,
    CONSTRAINT [PK_Cats] PRIMARY KEY ([Id]));

CREATE TABLE [Dogs] (
    [Id] int NOT NULL DEFAULT (NEXT VALUE FOR [AnimalSequence]),
    [Name] nvarchar(max) NOT NULL,
    [FoodId] uniqueidentifier NULL,
    [Vet] nvarchar(max) NULL,
    [FavoriteToy] nvarchar(max) NOT NULL,
    CONSTRAINT [PK_Dogs] PRIMARY KEY ([Id]));

CREATE TABLE [FarmAnimals] (
    [Id] int NOT NULL DEFAULT (NEXT VALUE FOR [AnimalSequence]),
    [Name] nvarchar(max) NOT NULL,
    [FoodId] uniqueidentifier NULL,
    [Value] decimal(18,2) NOT NULL,
    [Species] nvarchar(max) NOT NULL,
    CONSTRAINT [PK_FarmAnimals] PRIMARY KEY ([Id]));

CREATE TABLE [Humans] (
    [Id] int NOT NULL DEFAULT (NEXT VALUE FOR [AnimalSequence]),
    [Name] nvarchar(max) NOT NULL,
    [FoodId] uniqueidentifier NULL,
    [FavoriteAnimalId] int NULL,
    CONSTRAINT [PK_Humans] PRIMARY KEY ([Id]));

Notez que :

  • Il n'existe pas de tables pour les types Animal ou Pet, car ceux-ci sont abstract dans le modèle objet. N'oubliez pas que C# n'autorise pas les instances de types abstraits et qu'il n'existe donc aucune situation dans laquelle une instance de type abstrait sera enregistrée dans la base de données.

  • Le mappage des propriétés dans les types de base est répété pour chaque type concret. Par exemple, chaque table a une colonne Name, et les chats et les chiens ont une colonne Vet.

  • L'enregistrement de certaines données dans cette base de données entraîne les résultats suivants :

Table Chats

Id Nom FoodId Vétérinaire EducationLevel
1 Alice 99ca3e98-b26d-4a0c-d4ae-08da7aca624f Pengelly MBA
2 Mac 99ca3e98-b26d-4a0c-d4ae-08da7aca624f Pengelly Préscolaire
8 Baxter 5dc5019e-6f72-454b-d4b0-08da7aca624f Hôpital pour animaux de compagnie Bothell BSc

Table Chiens

Id Nom FoodId Vétérinaire FavoriteToy
3 Toast 011aaf6f-d588-4fad-d4ac-08da7aca624f Pengelly M. Squirrel

Table des animaux de la ferme

Id Nom FoodId Valeur Species (Espèce)
4 Clyde 1d495075-f527-4498-d4af-08da7aca624f 100.00 Equus africanus asinus

Table Humains

Id Nom FoodId FavoriteAnimalId
5 Wendy 5418fd81-7660-432f-d4b1-08da7aca624f 2
6 Arthur 59b495d4-0414-46bf-d4ad-08da7aca624f 1
9 Katie null 8

Notez que, contrairement au mappage TPT, toutes les informations relatives à un seul objet sont contenues dans une seule table. Et, contrairement au mappage TPH, il n'y a aucune combinaison de colonne et de ligne dans aucune table où cela n'est jamais utilisé par le modèle. Nous verrons ci-dessous en quoi ces caractéristiques peuvent être importantes pour les requêtes et le stockage.

Génération de la clé

La stratégie de mappage d'héritage choisie a des conséquences sur la façon dont les valeurs de clé primaire sont générées et gérées. Les clés dans TPH sont simples puisque chaque instance d’entité est représentée par une seule ligne dans un seul tableau. Tout type de génération de valeur clé peut être utilisé et aucune contrainte supplémentaire n'est nécessaire.

Pour la stratégie TPT, il y a toujours une ligne dans le tableau mappée au type de base de la hiérarchie. Tout type de génération de clé peut être utilisé sur cette ligne, et les clés des autres tables sont liées à cette table à l'aide de contraintes de clé étrangère.

Les choses se compliquent un peu pour TPC. Tout d’abord, il est important de comprendre qu’EF Core exige que toutes les entités d’une hiérarchie aient une valeur de clé unique, même si les entités ont des types différents. Ainsi, en utilisant notre exemple de modèle, un chien ne peut pas avoir la même valeur de clé Id qu'un chat. Deuxièmement, contrairement à TPT, il n'existe pas de table commune pouvant servir de lieu unique où les valeurs clés vivent et peuvent être générées. Cela signifie qu’une simple colonne Identity ne peut pas être utilisée.

Pour les bases de données prenant en charge les séquences, les valeurs clés peuvent être générées en utilisant une seule séquence référencée dans la contrainte par défaut de chaque table. Il s'agit de la stratégie utilisée dans les tableaux TPC présentés ci-dessus, où chaque tableau contient les éléments suivants :

[Id] int NOT NULL DEFAULT (NEXT VALUE FOR [AnimalSequence])

AnimalSequence est une séquence de base de données créée par EF Core. Cette stratégie est utilisée par défaut pour les hiérarchies TPC lors de l'utilisation du fournisseur de base de données EF Core pour SQL Server. Les fournisseurs de bases de données pour d’autres bases de données prenant en charge les séquences doivent avoir une valeur par défaut similaire. D'autres stratégies de génération de clés utilisant des séquences, telles que les modèles Hi-Lo, peuvent également être utilisées avec TPC.

Bien que les colonnes d'identité standard ne fonctionnent pas avec TPC, il est possible d'utiliser des colonnes d'identité si chaque table est configurée avec une valeur initiale et un incrément appropriés de telle sorte que les valeurs générées pour chaque table ne soient jamais en conflit. Par exemple :

modelBuilder.Entity<Cat>().ToTable("Cats", tb => tb.Property(e => e.Id).UseIdentityColumn(1, 4));
modelBuilder.Entity<Dog>().ToTable("Dogs", tb => tb.Property(e => e.Id).UseIdentityColumn(2, 4));
modelBuilder.Entity<FarmAnimal>().ToTable("FarmAnimals", tb => tb.Property(e => e.Id).UseIdentityColumn(3, 4));
modelBuilder.Entity<Human>().ToTable("Humans", tb => tb.Property(e => e.Id).UseIdentityColumn(4, 4));

Important

L’utilisation de cette stratégie rend plus difficile l’ajout de types dérivés ultérieurement, car il nécessite le nombre total de types dans la hiérarchie à connaître au préalable.

SQLite ne prend pas en charge les séquences ou les valeurs initiales/incréments d'identité, et par conséquent la génération de valeurs de clé entières n'est pas prise en charge lors de l'utilisation de SQLite avec la stratégie TPC. Toutefois, la génération côté client ou les clés uniques globales (par exemple, les clés GUID) sont prises en charge sur n'importe quelle base de données, y compris SQLite.

Contraintes de clés étrangères

La stratégie de mappage TPC crée un schéma SQL dénormalisé. C'est l'une des raisons pour lesquelles certains puristes des bases de données s'y opposent. Par exemple, considérons la colonne de clé étrangère FavoriteAnimalId. La valeur de cette colonne doit correspondre à la valeur de la clé primaire d'un animal. Cela peut être appliqué dans la base de données avec une simple contrainte FK lors de l'utilisation de TPH ou TPT. Par exemple :

CONSTRAINT [FK_Animals_Animals_FavoriteAnimalId] FOREIGN KEY ([FavoriteAnimalId]) REFERENCES [Animals] ([Id])

Mais lors de l'utilisation de TPC, la clé primaire d'un animal est stockée dans la table pour le type concret de cet animal. Par exemple, la clé primaire d'un chat est stockée dans la colonne Cats.Id, tandis que la clé primaire d'un chien est stockée dans la colonne Dogs.Id, et ainsi de suite. Cela signifie qu'une contrainte FK ne peut pas être créée pour cette relation.

En pratique, cela ne pose pas de problème tant que l'application ne tente pas d'insérer des données non valides. Par exemple, si toutes les données sont insérées par EF Core et utilisent des navigations pour relier les entités, il est alors garanti que la colonne FK contiendra une valeur PK valide à tout moment.

Résumé et conseils

En résumé, TPH convient généralement à la plupart des applications et constitue une bonne valeur par défaut pour un large éventail de scénarios. N'ajoutez donc pas la complexité de TPC si vous n'en avez pas besoin. Plus précisément, si votre code interrogera principalement des entités de nombreux types, comme l'écriture de requêtes sur le type de base, tournez-vous alors vers TPH plutôt que vers TPC.

Cela dit, TPC est également une bonne stratégie de mappage à utiliser lorsque votre code interroge principalement les entités d’un type feuille unique et que vos performances montrent une amélioration par rapport à TPH.

N’utilisez le TPT que si des facteurs externes vous y obligent.