Novidades no EF Core 8

O EF Core 8.0 (EF8) foi lançado em novembro de 2023.

Dica

Você pode executar e depurar nos exemplos baixando o código de exemplo do GitHub. Cada seção vincula ao código-fonte específico dessa seção.

O EF8 requer que o SDK do .NET 8 seja compilado e requer que o runtime do .NET 8 seja executado. O EF8 não será executado em versões anteriores do .NET e não será executado no .NET Framework.

Objetos de valor que usam tipos complexos

Os objetos salvos no banco de dados podem ser divididos em três grandes categorias:

  • Objetos que não são estruturados e contêm um único valor. Por exemplo, int, Guid, string, IPAddress. Estes são (de maneira geral) chamados de "tipos primitivos".
  • Objetos estruturados para conter vários valores e nos quais a identidade do objeto é definida por um valor de chave. Por exemplo, Blog, Post, Customer. Eles são chamados de "tipos de entidade".
  • Objetos estruturados para conter vários valores, mas o objeto não tem nenhuma chave para definir a identidade. Por exemplo, Address, Coordinate.

Antes do EF8, não havia uma boa maneira de mapear o terceiro tipo de objeto. Tipos próprios podem ser usados, mas como os tipos próprios são, na verdade, tipos de entidade, eles têm semântica baseada em um valor de chave, mesmo quando esse valor de chave está oculto.

O EF8 agora dá suporte a "tipos complexos" para cobrir esse terceiro tipo de objeto. Objetos de tipo complexo:

  • Não são identificados nem rastreados pelo valor da chave.
  • Deve ser definido como parte de um tipo de entidade. (Em outras palavras, você não pode ter um DbSet de tipo complexo).
  • Podem ser tipos de valor ou tipos de referência do .NET.
  • As instâncias podem ser compartilhadas por várias propriedades.

Exemplo simples

Por exemplo, considere um 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; }
}

Assim, o Address é usado em três locais em um modelo simples de clientes/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 criar e salvar um cliente com o respectivo endereço:

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

Isso resulta na seguinte linha sendo inserida no banco de dados:

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 os tipos complexos não obtêm suas próprias tabelas. Em vez disso, eles são salvos embutidos em colunas da tabela Customers. Isso corresponde ao comportamento de compartilhamento de tabela de tipos próprios.

Observação

Não planejamos permitir que tipos complexos sejam mapeados em sua própria tabela. No entanto, em uma versão futura, planejamos permitir que o tipo complexo seja salvo como um documento JSON em uma única coluna. Vote no problema nº 31252 se isso for importante para você.

Agora, digamos que queremos enviar um pedido para um cliente e usar o endereço do cliente como endereço de cobrança e endereço de envio padrão. A maneira natural de fazer isso é copiar o objeto Address de Customer para Order. Por exemplo:

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

await context.SaveChangesAsync();

Com tipos complexos, isso funciona conforme o esperado e o endereço é inserido na tabela 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);

Até agora você pode estar dizendo, "mas eu poderia fazer isso com tipos próprios!" No entanto, a semântica do "tipo de entidade" de tipos próprios começa rapidamente a atrapalhar. Por exemplo, a execução do código acima com tipos próprios resulta em uma série de avisos e, em seguida, um erro:

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

Isso ocorre porque uma única instância do tipo de entidade Address (com o mesmo valor de chave oculta) está sendo usada para três instâncias de entidade diferentes. Por outro lado, é permitido compartilhar a mesma instância entre propriedades complexas e, portanto, o código funciona conforme o esperado ao usar tipos complexos.

Configuração de tipos complexos

Tipos complexos devem ser configurados no modelo usando atributos de mapeamento ou chamando a API ComplexProperty em OnModelCreating. Os tipos complexos não ficam descobertos por convenção.

Por exemplo, o tipo Address pode ser configurado usando o 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; }
}

Ou em 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);
    });
}

Mutabilidade

No exemplo acima, acabamos com a mesma instância Address usada em três locais. Isso é permitido e não causa problemas para o EF Core ao usar tipos complexos. No entanto, compartilhar instâncias do mesmo tipo de referência significa que, se um valor de propriedade na instância for modificado, essa alteração será refletida em todos os três usos. Por exemplo, seguindo o que foi feito acima, vamos alterar Line1 do endereço do cliente e salvar as alterações:

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

Isso resulta na seguinte atualização no banco de dados ao usar o 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 as três colunas Line1 foram alteradas, pois todas elas estão compartilhando a mesma instância. Isso geralmente não é o que queremos.

Dica

Se os endereços dos pedidos devem ser alterados automaticamente quando o endereço do cliente for alterado, considere mapear o endereço como um tipo de entidade. Assim, Order e Customer podem referenciar com segurança a mesma instância de endereço (que agora é identificada por uma chave) por meio de uma propriedade de navegação.

Uma boa maneira de lidar com problemas como esse é tornar o tipo imutável. De fato, essa imutabilidade geralmente é natural quando um tipo é um bom candidato para ser um tipo complexo. Por exemplo, geralmente faz sentido fornecer um novo objeto Address complexo em vez de apenas mudar, digamos, o país, deixando o resto da mesma forma.

Os tipos de referência e de valor podem ser tornados imutáveis. Examinaremos alguns exemplos nas seções a seguir.

Tipos de referência como tipos complexos

Classe imutável

Usamos um class simples e mutável no exemplo acima. Para evitar os problemas com a mutação acidental descrita acima, podemos tornar a classe imutável. Por exemplo:

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

Dica

Com o C# 12 ou superior, essa definição de classe pode ser simplificada com o uso de um construtor primário:

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

Agora não é possível alterar o valor Line1 em um endereço existente. Em vez disso, precisamos criar uma instância com o valor alterado. Por exemplo:

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

await context.SaveChangesAsync();

Desta vez, a chamada de SaveChangesAsync atualiza apenas o endereço do cliente:

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

Observe que, embora o objeto Address seja imutável e todo o objeto tenha sido alterado, o EF ainda está rastreando as alterações em cada propriedade, portanto, somente as colunas com valores alterados são atualizadas.

Registro imutável

O C# 9 introduziu tipos de registro, o que facilita a criação e o uso de objetos imutáveis. Por exemplo, o objeto Address pode se tornar um 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; }
}

Dica

Essa definição de registro pode ser simplificada usando um construtor primário:

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

Substituir o objeto mutável e chamar SaveChanges agora requer menos código:

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

await context.SaveChangesAsync();

Tipos de valor como tipos complexos

Struct mutável

Um tipo de valor mutável simples pode ser usado como um tipo complexo. Por exemplo, Address pode ser definido como um struct no 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; }
}

Atribuir o objeto do cliente Address às propriedades Address de envio e cobrança resulta em cada propriedade recebendo uma cópia do Address, pois é assim que os tipos de valor funcionam. Isso significa que modificar o Address no cliente não alterará as instâncias de envio ou cobrança Address, portanto, os structs mutáveis não têm os mesmos problemas de compartilhamento de instância que ocorrem com classes mutáveis.

No entanto, os structs mutáveis geralmente não são recomendados no C#, então pense com muito cuidado antes de usá-los.

Struct imutável

Structs imutáveis funcionam bem como tipos complexos, assim como as classes imutáveis. Por exemplo, Address pode ser definido de modo que não possa ser modificado:

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

O código para alterar o endereço agora tem a mesma aparência de quando se usa a classe imutável:

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

await context.SaveChangesAsync();

Registro de struct imutável

