Ereditarietà

Ef può eseguire il mapping di una gerarchia dei tipi .NET a un database. Ciò consente di scrivere le entità .NET nel codice come di consueto, usando tipi di base e derivati e di creare facilmente lo schema del database appropriato, eseguire query e così via. I dettagli effettivi sul modo in cui viene eseguito il mapping di una gerarchia dei tipi sono dipendenti dal provider; questa pagina descrive il supporto dell'ereditarietà nel contesto di un database relazionale.

Mapping della gerarchia dei tipi di entità

Per convenzione, EF non analizzerà automaticamente i tipi di base o derivati; Ciò significa che se si vuole eseguire il mapping di un tipo CLR nella gerarchia, è necessario specificare in modo esplicito tale tipo nel modello. Ad esempio, se si specifica solo il tipo di base di una gerarchia, EF Core non include in modo implicito tutti i relativi sottotipi.

Nell'esempio seguente viene esposto un oggetto DbSet per Blog e la relativa sottoclasse RssBlog. Se Blog è presente un'altra sottoclasse, non verrà inclusa nel modello.

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; }
}

Nota

Le colonne di database vengono rese automaticamente nullable in base alle esigenze quando si usa il mapping TPH. Ad esempio, la RssUrl colonna è nullable perché le istanze regolari Blog non dispongono di tale proprietà.

Se non si vuole esporre una DbSet per una o più entità nella gerarchia, è anche possibile usare l'API Fluent per assicurarsi che siano incluse nel modello.

Suggerimento

Se non si fa affidamento sulle convenzioni, è possibile specificare il tipo di base in modo esplicito usando HasBaseType. È anche possibile usare .HasBaseType((Type)null) per rimuovere un tipo di entità dalla gerarchia.

Configurazione di tabelle per gerarchia e discriminare

Per impostazione predefinita, Entity Framework esegue il mapping dell'ereditarietà usando il modello TPH (Table-Per-Hierarchy ). TPH usa una singola tabella per archiviare i dati per tutti i tipi nella gerarchia e viene usata una colonna discriminatoria per identificare il tipo rappresentato da ogni riga.

Il modello precedente viene mappato allo schema di database seguente (si noti la colonna creata Discriminator in modo implicito, che identifica il tipo di Blog archiviato in ogni riga).

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

È possibile configurare il nome e il tipo della colonna discriminatoria e i valori usati per identificare ogni tipo nella gerarchia:

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

Negli esempi precedenti Ef ha aggiunto il discriminare in modo implicito come proprietà shadow sull'entità di base della gerarchia. Questa proprietà può essere configurata come qualsiasi altra:

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

Infine, il discriminare può anche essere mappato a una normale proprietà .NET nell'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>();
}

Quando si eseguono query per le entità derivate, che usano il modello TPH, EF Core aggiunge un predicato sulla colonna discriminatoria nella query. Questo filtro garantisce che non vengano recuperate righe aggiuntive per i tipi di base o i tipi di pari livello non nel risultato. Questo predicato di filtro viene ignorato per il tipo di entità di base perché l'esecuzione di query per l'entità di base otterrà i risultati per tutte le entità nella gerarchia. Quando si materializzano i risultati di una query, se si riscontra un valore discriminatorio, che non viene mappato a alcun tipo di entità nel modello, viene generata un'eccezione poiché non si sa come materializzare i risultati. Questo errore si verifica solo se il database contiene righe con valori discriminatori, che non sono mappati nel modello di Entity Framework. Se si dispone di tali dati, è possibile contrassegnare il mapping discriminatorio nel modello EF Core come incompleto per indicare che è necessario aggiungere sempre il predicato di filtro per eseguire query su qualsiasi tipo nella gerarchia. IsComplete(false) chiamare sulla configurazione discriminatoria contrassegna il mapping incompleto.

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

Colonne condivise

Per impostazione predefinita, quando due tipi di entità di pari livello nella gerarchia hanno una proprietà con lo stesso nome, verranno mappate a due colonne separate. Tuttavia, se il tipo è identico, è possibile eseguire il mapping alla stessa colonna di database:

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; }
}

Nota

I provider di database relazionali, ad esempio SQL Server, non useranno automaticamente il predicato discriminatorio durante l'esecuzione di query su colonne condivise quando si usa un cast. La query Url = (blog as RssBlog).Url restituisce anche il Url valore per le righe di pari livello Blog . Per limitare la query alle RssBlog entità, è necessario aggiungere manualmente un filtro sul discriminante, ad esempio Url = blog is RssBlog ? (blog as RssBlog).Url : null.

Configurazione tabella per tipo

Nel modello di mapping TPT tutti i tipi vengono mappati a singole tabelle. Le proprietà che appartengono esclusivamente a un tipo di base o derivato sono archiviate in una tabella che viene mappata a quel tipo. Le tabelle mappate ai tipi derivati archiviano anche una chiave esterna che unisce la tabella derivata alla tabella di base.

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

Suggerimento

Anziché chiamare ToTable su ogni tipo di entità, è possibile chiamare modelBuilder.Entity<Blog>().UseTptMappingStrategy() per ogni tipo di entità radice e i nomi delle tabelle verranno generati da EF.

Suggerimento

Per configurare nomi di colonna diversi per le colonne chiave primaria in ogni tabella, vedere Configurazione di facet specifica della tabella.

Entity Framework creerà lo schema di database seguente per il modello precedente.

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
);

Nota

Se il vincolo di chiave primaria viene rinominato, il nuovo nome verrà applicato a tutte le tabelle mappate alla gerarchia, le versioni future di Entity Framework consentiranno di rinominare il vincolo solo per una determinata tabella quando viene risolto il problema 19970 .

Se si usa la configurazione in blocco, è possibile recuperare il nome della colonna per una tabella specifica chiamando 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();
}

Avviso

In molti casi, TPT mostra prestazioni inferiori rispetto al TPH. Per altre informazioni, vedere la documentazione sulle prestazioni.

Attenzione

Le colonne per un tipo derivato vengono mappate a tabelle diverse, pertanto i vincoli FK compositi e gli indici che usano le proprietà ereditate e dichiarate non possono essere create nel database.

Configurazione di tipo tabella per concreto

Nota

La funzionalità di tabella per tipo concreto (TPC) è stata introdotta in EF Core 7.0.

Nel modello di mapping TPC tutti i tipi vengono mappati a singole tabelle. Ogni tabella contiene colonne per tutte le proprietà nel tipo di entità corrispondente. Questo risolve alcuni problemi comuni di prestazioni con la strategia TPT.

Suggerimento

Il team ef ha dimostrato e parlato approfonditamente del mapping TPC in un episodio di .NET Data Community Standup. Come per tutti gli episodi community standup, è possibile guardare l'episodio TPC ora su YouTube.

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

Suggerimento

Anziché chiamare ToTable su ogni tipo di entità solo chiamando modelBuilder.Entity<Blog>().UseTpcMappingStrategy() su ogni tipo di entità radice, i nomi delle tabelle verranno generati per convenzione.

Suggerimento

Per configurare nomi di colonna diversi per le colonne chiave primaria in ogni tabella, vedere Configurazione di facet specifica della tabella.

Entity Framework creerà lo schema di database seguente per il modello precedente.

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])
);

Schema del database TPC

La strategia TPC è simile alla strategia TPT, ad eccezione del fatto che viene creata una tabella diversa per ogni tipo concreto nella gerarchia, ma le tabelle non vengono create per i tipi astratti, quindi il nome "table-per-concrete-type". Come per TPT, la tabella stessa indica il tipo dell'oggetto salvato. Tuttavia, a differenza del mapping TPT, ogni tabella contiene colonne per ogni proprietà nel tipo concreto e nei relativi tipi di base. Gli schemi del database TPC vengono denormalizzati.

Si consideri ad esempio il mapping di questa gerarchia:

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>"}";
}

Quando si usa SQL Server, le tabelle create per questa gerarchia sono:

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]));

Si noti che:

  • Non sono presenti tabelle per i Animal tipi o Pet , poiché si trovano abstract nel modello a oggetti. Tenere presente che C# non consente istanze di tipi astratti e non esiste quindi alcuna situazione in cui un'istanza di tipo astratta verrà salvata nel database.

  • Il mapping delle proprietà nei tipi di base viene ripetuto per ogni tipo concreto. Ad esempio, ogni tabella ha una Name colonna e sia Cats che Dogs hanno una Vet colonna.

  • Il salvataggio di alcuni dati in questo database comporta quanto segue:

Tavolo gatti

ID. Nome FoodId Veterinario EducationLevel
1 Alice 99ca3e98-b26d-4a0c-d4ae-08da7aca624f Pengelly MBA
2 Mac 99ca3e98-b26d-4a0c-d4ae-08da7aca624f Pengelly Scuola materna
8 Baxter 5dc5019e-6f72-454b-d4b0-08da7aca624f Bothell Pet Hospital Bsc

Tabella Cani

ID. Nome FoodId Veterinario FavoriteToy
3 Brindare 011aaf6f-d588-4fad-d4ac-08da7aca624f Pengelly Signor Squirrel

Tabella FarmAnimals

ID. Nome FoodId Valore Specie
4 Clyde 1d495075-f527-4498-d4af-08da7aca624f 100.00 Equus africanus asinus

Tabella Degli esseri umani

ID. Nome FoodId FavoriteAnimalId
5 Wendy 5418fd81-7660-432f-d4b1-08da7aca624f 2
6 Arthur 59b495d4-0414-46bf-d4ad-08da7aca624f 1
9 Katie Null 8

Si noti che, a differenza del mapping TPT, tutte le informazioni per un singolo oggetto sono contenute in una singola tabella. A differenza del mapping TPH, inoltre, non esiste alcuna combinazione di colonna e riga in qualsiasi tabella in cui non viene mai usato dal modello. Di seguito verrà illustrato come queste caratteristiche possono essere importanti per le query e l'archiviazione.

Generazione di chiavi

La strategia di mapping dell'ereditarietà scelta ha conseguenze sulla modalità di generazione e gestione dei valori di chiave primaria. Le chiavi in TPH sono semplici, poiché ogni istanza di entità è rappresentata da una singola riga in una singola tabella. È possibile usare qualsiasi tipo di generazione di valori chiave e non sono necessari vincoli aggiuntivi.

Per la strategia TPT, è sempre presente una riga nella tabella mappata al tipo di base della gerarchia. Qualsiasi tipo di generazione di chiavi può essere usato in questa riga e le chiavi per altre tabelle sono collegate a questa tabella usando vincoli di chiave esterna.

Le cose diventano un po' più complicate per TPC. Prima di tutto, è importante comprendere che EF Core richiede che tutte le entità in una gerarchia abbiano un valore di chiave univoco, anche se le entità hanno tipi diversi. Ad esempio, usando il modello di esempio, un cane non può avere lo stesso valore di chiave ID di un gatto. In secondo luogo, a differenza di TPT, non esiste una tabella comune che può fungere da singola posizione in cui i valori chiave risiedono e possono essere generati. Ciò significa che non è possibile utilizzare una colonna semplice Identity .

Per i database che supportano le sequenze, è possibile generare valori di chiave usando una singola sequenza a cui viene fatto riferimento nel vincolo predefinito per ogni tabella. Questa è la strategia usata nelle tabelle TPC illustrate in precedenza, in cui ogni tabella presenta quanto segue:

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

AnimalSequence è una sequenza di database creata da EF Core. Questa strategia viene usata per impostazione predefinita per le gerarchie TPC quando si usa il provider di database EF Core per SQL Server. I provider di database per altri database che supportano le sequenze devono avere un valore predefinito simile. Altre strategie di generazione chiave che usano sequenze, ad esempio i modelli Hi-Lo, possono essere usate anche con TPC.

Anche se le colonne Identity standard non funzionano con TPC, è possibile usare le colonne Identity se ogni tabella è configurata con un valore di inizializzazione appropriato e un incremento in modo che i valori generati per ogni tabella non siano mai in conflitto. Ad esempio:

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));

Importante

L'uso di questa strategia rende più difficile aggiungere tipi derivati in un secondo momento perché richiede che il numero totale di tipi nella gerarchia sia noto in anticipo.

SQLite non supporta sequenze o inizializzazione/incremento dell'identità e pertanto la generazione di valori di chiave integer non è supportata quando si usa SQLite con la strategia TPC. Tuttavia, la generazione lato client o chiavi univoche globali, ad esempio i GUID, sono supportate in qualsiasi database, incluso SQLite.

Vincoli di chiave esterna

La strategia di mapping TPC crea uno schema SQL denormalizzato. Questo è un motivo per cui alcuni puristi di database sono contrari. Si consideri ad esempio la colonna FavoriteAnimalIdchiave esterna . Il valore in questa colonna deve corrispondere al valore della chiave primaria di alcuni animali. Questa operazione può essere applicata nel database con un vincolo FK semplice quando si usa TPH o TPT. Ad esempio:

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

Tuttavia, quando si utilizza il TPC, la chiave primaria per qualsiasi animale specificato viene archiviata nella tabella corrispondente al tipo concreto di tale animale. Ad esempio, la Cats.Id chiave primaria di un gatto viene archiviata nella colonna, mentre la Dogs.Id chiave primaria di un cane viene archiviata nella colonna e così via. Ciò significa che non è possibile creare un vincolo FK per questa relazione.

In pratica, questo non è un problema, purché l'applicazione non tenti di inserire dati non validi. Ad esempio, se tutti i dati vengono inseriti da EF Core e usano gli spostamenti per correlare le entità, è garantito che la colonna FK conterrà sempre valori PK validi.

Riepilogo e indicazioni

In sintesi, TPH è in genere corretto per la maggior parte delle applicazioni ed è un buon valore predefinito per un'ampia gamma di scenari, quindi non aggiungere la complessità del TPC se non è necessario. In particolare, se il codice eseguirà principalmente query per entità di molti tipi, ad esempio la scrittura di query sul tipo di base, è consigliabile usare TPH su TPC.

Detto questo, TPC è anche una buona strategia di mapping da usare quando il codice eseguirà principalmente query per le entità di un singolo tipo foglia e i benchmark mostrano un miglioramento rispetto al TPH.

Usare TPT solo se vincolato a farlo da fattori esterni.