Herencia

EF puede asignar una jerarquía de tipos de .NET a una base de datos. Esto permite escribir las entidades de .NET en el código como de costumbre, usar tipos base y derivados y usar EF para crear sin problemas el esquema de base de datos adecuado, las consultas de problemas, etc. Los detalles concretos de cómo se asigna una jerarquía de tipos dependen del proveedor; en esta página se describe la compatibilidad con la herencia en el contexto de una base de datos relacional.

Asignación de jerarquía de tipos de entidad

Por convención, EF no buscará automáticamente tipos base ni derivados; esto significa que, si desea asignar un tipo CLR en la jerarquía, debe especificar explícitamente ese tipo en el modelo. Por ejemplo, si especifica solo el tipo base de una jerarquía, EF Core no incluirá implícitamente todos sus subtipos.

En el ejemplo siguiente se expone un DbSet para Blog y su subclase RssBlog. Si Blog tiene cualquier otra subclase, no se incluirá en el modelo.

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:

Las columnas de base de datos se convierten automáticamente en columnas que admiten un valor NULL según sea necesario cuando se usa la asignación de TPH. Por ejemplo, la columna RssUrl admite valores NULL porque las instancias de Blog normales no tienen esa propiedad.

Si no desea exponer un DbSet para una o varias entidades de la jerarquía, también puede usar la API fluida para asegurarse de que se incluyen en el modelo.

Sugerencia

Si no se basa en las convenciones, puede especificar explícitamente el tipo base mediante HasBaseType. También puede usar .HasBaseType((Type)null) para quitar un tipo de entidad de la jerarquía.

Configuración de tabla por jerarquía y de discriminador

De forma predeterminada, EF asigna la herencia mediante el patrón tabla por jerarquía (TPH). TPH usa una sola tabla para almacenar los datos de todos los tipos de la jerarquía y se usa una columna discriminadora para identificar el tipo que cada fila representa.

El modelo anterior se asigna al siguiente esquema de base de datos (fíjese en la columna Discriminator creada implícitamente, que identifica el tipo de Blog que se almacena en cada fila).

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

Puede configurar el nombre y el tipo de la columna discriminadora y los valores que se usan para identificar cada tipo de la jerarquía:

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

En los ejemplos anteriores, EF agregó implícitamente el discriminador como propiedad de sombra en la entidad base de la jerarquía. Esta propiedad se puede configurar como cualquier otra:

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

Por último, el discriminador también se puede asignar a una propiedad de .NET normal en la entidad:

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

Al consultar las entidades derivadas, que usan el patrón de TPH, EF Core agrega un predicado sobre la columna discriminadora en la consulta. Este filtro se asegura de que no se obtienen filas adicionales para tipos base ni tipos del mismo nivel que no estén en el resultado. Este predicado de filtro se omite para el tipo de entidad base, ya que la consulta de la entidad base obtiene resultados para todas las entidades de la jerarquía. Al materializar los resultados de una consulta, si encontramos un valor discriminador que no esté asignado a ningún tipo de entidad en el modelo, iniciaremos una excepción, ya que no sabemos cómo materializar los resultados. Este error solo se produce si la base de datos contiene filas con valores discriminadores que no están asignados en el modelo de EF. Si tiene dichos datos, puede marcar como incompleta la asignación de discriminadores en el modelo de EF Core para indicar que siempre debemos agregar el predicado de filtro para consultar cualquier tipo de la jerarquía. La llamada IsComplete(false) en la configuración de discriminador marca la asignación como incompleta.

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

Columnas compartidas

De forma predeterminada, cuando dos tipos de entidad del mismo nivel de la jerarquía tienen una propiedad con el mismo nombre, se asignarán a dos columnas independientes. Sin embargo, si su tipo es idéntico, se pueden asignar a la misma columna de base de datos:

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:

Los proveedores de bases de datos relacionales, como SQL Server, no usarán automáticamente el predicado discriminador al consultar columnas compartidas cuando usan una conversión. La consulta Url = (blog as RssBlog).Url también devolvería el valor Url para las filas Blog del mismo nivel. Para restringir la consulta a entidades RssBlog, debe agregar manualmente un filtro al discriminador, como Url = blog is RssBlog ? (blog as RssBlog).Url : null.

Configuración de tabla por tipo

En el patrón de asignación de TPT, todos los tipos se asignan a tablas individuales. Las propiedades que pertenecen solamente a un tipo base o a un tipo derivado se almacenan en una tabla que se asigna a ese tipo. Las tablas que se asignan a tipos derivados también almacenan una clave externa que combina la tabla derivada con la tabla base.

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

Sugerencia

En lugar de llamar a ToTable en cada tipo de entidad, puede llamar a modelBuilder.Entity<Blog>().UseTptMappingStrategy() en cada tipo de entidad raíz y EF generará los nombres de tabla.

Sugerencia

Para configurar nombres de columna diferentes para las columnas de clave principal de cada tabla, consulte la configuración de facetas específicas de tabla.

EF creará el siguiente esquema de base de datos para el modelo anterior.

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:

Si se cambia el nombre de la restricción de clave principal, el nuevo nombre se aplicará a todas las tablas asignadas a la jerarquía; las versiones futuras de EF permitirán cambiar el nombre de la restricción solo para una tabla determinada cuando se corrija el problema 19970.

Si usa la configuración masiva, puede recuperar el nombre de columna de una tabla específica llamando a 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();
}

Advertencia

En muchos casos, TPT presenta un rendimiento inferior en comparación con TPH. Consulte la documentación de rendimiento para obtener más información.

Precaución

Las columnas de un tipo derivado se asignan a tablas diferentes, por lo que las restricciones de FK compuestas y los índices que usan las propiedades heredadas y declaradas no se pueden crear en la base de datos.

Configuración de tabla por tipo concreto

Nota:

La característica de tabla por tipo concreto (TPC) se introdujo en EF Core 7.0.

En el patrón de asignación de TPC, todos los tipos se asignan a tablas individuales. Cada tabla contiene columnas para todas las propiedades del tipo de entidad correspondiente. Esto soluciona algunos problemas comunes de rendimiento que la estrategia de tabla por tipo tiene.

Sugerencia

El equipo de EF mostró y habló en profundidad sobre la asignación de TPC en un episodio de la reunión de la comunidad de datos de .NET. Al igual que todos los episodios de la reunión de la comunidad, puede ver ahora el episodio de TPC en YouTube.

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

Sugerencia

En lugar de llamar a ToTable en cada tipo de entidad, si llama a modelBuilder.Entity<Blog>().UseTpcMappingStrategy() en cada tipo de entidad raíz se generarán los nombres de tabla por convención.

Sugerencia

Para configurar nombres de columna diferentes para las columnas de clave principal de cada tabla, consulte la configuración de facetas específicas de tabla.

EF creará el siguiente esquema de base de datos para el modelo anterior.

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

Esquema de base de datos de TPC

La estrategia de TPC es similar a la estrategia de TPT, excepto que se crea una tabla diferente para cada tipo concreto de la jerarquía, pero las tablas no se crean para tipos abstractos; de ahí el nombre "tabla por tipo concreto". Al igual que con TPT, la propia tabla indica el tipo del objeto guardado. Sin embargo, a diferencia de la asignación de TPT, cada tabla contiene columnas para cada propiedad del tipo concreto y sus tipos base. Los esquemas de base de datos TPC se desnormalizan.

Por ejemplo, considere la asignación de esta jerarquía:

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

Al usar SQL Server, las tablas creadas para esta jerarquía son:

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

Tenga en lo siguiente:

  • No hay tablas para los tipos Animal o Pet, ya que son abstract en el modelo de objetos. Recuerde que C# no permite instancias de tipos abstractos y, por tanto, no hay ninguna situación en la que se guardará una instancia de tipo abstracto en la base de datos.

  • La asignación de propiedades en tipos base se repite para cada tipo concreto. Por ejemplo, cada tabla tiene una columna Name y los gatos y perros tienen una columna Vet.

  • Guardar algunos datos en esta base de datos da como resultado lo siguiente:

Tabla de gatos

Identificador Nombre FoodId Veterinario EducationLevel
1 Alice 99ca3e98-b26d-4a0c-d4ae-08da7aca624f Pengelly MBA
2 Mac 99ca3e98-b26d-4a0c-d4ae-08da7aca624f Pengelly Preescolar
8 Baxter 5dc5019e-6f72-454b-d4b0-08da7aca624f Hospital de Mascotas de Bothell BSc

Tabla de perros

Identificador Nombre FoodId Veterinario FavoriteToy
3 Notificación del sistema 011aaf6f-d588-4fad-d4ac-08da7aca624f Pengelly Sr. Ardilla

Tabla FarmAnimals

Identificador Nombre FoodId Valor Especie
4 Clyde 1d495075-f527-4498-d4af-08da7aca624f 100,00 Equus africanus asinus

Tabla de seres humanos

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

Tenga en cuenta que, a diferencia de la asignación de TPT, toda la información de un solo objeto se encuentra en una sola tabla. Y, a diferencia de la asignación de TPH, no hay ninguna combinación de columnas y filas en ninguna tabla donde el modelo nunca lo use. Veremos a continuación cómo estas características pueden ser importantes para las consultas y el almacenamiento.

Generación de la clave

La estrategia de asignación de herencia elegida tiene consecuencias sobre cómo se generan y administran los valores de clave principal. Las claves de TPH son fáciles, ya que cada instancia de entidad se representa mediante una sola fila en una sola tabla. Se puede usar cualquier tipo de generación de valores de clave y no se necesitan restricciones adicionales.

Para la estrategia de TPT, siempre hay una fila en la tabla asignada al tipo base de la jerarquía. Cualquier tipo de generación de claves se puede usar en esta fila y las claves de otras tablas están vinculadas a esta tabla mediante restricciones de clave externa.

Las cosas se complican un poco más en el caso de TPC. En primer lugar, es importante comprender que EF Core requiere que todas las entidades de una jerarquía tengan un valor de clave único, incluso si las entidades tienen tipos diferentes. Por ejemplo, con nuestro modelo de ejemplo, un perro no puede tener el mismo valor de clave de id. que un gato. En segundo lugar, a diferencia de TPT, no hay ninguna tabla común que pueda actuar como el único lugar en el que los valores de clave residen y se pueden generar. Esto significa que no se puede usar una columna simple Identity.

En el caso de las bases de datos que admiten secuencias, los valores de clave se pueden generar mediante una sola secuencia a la que se hace referencia en la restricción predeterminada para cada tabla. Esta es la estrategia que se usa en las tablas de TPC mostradas anteriormente, donde cada tabla tiene lo siguiente:

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

AnimalSequence es una secuencia de base de datos creada por EF Core. Esta estrategia se usa de forma predeterminada para las jerarquías de TPC cuando se usa el proveedor de bases de datos de EF Core para SQL Server. Los proveedores de bases de datos para otras bases de datos que admiten secuencias deben tener un valor predeterminado similar. Otras estrategias de generación clave que usan secuencias, como los patrones Hi-Lo, también se pueden usar con TPC.

Aunque las columnas Identity estándar no funcionan con TPC, es posible utilizar columnas Identity si cada tabla se configura con un valor de inicialización y un incremento adecuados, de forma que los valores generados para cada tabla nunca entren en conflicto. Por ejemplo:

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

El uso de esta estrategia hace que sea más difícil agregar tipos derivados más adelante, ya que requiere conocer de antemano el número total de tipos de la jerarquía.

SQLite no admite secuencias ni inicialización ni incremento de identidad y, por tanto, no se admite la generación de valores de clave enteros al usar SQLite con la estrategia de TPC. Sin embargo, la generación del lado cliente o claves únicas globalmente (por ejemplo, GUID) se admiten en cualquier base de datos, incluida SQLite.

Restricciones de clave externa

La estrategia de asignación de TPC crea un esquema SQL desnormalizado; este es un motivo por el que algunos puristas de bases de datos se oponen a dicha estrategia. Por ejemplo, considere la columna FavoriteAnimalId de clave externa. El valor de esta columna debe coincidir con el valor de clave principal de algún animal. Esto se puede aplicar en la base de datos con una restricción FK simple al usar TPH o TPT. Por ejemplo:

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

Pero, cuando se usa TPC, la clave principal para un animal determinado se almacena en la tabla que se corresponde con el tipo concreto de ese animal. Por ejemplo, la clave principal de un gato se almacena en la columna Cats.Id, mientras que la clave principal de un perro se almacena en la columna Dogs.Id, etc. Esto significa que no se puede crear una restricción FK para esta relación.

En la práctica, esto no es un problema siempre y cuando la aplicación no intente insertar datos no válidos. Por ejemplo, si EF Core inserta todos los datos y usa navegaciones para relacionar entidades, se garantiza que la columna FK contendrá un valor PK válido en todo momento.

Resumen e instrucciones

En resumen, TPH suele ser adecuado para la mayoría de las aplicaciones y es un buen valor predeterminado para una amplia gama de escenarios; por tanto, no agregue la complejidad de TPC si no le hace falta. En concreto, si el código va consultar principalmente las entidades de muchos tipos, como escribir consultas en el tipo base, después preferirá TPH antes que TPC.

Dicho esto, TPC también es una buena estrategia de asignación para usar cuando el código consulte principalmente las entidades de un tipo hoja único y los puntos de referencia muestren una mejora en comparación con el TPH.

Use TPT solo si se ve obligado a hacerlo por factores externos.