O C# 10 introduziu tipos struct record, que facilitam a criação e o trabalho com registros de struct imutáveis, como ocorre com registros de classe imutáveis. Por exemplo, podemos definir Address como um registro de struct imutável:

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

O código para alterar o endereço agora tem a mesma aparência de quando se usa o registro de classe imutável:

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

await context.SaveChangesAsync();

Tipos complexos aninhados

Um tipo complexo pode conter propriedades de outros tipos complexos. Por exemplo, vamos usar nosso tipo complexo Address acima junto com um tipo complexo PhoneNumber e aninhar ambos dentro de outro tipo complexo:

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

Estamos usando registros imutáveis aqui, pois eles são uma boa correspondência para a semântica de nossos tipos complexos, mas o aninhamento de tipos complexos pode ser feito com qualquer variante do .NET.

Observação

Não estamos usando um construtor primário para o tipo Contact porque o EF Core ainda não dá suporte à injeção de construtor de valores de tipo complexos. Vote no problema nº 31621 se isso for importante para você.

Adicionaremos Contact como uma propriedade do 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();
}

E PhoneNumber como propriedades do 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!;
}

A configuração de tipos complexos aninhados pode ser obtida novamente usando 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; }
}

Ou em 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

As propriedades de tipos complexos em tipos de entidade são tratadas como qualquer outra propriedade de não navegação do tipo de entidade. Isso significa que elas são sempre carregadas quando o tipo de entidade é carregado. Isso também se aplica a propriedades de tipo complexo aninhadas. Por exemplo, ao consultar um cliente:

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

Será convertido na seguinte SQL ao usar o 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 duas coisas desta SQL:

  • Tudo é retornado para popular o cliente e todos os tipos complexos aninhados, ContactAddress e PhoneNumber.
  • Todos os valores de tipo complexos são armazenados como colunas na tabela para o tipo de entidade. Tipos complexos nunca são mapeados como tabelas separadas.

Projeções

Tipos complexos podem ser projetados com base em uma consulta. Por exemplo, selecionando apenas o endereço de envio de um pedido:

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

Isso resulta no seguinte ao usar o 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

Observe que as projeções de tipos complexos não podem ser rastreadas, pois objetos de tipo complexo não têm identidade para usar para rastreamento.

Usar em predicados

Membros de tipos complexos podem ser usados em predicados. Por exemplo, encontrar todos os pedidos que vão para uma determinada cidade:

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

O que resulta na seguinte instrução SQL no 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

Uma instância completa de tipo complexo também pode ser usada em predicados. Por exemplo, encontrar todos os clientes com um determinado número de telefone:

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

Isso resulta no seguinte SQL ao usar o 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 a igualdade é executada expandindo cada membro do tipo complexo. Isso se alinha com tipos complexos que não têm chave para identidade e, portanto, uma instância de tipo complexo é igual a outra instância de tipo complexo se, e somente se, todos os membros forem iguais. Isso também se alinha à igualdade definida pelo .NET para tipos de registro.

Manipulação de valores de tipo complexo

O EF8 oferece acesso a informações de rastreamento, como os valores atuais e originais de tipos complexos e se um valor de propriedade foi modificado ou não. A API tipos complexos é uma extensão da API de controle de alterações já usada com tipos de entidade.

Os métodos ComplexProperty de EntityEntry retornam uma entrada para um objeto complexo inteiro. Por exemplo, para obter o valor atual do Order.BillingAddress:

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

Uma chamada a Property ser adicionada para acessar uma propriedade do tipo complexo. Por exemplo, para obter o valor atual apenas do código postal do endereço de cobrança:

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

Tipos complexos aninhados são acessados usando chamadas aninhadas a ComplexProperty. Por exemplo, para obter a cidade do Address aninhado de Contact em um Customer:

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

Há outros métodos disponíveis para ler e alterar o estado. Por exemplo, PropertyEntry.IsModified pode ser usado para definir uma propriedade de um tipo complexo como modificada:

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

Limitações atuais

Tipos complexos representam um investimento significativo na pilha do EF. Não conseguimos fazer tudo funcionar nesta versão, mas planejamos fechar algumas das lacunas em uma versão futura. Vote (👍) nos problemas apropriados do GitHub se a correção de qualquer uma dessas limitações for importante para você.

As limitações de tipo complexa no EF8 incluem:

Coleções primitivas

Uma questão persistente ao usar bancos de dados relacionais é o que fazer com coleções de tipos primitivos; ou seja, listas ou matrizes de inteiros, data/horas, cadeias de caracteres e assim por diante. Se você estiver usando o PostgreSQL, é fácil armazenar essas coisas usando o tipo de matriz interna do PostgreSQL. Para outros bancos de dados, há duas abordagens comuns:

  • Crie uma tabela com uma coluna para o valor de tipo simples e outra coluna para atuar como uma chave estrangeira vinculando cada valor ao proprietário da coleção.
  • Serialize a coleção simples em algum tipo de coluna manipulado pelo banco de dados - por exemplo, serialize de e para uma cadeia de caracteres.

A primeira opção tem vantagens em muitas situações - vamos dar uma olhada rápida no final desta seção. No entanto, não é uma representação natural dos dados no modelo, e se o que você realmente tem é uma coleção de um tipo simples, então a segunda opção pode ser mais eficaz.

A partir da versão prévia 4, o EF8 agora inclui suporte interno para a segunda opção, usando JSON como formato de serialização. O JSON funciona bem para isso, uma vez que os bancos de dados relacionais modernos incluem mecanismos internos para consultar e manipular JSON, de modo que a coluna JSON pode, efetivamente, ser tratada como uma tabela quando necessário, sem a sobrecarga de realmente criar essa tabela. Esses mesmos mecanismos permitem que o JSON seja passado em parâmetros e, em seguida, usado de maneira semelhante aos parâmetros com valor de tabela em consultas - abordaremos mais sobre isso mais tarde.

Dica

O código mostrado aqui vem de PrimitiveCollectionsSample.cs.

Propriedades simples da coleção

O EF Core pode mapear qualquer propriedade IEnumerable<T>, em que T é um tipo simples, para uma coluna JSON no banco de dados. Isso é feito por convenção para propriedades públicas que têm um getter e um setter. Por exemplo, todas as propriedades no seguinte tipo de entidade são mapeadas para colunas JSON por convenção:

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

Observação

O que queremos dizer com "tipo simples" nesse contexto? Essencialmente, algo que o provedor de banco de dados saiba mapear, usando algum tipo de conversão de valor, se necessário. Por exemplo, no tipo de entidade acima, os tipos int, string, DateTime, DateOnly e bool são todos manipulados sem conversão pelo provedor de banco de dados. O SQL Server não tem suporte nativo para ints ou URIs não assinados, mas uint e Uri ainda são tratados como tipos primitivos porque há conversores de valor internos para esses tipos.

Por padrão, o EF Core usa um tipo de coluna de cadeia de caracteres Unicode irrestrito para manter o JSON, pois isso protege contra perda de dados com coleções grandes. No entanto, em alguns sistemas de banco de dados, como o SQL Server, especificar um comprimento máximo para a cadeia de caracteres pode melhorar o desempenho. Isso, junto com outras configurações de coluna, pode ser feito da maneira normal. Por exemplo:

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

Ou, usando atributos de mapeamento:

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

Uma configuração de coluna padrão pode ser usada para todas as propriedades de um determinado tipo usando configuração de modelo pré-convenção. Por exemplo:

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

Consultas com coleções simples

Vejamos algumas das consultas que fazem uso de coleções de tipos simples. Para isso, precisaremos de um modelo simples com dois tipos de entidade. O primeiro representa um bar inglês, ou "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();
}

O tipo Pub contém duas coleções simples:

  • Beers é uma variedade de cordas que representam as marcas de cerveja disponíveis no pub.
  • DaysVisited é uma lista das datas em que o pub foi visitado.

Dica

Em um aplicativo real, provavelmente faria mais sentido criar um tipo de entidade para cerveja e ter uma mesa para cervejas. Estamos mostrando uma coleção simples aqui para ilustrar como eles funcionam. Mas lembre-se, só porque você pode modelar algo como uma coleção simples não significa que você necessariamente deveria.

O segundo tipo de entidade representa um passeio de cachorro no interior da Inglaterra:

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

Como Pub, DogWalk também contém uma coleção das datas visitadas e um link para o pub mais próximo, já que, você sabe, às vezes o cachorro precisa de um copo de cerveja depois de uma longa caminhada.

Usando este modelo, a primeira consulta que faremos é uma simples consulta Contains para encontrar todos os passeios com um dos vários terrenos diferentes:

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

Isso já é traduzido pelas versões atuais do EF Core, inserindo os valores a serem procurados. Por exemplo, ao usar o SQL Server:

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

No entanto, essa estratégia não funciona bem com o cache de consulta de banco de dados; consulte Anunciando a versão prévia 4 do EF8 no Blog do .NET para ver uma discussão sobre o problema.

Importante

A inserção de valores aqui é feita de tal forma que não há chance de um ataque de injeção de SQL. A mudança para usar JSON descrita abaixo tem tudo a ver com desempenho e nada a ver com segurança.

Para o EF Core 8, o padrão agora é passar a lista de terrenos como um único parâmetro contendo uma coleção JSON. Por exemplo:

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

Em seguida, a consulta usa OpenJson no 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])

Ou json_each no SQLite:

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

Observação

OpenJson só está disponível no SQL Server 2016 (nível de compatibilidade 130) e posterior. Você pode informar ao SQL Server que está usando uma versão mais antiga configurando o nível de compatibilidade como parte do UseSqlServer. Por exemplo:

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

Vamos tentar um tipo diferente de consulta Contains. Nesse caso, procuraremos um valor da coleção de parâmetros na coluna. Por exemplo, qualquer pub que armazene Heineken:

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

A documentação existente de Novidades no EF7 fornece informações detalhadas sobre mapeamento, consultas e atualizações JSON. Esta documentação agora também se aplica ao SQLite.

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

OpenJson agora é usado para extrair valores da coluna JSON para que cada valor possa ser correspondido ao parâmetro passado.

Podemos combinar o uso de OpenJson no parâmetro com OpenJson na coluna. Por exemplo, para encontrar pubs que estocam qualquer uma de uma variedade de lagers:

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

Isso se traduz no seguinte no 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)))

O valor do parâmetro @__beers_0 aqui é ["Carling","Heineken","Stella Artois","Carlsberg"].

Vejamos uma consulta que usa a coluna que contém uma coleção de datas. Por exemplo, para encontrar pubs visitados este ano:

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

Isso se traduz no seguinte no 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 a consulta usa a função específica de data DATEPART aqui porque EF sabe que a coleção simples contém datas. Pode não parecer, mas isso é realmente importante. Como o EF sabe o que está na coleção, ele pode gerar SQL apropriado para usar os valores digitados com parâmetros, funções, outras colunas etc.

Vamos usar a coleção de data novamente, desta vez para ordenar adequadamente os valores de tipo e projeto extraídos da coleção. Por exemplo, vamos listar os pubs na ordem em que foram visitados pela primeira vez e com a primeira e a última data em que cada pub foi visitado:

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

Isso se traduz no seguinte no 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))

E, finalmente, com que frequência acabamos visitando o pub mais próximo ao levar o cachorro para passear? Vamos descobrir isso:

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

Isso se traduz no seguinte no 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]

E revela os seguintes dados:

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 cerveja e passear com cachorro são uma combinação perfeita!

Coleções primitivas em documentos JSON

Em todos os exemplos acima, a coluna para coleção primitiva contém JSON. No entanto, isso não é o mesmo que mapear um tipo de entidade de propriedade para uma coluna que contém um documento JSON, que foi introduzido no EF7. Mas e se esse documento JSON em si contiver uma coleção simples? Bem, todas as consultas acima ainda funcionam da mesma maneira! Por exemplo, imagine que movemos os dados dias visitados para um tipo de propriedade Visits mapeados para um 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!;
}

Dica

O código mostrado aqui vem de PrimitiveCollectionsInJsonSample.cs.

Agora podemos executar uma variação de nossa consulta final que, desta vez, extrai dados do documento JSON, incluindo consultas nas coleções primitivas contidas no 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();

Isso se traduz no seguinte no 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]

E para uma consulta semelhante ao usar o 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"

Dica

Observe que no SQLite o EF Core agora usa o operador ->>, resultando em consultas mais fáceis de ler e, muitas vezes, mais eficientes.

Mapeando coleções simples para uma tabela

Mencionamos acima que outra opção para coleções primitivas é mapeá-las para uma tabela diferente. O suporte de primeira classe para isso é acompanhado por Problema nº 25163; certifique-se de votar nesta questão se for importante para você. Até que isso seja implementado, a melhor abordagem é criar um tipo de encapsulamento para o simples. Por exemplo, vamos criar um tipo para Beer:

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

    public string Name { get; private set; }
}

Observe que o tipo simplesmente encapsula o valor simples- ele não tem uma chave primária ou nenhuma chave estrangeira definida. Esse tipo pode ser usado na classe 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();
}

O EF agora criará uma tabela Beer, sintetizando as colunas de chave primária e de chave estrangeira de volta à tabela Pubs. Por exemplo, no 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

Aprimoramentos no mapeamento de colunas JSON

O EF8 inclui aprimoramentos no suporte ao mapeamento de colunas JSON introduzido no EF7.

Dica

O código mostrado aqui vem de JsonColumnsSample.cs.

Traduzir o acesso a elementos em matrizes JSON

O EF8 oferece suporte à indexação em matrizes JSON ao executar consultas. Por exemplo, a consulta a seguir verifica se as duas primeiras atualizações foram feitas antes de uma determinada data.

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

Isso se traduz no seguinte SQL ao usar o 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

Observação

Essa consulta será bem-sucedida mesmo se uma determinada postagem não tiver atualizações ou tiver apenas uma única atualização. Nesse caso, JSON_VALUE retorna NULL e o predicado não é correspondido.

A indexação em matrizes JSON também pode ser usada para projetar elementos de uma matriz nos resultados finais. Por exemplo, a consulta a seguir projeta a data UpdatedOn para a primeira e segunda atualizações de cada postagem.

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

Isso se traduz no seguinte SQL ao usar o 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 observado acima, JSON_VALUE retorna nulo se o elemento da matriz não existir. Isso é tratado na consulta convertendo o valor projetado em um DateOnly anulável. Uma alternativa para converter o valor é filtrar os resultados da consulta para que JSON_VALUE nunca retorne nulo. Por exemplo:

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

Isso se traduz no seguinte SQL ao usar o 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)

Traduzir consultas em coleções inseridas

O EF8 dá suporte a consultas em coleções de tipos primitivos (discutidos acima) e não primitivos inseridos no documento JSON. Por exemplo, a consulta a seguir retorna todas as postagens com qualquer uma de uma lista arbitrária de termos de pesquisa:

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

Isso se traduz no seguinte SQL ao usar o 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]))

Colunas JSON para SQLite

O EF7 introduziu suporte para mapeamento para colunas JSON ao usar o SQL do Azure/SQL Server. O EF8 estende esse suporte a bancos de dados SQLite. Quanto ao suporte do SQL Server, isso inclui:

  • Mapeamento de agregações criadas a partir de tipos .NET para documentos JSON armazenados em colunas SQLite
  • Consultas em colunas JSON, como filtragem e classificação pelos elementos dos documentos
  • Consulta os elementos do projeto do documento JSON nos resultados
  • Atualizando e salvando alterações em documentos JSON

A documentação existente de Novidades no EF7 fornece informações detalhadas sobre mapeamento, consultas e atualizações JSON. Esta documentação agora também se aplica ao SQLite.

Dica

O código mostrado na documentação do EF7 foi atualizado para também ser executado no SQLite pode ser encontrado em JsonColumnsSample.cs.

Consultas em colunas JSON

As consultas em colunas JSON no SQLite usam a função json_extract. Por exemplo, a consulta "autores em São Paulo" da documentação mencionada acima:

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

É convertido para o seguinte SQL ao usar SQLite:

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

Atualizando colunas JSON

Para atualizações, o EF usa a função json_set no SQLite. Por exemplo, ao atualizar uma única propriedade em um documento:

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

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

await context.SaveChangesAsync();

O EF gera os seguintes parâmetros:

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']

Quais usam a função json_set no SQLite:

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

HierarchyId no .NET e no EF Core

O SQL do Azure e o SQL Server têm um tipo de dados especial chamado hierarchyid que é usado para armazenar dados hierárquicos. Nesse caso, "dados hierárquicos" significa essencialmente dados que formam uma estrutura de árvore, onde cada item pode ter um pai e/ou filhos. Exemplos desses dados são:

  • Uma estrutura organizacional
  • Um sistema de arquivos
  • Um conjunto de tarefas em um projeto
  • Uma taxonomia de termos de linguagem
  • Um gráfico de links entre páginas da Web

O banco de dados é então capaz de executar consultas nesses dados usando sua estrutura hierárquica. Por exemplo, uma consulta pode encontrar ancestrais e dependentes de determinados itens ou localizar todos os itens em uma determinada profundidade na hierarquia.

Suporte no .NET e no EF Core

O suporte oficial para o tipo hierarchyid do SQL Server só recentemente chegou às plataformas .NET modernas (ou seja, ".NET Core"). Esse suporte está na forma do pacote NuGet Microsoft.SqlServer.Types que traz tipos específicos do SQL Server de baixo nível. Nesse caso, o tipo de baixo nível é chamado de SqlHierarchyId.

No próximo nível, um novo pacote Microsoft.EntityFrameworkCore.SqlServer.Abstractions foi introduzido, que inclui um tipo de HierarchyId de nível superior destinado ao uso em tipos de entidade.

Dica

O tipo HierarchyId é mais idiomático para as normas do .NET do que SqlHierarchyId, que é modelado de acordo com a forma como os tipos do .NET Framework são hospedados no mecanismo de banco de dados do SQL Server. HierarchyId foi projetado para funcionar com o EF Core, mas também pode ser usado fora do EF Core em outros aplicativos. O pacote Microsoft.EntityFrameworkCore.SqlServer.Abstractions não faz referência a nenhum outro pacote e, portanto, tem um impacto mínimo no tamanho e nas dependências do aplicativo implantado.

O uso da funcionalidade HierarchyId para EF Core, como consultas e atualizações, requer o pacote Microsoft.EntityFrameworkCore.SqlServer.HierarchyId. Este pacote traz Microsoft.EntityFrameworkCore.SqlServer.Abstractions e Microsoft.SqlServer.Types como dependências transitivas e, portanto, muitas vezes é o único pacote necessário. Depois que o pacote é instalado, o uso do HierarchyId é habilitado chamando UseHierarchyId como parte da chamada do aplicativo para UseSqlServer. Por exemplo:

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

Observação

O suporte não oficial para hierarchyid no EF Core está disponível há muitos anos por meio do pacote EntityFrameworkCore.SqlServer.HierarchyId. Este pacote foi mantido como uma colaboração entre a comunidade e a equipe EF. Agora que há suporte oficial para hierarchyid no .NET, o código deste pacote da comunidade forma, com a permissão dos contribuidores originais, a base para o pacote oficial descrito aqui. Muito obrigado a todos os envolvidos ao longo dos anos, incluindo @aljones, @cutig3r, @huan086, @kmataru, @mehdihaghshenas e @vyrotek

Hierarquias de modelagem

O tipo HierarchyId pode ser usado para propriedades de um tipo de entidade. Por exemplo, suponha que queremos modelar a árvore genealógica paterna de alguns halflings fictícios. No tipo de entidade para Halfling, uma propriedade HierarchyId pode ser usada para localizar cada halfling na árvore genealógica.

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

Dica

O código mostrado aqui e nos exemplos abaixo vem de HierarchyIdSample.cs.

Dica

Se desejar, HierarchyId é adequado para uso como um tipo de propriedade chave.

Neste caso, a árvore genealógica está enraizada com o patriarca da família. Cada halfling pode ser rastreado do patriarca até a árvore usando sua propriedade PathFromPatriarch. O SQL Server usa um formato binário compacto para esses caminhos, mas é comum analisar de e para uma representação de cadeia de caracteres legível por humanos ao trabalhar com código. Nessa representação, a posição em cada nível é separada por um caractere /. Por exemplo, considere a árvore genealógica no diagrama abaixo:

Árvore genealógica Halfling

Nesta árvore:

  • Balbo está na raiz da árvore, representada por /.
  • Balbo tem cinco filhos, representados por /1/, /2/, /3/, /4/ e /5/.
  • O primeiro filho de Balbo, Mungo, também tem cinco filhos, representados por /1/1/, /1/2/, /1/3/, /1/4/ e /1/5/. Observe que o HierarchyId para Balbo (/1/) é o prefixo para todos os seus filhos.
  • Da mesma forma, o terceiro filho de Balbo, Ponto, tem dois filhos, representados por /3/1/ e /3/2/. Novamente cada uma dessas crianças é prefixada pela HierarchyId para Ponto, que é representada como /3/.
  • E assim por diante...

O código a seguir insere essa árvore genealógica em um banco de dados usando o 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();

Dica

Se necessário, os valores decimais podem ser usados para criar novos nós entre dois nós existentes. Por exemplo, /3/2.5/2/ vai entre /3/2/2/ e /3/3/2/.

Consultando hierarquias

HierarchyId expõe vários métodos que podem ser usados em consultas LINQ.

Método Descrição
GetAncestor(int n) Obtém o nó n sobe de nível na árvore hierárquica.
GetDescendant(HierarchyId? child1, HierarchyId? child2) Obtém o valor de um nó descendente que é maior que child1 e menor que child2.
GetLevel() Obtém o nível desse nó na árvore hierárquica.
GetReparentedValue(HierarchyId? oldRoot, HierarchyId? newRoot) Obtém um valor que representa o local de um novo nó que tem um caminho de newRoot igual ao caminho de oldRoot para este, movendo-o efetivamente para o novo local.
IsDescendantOf(HierarchyId? parent) Obtém um valor que indica se esse nó é um descendente de parent.

Além disso, os operadores ==, !=, <, <=, > e >= podem ser usados.

A seguir estão exemplos de como usar esses métodos em consultas LINQ.

Obtenha entidades em um determinado nível na árvore

A consulta a seguir usa GetLevel para retornar todos os halflings em um determinado nível na árvore genealógica:

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

Isso se traduz no seguinte SQL:

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

Executando isso em um loop, podemos obter os halflings para cada geração:

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

Obter o ancestral direto de uma entidade

A consulta a seguir usa GetAncestor para encontrar o ancestral direto de um halfling, dado o nome desse halfling:

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

Isso se traduz no seguinte 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)

Executar esta consulta para o halfling "Bilbo" retorna "Bungo".

Obter os descendentes diretos de uma entidade

A consulta a seguir também usa GetAncestor, mas desta vez para encontrar os descendentes diretos de um halfling, dado o nome desse halfling:

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

Isso se traduz no seguinte 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)

Executando esta consulta para o halfling "Mungo" retorna "Bungo", "Belba", "Longo" e "Linda".

Obter todos os antepassados de uma entidade

GetAncestor é útil para pesquisar um único nível ou, de fato, um número especificado de níveis. Por outro lado, IsDescendantOf é útil para encontrar todos os antepassados ou dependentes. Por exemplo, a consulta a seguir usa IsDescendantOf para encontrar todos os ancestrais de um halfling, dado o nome desse halfling:

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 retorna true para si mesmo, e é por isso que ele é filtrado na consulta acima.

Isso se traduz no seguinte 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

Executar esta consulta para o halfling "Bilbo" retorna "Bungo", "Mungo" e "Balbo".

Obter todos os descendentes de uma entidade

A consulta a seguir também usa IsDescendantOf, mas desta vez para todos os descendentes de um halfling, dado o nome desse halfling:

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

Isso se traduz no seguinte 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()

Executando esta consulta para o halfling "Mungo" retorna "Bungo", "Belba", "Longo", "Linda", "Bingo", "Bilbo", "Otho", "Falco", "Lotho" e "Poppy".

Encontrar um ancestral comum

Uma das perguntas mais comuns feitas sobre essa árvore genealógica em particular é: "quem é o ancestral comum de Frodo e Bilbo?" Podemos usar IsDescendantOf para escrever tal consulta:

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

Isso se traduz no seguinte 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

Executando esta consulta com "Bilbo" e "Frodo" nos diz que seu ancestral comum é "Balbo".

Atualização de hierarquias

Os mecanismos normais controle de alterações e SaveChanges podem ser usados para atualizar hierarchyid colunas.

Recriação de uma sub-hierarquia

Por exemplo, tenho certeza de que todos nós nos lembramos do escândalo da SR 1752 (também conhecida como. "LongoGate") quando o teste de DNA revelou que Longo não era de fato o filho de Mungo, mas na verdade o filho de Ponto! Uma consequência desse escândalo foi que a árvore genealógica precisava ser reescrita. Em particular, Longo e todos os seus descendentes precisaram ser reparentados de Mungo para Ponto. GetReparentedValue pode ser usado para fazer isso. Por exemplo, primeiro "Longo" e todos os seus descendentes são consultados:

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

Em seguida, GetReparentedValue é usado para atualizar o HierarchyId para Longo e cada descendente, seguido de uma chamada para SaveChangesAsync:

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

await context.SaveChangesAsync();

Isso resulta na seguinte atualização do banco de dados:

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;

Usando esses 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)

Observação

Os valores de parâmetros para HierarchyId propriedades são enviados para o banco de dados em seu formato binário compacto.

Após a atualização, a consulta aos descendentes de "Mungo" retorna "Bungo", "Belba", "Linda", "Bingo", "Bilbo", "Falco" e "Poppy", enquanto a consulta aos descendentes de "Ponto" retorna "Longo", "Rosa", "Polo", "Otho", "Posco", "Prisca", "Lotho", "Ponto", "Porto", "Peony" e "Angelica".

Consultas SQL brutas para tipos não mapeados

O EF7 introduziu consultas SQL brutas que retornam tipos escalares. Isso é aprimorado no EF8 para incluir consultas SQL brutas que retornam qualquer tipo CLR mapeável, sem incluir esse tipo no modelo EF.

Dica

O código mostrado aqui vem de RawSqlSample.cs.

As consultas usando tipos não mapeados são executadas usando SqlQuery ou SqlQueryRaw. O primeiro usa interpolação de cadeia de caracteres para parametrizar a consulta, o que ajuda a garantir que todos os valores não constantes sejam parametrizados. Por exemplo, considere a seguinte tabela de banco de dados:

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 pode ser usado para consultar essa tabela e retornar instâncias de um tipo de BlogPost com propriedades correspondentes às colunas na tabela:

Por exemplo:

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 exemplo:

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 é parametrizada e executada como:

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

O tipo usado para os resultados da consulta pode conter construções de mapeamento comuns suportadas pelo EF Core, como construtores parametrizados e atributos de mapeamento. Por exemplo:

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

Observação

Os tipos usados dessa forma não têm chaves definidas e não podem ter relações com outros tipos. Tipos com relacionamentos devem ser mapeados no modelo.

O tipo usado deve ter uma propriedade para cada valor no conjunto de resultados, mas não precisa corresponder a nenhuma tabela no banco de dados. Por exemplo, o tipo a seguir representa apenas um subconjunto de informações para cada postagem e inclui o nome do blog, que vem da tabela Blogs:

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

E pode ser consultado usando SqlQuery da mesma forma 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();

Uma característica interessante do SqlQuery é que ele retorna um IQueryable que pode ser composto usando a LINQ. Por exemplo, uma cláusula 'Where' pode ser adicionada à consulta acima:

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

Isso é executado como:

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

Neste ponto, vale lembrar que tudo isso pode ser feito completamente na LINQ sem a necessidade de escrever qualquer SQL. Isso inclui o retorno de instâncias de um tipo não mapeado como PostSummary. Por exemplo, a consulta anterior pode ser escrita na LINQ como:

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

O que se traduz em um SQL muito mais limpo:

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

Dica

O EF é capaz de gerar SQL mais limpo quando é responsável por toda a consulta do que quando compõe sobre SQL fornecido pelo usuário porque, no primeiro caso, a semântica completa da consulta está disponível para o EF.

Até agora, todas as consultas foram executadas diretamente nas tabelas. SqlQuery também pode ser usado para retornar resultados de uma exibição sem mapear o tipo de exibição no modelo EF. Por exemplo:

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

Da mesma forma, SqlQuery pode ser usado para os resultados de uma função:

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

O IQueryable retornado pode ser composto quando é o resultado de uma exibição ou função, assim como pode ser para o resultado de uma consulta de tabela. Os procedimentos armazenados também podem ser executados usando SqlQuery, mas a maioria dos bancos de dados não oferece suporte à composição sobre eles. Por exemplo:

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

Aprimoramentos no carregamento adiado

Carregamento lento para consultas sem rastreamento

O EF8 adiciona suporte para carregamento lento de navegações em entidades que não estão sendo rastreadas pelo DbContext. Isso significa que uma consulta sem rastreamento pode ser seguida por carregamento lento de navegações nas entidades retornadas pela consulta sem rastreamento.

Dica

O código para os exemplos de carregamento lento mostrados abaixo vem de LazyLoadingSample.cs.

Por exemplo, considere uma consulta sem controle para blogs:

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

Se Blog.Posts estiver configurado para carregamento lento, por exemplo, usando proxies de carregamento lento, o acesso a Posts fará com que ele seja carregado a partir do banco de dados:

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

O EF8 também informa se uma determinada navegação é carregada ou não para entidades não rastreadas pelo contexto. Por exemplo:

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

Há algumas considerações importantes ao usar o carregamento lento dessa maneira:

  • O carregamento lento só terá êxito até que o DbContext usado para consultar a entidade seja descartado.
  • As entidades consultadas dessa forma mantêm uma referência ao seu DbContext, mesmo que não sejam rastreadas por ele. Deve-se tomar cuidado para evitar perdas de memória se as instâncias da entidade tiverem vida útil longa.
  • Desanexar explicitamente a entidade definindo seu estado como EntityState.Detached corta a referência ao DbContext e o carregamento lento não funcionará mais.
  • Lembre-se de que todo carregamento lento usa E/S síncrona, já que não há como acessar uma propriedade de maneira assíncrona.

O carregamento lento de entidades não rastreadas funciona tanto para proxies de carregamento lento quanto para carregamento lento sem proxies.

Carregamento explícito de entidades não rastreadas

O EF8 oferece suporte ao carregamento de navegações em entidades não rastreadas, mesmo quando a entidade ou a navegação não está configurada para carregamento lento. Ao contrário do carregamento lento, esse carregamento explícito pode ser feito de forma assíncrona. Por exemplo:

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

Desativar o carregamento lento para navegações específicas

O EF8 permite a configuração de navegações específicas para não carregar lentamente, mesmo quando todo o resto está configurado para isso. Por exemplo, para configurar a navegação Post.Author para não carregar lentamente, faça o seguinte:

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

O carregamento lento como esse funciona tanto para proxies de carregamento lento quanto para carregamento lento sem proxies.

Os proxies de carregamento lento funcionam substituindo as propriedades de navegação virtual. Em aplicativos EF6 clássicos, uma fonte comum de bugs é esquecer de fazer uma navegação virtual, já que a navegação não será carregada silenciosamente. Portanto, os proxies do EF Core são lançados por padrão quando uma navegação não é virtual.

Isso pode ser alterado no EF8 para aceitar o comportamento clássico do EF6, de modo que uma navegação possa ser feita para não carregar lentamente simplesmente tornando a navegação não virtual. Essa aceitação é configurada como parte da chamada para UseLazyLoadingProxies. Por exemplo:

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

Acesso a entidades rastreadas

Pesquisar entidades controladas por chave primária, alternativa ou estrangeira

Internamente, o EF mantém estruturas de dados para localizar entidades controladas por chave primária, alternativa ou estrangeira. Essas estruturas de dados são usadas para correção eficiente entre entidades relacionadas quando novas entidades são controladas ou os relacionamentos mudam.

O EF8 contém novas APIs públicas para que os aplicativos agora possam usar essas estruturas de dados para pesquisar entidades controladas com eficiência. Essas APIs são acessadas por meio do LocalView<TEntity> do tipo de entidade. Por exemplo, para pesquisar uma entidade controlada por sua chave primária:

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

Dica

O código mostrado aqui vem de LookupByKeySample.cs.

O método FindEntry retorna o EntityEntry<TEntity> para a entidade rastreada ou null se nenhuma entidade com a chave fornecida estiver sendo rastreada. Como todos os métodos em LocalView, o banco de dados nunca é consultado, mesmo se a entidade não for encontrada. A entrada retornada contém a própria entidade, bem como informações de rastreamento. Por exemplo:

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

A pesquisa de uma entidade por qualquer coisa que não seja uma chave primária requer que o nome da propriedade seja especificado. Por exemplo, para procurar por uma chave alternativa:

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

Ou para procurar por uma chave estrangeira exclusiva:

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

Até agora, as pesquisas sempre retornaram uma única entrada, ou null. No entanto, algumas pesquisas podem retornar mais de uma entrada, como ao procurar por uma chave estrangeira não exclusiva. O método GetEntries deve ser usado para essas pesquisas. Por exemplo:

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

Em todos esses casos, o valor que está sendo usado para a pesquisa é uma chave primária, chave alternativa ou valor de chave estrangeira. O EF usa suas estruturas de dados internas para essas pesquisas. No entanto, pesquisas por valor também podem ser usadas para o valor de qualquer propriedade ou combinação de propriedades. Por exemplo, para localizar todas as postagens arquivadas:

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

Essa pesquisa requer uma varredura de todas as instâncias de Post controladas e, portanto, será menos eficiente do que as pesquisas de chave. No entanto, geralmente ainda é mais rápido do que consultas ingênuas usando ChangeTracker.Entries<TEntity>().

Finalmente, também é possível executar pesquisas em chaves compostas, outras combinações de várias propriedades ou quando o tipo de propriedade não é conhecido em tempo de compilação. Por exemplo:

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

Criação de modelo

As colunas discriminatórias têm comprimento máximo

No EF8, as colunas do discriminador de cadeia de caracteres usadas para mapeamento de herança TPH agora são configuradas com um comprimento máximo. Esse comprimento é calculado como o menor número de Fibonacci que abrange todos os valores discriminatórios definidos. Por exemplo, considere a seguinte hierarquia:

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

Com a convenção de usar os nomes de classe para valores discriminadores, os valores possíveis aqui são "PaperbackEdition", "HardbackEdition" e "Magazine" e, portanto, a coluna discriminatória é configurada para um comprimento máximo de 21. Por exemplo, ao usar o 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]),

Dica

Os números de Fibonacci são usados para limitar o número de vezes que uma migração é gerada para alterar o comprimento da coluna à medida que novos tipos são adicionados à hierarquia.

Suporte para DateOnly/TimeOnly no SQL Server

Os tipos DateOnly e TimeOnly foram introduzidos no .NET 6 e têm suporte para vários provedores de banco de dados (por exemplo, SQLite, MySQL e PostgreSQL) desde sua introdução. Para o SQL Server, a versão recente de um pacote Microsoft.Data.SqlClient direcionado ao .NET 6 permitiu que o ErikEJ adicionasse suporte a esses tipos no nível ADO.NET. Isso, por sua vez, abriu caminho para o suporte no EF8 para DateOnly e TimeOnly como propriedades em tipos de entidade.

Dica

DateOnly e TimeOnly podem ser usados no EF Core 6 e 7 usando o pacote de comunidade ErikEJ.EntityFrameworkCore.SqlServer.DateOnlyTimeOnly do @ErikEJ.

Por exemplo, considere o seguinte modelo de EF para escolas 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; }
}

Dica

O código mostrado aqui vem de DateOnlyTimeOnlySample.cs.

Observação

Esse modelo representa apenas as escolas britânicas e armazena os horários como horários locais (GMT). Lidar com fusos horários diferentes complicaria significativamente esse código. Observe que usar DateTimeOffset não ajudaria aqui, já que os horários de abertura e fechamento têm compensações diferentes, dependendo se o horário de verão está ativo ou não.

Esses tipos de entidade são mapeados para as tabelas a seguir ao usar o SQL Server. Observe que as propriedades DateOnly mapeiam para date colunas e as propriedades TimeOnly mapeiam para time colunas.

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

As consultas usando DateOnly e TimeOnly funcionam da maneira esperada. Por exemplo, a seguinte consulta LINQ localiza escolas que estão abertas no momento:

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

Essa consulta é convertida no seguinte SQL, conforme mostrado por 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 e TimeOnly também podem ser usados em colunas JSON. Por exemplo, OpeningHours pode ser salvo como um documento JSON, resultando em dados semelhantes a este:

Coluna Valor
ID 2
Nome Colégio Farr
Fundado 01-05-1964
Horário de Funcionamento
[
{ "DayOfWeek": "Domingo", "ClosesAt": nulo, "OpensAt": nulo},
{ "DayOfWeek": "Segunda-feira", "ClosesAt": "15:35:00", "OpensAt": "08:45:00" },
{ "DayOfWeek": "Terça-feira", "ClosesAt": "15:35:00", "OpensAt": "08:45:00" },
{ "DayOfWeek": "Quarta-feira", "ClosesAt": "15:35:00", "OpensAt": "08:45:00" },
{ "DayOfWeek": "Quinta-feira", "ClosesAt": "15:35:00", "OpensAt": "08:45:00" },
{ "DayOfWeek": "Sexta-feira", "ClosesAt": "12:50:00", "OpensAt": "08:45:00" },
{ "DayOfWeek": "Sábado", "ClosesAt": nulo, "OpensAt": nulo }
]

Combinando dois recursos do EF8, agora podemos consultar o horário de funcionamento indexando na coleção JSON. Por exemplo:

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

Essa consulta é convertida no seguinte SQL, conforme mostrado por 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

Finalmente, as atualizações e exclusões podem ser realizadas com acompanhamento e SaveChanges ou usando ExecuteUpdate/ExecuteDelete. Por exemplo:

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

Esta atualização converte para o seguinte 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)

Engenharia reversa Synapse e Dynamics 365 TDS

A engenharia reversa do EF8 (também conhecido como scaffolding de um banco de dados existente) agora oferece suporte aos bancos de dados do Pool de SQL sem servidor do Synapse e Ponto de extremidade do TDS do Dynamics 365.

Aviso

Esses sistemas de banco de dados têm diferenças em relação aos bancos de dados SQL Server e SQL do Azure normais. Essas diferenças significam que nem todas as funcionalidades do EF Core têm suporte ao gravar consultas ou executar outras operações com esses sistemas de banco de dados.

Aprimoramentos nas conversões de Math

Interfaces matemáticas genéricas foram introduzidas no .NET 7. Tipos concretos, como double e float, implementaram essas interfaces adicionando novas APIs, espelhando a funcionalidade existente de Math e MathF.

O EF Core 8 converte chamadas a essas APIs matemáticas genéricas em LINQ usando as conversões de SQL existentes dos provedores para Math e MathF. Isso significa que agora você pode escolher entre chamadas como Math.Sin ou double.Sin em suas consultas do EF.

Trabalhamos com a equipe do .NET para adicionar dois novos métodos matemáticos genéricos no .NET 8 que estão implementados em double e float. Eles também são convertidos em SQL no EF Core 8.

.NET SQL
DegreesToRadians RADIANS
RadiansToDegrees DEGREES

Por fim, trabalhamos com Eric Sink no projeto SQLitePCLRaw para habilitar as funções matemáticas do SQLite em seus builds da biblioteca SQLite nativa. Isso inclui a biblioteca nativa que você obtém por padrão ao instalar o provedor SQLite do EF Core. Isso permite várias novas traduções SQL no LINQ, incluindo: Acos, Acosh, Asin, Asinh, Atan, Atan2, Atanh, Ceiling, Cosh, Cosh, DegreesToRadians, Exp, Floor, Log, Log2, Log10, Pow, RadiansToDegrees, Sign, Sin, Sinh, Sqrt, Tan, Tanh e Truncate.

Verificação de alterações pendentes no modelo

Adicionamos um novo comando dotnet ef para verificar se alguma alteração de modelo foi feita desde a última migração. Isso pode ser útil em cenários de CI/CD para garantir que você ou um colega de equipe não se esqueça de adicionar uma migração.

dotnet ef migrations has-pending-model-changes

Você também pode executar essa verificação programaticamente em seu aplicativo ou testes usando o novo método dbContext.Database.HasPendingModelChanges().

Aprimoramentos no andaime SQLite

O SQLite oferece suporte apenas a quatro tipos de dados primitivos: INTEGER, REAL, TEXT e BLOB. Anteriormente, isso significava que, quando você fazia engenharia reversa de um banco de dados SQLite para estruturar um modelo do EF Core, os tipos de entidade resultantes incluíam apenas propriedades do tipo long, double, string e byte[]. Tipos .NET adicionais são suportados pelo provedor SQLite do EF Core convertendo entre eles e um dos quatro tipos SQLite primitivos.

No EF Core 8, agora usamos o formato de dados e o nome do tipo de coluna, além do tipo SQLite, para determinar um tipo .NET mais apropriado a ser usado no modelo. As tabelas a seguir mostram alguns dos casos em que as informações adicionais levam a melhores tipos de propriedade no modelo.

Nome do tipo de coluna Tipo .NET
BOOLEAN byte[]bool
SMALLINT longocurto
INT longint
bigint long
STRING byte[]cadeia de caracteres
Formato dos dados Tipo .NET
'0.0' cadeia decimal
'1970-01-01' stringDateOnly
'1970-01-01 00:00:00' stringDateTime
'00:00:00' stringTimeSpan
'00000000-0000-0000-0000-000000000000' stringGuid

Valores do Sentinel e padrões de banco de dados

Os bancos de dados permitem que as colunas sejam configuradas para gerar um valor padrão se nenhum valor for fornecido ao inserir uma linha. Isso pode ser representado no EF usando HasDefaultValue para constantes:

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

Ou HasDefaultValueSql para cláusulas SQL arbitrárias:

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

Dica

O código mostrado abaixo vem de DefaultConstraintSample.cs.

Para que o EF faça uso disso, ele deve determinar quando enviar e quando não enviar um valor para a coluna. Por padrão, o EF usa o padrão CLR como sentinela para isso. Ou seja, quando o valor de Status ou LeaseDate nos exemplos acima são os padrões CLR para esses tipos, o EF interpreta que a propriedade não foi definida e, portanto, não envia um valor para o banco de dados. Isso funciona bem para tipos de referência, por exemplo, se a propriedade stringStatus for null, o EF não envia null para o banco de dados, mas não inclui nenhum valor para que o padrão do banco de dados ("Hidden") seja usado. Da mesma forma, para a DateTime propriedade LeaseDate, o EF não inserirá o valor padrão CLR de 1/1/0001 12:00:00 AM, mas omitirá esse valor para que o padrão do banco de dados seja usado.

No entanto, em alguns casos, o valor padrão CLR é um valor válido a ser inserido. O EF8 lida com isso permitindo que o valor sentinela de uma coluna seja alterado. Por exemplo, considere uma coluna de inteiro configurada com um padrão de banco de dados:

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

Nesse caso, queremos que a nova entidade seja inserida com o número determinado de créditos, a menos que isso não seja especificado, nesse caso, 10 créditos são atribuídos. No entanto, isso significa que a inserção de um registro sem créditos não é possível, pois zero é o padrão CLR e, portanto, fará com que o EF não envie nenhum valor. No EF8, isso pode ser corrigido alterando o sentinela da propriedade de zero para -1:

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

Agora, o EF usará apenas o padrão do banco de dados se Credits for definido como -1; um valor igual a zero será inserido como qualquer outro valor.

Geralmente, pode ser útil refletir isso no tipo de entidade, bem como na configuração do EF. Por exemplo:

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

Isso significa que o valor sentinela de -1 é definido automaticamente quando a instância é criada e a propriedade começa em seu estado "não definido".

Dica

Se você quiser configurar a restrição padrão do banco de dados para uso quando Migrationscriar a coluna, mas quiser que o EF sempre insira um valor, configure a propriedade como não gerada. Por exemplo, b.Property(e => e.Credits).HasDefaultValueSql(10).ValueGeneratedNever();.

Padrões de banco de dados para boolianos

As propriedades boolianas apresentam uma forma extrema desse problema, já que o padrão CLR (false) é um dos dois valores válidos. Isso significa que uma propriedade bool com uma restrição padrão de banco de dados terá apenas um valor inserido se esse valor for true. Quando o valor padrão do banco de dados for false, isso significa que, quando o valor da propriedade for false, o padrão do banco de dados, false, será usado. Caso contrário, se o valor da propriedade for true, então true será inserido. Portanto, quando o padrão do banco de dados é false, a coluna de banco de dados termina com o valor correto.

Por outro lado, se o valor padrão do banco de dados for true, isso significa que quando o valor da propriedade for false, o padrão do banco de dados será usado, que é true! E quando o valor da propriedade for true, então true será inserido. Portanto, o valor na coluna sempre resultará true no banco de dados, independentemente do valor da propriedade.

O EF8 corrige esse problema definindo o sentinela para propriedades bool com o mesmo valor que o valor padrão do banco de dados. Ambos os casos acima resultam na inserção do valor correto, independentemente de o padrão do banco de dados ser true ou false.

Dica

Ao fazer scaffolding de um banco de dados existente, o EF8 analisa e inclui valores padrão simples em chamadas HasDefaultValue. (Anteriormente, todos os valores padrão eram estrutrados como chamadas HasDefaultValueSql opacas.) Isso significa que colunas bool não anuláveis com um padrão de banco de dados true ou false constante não são mais estruturadas como anuláveis.

Padrões de banco de dados para enumerações

As propriedades de enumeração podem ter problemas semelhantes às propriedades bool porque as enumerações normalmente têm um conjunto muito pequeno de valores válidos e o padrão CLR pode ser um desses valores. Por exemplo, considere esse tipo de entidade e enumeração:

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

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

Em seguida, a propriedade Level é configurada com um padrão de banco de dados:

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

Com essa configuração, o EF excluirá o envio do valor para o banco de dados quando ele for definido como Level.Beginner e, em vez disso Level.Intermediate, será atribuído pelo banco de dados. Não era isso que se pretendia!

O problema não teria ocorrido se a enumeração fosse definida com o valor "desconhecido" ou "não especificado" sendo o padrão do banco de dados:

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

No entanto, nem sempre é possível alterar uma enumeração existente, portanto, no EF8, o sentinela pode ser especificado novamente. Por exemplo, voltando à enumeração original:

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

Agora Level.Beginner será inserido como normal e o padrão do banco de dados só será usado quando o valor da propriedade for Level.Unspecified. Ele pode ser útil novamente para refletir isso no próprio tipo de entidade. Por exemplo:

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

Usando um campo de backup anulável

Uma maneira mais geral de lidar com o problema descrito acima é criar um campo de backup anulável para a propriedade não anulável. Por exemplo, considere o seguinte tipo de entidade com uma propriedade bool:

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

A propriedade pode receber um campo de backup anulável:

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

    private bool? _isActive;

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

O campo de backup aqui permanecerá null, a menos que o setter de propriedade seja realmente chamado. Ou seja, o valor do campo de suporte indica melhor se a propriedade foi definida ou não do que o padrão CLR da propriedade. Isso funciona imediatamente com o EF, já que o EF usará o campo de backup para ler e gravar a propriedade por padrão.

Melhor ExecuteUpdate e ExecuteDelete

Os comandos SQL que executam atualizações e exclusões, como os gerados por métodos ExecuteUpdate e ExecuteDelete devem ter como destino uma única tabela de banco de dados. No entanto, no EF7, ExecuteUpdate e ExecuteDelete não deram suporte a atualizações que acessam vários tipos de entidade, mesmo quando a consulta acabou afetando uma única tabela. O EF8 remove essa limitação. Por exemplo, considere um tipo de entidade Customer com uma propriedade 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 os tipos de entidade são mapeados para a tabela Customers. No entanto, a seguinte atualização em massa falha no EF7 porque usa os dois tipos de entidade:

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

No EF8, isso agora se traduz para o seguinte SQL ao usar o SQL do Azure:

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

Da mesma forma, as instâncias retornadas de uma consulta Union podem ser atualizadas desde que todas as atualizações sejam direcionadas à mesma tabela. Por exemplo, podemos atualizar qualquer Customer com uma região de France, e ao mesmo tempo, qualquer Customer que tenha visitado um repositório com a região 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"));

No EF8, essa consulta gera o seguinte ao usar o SQL do Azure:

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 exemplo final, no EF8, ExecuteUpdate pode ser usado para atualizar entidades em uma hierarquia TPT, desde que todas as propriedades atualizadas sejam mapeadas para a mesma tabela. Por exemplo, considere esses tipos de entidade mapeados usando 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; }
}

Com o EF8, a propriedade Note pode ser atualizada:

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

Ou a propriedade Name pode ser atualizada:

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

No entanto, o EF8 falha ao tentar atualizar as propriedades Name e Note porque elas são mapeadas para tabelas diferentes. Por exemplo:

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

Gera a seguinte exceção:

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.

Melhor uso de consultas IN

Quando o Contains operador LINQ é usado com uma subconsulta, o EF Core agora gera consultas melhores usando SQL IN em vez de EXISTS; além de produzir um SQL mais legível. Em alguns casos isso pode resultar em consultas dramaticamente mais rápidas. Por exemplo, considere a seguinte consulta LINQ:

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

O EF7 gera o seguinte para PostgreSQL:

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

Como a subconsulta faz referência à tabela externa Blogs (via b."Id"), essa é uma subconsulta correlacionada, o que significa que a subconsulta Posts deve ser executada para cada linha na tabela Blogs. No EF8, o seguinte SQL é gerado em vez disso:

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

Como a subconsulta não faz mais referências Blogs, ela pode ser avaliada uma vez, gerando melhorias maciças de desempenho na maioria dos sistemas de banco de dados. No entanto, em alguns sistemas de banco de dados, principalmente no SQL Server, o banco de dados é capaz de otimizar a primeira consulta como a segunda consulta para que o desempenho seja o mesmo.

Versões de linha numéricas para SQL Azure/SQL Server

A simultaneidade otimista automática do SQL Server é tratada usando colunas rowversion. rowversion é um valor opaco de 8 bytes passado entre banco de dados, cliente e servidor. Por padrão, o SqlClient expõe tipos rowversion como byte[], apesar dos tipos de referência mutáveis serem uma correspondência inválida para a semântica rowversion. No EF8, é fácil mapear colunas rowversion para as propriedades long ou ulong. Por exemplo:

modelBuilder.Entity<Blog>()
    .Property(e => e.RowVersion)
    .HasConversion<byte[]>()
    .IsRowVersion();

Eliminação de parênteses

Gerar SQL legível é uma meta importante para o EF Core. No EF8, o SQL gerado é mais legível por meio da eliminação automática de parênteses desnecessários. Por exemplo, a seguinte consulta LINQ:

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

Traduz-se para o seguinte SQL do Azure ao usar o 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)

O que foi aprimorado para o seguinte ao usar o 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

Recusa específica para a cláusula RETURNING/OUTPUT

O EF7 alterou o SQL de atualização padrão a ser usado RETURNING/OUTPUT para buscar colunas geradas pelo banco de dados. Foram identificados alguns casos onde isso não funciona e, portanto, o EF8 apresenta recusas explícitas para esse comportamento.

Por exemplo, para recusar OUTPUT ao usar o provedor SQL Server/SQL do Azure:

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

Ou para recusar RETURNING ao usar o provedor SQLite:

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

Outras alterações secundárias

Além dos aprimoramentos descritos acima, houve muitas alterações menores feitas no EF8. Isso inclui: