Novedades de EF Core 8

EF Core 8.0 (EF8) se publicó en noviembre de 2023.

Sugerencia

Puede ejecutar y depurar los ejemplos descargando el código de ejemplo de GitHub. Cada sección se vincula al código fuente específico de esa sección.

EF8 requiere el SDK de .NET 8 para compilar y requiere que se ejecute el runtime de .NET 8. EF8 no se ejecutará en versiones anteriores de .NET y no se ejecutará en .NET Framework.

Objetos de valores mediante tipos complejos

Los objetos guardados en la base de datos se pueden dividir en tres categorías generales:

  • Objetos que no están estructurados y contienen un único valor. Por ejemplo, int, Guid, string, IPAddress. Estos se denominan (de forma un tanto imprecisa) "tipos primitivos".
  • Objetos que están estructurados para contener múltiples valores, y en los que la identidad del objeto está definida por un valor clave. Por ejemplo: Blog, Post y Customer. Se denominan ”tipos de entidad”.
  • Objetos que están estructurados para contener múltiples valores, pero el objeto no tiene una clave que defina la identidad. Por ejemplo: Address, Coordinate.

Antes de EF8, no había ninguna manera adecuada de asignar el tercer tipo de objeto. Se pueden utilizar los tipos en propiedad, pero dado que los tipos en propiedad son realmente tipos de entidad, tienen una semántica basada en un valor de clave, incluso cuando ese valor de clave está oculto.

EF8 ahora admite "Tipos complejos" para cubrir este tercer tipo de objeto. Objetos de tipo complejo:

  • No se identifica ni realiza un seguimiento por valor de clave.
  • Deben definirse como parte de un tipo de entidad. (Es decir, no pueden tener un DbSet de un tipo complejo).
  • Pueden ser tipos de valor o tipos de referencia de .NET.
  • Las instancias se pueden compartir por varias propiedades.

Ejemplo sencillo

Por ejemplo, considere un tipo Address:

public class Address
{
    public required string Line1 { get; set; }
    public string? Line2 { get; set; }
    public required string City { get; set; }
    public required string Country { get; set; }
    public required string PostCode { get; set; }
}

Address se usa después en tres lugares en un modelo simple de clientes y pedidos:

public class Customer
{
    public int Id { get; set; }
    public required string Name { get; set; }
    public required Address Address { get; set; }
    public List<Order> Orders { get; } = new();
}

public class Order
{
    public int Id { get; set; }
    public required string Contents { get; set; }
    public required Address ShippingAddress { get; set; }
    public required Address BillingAddress { get; set; }
    public Customer Customer { get; set; } = null!;
}

Vamos a crear y guardar un cliente con su dirección:

var customer = new Customer
{
    Name = "Willow",
    Address = new() { Line1 = "Barking Gate", City = "Walpole St Peter", Country = "UK", PostCode = "PE14 7AV" }
};

context.Add(customer);
await context.SaveChangesAsync();

Esto da como resultado la siguiente fila que se inserta en la base de datos:

INSERT INTO [Customers] ([Name], [Address_City], [Address_Country], [Address_Line1], [Address_Line2], [Address_PostCode])
OUTPUT INSERTED.[Id]
VALUES (@p0, @p1, @p2, @p3, @p4, @p5);

Observe que los tipos complejos no obtienen sus propias tablas. En su lugar, se guardan insertados en columnas de la tabla Customers. Esto coincide con el comportamiento de uso compartido de tablas de tipos en propiedad.

Nota:

No tenemos previsto permitir que los tipos complejos se asignen a su propia tabla. Sin embargo, en una versión futura, tenemos previsto permitir que el tipo complejo se guarde como un documento JSON en una sola columna. Vote por el problema 31252 si es importante para usted.

Ahora supongamos que queremos enviar un pedido a un cliente y usar la dirección del cliente como dirección de facturación y de envío predeterminada. La manera natural de hacerlo es copiar el objeto Address de Customer en Order. Por ejemplo:

customer.Orders.Add(
    new Order { Contents = "Tesco Tasty Treats", BillingAddress = customer.Address, ShippingAddress = customer.Address, });

await context.SaveChangesAsync();

Con tipos complejos, funciona según lo previsto y la dirección se inserta en la tabla Orders:

INSERT INTO [Orders] ([Contents], [CustomerId],
    [BillingAddress_City], [BillingAddress_Country], [BillingAddress_Line1], [BillingAddress_Line2], [BillingAddress_PostCode],
    [ShippingAddress_City], [ShippingAddress_Country], [ShippingAddress_Line1], [ShippingAddress_Line2], [ShippingAddress_PostCode])
OUTPUT INSERTED.[Id]
VALUES (@p0, @p1, @p2, @p3, @p4, @p5, @p6, @p7, @p8, @p9, @p10, @p11);

Hasta aquí podría estar diciendo: "pero yo podría hacer esto con tipos en propiedad". Sin embargo, la semántica de "tipo de entidad" de los tipos en propiedad se interpone rápidamente en el camino. Por ejemplo, ejecutar el código anterior con tipos en propiedad da como resultado una serie de advertencias y después un error:

warn: 8/20/2023 12:48:01.678 CoreEventId.DuplicateDependentEntityTypeInstanceWarning[10001] (Microsoft.EntityFrameworkCore.Update) 
      The same entity is being tracked as different entity types 'Order.BillingAddress#Address' and 'Customer.Address#Address' with defining navigations. If a property value changes, it will result in two store changes, which might not be the desired outcome.
warn: 8/20/2023 12:48:01.687 CoreEventId.DuplicateDependentEntityTypeInstanceWarning[10001] (Microsoft.EntityFrameworkCore.Update) 
      The same entity is being tracked as different entity types 'Order.ShippingAddress#Address' and 'Customer.Address#Address' with defining navigations. If a property value changes, it will result in two store changes, which might not be the desired outcome.
warn: 8/20/2023 12:48:01.687 CoreEventId.DuplicateDependentEntityTypeInstanceWarning[10001] (Microsoft.EntityFrameworkCore.Update)
      The same entity is being tracked as different entity types 'Order.ShippingAddress#Address' and 'Order.BillingAddress#Address' with defining navigations. If a property value changes, it will result in two store changes, which might not be the desired outcome.
fail: 8/20/2023 12:48:01.709 CoreEventId.SaveChangesFailed[10000] (Microsoft.EntityFrameworkCore.Update) 
      An exception occurred in the database while saving changes for context type 'NewInEfCore8.ComplexTypesSample+CustomerContext'.
      System.InvalidOperationException: Cannot save instance of 'Order.ShippingAddress#Address' because it is an owned entity without any reference to its owner. Owned entities can only be saved as part of an aggregate also including the owner entity.
         at Microsoft.EntityFrameworkCore.ChangeTracking.Internal.InternalEntityEntry.PrepareToSave()

Esto se debe a que se usa una sola instancia del tipo de entidad Address (con el mismo valor de clave oculta) para tres instancias de la entidad diferentes. Por otro lado, se permite compartir la misma instancia entre propiedades complejas, por lo que el código funciona según lo previsto cuando se usan tipos complejos.

Configuración de tipos complejos

Los tipos complejos deben configurarse en el modelo mediante la asignación de atributos o llamando a la ComplexProperty API en OnModelCreating. Los tipos complejos no se detectan por convención.

Por ejemplo, el tipo Address se puede configurar mediante ComplexTypeAttribute:

[ComplexType]
public class Address
{
    public required string Line1 { get; set; }
    public string? Line2 { get; set; }
    public required string City { get; set; }
    public required string Country { get; set; }
    public required string PostCode { get; set; }
}

O en OnModelCreating:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Customer>()
        .ComplexProperty(e => e.Address);

    modelBuilder.Entity<Order>(b =>
    {
        b.ComplexProperty(e => e.BillingAddress);
        b.ComplexProperty(e => e.ShippingAddress);
    });
}

Mutabilidad

En el ejemplo anterior, terminamos con la misma instancia de Address usada en tres lugares. Esto se permite y no causa ningún problema para EF Core al usar tipos complejos. Sin embargo, compartir instancias del mismo tipo de referencia significa que si se modifica un valor de propiedad en la instancia, ese cambio se reflejará en los tres usos. Por ejemplo, después de lo anterior, vamos a cambiar Line1 de la dirección del cliente y guardar los cambios:

customer.Address.Line1 = "Peacock Lodge";
await context.SaveChangesAsync();

Esto da como resultado la siguiente actualización a la base de datos cuando se usa SQL Server:

UPDATE [Customers] SET [Address_Line1] = @p0
OUTPUT 1
WHERE [Id] = @p1;
UPDATE [Orders] SET [BillingAddress_Line1] = @p2, [ShippingAddress_Line1] = @p3
OUTPUT 1
WHERE [Id] = @p4;

Observe que las tres columnas Line1 han cambiado, ya que todas comparten la misma instancia. Esto no suele ser lo que queremos.

Sugerencia

Si las direcciones del pedido deben cambiar automáticamente cuando cambie la dirección del cliente, considere la posibilidad de asignar la dirección como un tipo de entidad. Order y Customer pueden entonces hacer referencia de forma segura a la misma instancia de dirección (que ahora se identifica mediante una clave) a través de una propiedad de navegación.

Una buena manera de tratar problemas como este es hacer que el tipo sea inmutable. De hecho, esta inmutabilidad suele ser natural cuando un tipo es un buen candidato para ser un tipo complejo. Por ejemplo, normalmente tiene sentido proporcionar un nuevo objeto complejo Address en lugar de simplemente mutar, por ejemplo, el país mientras deja el resto igual.

Los tipos de referencia y de valor se pueden hacer inmutables. Veremos algunos ejemplos en las secciones siguientes.

Tipos de referencia como tipos complejos

Clase inmutable

Hemos usado una class mutable y simple en el ejemplo anterior. Para evitar los problemas con la mutación accidental descrita anteriormente, podemos hacer que la clase sea inmutable. Por ejemplo:

public class Address
{
    public Address(string line1, string? line2, string city, string country, string postCode)
    {
        Line1 = line1;
        Line2 = line2;
        City = city;
        Country = country;
        PostCode = postCode;
    }

    public string Line1 { get; }
    public string? Line2 { get; }
    public string City { get; }
    public string Country { get; }
    public string PostCode { get; }
}

Sugerencia

Con C# 12 o versiones superiores, esta definición de clase se puede simplificar mediante un constructor principal:

public class Address(string line1, string? line2, string city, string country, string postCode)
{
    public string Line1 { get; } = line1;
    public string? Line2 { get; } = line2;
    public string City { get; } = city;
    public string Country { get; } = country;
    public string PostCode { get; } = postCode;
}

Ahora no es posible cambiar el valor de Line1 en una dirección existente. En su lugar, hay que crear una nueva instancia con el valor cambiado. Por ejemplo:

var currentAddress = customer.Address;
customer.Address = new Address(
    "Peacock Lodge", currentAddress.Line2, currentAddress.City, currentAddress.Country, currentAddress.PostCode);

await context.SaveChangesAsync();

Esta vez, la llamada a SaveChangesAsync solo actualiza la dirección del cliente:

UPDATE [Customers] SET [Address_Line1] = @p0
OUTPUT 1
WHERE [Id] = @p1;

Tenga en cuenta que, aunque el objeto de dirección es inmutable y se ha cambiado todo el objeto, EF sigue realizando el seguimiento de los cambios en las propiedades individuales, por lo que solo se actualizan las columnas con valores modificados.

Registro inmutable

C# 9 introdujo los tipos de registro, que facilitan la creación y el uso de objetos inmutables. Por ejemplo, el objeto Address se puede convertir en un tipo de registro:

public record Address
{
    public Address(string line1, string? line2, string city, string country, string postCode)
    {
        Line1 = line1;
        Line2 = line2;
        City = city;
        Country = country;
        PostCode = postCode;
    }

    public string Line1 { get; init; }
    public string? Line2 { get; init; }
    public string City { get; init; }
    public string Country { get; init; }
    public string PostCode { get; init; }
}

Sugerencia

Esta definición de registro se puede simplificar mediante un constructor principal:

public record Address(string Line1, string? Line2, string City, string Country, string PostCode);

Reemplazar el objeto mutable y llamar a SaveChanges ahora requiere menos código:

customer.Address = customer.Address with { Line1 = "Peacock Lodge" };

await context.SaveChangesAsync();

Tipos de valor como tipos complejos

Estructura mutable

Un tipo de valor simple y mutable se puede usar como un tipo complejo. Por ejemplo, Address se puede definir como un struct en C#:

public struct Address
{
    public required string Line1 { get; set; }
    public string? Line2 { get; set; }
    public required string City { get; set; }
    public required string Country { get; set; }
    public required string PostCode { get; set; }
}

Asignar el objeto Address del cliente a las propiedades de la Address de envío y facturación da como resultado que cada propiedad obtenga una copia de la Address, ya que es así cómo funcionan los tipos de valor. Esto significa que modificar Address en el cliente no cambiará las instancias de envío o facturación Address, por lo que las estructuras mutables no tienen los mismos problemas de uso compartido de instancias que se producen con clases mutables.

Sin embargo, las estructuras mutables generalmente se desaconsejan en C#, por lo que piense con mucho cuidado antes de usarlas.

Estructura inmutable

Las estructuras inmutables funcionan bien como los tipos complejos, al igual que lo hacen las clases inmutables. Por ejemplo, Address se puede definir de forma que no se pueda modificar:

public readonly struct Address(string line1, string? line2, string city, string country, string postCode)
{
    public string Line1 { get; } = line1;
    public string? Line2 { get; } = line2;
    public string City { get; } = city;
    public string Country { get; } = country;
    public string PostCode { get; } = postCode;
}

El código para cambiar la dirección ahora tiene el mismo aspecto que cuando se usa la clase inmutable:

var currentAddress = customer.Address;
customer.Address = new Address(
    "Peacock Lodge", currentAddress.Line2, currentAddress.City, currentAddress.Country, currentAddress.PostCode);

await context.SaveChangesAsync();

Registro de estructura inmutable

C# 10 introdujo tipos struct record, lo que facilita la creación y el trabajo con registros de estructura inmutables, como sucede con registros de clase inmutables. Por ejemplo, podemos definir Address como un registro de estructura inmutable:

public readonly record struct Address(string Line1, string? Line2, string City, string Country, string PostCode);

El código para cambiar la dirección ahora tiene el mismo aspecto que cuando se usa un registro de clase inmutable:

customer.Address = customer.Address with { Line1 = "Peacock Lodge" };

await context.SaveChangesAsync();

Tipos complejos anidados

Un tipo complejo puede contener propiedades de otros tipos complejos. Por ejemplo, vamos a usar nuestro tipo complejo anterior Address junto con un tipo complejo PhoneNumber y anidarlos ambos dentro de otro tipo complejo:

public record Address(string Line1, string? Line2, string City, string Country, string PostCode);

public record PhoneNumber(int CountryCode, long Number);

public record Contact
{
    public required Address Address { get; init; }
    public required PhoneNumber HomePhone { get; init; }
    public required PhoneNumber WorkPhone { get; init; }
    public required PhoneNumber MobilePhone { get; init; }
}

Aquí se usan registros inmutables, ya que son una buena coincidencia para la semántica de nuestros tipos complejos, pero el anidamiento de tipos complejos se puede realizar con cualquier tipo de .NET.

Nota:

No se usa un constructor principal para el tipo Contact porque EF Core aún no admite la inserción de constructores de valores de tipo complejo. Vote por el problema 31621 si esto es importante para usted.

Agregaremos Contact como una propiedad de Customer:

public class Customer
{
    public int Id { get; set; }
    public required string Name { get; set; }
    public required Contact Contact { get; set; }
    public List<Order> Orders { get; } = new();
}

Y PhoneNumber como propiedades de Order:

public class Order
{
    public int Id { get; set; }
    public required string Contents { get; set; }
    public required PhoneNumber ContactPhone { get; set; }
    public required Address ShippingAddress { get; set; }
    public required Address BillingAddress { get; set; }
    public Customer Customer { get; set; } = null!;
}

La configuración de tipos complejos anidados se puede lograr de nuevo mediante ComplexTypeAttribute:

[ComplexType]
public record Address(string Line1, string? Line2, string City, string Country, string PostCode);

[ComplexType]
public record PhoneNumber(int CountryCode, long Number);

[ComplexType]
public record Contact
{
    public required Address Address { get; init; }
    public required PhoneNumber HomePhone { get; init; }
    public required PhoneNumber WorkPhone { get; init; }
    public required PhoneNumber MobilePhone { get; init; }
}

O en OnModelCreating:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Customer>(
        b =>
        {
            b.ComplexProperty(
                e => e.Contact,
                b =>
                {
                    b.ComplexProperty(e => e.Address);
                    b.ComplexProperty(e => e.HomePhone);
                    b.ComplexProperty(e => e.WorkPhone);
                    b.ComplexProperty(e => e.MobilePhone);
                });
        });

    modelBuilder.Entity<Order>(
        b =>
        {
            b.ComplexProperty(e => e.ContactPhone);
            b.ComplexProperty(e => e.BillingAddress);
            b.ComplexProperty(e => e.ShippingAddress);
        });
}

Consultas

Las propiedades de tipos complejos en los tipos de entidad se tratan como cualquier otra propiedad que no sea de navegación del tipo de entidad. Esto significa que siempre se cargan cuando se carga el tipo de entidad. Esto también es cierto en cualquier propiedad de tipo complejo anidado. Por ejemplo, consultando para un cliente:

var customer = await context.Customers.FirstAsync(e => e.Id == customerId);

Se traducirá al siguiente código SQL al usar SQL Server:

SELECT TOP(1) [c].[Id], [c].[Name], [c].[Contact_Address_City], [c].[Contact_Address_Country],
    [c].[Contact_Address_Line1], [c].[Contact_Address_Line2], [c].[Contact_Address_PostCode],
    [c].[Contact_HomePhone_CountryCode], [c].[Contact_HomePhone_Number], [c].[Contact_MobilePhone_CountryCode],
    [c].[Contact_MobilePhone_Number], [c].[Contact_WorkPhone_CountryCode], [c].[Contact_WorkPhone_Number]
FROM [Customers] AS [c]
WHERE [c].[Id] = @__customerId_0

Observe dos cosas de este código SQL:

  • Todo se devuelve para rellenar y del cliente todos los tipos complejos anidados Contact, Address y PhoneNumber.
  • Todos los valores de tipo complejo se almacenan como columnas de la tabla para el tipo de entidad. Los tipos complejos nunca se asignan a tablas independientes.

Proyecciones

Los tipos complejos se pueden proyectar desde una consulta. Por ejemplo, al seleccionar solo la dirección de envío de un pedido:

var shippingAddress = await context.Orders
    .Where(e => e.Id == orderId)
    .Select(e => e.ShippingAddress)
    .SingleAsync();

Esto se traduce en lo siguiente cuando se usa SQL Server:

SELECT TOP(2) [o].[ShippingAddress_City], [o].[ShippingAddress_Country], [o].[ShippingAddress_Line1],
    [o].[ShippingAddress_Line2], [o].[ShippingAddress_PostCode]
FROM [Orders] AS [o]
WHERE [o].[Id] = @__orderId_0

Tenga en cuenta que no se puede realizar un seguimiento de las proyecciones de tipos complejos, ya que los objetos de tipo complejo no tienen ninguna identidad que usar para el seguimiento.

Uso en predicados

Los miembros de tipos complejos se pueden usar en predicados. Por ejemplo, buscar todos los pedidos que van a una determinada ciudad:

var city = "Walpole St Peter";
var walpoleOrders = await context.Orders.Where(e => e.ShippingAddress.City == city).ToListAsync();

Lo que se traduce en el siguiente código SQL en SQL Server:

SELECT [o].[Id], [o].[Contents], [o].[CustomerId], [o].[BillingAddress_City], [o].[BillingAddress_Country],
    [o].[BillingAddress_Line1], [o].[BillingAddress_Line2], [o].[BillingAddress_PostCode],
    [o].[ContactPhone_CountryCode], [o].[ContactPhone_Number], [o].[ShippingAddress_City],
    [o].[ShippingAddress_Country], [o].[ShippingAddress_Line1], [o].[ShippingAddress_Line2],
    [o].[ShippingAddress_PostCode]
FROM [Orders] AS [o]
WHERE [o].[ShippingAddress_City] = @__city_0

También se puede usar una instancia de tipo complejo completa en predicados. Por ejemplo, buscar todos los clientes con un número de teléfono determinado:

var phoneNumber = new PhoneNumber(44, 7777555777);
var customersWithNumber = await context.Customers
    .Where(
        e => e.Contact.MobilePhone == phoneNumber
             || e.Contact.WorkPhone == phoneNumber
             || e.Contact.HomePhone == phoneNumber)
    .ToListAsync();

Esto se traduce en el siguiente código SQL cuando se usa SQL Server:

SELECT [c].[Id], [c].[Name], [c].[Contact_Address_City], [c].[Contact_Address_Country], [c].[Contact_Address_Line1],
     [c].[Contact_Address_Line2], [c].[Contact_Address_PostCode], [c].[Contact_HomePhone_CountryCode],
     [c].[Contact_HomePhone_Number], [c].[Contact_MobilePhone_CountryCode], [c].[Contact_MobilePhone_Number],
     [c].[Contact_WorkPhone_CountryCode], [c].[Contact_WorkPhone_Number]
FROM [Customers] AS [c]
WHERE ([c].[Contact_MobilePhone_CountryCode] = @__entity_equality_phoneNumber_0_CountryCode
    AND [c].[Contact_MobilePhone_Number] = @__entity_equality_phoneNumber_0_Number)
OR ([c].[Contact_WorkPhone_CountryCode] = @__entity_equality_phoneNumber_0_CountryCode
    AND [c].[Contact_WorkPhone_Number] = @__entity_equality_phoneNumber_0_Number)
OR ([c].[Contact_HomePhone_CountryCode] = @__entity_equality_phoneNumber_0_CountryCode
    AND [c].[Contact_HomePhone_Number] = @__entity_equality_phoneNumber_0_Number)

Observe que la igualdad se realiza expandiendo cada miembro del tipo complejo. Esto se alinea con tipos complejos que no tienen ninguna clave para la identidad y, por tanto, una instancia de tipo complejo es igual a otra instancia de tipo complejo si y solo si todos sus miembros son iguales. Esto también se alinea con la igualdad definida por .NET para los tipos de registro.

Manipulación de valores de tipo complejo

EF8 proporciona acceso a información de seguimiento, como los valores actuales y originales de tipos complejos y si se ha modificado o no un valor de propiedad. Los tipos complejos de API son una extensión de la API de seguimiento de cambios que ya se usa para los tipos de entidad.

Los métodos ComplexProperty de EntityEntry devuelven una entrada para un objeto complejo completo. Por ejemplo, para obtener el valor actual de Order.BillingAddress:

var billingAddress = context.Entry(order)
    .ComplexProperty(e => e.BillingAddress)
    .CurrentValue;

Se puede agregar una llamada a Property para tener acceso a una propiedad del tipo complejo. Por ejemplo, para obtener el valor actual de solo el código de publicación de facturación:

var postCode = context.Entry(order)
    .ComplexProperty(e => e.BillingAddress)
    .Property(e => e.PostCode)
    .CurrentValue;

Se accede a los tipos complejos anidados mediante llamadas anidadas a ComplexProperty. Por ejemplo, para obtener la ciudad del Address anidado del Contact en un Customer:

var currentCity = context.Entry(customer)
    .ComplexProperty(e => e.Contact)
    .ComplexProperty(e => e.Address)
    .Property(e => e.City)
    .CurrentValue;

Otros métodos están disponibles para leer y cambiar el estado. Por ejemplo, PropertyEntry.IsModified se puede usar para establecer una propiedad de un tipo complejo como modificada:

context.Entry(customer)
    .ComplexProperty(e => e.Contact)
    .ComplexProperty(e => e.Address)
    .Property(e => e.PostCode)
    .IsModified = true;

Limitaciones actuales

Los tipos complejos representan una inversión significativa en toda la pila de EF. No hemos podido hacer que todo funcione en esta versión, pero tenemos previsto llenar algunas lagunas en una versión futura. Asegúrese de votar () en los problemas de GitHub adecuados si solucionar cualquiera de estas limitaciones es importante para usted.

Entre las limitaciones de tipos complejos de EF8 se incluyen:

  • Admitir colecciones de tipos complejos. (Problema 31237)
  • Permitir que las propiedades de tipo complejo sean null. (Problema 31376)
  • Asignar propiedades de tipo complejo a columnas JSON. (Problema 31252)
  • Insertar constructores para tipos complejos. (Problema 31621)
  • Agregar compatibilidad con datos de inicialización para tipos complejos. (Problema 31254)
  • Asignar propiedades de tipo complejo para el proveedor de Cosmos. (Problema 31253)
  • Implementar tipos complejos para la base de datos en memoria. (Problema 31464)

Colecciones primitivas

Una pregunta persistente al usar bases de datos relacionales es qué hacer con las colecciones de tipos primitivos; es decir, las listas o matrices de enteros, fecha y hora, cadenas, etc. Si usa PostgreSQL, es fácil almacenar estos elementos mediante su tipo de matriz integrado. Para otras bases de datos, hay dos enfoques comunes:

  • Cree una tabla con una columna para el valor de tipo primitivo y otra columna que actúe de clave externa que vincule cada valor a su propietario de la colección.
  • Serialice la colección primitiva en algún tipo de columna que controle la base de datos, por ejemplo, desde y hacia una cadena.

La primera opción tiene ventajas en muchas situaciones: la echaremos un vistazo rápido al final de esta sección. Sin embargo, no es una representación natural de los datos del modelo y, si lo que realmente tiene es una colección de un tipo primitivo, la segunda opción puede ser más eficaz.

A partir de la versión preliminar 4, EF8 incluye compatibilidad integrada con la segunda opción, con JSON como formato de serialización. JSON funciona bien para esto, ya que las bases de datos relacionales modernas incluyen mecanismos integrados para consultar y manipular JSON, de modo que la columna JSON puede tratarse eficazmente como tabla cuando sea necesario, sin la sobrecarga de crear realmente esa tabla. Estos mismos mecanismos permiten pasar JSON en parámetros y usarlos como parámetros con valores de tabla en las consultas, más información sobre esto más adelante.

Sugerencia

El código que se muestra aquí procede de PrimitiveCollectionsSample.cs.

Propiedades de la colección primitiva

EF Core puede asignar cualquier propiedad IEnumerable<T>, donde T es un tipo primitivo, a una columna JSON de la base de datos. Esto se realiza por convención para las propiedades públicas que tienen un captador y un establecedor. Por ejemplo, todas las propiedades del tipo de entidad siguiente se asignan a columnas JSON por convención:

public class PrimitiveCollections
{
    public IEnumerable<int> Ints { get; set; }
    public ICollection<string> Strings { get; set; }
    public IList<DateOnly> Dates { get; set; }
    public uint[] UnsignedInts { get; set; }
    public List<bool> Booleans { get; set; }
    public List<Uri> Urls { get; set; }
}

Nota

¿Qué quiere decir "tipo primitivo" en este contexto? Básicamente, algo que el proveedor de base de datos sabe cómo asignar, mediante algún tipo de conversión de valores si es necesario. Por ejemplo, en el tipo de entidad anterior, el proveedor de base de datos controla todos los tipos int, string, DateTime, DateOnly y bool sin conversión. SQL Server no tiene compatibilidad nativa con ints o URI sin firmar, pero uint y Uri todavía se tratan como tipos primitivos porque hay convertidores de valores integrados para estos tipos.

De forma predeterminada, EF Core usa un tipo de columna de cadena Unicode sin restricciones para contener el JSON, ya que protege contra la pérdida de datos con las colecciones grandes. Sin embargo, en algunos sistemas de bases de datos, como SQL Server, especificar una longitud máxima para la cadena puede mejorar el rendimiento. Esto, junto con otra configuración de columna, se puede realizar normalmente. Por ejemplo:

modelBuilder
    .Entity<PrimitiveCollections>()
    .Property(e => e.Booleans)
    .HasMaxLength(1024)
    .IsUnicode(false);

O bien, mediante atributos de asignación:

[MaxLength(2500)]
[Unicode(false)]
public uint[] UnsignedInts { get; set; }

Se puede usar una configuración de columna predeterminada para todas las propiedades de un tipo determinado mediante la configuración del modelo anterior a la convención. Por ejemplo:

protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
{
    configurationBuilder
        .Properties<List<DateOnly>>()
        .AreUnicode(false)
        .HaveMaxLength(4000);
}

Consultas con colecciones primitivas

Echemos un vistazo a algunas de las consultas que usan colecciones de tipos primitivos. Para ello, necesitaremos un modelo sencillo con dos tipos de entidad. El primero representa una casa pública británica o "pub":

public class Pub
{
    public Pub(string name, string[] beers)
    {
        Name = name;
        Beers = beers;
    }

    public int Id { get; set; }
    public string Name { get; set; }
    public string[] Beers { get; set; }
    public List<DateOnly> DaysVisited { get; private set; } = new();
}

El tipo Pub contiene dos colecciones primitivas:

  • Beers es una matriz de cadenas que representan las marcas de cerveza disponibles en el pub.
  • DaysVisited es una lista de las fechas en las que se visitó el pub.

Sugerencia

En una aplicación real, probablemente tendría más sentido crear un tipo de entidad para la cerveza y tener una tabla para las cervezas. Aquí se muestra una colección primitiva para ilustrar cómo funcionan. Pero recuerde, solo porque puede modelar algo como una colección primitiva no significa que deba hacerlo necesariamente.

El segundo tipo de entidad representa un paseo para perros en la campaña británica:

public class DogWalk
{
    public DogWalk(string name)
    {
        Name = name;
    }

    public int Id { get; set; }
    public string Name { get; set; }
    public Terrain Terrain { get; set; }
    public List<DateOnly> DaysVisited { get; private set; } = new();
    public Pub ClosestPub { get; set; } = null!;
}

public enum Terrain
{
    Forest,
    River,
    Hills,
    Village,
    Park,
    Beach,
}

Al igual que Pub, DogWalk contiene también una colección de las fechas de visita, y un enlace al pub más cercano ya que, ya sabe, a veces el perro necesita un platillo de cerveza después de un largo paseo.

Con este modelo, la primera consulta que haremos es una consulta Contains sencilla para encontrar todos los paseos con uno de los diferentes terrenos:

var terrains = new[] { Terrain.River, Terrain.Beach, Terrain.Park };
var walksWithTerrain = await context.Walks
    .Where(e => terrains.Contains(e.Terrain))
    .Select(e => e.Name)
    .ToListAsync();

Esto ya se ha traducido con las versiones actuales de EF Core al insertarse los valores que se van a buscar. Por ejemplo, al usar SQL Server:

SELECT [w].[Name]
FROM [Walks] AS [w]
WHERE [w].[Terrain] IN (1, 5, 4)

Sin embargo, esta estrategia no funciona bien con el almacenamiento en caché de las consultas de base de datos; consulte el artículo Anuncio de la versión preliminar 4 de EF8 en el blog de .NET para una explicación de este problema.

Importante

La inserción de valores aquí se realiza de tal manera que no hay ninguna posibilidad de ataques por inyección de código SQL. El cambio para usar JSON que se describe a continuación es para el rendimiento y no tiene nada que ver con la seguridad.

Para EF Core 8, el valor predeterminado ahora es pasar la lista de terrenos como un único parámetro que contiene una colección JSON. Por ejemplo:

@__terrains_0='[1,5,4]'

A continuación, la consulta usa OpenJson en SQL Server:

SELECT [w].[Name]
FROM [Walks] AS [w]
WHERE EXISTS (
    SELECT 1
    FROM OpenJson(@__terrains_0) AS [t]
    WHERE CAST([t].[value] AS int) = [w].[Terrain])

O json_each en SQLite:

SELECT "w"."Name"
FROM "Walks" AS "w"
WHERE EXISTS (
    SELECT 1
    FROM json_each(@__terrains_0) AS "t"
    WHERE "t"."value" = "w"."Terrain")

Nota

OpenJson solo está disponible en SQL Server 2016 (nivel de compatibilidad 130) y las versiones posteriores. Puede indicarle a SQL Server que está usando una versión anterior si configura el nivel de compatibilidad como parte de UseSqlServer. Por ejemplo:

protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    => optionsBuilder
        .UseSqlServer(
            @"Data Source=(LocalDb)\MSSQLLocalDB;Database=AllTogetherNow",
            sqlServerOptionsBuilder => sqlServerOptionsBuilder.UseCompatibilityLevel(120));

Vamos a probar otro tipo de consulta Contains. En este caso, buscaremos un valor de la colección de parámetros en la columna. Por ejemplo, cualquier pub que tenga Heineken:

var beer = "Heineken";
var pubsWithHeineken = await context.Pubs
    .Where(e => e.Beers.Contains(beer))
    .Select(e => e.Name)
    .ToListAsync();

La documentación de las novedades de EF7 existente proporciona información detallada sobre la asignación, las consultas y las actualizaciones de JSON. Esta documentación ahora también se aplica a SQLite.

SELECT [p].[Name]
FROM [Pubs] AS [p]
WHERE EXISTS (
    SELECT 1
    FROM OpenJson([p].[Beers]) AS [b]
    WHERE [b].[value] = @__beer_0)

OpenJson ahora se usa para extraer valores de la columna JSON para que cada valor coincida con el parámetro pasado.

Podemos combinar el uso de OpenJson en el parámetro con OpenJson en la columna. Por ejemplo, para buscar pubs con cualquiera de las distintas cervezas de tipo Lager disponibles:

var beers = new[] { "Carling", "Heineken", "Stella Artois", "Carlsberg" };
var pubsWithLager = await context.Pubs
    .Where(e => beers.Any(b => e.Beers.Contains(b)))
    .Select(e => e.Name)
    .ToListAsync();

Esto se traduce en lo siguiente en SQL Server:

SELECT [p].[Name]
FROM [Pubs] AS [p]
WHERE EXISTS (
    SELECT 1
    FROM OpenJson(@__beers_0) AS [b]
    WHERE EXISTS (
        SELECT 1
        FROM OpenJson([p].[Beers]) AS [b0]
        WHERE [b0].[value] = [b].[value] OR ([b0].[value] IS NULL AND [b].[value] IS NULL)))

El valor del parámetro @__beers_0 aquí es ["Carling","Heineken","Stella Artois","Carlsberg"].

Echemos un vistazo a una consulta que usa la columna que contiene una colección de fechas. Por ejemplo, para buscar pubs visitados este año:

var thisYear = DateTime.Now.Year;
var pubsVisitedThisYear = await context.Pubs
    .Where(e => e.DaysVisited.Any(v => v.Year == thisYear))
    .Select(e => e.Name)
    .ToListAsync();

Esto se traduce en lo siguiente en SQL Server:

SELECT [p].[Name]
FROM [Pubs] AS [p]
WHERE EXISTS (
    SELECT 1
    FROM OpenJson([p].[DaysVisited]) AS [d]
    WHERE DATEPART(year, CAST([d].[value] AS date)) = @__thisYear_0)

Observe que la consulta usa la función DATEPART específica de fecha aquí, ya que EF sabe que la colección primitiva contiene fechas. Es posible que no lo parezca, pero esto es realmente importante. Dado que EF sabe lo que hay en la colección, puede generar el código SQL adecuado para usar los valores con tipo con parámetros, funciones, otras columnas, etc.

Vamos a usar de nuevo la colección de fechas, esta vez para ordenar correctamente los valores de tipo y proyecto extraídos de la colección. Por ejemplo, vamos a enumerar los pubs por orden de la primera visita, y con la primera y última fecha en la que se visitó cada pub:

var pubsVisitedInOrder = await context.Pubs
    .Select(e => new
    {
        e.Name,
        FirstVisited = e.DaysVisited.OrderBy(v => v).First(),
        LastVisited = e.DaysVisited.OrderByDescending(v => v).First(),
    })
    .OrderBy(p => p.FirstVisited)
    .ToListAsync();

Esto se traduce en lo siguiente en SQL Server:

SELECT [p].[Name], (
    SELECT TOP(1) CAST([d0].[value] AS date)
    FROM OpenJson([p].[DaysVisited]) AS [d0]
    ORDER BY CAST([d0].[value] AS date)) AS [FirstVisited], (
    SELECT TOP(1) CAST([d1].[value] AS date)
    FROM OpenJson([p].[DaysVisited]) AS [d1]
    ORDER BY CAST([d1].[value] AS date) DESC) AS [LastVisited]
FROM [Pubs] AS [p]
ORDER BY (
    SELECT TOP(1) CAST([d].[value] AS date)
    FROM OpenJson([p].[DaysVisited]) AS [d]
    ORDER BY CAST([d].[value] AS date))

Y por último, ¿con qué frecuencia terminamos visitando el pub más cercano al llevar al perro de paseo? Lo averiguará ahora:

var walksWithADrink = await context.Walks.Select(
    w => new
    {
        WalkName = w.Name,
        PubName = w.ClosestPub.Name,
        Count = w.DaysVisited.Count(v => w.ClosestPub.DaysVisited.Contains(v)),
        TotalCount = w.DaysVisited.Count
    }).ToListAsync();

Esto se traduce en lo siguiente en SQL Server:

SELECT [w].[Name] AS [WalkName], [p].[Name] AS [PubName], (
    SELECT COUNT(*)
    FROM OpenJson([w].[DaysVisited]) AS [d]
    WHERE EXISTS (
        SELECT 1
        FROM OpenJson([p].[DaysVisited]) AS [d0]
        WHERE CAST([d0].[value] AS date) = CAST([d].[value] AS date) OR ([d0].[value] IS NULL AND [d].[value] IS NULL))) AS [Count], (
    SELECT COUNT(*)
    FROM OpenJson([w].[DaysVisited]) AS [d1]) AS [TotalCount]
FROM [Walks] AS [w]
INNER JOIN [Pubs] AS [p] ON [w].[ClosestPubId] = [p].[Id]

Y revela los siguientes datos:

The Prince of Wales Feathers was visited 5 times in 8 "Ailsworth to Nene" walks.
The Prince of Wales Feathers was visited 6 times in 9 "Caster Hanglands" walks.
The Royal Oak was visited 6 times in 8 "Ferry Meadows" walks.
The White Swan was visited 7 times in 9 "Woodnewton" walks.
The Eltisley was visited 6 times in 8 "Eltisley" walks.
Farr Bay Inn was visited 7 times in 11 "Farr Beach" walks.
Farr Bay Inn was visited 7 times in 9 "Newlands" walks.

Parece que cerveza y paseo del perro son la combinación perfecta.

Colecciones primitivas en documentos JSON

En todos los ejemplos anteriores, la columna de la colección primitiva contiene JSON. Sin embargo, esto no es lo mismo que asignar un tipo de entidad en propiedad a una columna que contiene un documento JSON, que se introdujo en EF7. Pero, ¿qué ocurre si ese documento JSON contiene una colección primitiva? Bueno, todas las consultas anteriores funcionan de la misma manera. Por ejemplo, imagine que movemos los datos días de visita a un tipo de propiedad Visits asignado a un documento JSON:

public class Pub
{
    public Pub(string name)
    {
        Name = name;
    }

    public int Id { get; set; }
    public string Name { get; set; }
    public BeerData Beers { get; set; } = null!;
    public Visits Visits { get; set; } = null!;
}

public class Visits
{
    public string? LocationTag { get; set; }
    public List<DateOnly> DaysVisited { get; set; } = null!;
}

Sugerencia

El código que se muestra aquí procede de PrimitiveCollectionsInJsonSample.cs.

Ahora podemos ejecutar una variación de nuestra consulta final que ahora extraiga los datos del documento JSON, incluidas las consultas en las colecciones primitivas contenidas en el documento:

var walksWithADrink = await context.Walks.Select(
    w => new
    {
        WalkName = w.Name,
        PubName = w.ClosestPub.Name,
        WalkLocationTag = w.Visits.LocationTag,
        PubLocationTag = w.ClosestPub.Visits.LocationTag,
        Count = w.Visits.DaysVisited.Count(v => w.ClosestPub.Visits.DaysVisited.Contains(v)),
        TotalCount = w.Visits.DaysVisited.Count
    }).ToListAsync();

Esto se traduce en lo siguiente en SQL Server:

SELECT [w].[Name] AS [WalkName], [p].[Name] AS [PubName], JSON_VALUE([w].[Visits], '$.LocationTag') AS [WalkLocationTag], JSON_VALUE([p].[Visits], '$.LocationTag') AS [PubLocationTag], (
    SELECT COUNT(*)
    FROM OpenJson(JSON_VALUE([w].[Visits], '$.DaysVisited')) AS [d]
    WHERE EXISTS (
        SELECT 1
        FROM OpenJson(JSON_VALUE([p].[Visits], '$.DaysVisited')) AS [d0]
        WHERE CAST([d0].[value] AS date) = CAST([d].[value] AS date) OR ([d0].[value] IS NULL AND [d].[value] IS NULL))) AS [Count], (
    SELECT COUNT(*)
    FROM OpenJson(JSON_VALUE([w].[Visits], '$.DaysVisited')) AS [d1]) AS [TotalCount]
FROM [Walks] AS [w]
INNER JOIN [Pubs] AS [p] ON [w].[ClosestPubId] = [p].[Id]

Y en una consulta similar cuando se usa SQLite:

SELECT "w"."Name" AS "WalkName", "p"."Name" AS "PubName", "w"."Visits" ->> 'LocationTag' AS "WalkLocationTag", "p"."Visits" ->> 'LocationTag' AS "PubLocationTag", (
    SELECT COUNT(*)
    FROM json_each("w"."Visits" ->> 'DaysVisited') AS "d"
    WHERE EXISTS (
        SELECT 1
        FROM json_each("p"."Visits" ->> 'DaysVisited') AS "d0"
        WHERE "d0"."value" = "d"."value")) AS "Count", json_array_length("w"."Visits" ->> 'DaysVisited') AS "TotalCount"
FROM "Walks" AS "w"
INNER JOIN "Pubs" AS "p" ON "w"."ClosestPubId" = "p"."Id"

Sugerencia

Tenga en cuenta que, en SQLite, EF Core ahora usa el operador ->>, lo que da lugar a consultas más fáciles de leer y que a menudo tienen un mayor rendimiento.

Asignación de colecciones primitivas a una tabla

Hemos mencionado anteriormente que otra opción para las colecciones primitivas es asignarlas a otra tabla. El problema n.º 25163 realiza un seguimiento de la compatibilidad de primera clase para esto; asegúrese de elegir este problema si es importante para usted. Hasta que esto se implemente, el mejor enfoque es crear un tipo de ajuste para el primitivo. Por ejemplo, vamos a crear un tipo para Beer:

[Owned]
public class Beer
{
    public Beer(string name)
    {
        Name = name;
    }

    public string Name { get; private set; }
}

Tenga en cuenta que el tipo simplemente ajusta el valor primitivo; no tiene clave principal ni ninguna clave externa definida. A continuación, este tipo se puede usar en la clase Pub:

public class Pub
{
    public Pub(string name)
    {
        Name = name;
    }

    public int Id { get; set; }
    public string Name { get; set; }
    public List<Beer> Beers { get; set; } = new();
    public List<DateOnly> DaysVisited { get; private set; } = new();
}

EF ahora crea una tabla Beer, sintetizando las columnas de clave principal y clave externa en la tabla Pubs. Por ejemplo, en SQL Server:

CREATE TABLE [Beer] (
    [PubId] int NOT NULL,
    [Id] int NOT NULL IDENTITY,
    [Name] nvarchar(max) NOT NULL,
    CONSTRAINT [PK_Beer] PRIMARY KEY ([PubId], [Id]),
    CONSTRAINT [FK_Beer_Pubs_PubId] FOREIGN KEY ([PubId]) REFERENCES [Pubs] ([Id]) ON DELETE CASCADE

Mejoras en la compatibilidad con columnas JSON

EF8 incluye mejoras en la compatibilidad con la asignación de columnas JSON introducida en EF7.

Sugerencia

El código que se muestra aquí procede de JsonColumnsSample.cs.

Traducción del acceso de elementos a matrices JSON

EF8 admite la indexación en matrices JSON al ejecutar consultas. Por ejemplo, la consulta siguiente comprueba si las dos primeras actualizaciones se realizaron antes de una fecha determinada.

var cutoff = DateOnly.FromDateTime(DateTime.UtcNow - TimeSpan.FromDays(365));
var updatedPosts = await context.Posts
    .Where(
        p => p.Metadata!.Updates[0].UpdatedOn < cutoff
             && p.Metadata!.Updates[1].UpdatedOn < cutoff)
    .ToListAsync();

Esto se traduce en el siguiente código SQL cuando se usa SQL Server:

SELECT [p].[Id], [p].[Archived], [p].[AuthorId], [p].[BlogId], [p].[Content], [p].[Discriminator], [p].[PublishedOn], [p].[Title], [p].[PromoText], [p].[Metadata]
FROM [Posts] AS [p]
WHERE CAST(JSON_VALUE([p].[Metadata],'$.Updates[0].UpdatedOn') AS date) < @__cutoff_0
  AND CAST(JSON_VALUE([p].[Metadata],'$.Updates[1].UpdatedOn') AS date) < @__cutoff_0

Nota

Esta consulta se realizará correctamente incluso aunque una publicación determinada no tenga ninguna actualización o solo tenga una. En tal caso, JSON_VALUE devuelve NULL y el predicado no coincide.

La indexación en matrices JSON también se puede usar para proyectar elementos de una matriz en los resultados finales. Por ejemplo, la siguiente consulta proyecta la fecha UpdatedOn de las actualizaciones primera y segunda de cada publicación.

var postsAndRecentUpdatesNullable = await context.Posts
    .Select(p => new
    {
        p.Title,
        LatestUpdate = (DateOnly?)p.Metadata!.Updates[0].UpdatedOn,
        SecondLatestUpdate = (DateOnly?)p.Metadata.Updates[1].UpdatedOn
    })
    .ToListAsync();

Esto se traduce en el siguiente código SQL cuando se usa SQL Server:

SELECT [p].[Title],
       CAST(JSON_VALUE([p].[Metadata],'$.Updates[0].UpdatedOn') AS date) AS [LatestUpdate],
       CAST(JSON_VALUE([p].[Metadata],'$.Updates[1].UpdatedOn') AS date) AS [SecondLatestUpdate]
FROM [Posts] AS [p]

Como se indicó anteriormente, JSON_VALUE devuelve null si el elemento de la matriz no existe. Esto se controla en la consulta mediante la conversión del valor proyectado a uno DateOnly que admita un valor NULL. Una alternativa a la conversión del valor es filtrar los resultados de la consulta para que JSON_VALUE nunca devuelva null. Por ejemplo:

var postsAndRecentUpdates = await context.Posts
    .Where(p => p.Metadata!.Updates[0].UpdatedOn != null
                && p.Metadata!.Updates[1].UpdatedOn != null)
    .Select(p => new
    {
        p.Title,
        LatestUpdate = p.Metadata!.Updates[0].UpdatedOn,
        SecondLatestUpdate = p.Metadata.Updates[1].UpdatedOn
    })
    .ToListAsync();

Esto se traduce en el siguiente código SQL cuando se usa SQL Server:

SELECT [p].[Title],
       CAST(JSON_VALUE([p].[Metadata],'$.Updates[0].UpdatedOn') AS date) AS [LatestUpdate],
       CAST(JSON_VALUE([p].[Metadata],'$.Updates[1].UpdatedOn') AS date) AS [SecondLatestUpdate]
FROM [Posts] AS [p]
      WHERE (CAST(JSON_VALUE([p].[Metadata],'$.Updates[0].UpdatedOn') AS date) IS NOT NULL)
        AND (CAST(JSON_VALUE([p].[Metadata],'$.Updates[1].UpdatedOn') AS date) IS NOT NULL)

Traducción de consultas en colecciones insertadas

EF8 admite consultas en colecciones de tipos primitivos (descritos anteriormente) y no primitivos insertados en el documento JSON. Por ejemplo, la consulta siguiente devuelve todas las publicaciones con cualquiera de una lista arbitraria de términos de búsqueda:

var searchTerms = new[] { "Search #2", "Search #3", "Search #5", "Search #8", "Search #13", "Search #21", "Search #34" };

var postsWithSearchTerms = await context.Posts
    .Where(post => post.Metadata!.TopSearches.Any(s => searchTerms.Contains(s.Term)))
    .ToListAsync();

Esto se traduce en el siguiente código SQL cuando se usa SQL Server:

SELECT [p].[Id], [p].[Archived], [p].[AuthorId], [p].[BlogId], [p].[Content], [p].[Discriminator], [p].[PublishedOn], [p].[Title], [p].[PromoText], [p].[Metadata]
FROM [Posts] AS [p]
WHERE EXISTS (
    SELECT 1
    FROM OPENJSON([p].[Metadata], '$.TopSearches') WITH (
        [Count] int '$.Count',
        [Term] nvarchar(max) '$.Term'
    ) AS [t]
    WHERE EXISTS (
        SELECT 1
        FROM OPENJSON(@__searchTerms_0) WITH ([value] nvarchar(max) '$') AS [s]
        WHERE [s].[value] = [t].[Term]))

Columnas JSON para SQLite

EF7 introdujo la compatibilidad con la asignación a columnas JSON al usar Azure SQL/SQL Server. EF8 amplía esta compatibilidad a las bases de datos de SQLite. La compatibilidad de SQL Server por su parte incluye:

  • Asignación de agregados creados a partir de tipos de .NET a documentos JSON almacenados en columnas de SQLite
  • Consultas en columnas JSON, como filtrar y ordenar por los elementos de los documentos
  • Consultas que proyectan elementos fuera del documento JSON en los resultados
  • Actualización y guardado de cambios en documentos JSON

La documentación de las novedades de EF7 existente proporciona información detallada sobre la asignación, las consultas y las actualizaciones de JSON. Esta documentación ahora también se aplica a SQLite.

Sugerencia

El código que se muestra en la documentación de EF7 se ha actualizado para que también se ejecute en SQLite y se encuentra en JsonColumnsSample.cs.

Consultas en columnas JSON

Las consultas en columnas JSON en SQLite usan la función json_extract. Por ejemplo, la consulta "authors in Chigley" de la documentación a la que se hace referencia anteriormente:

var authorsInChigley = await context.Authors
    .Where(author => author.Contact.Address.City == "Chigley")
    .ToListAsync();

Se traduce al siguiente código SQL al usar SQLite:

SELECT "a"."Id", "a"."Name", "a"."Contact"
FROM "Authors" AS "a"
WHERE json_extract("a"."Contact", '$.Address.City') = 'Chigley'

Actualización de columnas JSON

Para las actualizaciones, EF usa la función json_set en SQLite. Por ejemplo, al actualizar una sola propiedad en un documento:

var arthur = await context.Authors.SingleAsync(author => author.Name.StartsWith("Arthur"));

arthur.Contact.Address.Country = "United Kingdom";

await context.SaveChangesAsync();

EF genera los parámetros siguientes:

info: 3/10/2023 10:51:33.127 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (0ms) [Parameters=[@p0='["United Kingdom"]' (Nullable = false) (Size = 18), @p1='4'], CommandType='Text', CommandTimeout='30']

Que usa la función json_set en SQLite:

UPDATE "Authors" SET "Contact" = json_set("Contact", '$.Address.Country', json_extract(@p0, '$[0]'))
WHERE "Id" = @p1
RETURNING 1;

Uso de HierarchyId en .NET y EF Core

Azure SQL y SQL Server tienen un tipo de datos especial denominado hierarchyid que se usa para almacenar datos jerárquicos. En este caso, "datos jerárquicos" básicamente significa datos que forman una estructura de árbol, donde cada elemento puede tener un elemento primario o secundario. Algunos ejemplos de estos datos son:

  • Una estructura organizativa
  • Un sistema de archivos
  • Un conjunto de tareas de un proyecto
  • Una taxonomía de términos de idioma
  • Un gráfico de vínculos entre páginas web

Después, la base de datos puede ejecutar consultas en estos datos mediante su estructura jerárquica. Por ejemplo, una consulta puede buscar antecesores y dependientes de elementos determinados, o los elementos de una profundidad determinada en la jerarquía.

Compatibilidad con .NET y EF Core

La compatibilidad oficial con el tipo de SQL Server hierarchyid solo ha llegado recientemente a las plataformas modernas de .NET (como ".NET Core"). Esta compatibilidad está en forma del paquete NuGet Microsoft.SqlServer.Types, que incluye tipos específicos de SQL Server de bajo nivel. En este caso, el tipo de bajo nivel se denomina SqlHierarchyId.

En el siguiente nivel, se ha introducido un nuevo paquete Microsoft.EntityFrameworkCore.SqlServer.Abstractions, que incluye un tipo de nivel superior HierarchyId destinado a su uso en tipos de entidad.

Sugerencia

El tipo HierarchyId es más idiomático para las normas de .NET que SqlHierarchyId, que en su lugar se modela según cómo se hospeden los tipos de .NET Framework dentro del motor de base de datos de SQL Server. HierarchyId está diseñado para funcionar con EF Core, pero también se puede usar fuera de EF Core en otras aplicaciones. El paquete Microsoft.EntityFrameworkCore.SqlServer.Abstractions no hace referencia a ningún otro paquete, por lo que tiene un impacto mínimo en el tamaño y las dependencias de la aplicación implementadas.

El uso de HierarchyId para la funcionalidad de EF Core, como las consultas y las actualizaciones, requiere el paquete Microsoft.EntityFrameworkCore.SqlServer.HierarchyId. Este paquete incluye Microsoft.EntityFrameworkCore.SqlServer.Abstractions y Microsoft.SqlServer.Types como dependencias transitivas, por lo que suele ser el único paquete necesario. Una vez instalado el paquete, se habilita el uso de HierarchyId mediante una llamada UseHierarchyId a como parte de la llamada de la aplicación a UseSqlServer. Por ejemplo:

options.UseSqlServer(
    connectionString,
    x => x.UseHierarchyId());

Nota

La compatibilidad no oficial con hierarchyid en EF Core está disponible desde hace muchos años con el paquete EntityFrameworkCore.SqlServer.HierarchyId. Este paquete se ha mantenido con la colaboración entre la comunidad y el equipo de EF. Ahora que hay compatibilidad oficial con hierarchyid en .NET, el código de este paquete de comunidad genera, con el permiso de los colaboradores originales, la base para el paquete oficial que se describe aquí. Muchas gracias a todos los involucrados a lo largo de los años, como @aljones, @cutig3r, @huan086, @kmataru, @mehdihaghshenas y @vyrotek.

Jerarquías de modelado

El tipo HierarchyId se puede usar para las propiedades de un tipo de entidad. Por ejemplo, supongamos que queremos modelar el árbol genealógico paterno de unos medianos. En el tipo de entidad de Halfling, se puede usar una propiedad HierarchyId para buscar los medianos en el árbol genealógico.

public class Halfling
{
    public Halfling(HierarchyId pathFromPatriarch, string name, int? yearOfBirth = null)
    {
        PathFromPatriarch = pathFromPatriarch;
        Name = name;
        YearOfBirth = yearOfBirth;
    }

    public int Id { get; private set; }
    public HierarchyId PathFromPatriarch { get; set; }
    public string Name { get; set; }
    public int? YearOfBirth { get; set; }
}

Sugerencia

El código que se muestra aquí y en los ejemplos siguientes procede de HierarchyIdSample.cs.

Sugerencia

Si lo desea, HierarchyId es adecuado para su uso como tipo de propiedad de clave.

En este caso, el árbol genealógico tiene el patriarca de la familia en la raíz. Cada mediano se puede rastrear desde el patriarca hacia abajo por el árbol con su propiedad PathFromPatriarch. SQL Server usa un formato binario compacto para estas rutas de acceso, pero es habitual analizar hacia y desde una representación de cadena legible cuando se trabaja con código. En esta representación, las posiciones se separan por un carácter / en cada nivel. Por ejemplo, considere el árbol genealógico del diagrama siguiente:

Árbol genealógico de los medianos

En este árbol:

  • Balbo está en la raíz del árbol, que se representa con /.
  • Balbo tiene cinco hijos, representados por /1/, /2/, /3/, /4/ y /5/.
  • El primer hijo de Balbo, Mungo, también tiene cinco hijos, representados por /1/1/, /1/2/, /1/3/, /1/4/ y /1/5/. Observe que el HierarchyId de Balbo (/1/) es el prefijo de todos sus hijos.
  • Del mismo modo, el tercer hijo de Balbo, Ponto, tiene dos hijos, representados por /3/1/ y /3/2/. De nuevo, cada hijo tiene el prefijo HierarchyId de Ponto, que se representa como /3/.
  • Y así se desciende por el árbol...

El código siguiente inserta este árbol genealógico en una base de datos mediante EF Core:

await AddRangeAsync(
    new Halfling(HierarchyId.Parse("/"), "Balbo", 1167),
    new Halfling(HierarchyId.Parse("/1/"), "Mungo", 1207),
    new Halfling(HierarchyId.Parse("/2/"), "Pansy", 1212),
    new Halfling(HierarchyId.Parse("/3/"), "Ponto", 1216),
    new Halfling(HierarchyId.Parse("/4/"), "Largo", 1220),
    new Halfling(HierarchyId.Parse("/5/"), "Lily", 1222),
    new Halfling(HierarchyId.Parse("/1/1/"), "Bungo", 1246),
    new Halfling(HierarchyId.Parse("/1/2/"), "Belba", 1256),
    new Halfling(HierarchyId.Parse("/1/3/"), "Longo", 1260),
    new Halfling(HierarchyId.Parse("/1/4/"), "Linda", 1262),
    new Halfling(HierarchyId.Parse("/1/5/"), "Bingo", 1264),
    new Halfling(HierarchyId.Parse("/3/1/"), "Rosa", 1256),
    new Halfling(HierarchyId.Parse("/3/2/"), "Polo"),
    new Halfling(HierarchyId.Parse("/4/1/"), "Fosco", 1264),
    new Halfling(HierarchyId.Parse("/1/1/1/"), "Bilbo", 1290),
    new Halfling(HierarchyId.Parse("/1/3/1/"), "Otho", 1310),
    new Halfling(HierarchyId.Parse("/1/5/1/"), "Falco", 1303),
    new Halfling(HierarchyId.Parse("/3/2/1/"), "Posco", 1302),
    new Halfling(HierarchyId.Parse("/3/2/2/"), "Prisca", 1306),
    new Halfling(HierarchyId.Parse("/4/1/1/"), "Dora", 1302),
    new Halfling(HierarchyId.Parse("/4/1/2/"), "Drogo", 1308),
    new Halfling(HierarchyId.Parse("/4/1/3/"), "Dudo", 1311),
    new Halfling(HierarchyId.Parse("/1/3/1/1/"), "Lotho", 1310),
    new Halfling(HierarchyId.Parse("/1/5/1/1/"), "Poppy", 1344),
    new Halfling(HierarchyId.Parse("/3/2/1/1/"), "Ponto", 1346),
    new Halfling(HierarchyId.Parse("/3/2/1/2/"), "Porto", 1348),
    new Halfling(HierarchyId.Parse("/3/2/1/3/"), "Peony", 1350),
    new Halfling(HierarchyId.Parse("/4/1/2/1/"), "Frodo", 1368),
    new Halfling(HierarchyId.Parse("/4/1/3/1/"), "Daisy", 1350),
    new Halfling(HierarchyId.Parse("/3/2/1/1/1/"), "Angelica", 1381));

await SaveChangesAsync();

Sugerencia

Si es necesario, se pueden usar valores decimales para crear nodos entre dos nodos existentes. Por ejemplo, /3/2.5/2/ va entre /3/2/2/ y /3/3/2/.

Consulta de jerarquías

HierarchyId expone varios métodos que pueden usarse en las consultas LINQ.

Método Descripción
GetAncestor(int n) Obtiene los niveles n del nodo hacia arriba en el árbol jerárquico.
GetDescendant(HierarchyId? child1, HierarchyId? child2) Obtiene el valor de un nodo descendiente mayor que child1 y menor que child2.
GetLevel() Obtiene el nivel de este nodo en el árbol jerárquico.
GetReparentedValue(HierarchyId? oldRoot, HierarchyId? newRoot) Obtiene un valor que representa la ubicación de un nuevo nodo con una ruta de acceso desde newRoot igual a la ruta de acceso desde oldRoot hasta este, con lo que, de hecho, se mueve este a la nueva ubicación.
IsDescendantOf(HierarchyId? parent) Obtiene un valor que indica si este nodo es descendiente de parent.

Además, se pueden usar los operadores ==, !=, <, <=, > y >=.

A continuación se muestran ejemplos de uso de estos métodos en consultas LINQ.

Obtención de entidades en un nivel determinado en el árbol

La consulta siguiente usa GetLevel para devolver todos los medianos de un nivel determinado en el árbol genealógico:

var generation = await context.Halflings.Where(halfling => halfling.PathFromPatriarch.GetLevel() == level).ToListAsync();

Esto se traduce en el siguiente código SQL:

SELECT [h].[Id], [h].[Name], [h].[PathFromPatriarch], [h].[YearOfBirth]
FROM [Halflings] AS [h]
WHERE [h].[PathFromPatriarch].GetLevel() = @__level_0

Al ejecutar esto en bucle, podemos obtener los medianos de cada generación:

Generation 0: Balbo
Generation 1: Mungo, Pansy, Ponto, Largo, Lily
Generation 2: Bungo, Belba, Longo, Linda, Bingo, Rosa, Polo, Fosco
Generation 3: Bilbo, Otho, Falco, Posco, Prisca, Dora, Drogo, Dudo
Generation 4: Lotho, Poppy, Ponto, Porto, Peony, Frodo, Daisy
Generation 5: Angelica

Obtención del antecesor directo de una entidad

En la consulta siguiente se usa GetAncestor para buscar el antecesor directo de un mediano mediante el nombre de ese mediano:

async Task<Halfling?> FindDirectAncestor(string name)
    => await context.Halflings
        .SingleOrDefaultAsync(
            ancestor => ancestor.PathFromPatriarch == context.Halflings
                .Single(descendent => descendent.Name == name).PathFromPatriarch
                .GetAncestor(1));

Esto se traduce en el siguiente código SQL:

SELECT TOP(2) [h].[Id], [h].[Name], [h].[PathFromPatriarch], [h].[YearOfBirth]
FROM [Halflings] AS [h]
WHERE [h].[PathFromPatriarch] = (
    SELECT TOP(1) [h0].[PathFromPatriarch]
    FROM [Halflings] AS [h0]
    WHERE [h0].[Name] = @__name_0).GetAncestor(1)

La ejecución de esta consulta para el mediano "Bilbo" devuelve "Bungo".

Obtención de los descendientes directos de una entidad

La consulta siguiente también usa GetAncestor, pero esta vez para buscar los descendientes directos de un mediano mediante el nombre de ese mediano:

IQueryable<Halfling> FindDirectDescendents(string name)
    => context.Halflings.Where(
        descendent => descendent.PathFromPatriarch.GetAncestor(1) == context.Halflings
            .Single(ancestor => ancestor.Name == name).PathFromPatriarch);

Esto se traduce en el siguiente código SQL:

SELECT [h].[Id], [h].[Name], [h].[PathFromPatriarch], [h].[YearOfBirth]
FROM [Halflings] AS [h]
WHERE [h].[PathFromPatriarch].GetAncestor(1) = (
    SELECT TOP(1) [h0].[PathFromPatriarch]
    FROM [Halflings] AS [h0]
    WHERE [h0].[Name] = @__name_0)

La ejecución de esta consulta para el mediano "Mungo" devuelve "Bungo", "Belba", "Longo" y "Linda".

Obtención de todos los antecesores de una entidad

GetAncestor es útil para buscar o bajar un único nivel o, de hecho, un número especificado de niveles. Por otro lado, IsDescendantOf es útil para encontrar todos los antepasados o dependientes. Por ejemplo, la consulta siguiente usa IsDescendantOf para buscar todos los antepasados de un mediano mediante el nombre de ese mediano:

IQueryable<Halfling> FindAllAncestors(string name)
    => context.Halflings.Where(
            ancestor => context.Halflings
                .Single(
                    descendent =>
                        descendent.Name == name
                        && ancestor.Id != descendent.Id)
                .PathFromPatriarch.IsDescendantOf(ancestor.PathFromPatriarch))
        .OrderByDescending(ancestor => ancestor.PathFromPatriarch.GetLevel());

Importante

IsDescendantOf devuelve true para sí mismo, por lo que se filtra en la consulta anterior.

Esto se traduce en el siguiente código SQL:

SELECT [h].[Id], [h].[Name], [h].[PathFromPatriarch], [h].[YearOfBirth]
FROM [Halflings] AS [h]
WHERE (
    SELECT TOP(1) [h0].[PathFromPatriarch]
    FROM [Halflings] AS [h0]
    WHERE [h0].[Name] = @__name_0 AND [h].[Id] <> [h0].[Id]).IsDescendantOf([h].[PathFromPatriarch]) = CAST(1 AS bit)
ORDER BY [h].[PathFromPatriarch].GetLevel() DESC

La ejecución de esta consulta para el mediano "Bilbo" devuelve "Bungo", "Mungo" y "Balbo".

Obtención de todos los descendientes de una entidad

La consulta siguiente también usa IsDescendantOf, pero esta vez para buscar todos los descendientes de un mediano mediante el nombre de ese mediano:

IQueryable<Halfling> FindAllDescendents(string name)
    => context.Halflings.Where(
            descendent => descendent.PathFromPatriarch.IsDescendantOf(
                context.Halflings
                    .Single(
                        ancestor =>
                            ancestor.Name == name
                            && descendent.Id != ancestor.Id)
                    .PathFromPatriarch))
        .OrderBy(descendent => descendent.PathFromPatriarch.GetLevel());

Esto se traduce en el siguiente código SQL:

SELECT [h].[Id], [h].[Name], [h].[PathFromPatriarch], [h].[YearOfBirth]
FROM [Halflings] AS [h]
WHERE [h].[PathFromPatriarch].IsDescendantOf((
    SELECT TOP(1) [h0].[PathFromPatriarch]
    FROM [Halflings] AS [h0]
    WHERE [h0].[Name] = @__name_0 AND [h].[Id] <> [h0].[Id])) = CAST(1 AS bit)
ORDER BY [h].[PathFromPatriarch].GetLevel()

La ejecución de esta consulta para el mediano "Mungo" devuelve "Bungo", "Belba", "Longo", "Linda", "Bingo", "Bilbo", "Otho", "Falco", "Lotho" y "Poppy".

Búsqueda de un antepasado común

Una de las preguntas más comunes que se formulan sobre este árbol genealógico en particular es: "¿quién es el antepasado común de Frodo y Bilbo?" Podemos usar IsDescendantOf para escribir una consulta de este tipo:

async Task<Halfling?> FindCommonAncestor(Halfling first, Halfling second)
    => await context.Halflings
        .Where(
            ancestor => first.PathFromPatriarch.IsDescendantOf(ancestor.PathFromPatriarch)
                        && second.PathFromPatriarch.IsDescendantOf(ancestor.PathFromPatriarch))
        .OrderByDescending(ancestor => ancestor.PathFromPatriarch.GetLevel())
        .FirstOrDefaultAsync();

Esto se traduce en el siguiente código SQL:

SELECT TOP(1) [h].[Id], [h].[Name], [h].[PathFromPatriarch], [h].[YearOfBirth]
FROM [Halflings] AS [h]
WHERE @__first_PathFromPatriarch_0.IsDescendantOf([h].[PathFromPatriarch]) = CAST(1 AS bit)
  AND @__second_PathFromPatriarch_1.IsDescendantOf([h].[PathFromPatriarch]) = CAST(1 AS bit)
ORDER BY [h].[PathFromPatriarch].GetLevel() DESC

La ejecución de esta consulta con "Bilbo" y "Frodo" nos dice que su antepasado común es "Balbo".

Actualización de las jerarquías

Para actualizar las columnas hierarchyid se pueden usar los mecanismos normales de control de cambios y SaveChanges.

Reorganización de la relación de una subjerarquía

Por ejemplo, estoy seguro de que todos recordamos el escándalo de SR 1752 (también conocido como "LongoGate"), cuando las pruebas de ADN revelaron que Longo no era de hecho el hijo de Mungo, sino de Ponto. Un efecto colateral de este escándalo fue que había que reescribir el árbol genealógico. En particular, debía cambiarse la jerarquía en la relación de Longo y todos sus descendientes, de Mungo a Ponto. Para ello, se puede usar GetReparentedValue. Por ejemplo, primero se consulta "Longo" y todos sus descendientes:

var longoAndDescendents = await context.Halflings.Where(
        descendent => descendent.PathFromPatriarch.IsDescendantOf(
            context.Halflings.Single(ancestor => ancestor.Name == "Longo").PathFromPatriarch))
    .ToListAsync();

A continuación, se usa GetReparentedValue para actualizar HierarchyId para Longo y cada descendiente, seguido de una llamada a SaveChangesAsync:

foreach (var descendent in longoAndDescendents)
{
    descendent.PathFromPatriarch
        = descendent.PathFromPatriarch.GetReparentedValue(
            mungo.PathFromPatriarch, ponto.PathFromPatriarch)!;
}

await context.SaveChangesAsync();

El resultado es la siguiente actualización en la base de datos:

SET NOCOUNT ON;
UPDATE [Halflings] SET [PathFromPatriarch] = @p0
OUTPUT 1
WHERE [Id] = @p1;
UPDATE [Halflings] SET [PathFromPatriarch] = @p2
OUTPUT 1
WHERE [Id] = @p3;
UPDATE [Halflings] SET [PathFromPatriarch] = @p4
OUTPUT 1
WHERE [Id] = @p5;

Con estos parámetros:

 @p1='9',
 @p0='0x7BC0' (Nullable = false) (Size = 2) (DbType = Object),
 @p3='16',
 @p2='0x7BD6' (Nullable = false) (Size = 2) (DbType = Object),
 @p5='23',
 @p4='0x7BD6B0' (Nullable = false) (Size = 3) (DbType = Object)

Nota

Los valores de los parámetros de las propiedades HierarchyId se envían a la base de datos en su formato binario compacto.

Después de la actualización, la consulta de los descendientes de "Mungo" devuelve "Bungo", "Belba", "Linda", "Bingo", "Bilbo", "Falco" y "Poppy", mientras que la consulta de los descendientes de "Ponto" devuelve "Longo", "Rosa", "Polo", "Otho", "Posco", "Prisca", "Lotho", "Ponto", "Porto", "Peony" y "Angelica".

Consultas SQL sin procesar para tipos no asignados

EF7 ha introducido consultas SQL sin procesar que devuelven tipos escalares. Esto se ha mejorado en EF8 para incluir consultas SQL sin procesar que devuelvan cualquier tipo CLR asignable, sin incluir ese tipo en el modelo de EF.

Sugerencia

El código que se muestra aquí procede de RawSqlSample.cs.

Las consultas que usan tipos no asignados se ejecutan mediante SqlQuery o SqlQueryRaw. El primero usa la interpolación de cadenas para parametrizar la consulta, lo que ayuda a garantizar que todos los valores no constantes estén parametrizados. Por ejemplo, considere la siguiente tabla de bases de datos:

CREATE TABLE [Posts] (
    [Id] int NOT NULL IDENTITY,
    [Title] nvarchar(max) NOT NULL,
    [Content] nvarchar(max) NOT NULL,
    [PublishedOn] date NOT NULL,
    [BlogId] int NOT NULL,
);

SqlQuery se puede usar para consultar esta tabla y devolver instancias de un tipo BlogPost con propiedades correspondientes a las columnas de la tabla:

Por ejemplo:

public class BlogPost
{
    public int Id { get; set; }
    public string Title { get; set; }
    public string Content { get; set; }
    public DateOnly PublishedOn { get; set; }
    public int BlogId { get; set; }
}

Por ejemplo:

var start = new DateOnly(2022, 1, 1);
var end = new DateOnly(2023, 1, 1);
var postsIn2022 =
    await context.Database
        .SqlQuery<BlogPost>($"SELECT * FROM Posts as p WHERE p.PublishedOn >= {start} AND p.PublishedOn < {end}")
        .ToListAsync();

Esta consulta se parametriza y se ejecuta como:

SELECT * FROM Posts as p WHERE p.PublishedOn >= @p0 AND p.PublishedOn < @p1

El tipo usado para los resultados de la consulta puede contener construcciones de asignación comunes admitidas por EF Core, como constructores con parámetros y atributos de asignación. Por ejemplo:

public class BlogPost
{
    public BlogPost(string blogTitle, string content, DateOnly publishedOn)
    {
        BlogTitle = blogTitle;
        Content = content;
        PublishedOn = publishedOn;
    }

    public int Id { get; private set; }

    [Column("Title")]
    public string BlogTitle { get; set; }

    public string Content { get; set; }
    public DateOnly PublishedOn { get; set; }
    public int BlogId { get; set; }
}

Nota

Los tipos usados de esta manera no tienen claves definidas y no pueden tener relaciones con otros tipos. Los tipos con relaciones deben asignarse en el modelo.

El tipo usado debe tener una propiedad para cada valor del conjunto de resultados, pero no es necesario que coincida con ninguna tabla de la base de datos. Por ejemplo, el siguiente tipo representa solo un subconjunto de información para cada publicación e incluye el nombre del blog, que procede de la tabla Blogs:

public class PostSummary
{
    public string BlogName { get; set; } = null!;
    public string PostTitle { get; set; } = null!;
    public DateOnly? PublishedOn { get; set; }
}

Y se puede consultar usando SqlQuery de la misma manera que antes:


var cutoffDate = new DateOnly(2022, 1, 1);
var summaries =
    await context.Database.SqlQuery<PostSummary>(
            @$"SELECT b.Name AS BlogName, p.Title AS PostTitle, p.PublishedOn
               FROM Posts AS p
               INNER JOIN Blogs AS b ON p.BlogId = b.Id
               WHERE p.PublishedOn >= {cutoffDate}")
        .ToListAsync();

Una buena característica de SqlQuery es que devuelve un objeto IQueryable que se puede componer mediante LINQ. Por ejemplo, se puede agregar una cláusula "Where" a la consulta anterior:

var summariesIn2022 =
    await context.Database.SqlQuery<PostSummary>(
            @$"SELECT b.Name AS BlogName, p.Title AS PostTitle, p.PublishedOn
               FROM Posts AS p
               INNER JOIN Blogs AS b ON p.BlogId = b.Id")
        .Where(p => p.PublishedOn >= cutoffDate && p.PublishedOn < end)
        .ToListAsync();

Este elemento se ejecutará de la siguiente manera:

SELECT [n].[BlogName], [n].[PostTitle], [n].[PublishedOn]
FROM (
         SELECT b.Name AS BlogName, p.Title AS PostTitle, p.PublishedOn
         FROM Posts AS p
                  INNER JOIN Blogs AS b ON p.BlogId = b.Id
     ) AS [n]
WHERE [n].[PublishedOn] >= @__cutoffDate_1 AND [n].[PublishedOn] < @__end_2

Llegados a este punto, merece la pena recordar que todo lo anterior se puede hacer completamente en LINQ sin necesidad de escribir ningún código SQL. Esto incluye devolver instancias de un tipo no asignado, como PostSummary. Por ejemplo, la consulta anterior se puede escribir en LINQ de la siguiente manera:

var summaries =
    await context.Posts.Select(
            p => new PostSummary
            {
                BlogName = p.Blog.Name,
                PostTitle = p.Title,
                PublishedOn = p.PublishedOn,
            })
        .Where(p => p.PublishedOn >= start && p.PublishedOn < end)
        .ToListAsync();

Lo que se traduce en un código SQL mucho más limpio:

SELECT [b].[Name] AS [BlogName], [p].[Title] AS [PostTitle], [p].[PublishedOn]
FROM [Posts] AS [p]
INNER JOIN [Blogs] AS [b] ON [p].[BlogId] = [b].[Id]
WHERE [p].[PublishedOn] >= @__start_0 AND [p].[PublishedOn] < @__end_1

Sugerencia

EF puede generar código SQL más limpio cuando es responsable de toda la consulta que cuando se redacta a través de SQL proporcionado por el usuario porque, en el primer caso, la semántica completa de la consulta está disponible para EF.

Hasta ahora, todas las consultas se han ejecutado directamente en las tablas. SqlQuery también se puede usar para devolver resultados de una vista sin asignar el tipo de vista en el modelo de EF. Por ejemplo:

var summariesFromView =
    await context.Database.SqlQuery<PostSummary>(
            @$"SELECT * FROM PostAndBlogSummariesView")
        .Where(p => p.PublishedOn >= cutoffDate && p.PublishedOn < end)
        .ToListAsync();

Del mismo modo, SqlQuery se puede usar para los resultados de una función:

var summariesFromFunc =
    await context.Database.SqlQuery<PostSummary>(
            @$"SELECT * FROM GetPostsPublishedAfter({cutoffDate})")
        .Where(p => p.PublishedOn < end)
        .ToListAsync();

El objeto IQueryable devuelto se puede componer cuando es el resultado de una vista o función, al igual que puede suceder para el resultado de una consulta de tabla. Los procedimientos almacenados también se pueden ejecutar mediante SqlQuery, pero la mayoría de las bases de datos no admiten la redacción sobre ellos. Por ejemplo:

var summariesFromStoredProc =
    await context.Database.SqlQuery<PostSummary>(
            @$"exec GetRecentPostSummariesProc")
        .ToListAsync();

Mejoras en la carga diferida

Carga diferida para consultas sin seguimiento

EF8 agrega compatibilidad con la carga diferida de las navegaciones en entidades a las que el objeto DbContext no realiza un seguimiento. Esto significa que una consulta sin seguimiento puede ir seguida de la carga diferida de las navegaciones en las entidades devueltas por la consulta sin seguimiento.

Sugerencia

El código de los ejemplos de carga diferida que se muestran a continuación procede de LazyLoadingSample.cs.

Por ejemplo, piense en una consulta sin seguimiento para blogs:

var blogs = await context.Blogs.AsNoTracking().ToListAsync();

Si Blog.Posts está configurado para la carga diferida, por ejemplo, mediante servidores proxy de carga diferida, el acceso a Posts hará que se cargue desde la base de datos:

Console.WriteLine();
Console.Write("Choose a blog: ");
if (int.TryParse(ReadLine(), out var blogId))
{
    Console.WriteLine("Posts:");
    foreach (var post in blogs[blogId - 1].Posts)
    {
        Console.WriteLine($"  {post.Title}");
    }
}

EF8 también informa de si una navegación determinada se carga o no para las entidades de las cuales el contexto no realiza un seguimiento. Por ejemplo:

foreach (var blog in blogs)
{
    if (context.Entry(blog).Collection(e => e.Posts).IsLoaded)
    {
        Console.WriteLine($" Posts for blog '{blog.Name}' are loaded.");
    }
}

Hay algunas consideraciones importantes al usar la carga diferida de esta manera:

  • La carga diferida solo se realizará correctamente hasta que se elimine el objeto DbContext usado para consultar la entidad.
  • Las entidades consultadas de esta manera mantienen la referencia a su DbContext, aunque este objeto no les haga un seguimiento. Se debe tener cuidado para evitar pérdidas de memoria si las instancias de entidad van a tener una larga duración.
  • Desasociar explícitamente la entidad estableciendo su estado en servidores EntityState.Detached, la referencia al objeto DbContext y la carga diferida ya no funcionará.
  • Recuerde que toda la carga diferida usa E/S sincrónica, ya que no hay ninguna manera de acceder a una propiedad de forma asincrónica.

La carga diferida desde entidades sin seguimiento funciona tanto para servidores proxy de carga diferida como para la carga diferida sin servidores proxy.

Carga explícita desde entidades sin seguimiento

EF8 admite la carga de navegaciones en entidades sin seguimiento incluso cuando la entidad o la navegación no están configuradas para la carga diferida. A diferencia de la carga diferida, esta carga explícita se puede realizar de forma asincrónica. Por ejemplo:

await context.Entry(blog).Collection(e => e.Posts).LoadAsync();

No participar en la carga diferida para navegaciones específicas

EF8 permite la configuración de navegaciones específicas para no realizar una carga diferida, incluso cuando todo lo demás está configurado para hacerlo. Por ejemplo, para configurar la navegación Post.Author para que no sea de carga diferida, haga lo siguiente:

modelBuilder
    .Entity<Post>()
    .Navigation(p => p.Author)
    .EnableLazyLoading(false);

Deshabilitar la carga diferida así funciona tanto para servidores proxy de carga diferida como para la carga diferida sin servidores proxy.

Los servidores proxy de carga diferida funcionan invalidando las propiedades de navegación virtual. En las aplicaciones clásicas de EF6, un origen común de errores se olvida de hacer que una navegación sea virtual, ya que la navegación no realizará la carga diferida de forma silenciosa. Por lo tanto, los servidores proxy de EF Core se inician de forma predeterminada cuando una navegación no es virtual.

Esto se puede cambiar en EF8 para participar en el comportamiento clásico de EF6, de modo que se pueda realizar una navegación para no efectuar una carga diferida simplemente haciendo que la navegación no sea virtual. Esta participación se configura como parte de la llamada a UseLazyLoadingProxies. Por ejemplo:

optionsBuilder.UseLazyLoadingProxies(b => b.IgnoreNonVirtualNavigations());

Acceso a entidades con seguimiento

Búsqueda de entidades con seguimiento por clave principal, alternativa o externa

Internamente, EF mantiene estructuras de datos para buscar entidades con seguimiento por clave principal, alternativa o externa. Estas estructuras de datos se usan para una corrección eficaz entre entidades relacionadas cuando se realiza un seguimiento de las nuevas entidades o se cambian las relaciones.

EF8 contiene nuevas API públicas para que las aplicaciones ahora puedan usar estas estructuras de datos para buscar de forma eficaz entidades con seguimiento. Se accede a estas API a través de LocalView<TEntity> del tipo de entidad. Por ejemplo, para buscar una entidad con seguimiento mediante su clave principal:

var blogEntry = context.Blogs.Local.FindEntry(2)!;

Sugerencia

El código que se muestra aquí procede de LookupByKeySample.cs.

El método FindEntry devuelve el objeto EntityEntry<TEntity> para la entidad con seguimiento o null si no se realiza el seguimiento de ninguna entidad con la clave especificada. Al igual que todos los métodos de LocalView, la base de datos nunca se consulta, incluso aunque no se encuentre la entidad. La entrada devuelta contiene la propia entidad, así como la información de seguimiento. Por ejemplo:

Console.WriteLine($"Blog '{blogEntry.Entity.Name}' with key {blogEntry.Entity.Id} is tracked in the '{blogEntry.State}' state.");

La búsqueda de una entidad por algo distinto de una clave principal requiere que se especifique el nombre de la propiedad. Por ejemplo, para buscar mediante una clave alternativa:

var siteEntry = context.Websites.Local.FindEntry(nameof(Website.Uri), new Uri("https://www.bricelam.net/"))!;

O para buscar mediante una clave externa única:

var blogAtSiteEntry = context.Blogs.Local.FindEntry(nameof(Blog.SiteUri), new Uri("https://www.bricelam.net/"))!;

Hasta ahora, las búsquedas siempre han devuelto una sola entrada, o null. Sin embargo, algunas búsquedas pueden devolver más de una entrada, como cuando se busca mediante una clave externa no única. El método GetEntries debe usarse para estas búsquedas. Por ejemplo:

var postEntries = context.Posts.Local.GetEntries(nameof(Post.BlogId), 2);

En todos estos casos, el valor que se usa para la búsqueda es una clave principal, una clave alternativa o un valor de clave externa. EF usa sus estructuras de datos internas para estas búsquedas. Sin embargo, las búsquedas por valor también se pueden usar para el valor de cualquier propiedad o combinación de propiedades. Por ejemplo, para buscar todas las publicaciones archivadas:

var archivedPostEntries = context.Posts.Local.GetEntries(nameof(Post.Archived), true);

Esta búsqueda requiere un examen de todas las instancias Post con seguimiento, por lo que será menos eficaz que las búsquedas de claves. Sin embargo, suele ser más rápida que las consultas ingenuas que usan ChangeTracker.Entries<TEntity>().

Por último, también es posible realizar búsquedas en claves compuestas, otras combinaciones de varias propiedades o cuando el tipo de propiedad no se conoce en el tiempo de compilación. Por ejemplo:

var postTagEntry = context.Set<PostTag>().Local.FindEntryUntyped(new object[] { 4, "TagEF" });

Creación de modelos

Las columnas de discriminador tienen una longitud máxima

En EF8, las columnas de discriminador de cadenas usadas para la asignación de herencia de TPH ahora están configuradas con una longitud máxima. Esta longitud se calcula como el número de Fibonacci más pequeño que cubre todos los valores discriminadores definidos. Por ejemplo, considere la siguiente jerarquía:

public abstract class Document
{
    public int Id { get; set; }
    public string Title { get; set; }
}

public abstract class Book : Document
{
    public string? Isbn { get; set; }
}

public class PaperbackEdition : Book
{
}

public class HardbackEdition : Book
{
}

public class Magazine : Document
{
    public int IssueNumber { get; set; }
}

Con la convención de usar los nombres de clase para los valores discriminadores, los valores posibles aquí son "PaperbackEdition", "HardbackEdition" y "Magazine", y por lo tanto, la columna discriminadora está configurada para una longitud máxima de 21. Por ejemplo, al usar SQL Server:

CREATE TABLE [Documents] (
    [Id] int NOT NULL IDENTITY,
    [Title] nvarchar(max) NOT NULL,
    [Discriminator] nvarchar(21) NOT NULL,
    [Isbn] nvarchar(max) NULL,
    [IssueNumber] int NULL,
    CONSTRAINT [PK_Documents] PRIMARY KEY ([Id]),

Sugerencia

Los números de Fibonacci se usan para limitar el número de veces que se genera una migración para cambiar la longitud de la columna a medida que se agregan nuevos tipos a la jerarquía.

DateOnly/TimeOnly compatible con SQL Server

Los tipos DateOnly y TimeOnly se introdujeron en .NET 6 y se han admitido para varios proveedores de bases de datos (por ejemplo, SQLite, MySQL y PostgreSQL) desde su introducción. Para SQL Server, la versión reciente de un paquete Microsoft.Data.SqlClient destinado a .NET 6 ha permitido a ErikEJ agregar compatibilidad con estos tipos en el nivel de ADO.NET. Esto, a su vez, allanó la forma de admitir en EF8 para DateOnly y TimeOnly como propiedades en los tipos de entidad.

Sugerencia

DateOnly y TimeOnly se pueden usar en EF Core 6 y 7 mediante el paquete de comunidad ErikEJ.EntityFrameworkCore.SqlServer.DateOnlyTimeOnly de @ErikEJ.

Por ejemplo, considere el siguiente modelo EF para escuelas británicas:

public class School
{
    public int Id { get; set; }
    public string Name { get; set; } = null!;
    public DateOnly Founded { get; set; }
    public List<Term> Terms { get; } = new();
    public List<OpeningHours> OpeningHours { get; } = new();
}

public class Term
{
    public int Id { get; set; }
    public string Name { get; set; } = null!;
    public DateOnly FirstDay { get; set; }
    public DateOnly LastDay { get; set; }
    public School School { get; set; } = null!;
}

[Owned]
public class OpeningHours
{
    public OpeningHours(DayOfWeek dayOfWeek, TimeOnly? opensAt, TimeOnly? closesAt)
    {
        DayOfWeek = dayOfWeek;
        OpensAt = opensAt;
        ClosesAt = closesAt;
    }

    public DayOfWeek DayOfWeek { get; private set; }
    public TimeOnly? OpensAt { get; set; }
    public TimeOnly? ClosesAt { get; set; }
}

Sugerencia

El código que se muestra aquí procede de DateOnlyTimeOnlySample.cs.

Nota:

Este modelo representa solo las escuelas británicas y almacena las horas como horas locales (GMT). El control de diferentes zonas horarias complicaría significativamente este código. Tenga en cuenta que el uso de DateTimeOffset no ayuda aquí, ya que las horas de apertura y cierre tienen desplazamientos diferentes en función de si el horario de verano está activo o no.

Estos tipos de entidad se asignan a las tablas siguientes al usar SQL Server. Observe que las propiedades DateOnly se asignan a las columnas date y las propiedades TimeOnly se asignan a las columnas time.

CREATE TABLE [Schools] (
    [Id] int NOT NULL IDENTITY,
    [Name] nvarchar(max) NOT NULL,
    [Founded] date NOT NULL,
    CONSTRAINT [PK_Schools] PRIMARY KEY ([Id]));

CREATE TABLE [OpeningHours] (
    [SchoolId] int NOT NULL,
    [Id] int NOT NULL IDENTITY,
    [DayOfWeek] int NOT NULL,
    [OpensAt] time NULL,
    [ClosesAt] time NULL,
    CONSTRAINT [PK_OpeningHours] PRIMARY KEY ([SchoolId], [Id]),
    CONSTRAINT [FK_OpeningHours_Schools_SchoolId] FOREIGN KEY ([SchoolId]) REFERENCES [Schools] ([Id]) ON DELETE CASCADE);

CREATE TABLE [Term] (
    [Id] int NOT NULL IDENTITY,
    [Name] nvarchar(max) NOT NULL,
    [FirstDay] date NOT NULL,
    [LastDay] date NOT NULL,
    [SchoolId] int NOT NULL,
    CONSTRAINT [PK_Term] PRIMARY KEY ([Id]),
    CONSTRAINT [FK_Term_Schools_SchoolId] FOREIGN KEY ([SchoolId]) REFERENCES [Schools] ([Id]) ON DELETE CASCADE);

Las consultas que usan DateOnly y TimeOnly funcionan de la manera esperada. Por ejemplo, la siguiente consulta LINQ busca centros educativos que están abiertos actualmente:

openSchools = await context.Schools
    .Where(
        s => s.Terms.Any(
                 t => t.FirstDay <= today
                      && t.LastDay >= today)
             && s.OpeningHours.Any(
                 o => o.DayOfWeek == dayOfWeek
                      && o.OpensAt < time && o.ClosesAt >= time))
    .ToListAsync();

Esta consulta se traduce en el siguiente código SQL, como se muestra en ToQueryString:

DECLARE @__today_0 date = '2023-02-07';
DECLARE @__dayOfWeek_1 int = 2;
DECLARE @__time_2 time = '19:53:40.4798052';

SELECT [s].[Id], [s].[Founded], [s].[Name], [o0].[SchoolId], [o0].[Id], [o0].[ClosesAt], [o0].[DayOfWeek], [o0].[OpensAt]
FROM [Schools] AS [s]
LEFT JOIN [OpeningHours] AS [o0] ON [s].[Id] = [o0].[SchoolId]
WHERE EXISTS (
    SELECT 1
    FROM [Term] AS [t]
    WHERE [s].[Id] = [t].[SchoolId] AND [t].[FirstDay] <= @__today_0 AND [t].[LastDay] >= @__today_0) AND EXISTS (
    SELECT 1
    FROM [OpeningHours] AS [o]
    WHERE [s].[Id] = [o].[SchoolId] AND [o].[DayOfWeek] = @__dayOfWeek_1 AND [o].[OpensAt] < @__time_2 AND [o].[ClosesAt] >= @__time_2)
ORDER BY [s].[Id], [o0].[SchoolId]

DateOnly y TimeOnly también se pueden usar en columnas JSON. Por ejemplo, OpeningHours se puede guardar como un documento JSON, lo que da como resultado datos similares a los siguientes:

Columna Valor
Identificador 2
Nombre Escuela secundaria Farr
Fundación 1964-05-01
OpeningHours
[
{ "DayOfWeek": "Sunday", "ClosesAt": null, "OpensAt": null },
{ "DayOfWeek": "Monday", "ClosesAt": "15:35:00", "OpensAt": "08:45:00" },
{ "DayOfWeek": "Tuesday", "ClosesAt": "15:35:00", "OpensAt": "08:45:00" },
{ "DayOfWeek": "Wednesday", "ClosesAt": "15:35:00", "OpensAt": "08:45:00" },
{ "DayOfWeek": "Thursday", "ClosesAt": "15:35:00", "OpensAt": "08:45:00" },
{ "DayOfWeek": "Friday", "ClosesAt": "12:50:00", "OpensAt": "08:45:00" },
{ "DayOfWeek": "Saturday", "ClosesAt": null, "OpensAt": null }
]

Al combinar dos características de EF8, ahora podemos consultar las horas de apertura indizando en la colección JSON. Por ejemplo:

openSchools = await context.Schools
    .Where(
        s => s.Terms.Any(
                 t => t.FirstDay <= today
                      && t.LastDay >= today)
             && s.OpeningHours[(int)dayOfWeek].OpensAt < time
             && s.OpeningHours[(int)dayOfWeek].ClosesAt >= time)
    .ToListAsync();

Esta consulta se traduce en el siguiente código SQL, como se muestra en ToQueryString:

DECLARE @__today_0 date = '2023-02-07';
DECLARE @__dayOfWeek_1 int = 2;
DECLARE @__time_2 time = '20:14:34.7795877';

SELECT [s].[Id], [s].[Founded], [s].[Name], [s].[OpeningHours]
FROM [Schools] AS [s]
WHERE EXISTS (
    SELECT 1
    FROM [Term] AS [t]
    WHERE [s].[Id] = [t].[SchoolId] AND [t].[FirstDay] <= @__today_0
      AND [t].[LastDay] >= @__today_0)
      AND CAST(JSON_VALUE([s].[OpeningHours],'$[' + CAST(CAST(@__dayOfWeek_1 AS int) AS nvarchar(max)) + '].OpensAt') AS time) < @__time_2
      AND CAST(JSON_VALUE([s].[OpeningHours],'$[' + CAST(CAST(@__dayOfWeek_1 AS int) AS nvarchar(max)) + '].ClosesAt') AS time) >= @__time_2

Por último, las actualizaciones y eliminaciones se pueden realizar con seguimiento y SaveChanges, o mediante ExecuteUpdate/ExecuteDelete. Por ejemplo:

await context.Schools
    .Where(e => e.Terms.Any(t => t.LastDay.Year == 2022))
    .SelectMany(e => e.Terms)
    .ExecuteUpdateAsync(s => s.SetProperty(t => t.LastDay, t => t.LastDay.AddDays(1)));

Lo que se traduce en el siguiente código SQL:

UPDATE [t0]
SET [t0].[LastDay] = DATEADD(day, CAST(1 AS int), [t0].[LastDay])
FROM [Schools] AS [s]
INNER JOIN [Term] AS [t0] ON [s].[Id] = [t0].[SchoolId]
WHERE EXISTS (
    SELECT 1
    FROM [Term] AS [t]
    WHERE [s].[Id] = [t].[SchoolId] AND DATEPART(year, [t].[LastDay]) = 2022)

Ingeniero inverso de Synapse y Dynamics 365 TDS

La utilización de técnicas de ingeniería inversa de EF8 (también conocida como scaffolding desde una base de datos existente) ahora admite las bases de datos del Grupo de SQL sin servidor de Synapse y Punto de conexión de Dynamics 365 TDS.

Advertencia

Estos sistemas de bases de datos tienen diferencias con respecto a las bases de datos normales de SQL Server y Azure SQL. Estas diferencias significan que no se admite toda la funcionalidad de EF Core al escribir consultas en ellos o realizar otras operaciones con estos sistemas de base de datos.

Mejoras en las traducciones matemáticas

Las interfaces matemáticas genéricas se introdujeron en .NET 7. Los tipos concretos como double y float han implementado estas interfaces agregando nuevas API que reflejan la funcionalidad existente de Math y MathF.

EF Core 8 traduce las llamadas a estas API matemáticas genéricas en LINQ mediante las traducciones SQL existentes de los proveedores para Math y MathF. Esto significa que ahora tiene la libertad de elegir entre llamadas como Math.Sin o double.Sin en las consultas de EF.

Hemos trabajado con el equipo de .NET para agregar dos nuevos métodos matemáticos genéricos en .NET 8 que se implementan en double y float. También se traducen a SQL en EF Core 8.

.NET SQL
DegreesToRadians RADIANS
RadiansToDegrees DEGREES

Por último, trabajamos con Eric Sink en el proyecto SQLitePCLRaw para habilitar las funciones matemáticas de SQLite en sus compilaciones de la biblioteca nativa de SQLite. Esto incluye la biblioteca nativa que obtiene de forma predeterminada al instalar el proveedor de SQLite de EF Core. Esto permite varias nuevas traducciones de SQL en LINQ, como: Acos, Acosh, Asin, Asinh, Atan, Atan2, Atanh, Ceiling, Cos, Cosh, DegreesToRadians, Exp, Floor, Log, Log2, Log10, Pow, RadiansToDegrees, Sign, Sin, Sinh, Sqrt, Tan, Tanh y Truncate.

Comprobando los cambios de modelo pendientes

Se ha agregado un nuevo comando dotnet ef para comprobar si se han realizado cambios en el modelo desde la última migración. Esto puede ser útil en escenarios de CI/CD para asegurarse de que usted o un compañero de equipo no olvidaron agregar una migración.

dotnet ef migrations has-pending-model-changes

También puede realizar esta comprobación mediante programación en la aplicación o las pruebas mediante el nuevo método dbContext.Database.HasPendingModelChanges().

Mejoras en el andamiaje de SQLite

SQLite solo admite cuatro tipos de datos primitivos: INTEGER, REAL, TEXT y BLOB. Anteriormente, esto significaba que cuando se aplicaba ingeniería inversa a una base de datos SQLite para estructurar un modelo EF Core, los tipos de entidad resultantes solo incluían propiedades de tipo long, double, string y byte[]. El proveedor EF Core SQLite admite tipos .NET adicionales mediante la conversión entre ellos y uno de los cuatro tipos primitivos de SQLite.

En EF Core 8, ahora usamos el formato de datos y el nombre del tipo de columna además del tipo SQLite para determinar un tipo .NET más apropiado para usar en el modelo. Las siguientes tablas muestran algunos de los casos en los que la información adicional conduce a mejores tipos de propiedades en el modelo.

Nombre de tipo de columna Tipo de .NET
BOOLEAN byte[]bool
SMALLINT longshort
INT longint
BIGINT long
STRING byte[]string
Formato de datos Tipo de .NET
'0.0' stringdecimal
'1970-01-01' stringDateOnly
'1970-01-01 00:00:00' stringDateTime
'00:00:00' stringTimeSpan
'00000000-0000-0000-0000-000000000000' stringGuid

Valores predeterminados de base de datos y valores de Sentinel

Las bases de datos permiten configurar columnas para generar un valor predeterminado si no se proporciona ningún valor al insertar una fila. Esto se puede representar en EF mediante HasDefaultValue para constantes:

b.Property(e => e.Status).HasDefaultValue("Hidden");

O HasDefaultValueSql para cláusulas SQL arbitrarias:

b.Property(e => e.LeaseDate).HasDefaultValueSql("getutcdate()");

Sugerencia

El código que se muestra a continuación procede de DefaultConstraintSample.cs.

Para que EF use esto, debe determinar cuándo y cuándo no enviar un valor para la columna. De forma predeterminada, EF usa el valor predeterminado de CLR como centinela para esto. Es decir, cuando el valor de Status o LeaseDate en los ejemplos anteriores son los valores predeterminados de CLR para estos tipos, EF interpreta que la propiedad no se ha establecido y, por tanto, no envía un valor a la base de datos. Esto funciona bien para los tipos de referencia; por ejemplo, si la propiedad string Status es null, EF no envía null a la base de datos, sino que no incluye ningún valor para que se use el valor predeterminado de la base de datos ("Hidden"). Del mismo modo, para la propiedad DateTime LeaseDate, EF no insertará el valor predeterminado de CLR de 1/1/0001 12:00:00 AM, sino que, en su lugar, omitirá este valor para que se use el valor predeterminado de la base de datos.

Sin embargo, en algunos casos, el valor predeterminado de CLR es un valor válido para insertar. EF8 controla esto al permitir que el valor de sentinel de una columna cambie. Por ejemplo, considere una columna entera configurada con un valor predeterminado de base de datos:

b.Property(e => e.Credits).HasDefaultValueSql(10);

En este caso, queremos que la nueva entidad se inserte con el número de créditos dado, a menos que no se especifique, en cuyo caso se asignan diez créditos. Sin embargo, esto significa que no es posible insertar un registro con cero créditos, ya que cero es el valor predeterminado de CLR y, por lo tanto, hará que EF no envíe ningún valor. En EF8, esto se puede corregir cambiando el centinela de la propiedad de cero a -1:

b.Property(e => e.Credits).HasDefaultValueSql(10).HasSentinel(-1);

EF ahora solo usará el valor predeterminado de la base de datos si Credits se establece en -1; se insertará un valor de cero como cualquier otra cantidad.

A menudo puede ser útil reflejar esto en el tipo de entidad, así como en la configuración de EF. Por ejemplo:

public class Person
{
    public int Id { get; set; }
    public int Credits { get; set; } = -1;
}

Esto significa que el valor de centinela de -1 se establece automáticamente cuando se crea la instancia, lo que indica que la propiedad se inicia en su estado "no establecido".

Sugerencia

Si desea configurar la restricción predeterminada de la base de datos para su uso cuando Migrations crea la columna, pero quiere que EF inserte siempre un valor, configure entonces la propiedad como no generada. Por ejemplo, b.Property(e => e.Credits).HasDefaultValueSql(10).ValueGeneratedNever();.

Valores predeterminados de la base de datos para booleanos

Las propiedades booleanas presentan una forma extrema de este problema, ya que el valor predeterminado de CLR (false) es uno de solo dos valores válidos. Esto significa que una propiedad bool con una restricción predeterminada de la base de datos solamente tendrá un valor insertado si ese valor es true. Cuando el valor predeterminado de la base de datos es false, significa que cuando el valor de la propiedad es false, se usará el valor predeterminado de la base de datos, que es false. De lo contrario, si el valor de la propiedad es true, se insertará true. Por lo tanto, cuando el valor predeterminado de la base de datos es false, la columna de base de datos termina con el valor correcto.

Por otro lado, cuando el valor predeterminado de la base de datos es true, significa que cuando el valor de propiedad es false, se usará el valor predeterminado de la base de datos, que es true. Y cuando el valor de la propiedad es true, se insertará true. Por lo tanto, el valor de la columna siempre finalizará true en la base de datos, independientemente del valor de la propiedad.

EF8 corrige este problema estableciendo el centinela para las propiedades bool en el mismo valor que el valor predeterminado de la base de datos. Ambos casos anteriores después dan lugar a que se inserte el valor correcto, independientemente de si el valor predeterminado de la base de datos es true o false.

Sugerencia

Al aplicar scaffolding desde una base de datos existente, EF8 analiza y, a continuación, incluye valores predeterminados simples en llamadas HasDefaultValue. (Anteriormente, se aplicaba scaffolding a todos los valores predeterminados como llamadas HasDefaultValueSql opacas). Esto significa que ya no se aplica scaffolding como que admiten valores NULL a las columnas bool que no aceptan valores NULL con un valor predeterminado de base de datos constante true o false.

Valores predeterminados de la base de datos para enumeraciones

Las propiedades de enumeración pueden tener problemas similares a las propiedades bool porque las enumeraciones suelen tener un conjunto muy pequeño de valores válidos y el valor predeterminado de CLR puede ser uno de estos valores. Por ejemplo, considere este tipo de entidad y enumeración:

public class Course
{
    public int Id { get; set; }
    public Level Level { get; set; }
}

public enum Level
{
    Beginner,
    Intermediate,
    Advanced,
    Unspecified
}

La propiedad Level se configura después con un valor predeterminado de base de datos:

modelBuilder.Entity<Course>()
    .Property(e => e.Level)
    .HasDefaultValue(Level.Intermediate);

Con esta configuración, EF excluirá el envío del valor a la base de datos cuando se establezca en Level.Beginner y, en su lugar, la base de datos asigna Level.Intermediate. ¡Esto no es lo que se pretendía!

El problema no habría ocurrido si la enumeración se hubiera definido con el valor "desconocido" o "no especificado" como el valor predeterminado de la base de datos:

public enum Level
{
    Unspecified,
    Beginner,
    Intermediate,
    Advanced
}

Sin embargo, no siempre es posible cambiar una enumeración existente, por lo que en EF8, se puede especificar de nuevo el centinela. Por ejemplo, volviendo a la enumeración original:

modelBuilder.Entity<Course>()
    .Property(e => e.Level)
    .HasDefaultValue(Level.Intermediate)
    .HasSentinel(Level.Unspecified);

Ahora Level.Beginner se insertará como normal y el valor predeterminado de la base de datos solo se usará cuando el valor de la propiedad sea Level.Unspecified. De nuevo, puede ser útil reflejarlo en el propio tipo de entidad. Por ejemplo:

public class Course
{
    public int Id { get; set; }
    public Level Level { get; set; } = Level.Unspecified;
}

Uso de un campo de respaldo que acepte un valor NULL

Una manera más general de controlar el problema descrito anteriormente consiste en crear un campo de respaldo que acepte valores NULL para la propiedad que no acepta valores NULL. Por ejemplo, considere el tipo de entidad siguiente con una propiedad bool:

public class Account
{
    public int Id { get; set; }
    public bool IsActive { get; set; }
}

La propiedad puede tener un campo de respaldo que acepta valores NULL:

public class Account
{
    public int Id { get; set; }

    private bool? _isActive;

    public bool IsActive
    {
        get => _isActive ?? false;
        set => _isActive = value;
    }
}

El campo de respaldo aquí permanecerá null a menos que se llame realmente al establecedor de propiedades. Es decir, el valor del campo de respaldo es una indicación mejor de si la propiedad se ha establecido o no que el valor predeterminado de CLR de la propiedad. Esto funciona de forma predeterminada con EF, ya que EF usará el campo de respaldo para leer y escribir la propiedad de forma predeterminada.

ExecuteUpdate y ExecuteDelete mejores

Los comandos SQL que realizan actualizaciones y eliminaciones, como los generados por los métodos ExecuteUpdate y ExecuteDelete, deben tener como destino una sola tabla de base de datos. Sin embargo, en EF7, ExecuteUpdate y ExecuteDelete no admitían actualizaciones que accedían a varios tipos de entidad aunque la consulta en última instancia afectara a una sola tabla. EF8 quita esta limitación. Por ejemplo, considere un tipo de entidad Customer con el tipo en propiedad CustomerInfo:

public class Customer
{
    public int Id { get; set; }
    public required string Name { get; set; }
    public required CustomerInfo CustomerInfo { get; set; }
}

[Owned]
public class CustomerInfo
{
    public string? Tag { get; set; }
}

Ambos tipos de entidad se asignan a la tabla Customers. Sin embargo, se produce un error en la siguiente actualización masiva en EF7 porque usa ambos tipos de entidad:

await context.Customers
    .Where(e => e.Name == name)
    .ExecuteUpdateAsync(
        s => s.SetProperty(b => b.CustomerInfo.Tag, "Tagged")
            .SetProperty(b => b.Name, b => b.Name + "_Tagged"));

En EF8, esto se traduce ahora en el siguiente código SQL cuando se usa Azure SQL:

UPDATE [c]
SET [c].[Name] = [c].[Name] + N'_Tagged',
    [c].[CustomerInfo_Tag] = N'Tagged'
FROM [Customers] AS [c]
WHERE [c].[Name] = @__name_0

Del mismo modo, las instancias devueltas desde una consulta Union se pueden actualizar siempre y cuando todas las actualizaciones se dirijan a la misma tabla. Por ejemplo, podemos actualizar cualquier Customer con una región de France y, al mismo tiempo, cualquier Customer que haya visitado una tienda con la región France:

await context.CustomersWithStores
    .Where(e => e.Region == "France")
    .Union(context.Stores.Where(e => e.Region == "France").SelectMany(e => e.Customers))
    .ExecuteUpdateAsync(s => s.SetProperty(b => b.Tag, "The French Connection"));

En EF8, esta consulta genera lo siguiente cuando se utiliza Azure SQL:

UPDATE [c]
SET [c].[Tag] = N'The French Connection'
FROM [CustomersWithStores] AS [c]
INNER JOIN (
    SELECT [c0].[Id], [c0].[Name], [c0].[Region], [c0].[StoreId], [c0].[Tag]
    FROM [CustomersWithStores] AS [c0]
    WHERE [c0].[Region] = N'France'
    UNION
    SELECT [c1].[Id], [c1].[Name], [c1].[Region], [c1].[StoreId], [c1].[Tag]
    FROM [Stores] AS [s]
    INNER JOIN [CustomersWithStores] AS [c1] ON [s].[Id] = [c1].[StoreId]
    WHERE [s].[Region] = N'France'
) AS [t] ON [c].[Id] = [t].[Id]

Como ejemplo final, en EF8, ExecuteUpdate se puede usar para actualizar entidades en una jerarquía de TPT siempre que todas las propiedades actualizadas se asignen a la misma tabla. Por ejemplo, considere estos tipos de entidad asignados mediante TPT:

[Table("TptSpecialCustomers")]
public class SpecialCustomerTpt : CustomerTpt
{
    public string? Note { get; set; }
}

[Table("TptCustomers")]
public class CustomerTpt
{
    public int Id { get; set; }
    public required string Name { get; set; }
}

Con EF8, se puede actualizar la propiedad Note:

await context.TptSpecialCustomers
    .Where(e => e.Name == name)
    .ExecuteUpdateAsync(s => s.SetProperty(b => b.Note, "Noted"));

O se puede actualizar la propiedad Name:

await context.TptSpecialCustomers
    .Where(e => e.Name == name)
    .ExecuteUpdateAsync(s => s.SetProperty(b => b.Name, b => b.Name + " (Noted)"));

Sin embargo, EF8 no puede intentar actualizar las propiedades Name y Note porque están asignadas a tablas diferentes. Por ejemplo:

await context.TptSpecialCustomers
    .Where(e => e.Name == name)
    .ExecuteUpdateAsync(s => s.SetProperty(b => b.Note, "Noted")
        .SetProperty(b => b.Name, b => b.Name + " (Noted)"));

Produce la siguiente excepción:

The LINQ expression 'DbSet<SpecialCustomerTpt>()
    .Where(s => s.Name == __name_0)
    .ExecuteUpdate(s => s.SetProperty<string>(
        propertyExpression: b => b.Note,
        valueExpression: "Noted").SetProperty<string>(
        propertyExpression: b => b.Name,
        valueExpression: b => b.Name + " (Noted)"))' could not be translated. Additional information: Multiple 'SetProperty' invocations refer to different tables ('b => b.Note' and 'b => b.Name'). A single 'ExecuteUpdate' call can only update the columns of a single table. See https://go.microsoft.com/fwlink/?linkid=2101038 for more information.

Mejor uso de consultas IN

Cuando el operador LINQ Contains se usa con una subconsulta, EF Core ahora genera mejores consultas con IN en lugar de EXISTS de SQL; además de producir un código SQL más legible, en algunos casos esto puede dar lugar a consultas considerablemente más rápidas. Por ejemplo, considere la siguiente consulta LINQ:

var blogsWithPosts = await context.Blogs
    .Where(b => context.Posts.Select(p => p.BlogId).Contains(b.Id))
    .ToListAsync();

EF7 genera lo siguiente para PostgreSQL:

SELECT b."Id", b."Name"
      FROM "Blogs" AS b
      WHERE EXISTS (
          SELECT 1
          FROM "Posts" AS p
          WHERE p."BlogId" = b."Id")

Dado que la subconsulta hace referencia a la tabla externa Blogs (a través de b."Id"), se trata de una subconsulta correlacionada, lo que significa que la subconsulta Posts debe ejecutarse para cada fila de la tabla Blogs. En EF8, en su lugar se genera el siguiente SQL:

SELECT b."Id", b."Name"
      FROM "Blogs" AS b
      WHERE b."Id" IN (
          SELECT p."BlogId"
          FROM "Posts" AS p
      )

Dado que la subconsulta ya no hace referencia a Blogs, se puede evaluar una vez, lo que produce mejoras masivas de rendimiento en la mayoría de los sistemas de base de datos. Sin embargo, algunos sistemas de base de datos, sobre todo SQL Server, la base de datos puede optimizar la primera consulta a la segunda consulta para que el rendimiento sea el mismo.

Valor de rowversions numéricos para SQL Azure o SQL Server

La simultaneidad optimista automática de SQL Server se controla mediante columnas rowversion. rowversion es un valor opaco de 8 bytes que se pasa entre la base de datos, el cliente y el servidor. De forma predeterminada, SqlClient expone tipos rowversion como byte[], a pesar de que los tipos de referencia mutables son una coincidencia incorrecta para la semántica rowversion. En EF8, es fácil asignar columnas rowversion a propiedades long o ulong. Por ejemplo:

modelBuilder.Entity<Blog>()
    .Property(e => e.RowVersion)
    .IsRowVersion();

Eliminación de paréntesis

La generación de SQL legible es un objetivo importante para EF Core. En EF8, el código SQL generado es más legible mediante la eliminación automática de paréntesis innecesarios. Por ejemplo, la siguiente consulta LINQ:

await ctx.Customers  
    .Where(c => c.Id * 3 + 2 > 0 && c.FirstName != null || c.LastName != null)  
    .ToListAsync();  

Se traduce al siguiente código de Azure SQL al usar EF7:

SELECT [c].[Id], [c].[City], [c].[FirstName], [c].[LastName], [c].[Street]
FROM [Customers] AS [c]
WHERE ((([c].[Id] * 3) + 2) > 0 AND ([c].[FirstName] IS NOT NULL)) OR ([c].[LastName] IS NOT NULL)

Lo que se ha mejorado a continuación al usar EF8:

SELECT [c].[Id], [c].[City], [c].[FirstName], [c].[LastName], [c].[Street]
FROM [Customers] AS [c]
WHERE ([c].[Id] * 3 + 2 > 0 AND [c].[FirstName] IS NOT NULL) OR [c].[LastName] IS NOT NULL

Exclusión específica para la cláusula RETURNING/OUTPUT

EF7 cambió la actualización predeterminada de SQL para utilizar RETURNING/OUTPUT para recuperar cambios de columnas generadas por la base de datos. Se identificaron algunos casos en los que esto no funciona y, por tanto, EF8 presenta exclusiones explícitas para este comportamiento.

Por ejemplo, para excluir OUTPUT al usar el proveedor de SQL Server o Azure SQL:

 modelBuilder.Entity<Customer>().ToTable(tb => tb.UseSqlOutputClause(false));

O para excluir RETURNING al usar el proveedor de SQLite:

 modelBuilder.Entity<Customer>().ToTable(tb => tb.UseSqlReturningClause(false));

Otros cambios menores

Además de las mejoras descritas anteriormente, se han realizado muchos cambios de menor importancia en EF8. Esta característica incluye: