O que há de novo no EF Core 7.0

O EF Core 7.0 (EF7) foi lançado em novembro de 2022.

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 EF7 tem como destino o .NET 6 e, portanto, pode ser usado com o .NET 6 (LTS) ou .NET 7.

Modelo de exemplo

Muitos dos exemplos abaixo usam um modelo simples com blogs, posts, tags e autores:

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

    public int Id { get; private set; }
    public string Name { get; set; }
    public List<Post> Posts { get; } = new();
}

public class Post
{
    public Post(string title, string content, DateTime publishedOn)
    {
        Title = title;
        Content = content;
        PublishedOn = publishedOn;
    }

    public int Id { get; private set; }
    public string Title { get; set; }
    public string Content { get; set; }
    public DateTime PublishedOn { get; set; }
    public Blog Blog { get; set; } = null!;
    public List<Tag> Tags { get; } = new();
    public Author? Author { get; set; }
    public PostMetadata? Metadata { get; set; }
}

public class FeaturedPost : Post
{
    public FeaturedPost(string title, string content, DateTime publishedOn, string promoText)
        : base(title, content, publishedOn)
    {
        PromoText = promoText;
    }

    public string PromoText { get; set; }
}

public class Tag
{
    public Tag(string id, string text)
    {
        Id = id;
        Text = text;
    }

    public string Id { get; private set; }
    public string Text { get; set; }
    public List<Post> Posts { get; } = new();
}

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

    public int Id { get; private set; }
    public string Name { get; set; }
    public ContactDetails Contact { get; set; } = null!;
    public List<Post> Posts { get; } = new();
}

Alguns dos exemplos também usam tipos de agregações, que são mapeados de maneiras diferentes em amostras diferentes. Há um tipo agregado para contatos:

public class ContactDetails
{
    public Address Address { get; set; } = null!;
    public string? Phone { get; set; }
}

public class Address
{
    public Address(string street, string city, string postcode, string country)
    {
        Street = street;
        City = city;
        Postcode = postcode;
        Country = country;
    }

    public string Street { get; set; }
    public string City { get; set; }
    public string Postcode { get; set; }
    public string Country { get; set; }
}

E um segundo tipo agregado para metadados de postagem:

public class PostMetadata
{
    public PostMetadata(int views)
    {
        Views = views;
    }

    public int Views { get; set; }
    public List<SearchTerm> TopSearches { get; } = new();
    public List<Visits> TopGeographies { get; } = new();
    public List<PostUpdate> Updates { get; } = new();
}

public class SearchTerm
{
    public SearchTerm(string term, int count)
    {
        Term = term;
        Count = count;
    }

    public string Term { get; private set; }
    public int Count { get; private set; }
}

public class Visits
{
    public Visits(double latitude, double longitude, int count)
    {
        Latitude = latitude;
        Longitude = longitude;
        Count = count;
    }

    public double Latitude { get; private set; }
    public double Longitude { get; private set; }
    public int Count { get; private set; }
    public List<string>? Browsers { get; set; }
}

public class PostUpdate
{
    public PostUpdate(IPAddress postedFrom, DateTime updatedOn)
    {
        PostedFrom = postedFrom;
        UpdatedOn = updatedOn;
    }

    public IPAddress PostedFrom { get; private set; }
    public string? UpdatedBy { get; init; }
    public DateTime UpdatedOn { get; private set; }
    public List<Commit> Commits { get; } = new();
}

public class Commit
{
    public Commit(DateTime committedOn, string comment)
    {
        CommittedOn = committedOn;
        Comment = comment;
    }

    public DateTime CommittedOn { get; private set; }
    public string Comment { get; set; }
}

Dica

O modelo de exemplo pode ser encontrado em BlogsContext.cs.

Colunas JSON

A maioria dos bancos de dados relacionais oferece suporte a colunas que contêm documentos JSON. O JSON nessas colunas pode ser detalhado com consultas. Isso permite, por exemplo, filtrar e classificar pelos elementos dos documentos, bem como a projeção de elementos fora dos documentos em resultados. As colunas JSON permitem que os bancos de dados relacionais assumam algumas das características dos bancos de dados de documentos, criando um híbrido útil entre os dois.

O EF7 contém suporte independente de provedor para colunas JSON, com uma implementação para o SQL Server. Esse suporte permite o mapeamento de agregações criadas a partir de tipos .NET para documentos JSON. As consultas LINQ normais podem ser usadas nas agregações e elas serão convertidas para as construções de consulta apropriadas necessárias para detalhar o JSON. O EF7 também oferece suporte à atualização e ao salvamento de alterações em documentos JSON.

Observação

O suporte do SQLite para JSON está planejado para o pós-EF7. Os provedores PostgreSQL e Pomelo MySQL já contêm algum suporte para colunas JSON. Trabalharemos com os autores desses provedores para alinhar o suporte JSON em todos os provedores.

Mapeando para colunas JSON

No EF Core, os tipos de agregação são definidos usando OwnsOne e OwnsMany. Por exemplo, considere o tipo de agregação do nosso modelo de exemplo usado para armazenar informações de contato:

public class ContactDetails
{
    public Address Address { get; set; } = null!;
    public string? Phone { get; set; }
}

public class Address
{
    public Address(string street, string city, string postcode, string country)
    {
        Street = street;
        City = city;
        Postcode = postcode;
        Country = country;
    }

    public string Street { get; set; }
    public string City { get; set; }
    public string Postcode { get; set; }
    public string Country { get; set; }
}

Isso pode ser usado em um tipo de entidade "proprietário", por exemplo, para armazenar os detalhes de contato de um autor:

public class Author
{
    public int Id { get; set; }
    public string Name { get; set; }
    public ContactDetails Contact { get; set; }
}

O tipo de agregação é configurado em OnModelCreating usando OwnsOne:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Author>().OwnsOne(
        author => author.Contact, ownedNavigationBuilder =>
        {
            ownedNavigationBuilder.OwnsOne(contactDetails => contactDetails.Address);
        });
}

Dica

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

Por padrão, os provedores de banco de dados relacional mapeiam tipos agregados como esse para a mesma tabela que o tipo de entidade proprietário. Ou seja, cada propriedade das classes ContactDetails e Address é mapeada para uma coluna na tabela Authors.

Alguns autores salvos com detalhes de contato terão a seguinte aparência:

Autores

ID Nome Contact_Address_Street Contact_Address_City Contact_Address_Postcode Contact_Address_Country Contact_Phone
1 Carlos Monteiro Rua Principal 1 Vale Verde 8321040 Reino Unido 2199999999
2 Jeremy Likness Rua Principal 2 São Paulo 8321040 Reino Unido 2199999998
3 Daniel Ribeiro Rua Principal 3 Vale Verde 8321040 Reino Unido 2199999997
4 Arthur Vitor Rua Principal 15 São Paulo 8321040 Reino Unido 2199999996
5 Bruno Castro Rua Principal 4 São Paulo 8321040 Reino Unido 2199999996

Se desejado, cada tipo de entidade que compõe a agregação pode ser mapeado para sua própria tabela:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Author>().OwnsOne(
        author => author.Contact, ownedNavigationBuilder =>
        {
            ownedNavigationBuilder.ToTable("Contacts");
            ownedNavigationBuilder.OwnsOne(
                contactDetails => contactDetails.Address, ownedOwnedNavigationBuilder =>
                {
                    ownedOwnedNavigationBuilder.ToTable("Addresses");
                });
        });
}

Os mesmos dados são então armazenados em três tabelas:

Autores

ID Nome
1 Carlos Monteiro
2 Jeremy Likness
3 Daniel Ribeiro
4 Arthur Vitor
5 Bruno Castro

Contatos

AuthorId Telefone
1 2199999999
2 2199999998
3 2199999997
4 2199999996
5 2199999995

Endereços

ContactDetailsAuthorId Street City CEP País
1 Rua Principal 1 Vale Verde 83321-040 Reino Unido
2 Rua Principal 2 São Paulo 83321-040 Reino Unido
3 Rua Principal 3 Vale Verde 83321-040 Reino Unido
4 Rua Principal 15 São Paulo 83321-040 Reino Unido
5 Rua Principal 4 São Paulo 83321-040 Reino Unido

Agora, para a parte interessante. No EF7, o tipo de agregação ContactDetails pode ser mapeado para uma coluna JSON. Isso requer apenas uma chamada para ToJson() ao configurar o tipo de agregação:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Author>().OwnsOne(
        author => author.Contact, ownedNavigationBuilder =>
        {
            ownedNavigationBuilder.ToJson();
            ownedNavigationBuilder.OwnsOne(contactDetails => contactDetails.Address);
        });
}

A tabela Authors agora conterá uma coluna JSON para ContactDetails preenchida com um documento JSON para cada autor:

Autores

ID Nome Contato
1 Carlos Monteiro {
  "Telefone":"2199999999",
  "Endereço": {
    "Cidade":"Vale Verde",
    "País":"Brasil",
    "CEP":"83321-040",
    "Rua":"Rua Principal 1"
  }
}
2 Jeremy Likness {
  "Telefone":"2199999998",
  "Endereço": {
    "Cidade":"São Paulo",
    "País":"Brasil",
    "CEP":"83321-040",
    "Rua":"Rua Principal 2"
  }
}
3 Daniel Ribeiro {
  "Telefone":"2199999997",
  "Endereço": {
    "Cidade":"Vale Verde",
    "País":"Brasil",
    "CEP":"83321-040",
    "Rua":"Rua Principal 3"
  }
}
4 Arthur Vitor {
  "Telefone":"2199999996",
  "Endereço": {
    "Cidade":"São Paulo",
    "País":"Brasil",
    "CEP":"83321-040",
    "Rua":"Rua Principal 15"
  }
}
5 Bruno Castro {
  "Telefone":"2199999995",
  "Endereço": {
    "Cidade":"São Paulo",
    "País":"Brasil",
    "CEP":"83321-040",
    "Rua":"Rua Principal 4"
  }
}

Dica

Esse uso de agregações é muito semelhante à maneira como os documentos JSON são mapeados ao usar o provedor EF Core para o Azure Cosmos DB. As colunas JSON trazem os recursos de uso do EF Core em bancos de dados de documentos para documentos incorporados em um banco de dados relacional.

Os documentos JSON mostrados acima são muito simples, mas esse recurso de mapeamento também pode ser usado com estruturas de documentos mais complexas. Por exemplo, considere outro tipo agregado de nosso modelo de exemplo, usado para representar metadados sobre uma postagem:

public class PostMetadata
{
    public PostMetadata(int views)
    {
        Views = views;
    }

    public int Views { get; set; }
    public List<SearchTerm> TopSearches { get; } = new();
    public List<Visits> TopGeographies { get; } = new();
    public List<PostUpdate> Updates { get; } = new();
}

public class SearchTerm
{
    public SearchTerm(string term, int count)
    {
        Term = term;
        Count = count;
    }

    public string Term { get; private set; }
    public int Count { get; private set; }
}

public class Visits
{
    public Visits(double latitude, double longitude, int count)
    {
        Latitude = latitude;
        Longitude = longitude;
        Count = count;
    }

    public double Latitude { get; private set; }
    public double Longitude { get; private set; }
    public int Count { get; private set; }
    public List<string>? Browsers { get; set; }
}

public class PostUpdate
{
    public PostUpdate(IPAddress postedFrom, DateTime updatedOn)
    {
        PostedFrom = postedFrom;
        UpdatedOn = updatedOn;
    }

    public IPAddress PostedFrom { get; private set; }
    public string? UpdatedBy { get; init; }
    public DateTime UpdatedOn { get; private set; }
    public List<Commit> Commits { get; } = new();
}

public class Commit
{
    public Commit(DateTime committedOn, string comment)
    {
        CommittedOn = committedOn;
        Comment = comment;
    }

    public DateTime CommittedOn { get; private set; }
    public string Comment { get; set; }
}

Esse tipo agregado contém vários tipos aninhados e coleções. As chamadas para OwnsOne e OwnsMany são usadas para mapear esse tipo de agregação:

modelBuilder.Entity<Post>().OwnsOne(
    post => post.Metadata, ownedNavigationBuilder =>
    {
        ownedNavigationBuilder.ToJson();
        ownedNavigationBuilder.OwnsMany(metadata => metadata.TopSearches);
        ownedNavigationBuilder.OwnsMany(metadata => metadata.TopGeographies);
        ownedNavigationBuilder.OwnsMany(
            metadata => metadata.Updates,
            ownedOwnedNavigationBuilder => ownedOwnedNavigationBuilder.OwnsMany(update => update.Commits));
    });

Dica

ToJson é necessário apenas na raiz de agregação para mapear toda a agregação para um documento JSON.

Com esse mapeamento, o EF7 pode criar e consultar um documento JSON complexo como esse:

{
  "Views": 5085,
  "TopGeographies": [
    {
      "Browsers": "Firefox, Netscape",
      "Count": 924,
      "Latitude": 110.793,
      "Longitude": 39.2431
    },
    {
      "Browsers": "Firefox, Netscape",
      "Count": 885,
      "Latitude": 133.793,
      "Longitude": 45.2431
    }
  ],
  "TopSearches": [
    {
      "Count": 9359,
      "Term": "Search #1"
    }
  ],
  "Updates": [
    {
      "PostedFrom": "127.0.0.1",
      "UpdatedBy": "Admin",
      "UpdatedOn": "1996-02-17T19:24:29.5429092Z",
      "Commits": []
    },
    {
      "PostedFrom": "127.0.0.1",
      "UpdatedBy": "Admin",
      "UpdatedOn": "2019-11-24T19:24:29.5429093Z",
      "Commits": [
        {
          "Comment": "Commit #1",
          "CommittedOn": "2022-08-21T00:00:00+01:00"
        }
      ]
    },
    {
      "PostedFrom": "127.0.0.1",
      "UpdatedBy": "Admin",
      "UpdatedOn": "1997-05-28T19:24:29.5429097Z",
      "Commits": [
        {
          "Comment": "Commit #1",
          "CommittedOn": "2022-08-21T00:00:00+01:00"
        },
        {
          "Comment": "Commit #2",
          "CommittedOn": "2022-08-21T00:00:00+01:00"
        }
      ]
    }
  ]
}

Observação

Ainda não há suporte para o mapeamento de tipos espaciais diretamente para JSON. O documento acima usa valores double como uma solução alternativa. Vote em Suportar tipos espaciais em colunas JSON se isso for algo em que você esteja interessado(a).

Observação

Ainda não há suporte para o mapeamento de coleções de tipos primitivos para JSON. O documento acima usa um conversor de valor para transformar a coleção em uma cadeia de caracteres separada por vírgula. Vote em JSON: adicione suporte para coleção de tipos primitivos se isso for algo que você esteja interessado(a).

Observação

O mapeamento de tipos próprios para JSON ainda não é suportado em conjunto com a herança TPT ou TPC. Vote em Suportar propriedades JSON com mapeamento de herança TPT/TPC se isso for algo em que você esteja interessado(a).

Consultas em colunas JSON

As consultas em colunas JSON funcionam da mesma forma que a consulta em qualquer outro tipo agregado no EF Core. Ou seja, basta usar o LINQ! Veja alguns exemplos.

Uma consulta para todos os autores que vivem em Chigley:

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

Essa consulta gera o seguinte SQL ao usar o SQL Server:

SELECT [a].[Id], [a].[Name], JSON_QUERY([a].[Contact],'$')
FROM [Authors] AS [a]
WHERE CAST(JSON_VALUE([a].[Contact],'$.Address.City') AS nvarchar(max)) = N'Chigley'

Observe o uso de JSON_VALUE para obter o City do Address dentro do documento JSON.

Select pode ser usado para extrair e projetar elementos do documento JSON:

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

Essa consulta gera o seguinte SQL:

SELECT CAST(JSON_VALUE([a].[Contact],'$.Address.Postcode') AS nvarchar(max))
FROM [Authors] AS [a]
WHERE CAST(JSON_VALUE([a].[Contact],'$.Address.City') AS nvarchar(max)) = N'Chigley'

Aqui está um exemplo que faz um pouco mais no filtro e projeção, e também pedidos pelo número de telefone no documento JSON:

var orderedAddresses = await context.Authors
    .Where(
        author => (author.Contact.Address.City == "Chigley"
                   && author.Contact.Phone != null)
                  || author.Name.StartsWith("D"))
    .OrderBy(author => author.Contact.Phone)
    .Select(
        author => author.Name + " (" + author.Contact.Address.Street
                  + ", " + author.Contact.Address.City
                  + " " + author.Contact.Address.Postcode + ")")
    .ToListAsync();

Essa consulta gera o seguinte SQL:

SELECT (((((([a].[Name] + N' (') + CAST(JSON_VALUE([a].[Contact],'$.Address.Street') AS nvarchar(max))) + N', ') + CAST(JSON_VALUE([a].[Contact],'$.Address.City') AS nvarchar(max))) + N' ') + CAST(JSON_VALUE([a].[Contact],'$.Address.Postcode') AS nvarchar(max))) + N')'
FROM [Authors] AS [a]
WHERE (CAST(JSON_VALUE([a].[Contact],'$.Address.City') AS nvarchar(max)) = N'Chigley' AND CAST(JSON_VALUE([a].[Contact],'$.Phone') AS nvarchar(max)) IS NOT NULL) OR ([a].[Name] LIKE N'D%')
ORDER BY CAST(JSON_VALUE([a].[Contact],'$.Phone') AS nvarchar(max))

E quando o documento JSON contém coleções, elas podem ser projetadas nos resultados:

var postsWithViews = await context.Posts.Where(post => post.Metadata!.Views > 3000)
    .AsNoTracking()
    .Select(
        post => new
        {
            post.Author!.Name, post.Metadata!.Views, Searches = post.Metadata.TopSearches, Commits = post.Metadata.Updates
        })
    .ToListAsync();

Essa consulta gera o seguinte SQL:

SELECT [a].[Name], CAST(JSON_VALUE([p].[Metadata],'$.Views') AS int), JSON_QUERY([p].[Metadata],'$.TopSearches'), [p].[Id], JSON_QUERY([p].[Metadata],'$.Updates')
FROM [Posts] AS [p]
LEFT JOIN [Authors] AS [a] ON [p].[AuthorId] = [a].[Id]
WHERE CAST(JSON_VALUE([p].[Metadata],'$.Views') AS int) > 3000

Observação

Consultas mais complexas envolvendo coleções JSON exigem suporte jsonpath. Vote em Apoiar a consulta jsonpath se isso for algo em que você está interessado(a).

Dica

Considere a criação de índices para melhorar o desempenho da consulta em documentos JSON. Por exemplo, consulte Indexar dados JSON ao usar o SQL Server.

Atualizando colunas JSON

SaveChanges e SaveChangesAsync funcionam normalmente para fazer atualizações em uma coluna JSON. Para alterações extensas, todo o documento será atualizado. Por exemplo, substituir a maior parte do documento Contact por um autor:

var jeremy = await context.Authors.SingleAsync(author => author.Name.StartsWith("Jeremy"));

jeremy.Contact = new() { Address = new("2 Riverside", "Trimbridge", "TB1 5ZS", "UK"), Phone = "01632 88346" };

await context.SaveChangesAsync();

Nesse caso, todo o novo documento é passado como um parâmetro:

info: 8/30/2022 20:21:24.392 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (2ms) [Parameters=[@p0='{"Phone":"01632 88346","Address":{"City":"Trimbridge","Country":"UK","Postcode":"TB1 5ZS","Street":"2 Riverside"}}' (Nullable = false) (Size = 114), @p1='2'], CommandType='Text', CommandTimeout='30']

Que é então usado no SQL do UPDATE:

UPDATE [Authors] SET [Contact] = @p0
OUTPUT 1
WHERE [Id] = @p1;

No entanto, se apenas um subdocumento for alterado, o EF Core usará um comando JSON_MODIFY para atualizar somente o subdocumento. Por exemplo, alterar o Address dentro de um documento Contact:

var brice = await context.Authors.SingleAsync(author => author.Name.StartsWith("Brice"));

brice.Contact.Address = new("4 Riverside", "Trimbridge", "TB1 5ZS", "UK");

await context.SaveChangesAsync();

Gera os seguintes parâmetros:

info: 10/2/2022 15:51:15.895 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (2ms) [Parameters=[@p0='{"City":"Trimbridge","Country":"UK","Postcode":"TB1 5ZS","Street":"4 Riverside"}' (Nullable = false) (Size = 80), @p1='5'], CommandType='Text', CommandTimeout='30']

Que é usado no UPDATE através de uma chamada JSON_MODIFY:

UPDATE [Authors] SET [Contact] = JSON_MODIFY([Contact], 'strict $.Address', JSON_QUERY(@p0))
OUTPUT 1
WHERE [Id] = @p1;

Finalmente, se apenas uma única propriedade for alterada, o EF Core usará novamente um comando "JSON_MODIFY", dessa vez para corrigir apenas o valor da propriedade alterada. Por exemplo:

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

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

await context.SaveChangesAsync();

Gera os seguintes parâmetros:

info: 10/2/2022 15:54:05.112 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (2ms) [Parameters=[@p0='["United Kingdom"]' (Nullable = false) (Size = 18), @p1='4'], CommandType='Text', CommandTimeout='30']

Que são novamente usados com um JSON_MODIFY:

UPDATE [Authors] SET [Contact] = JSON_MODIFY([Contact], 'strict $.Address.Country', JSON_VALUE(@p0, '$[0]'))
OUTPUT 1
WHERE [Id] = @p1;

ExecuteUpdate e ExecuteDelete (atualizações em massa)

Por padrão, o EF Core controla as alterações nas entidades e envia atualizações para o banco de dados quando um dos métodos SaveChanges é chamado. As alterações são enviadas apenas para propriedades e relacionamentos que realmente foram alterados. Além disso, as entidades controladas permanecem sincronizadas com as alterações enviadas ao banco de dados. Esse mecanismo é uma maneira eficiente e conveniente de enviar inserções, atualizações e exclusões de uso geral para o banco de dados. Essas alterações também são agrupadas em lotes para reduzir o número de viagens de ida e volta do banco de dados.

No entanto, às vezes é útil executar comandos de atualização ou exclusão no banco de dados sem envolver o controlador de alterações. O EF7 permite isso com os novos métodos ExecuteUpdate e ExecuteDelete. Esses métodos são aplicados a uma consulta LINQ e atualizarão ou excluirão entidades no banco de dados com base nos resultados dessa consulta. Muitas entidades podem ser atualizadas com um único comando e as entidades não são carregadas na memória, o que significa que isso pode resultar em atualizações e exclusões mais eficientes.

No entanto, tenha em mente que:

  • As alterações específicas a serem feitas devem ser especificadas explicitamente; elas não são detectadas automaticamente pelo EF Core.
  • Quaisquer entidades rastreadas não serão mantidas em sincronia.
  • Comandos adicionais talvez precisem ser enviados na ordem correta para não violar as restrições do banco de dados. Por exemplo, excluir dependentes antes que uma entidade de segurança possa ser excluída.

Tudo isto significa que os métodos ExecuteUpdate e ExecuteDelete complementam, em vez de substituir, o mecanismo de SaveChanges existente.

Exemplos ExecuteDelete básicos

Dica

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

Chamar ExecuteDelete ou ExecuteDeleteAsync em um DbSet exclui imediatamente todas as entidades desse DbSet do banco de dados. Por exemplo, para excluir todas as entidades Tag:

await context.Tags.ExecuteDeleteAsync();

Isso executa o seguinte SQL ao usar o SQL Server:

DELETE FROM [t]
FROM [Tags] AS [t]

Mais interessante, a consulta pode conter um filtro. Por exemplo:

await context.Tags.Where(t => t.Text.Contains(".NET")).ExecuteDeleteAsync();

Isso executa o seguinte SQL:

DELETE FROM [t]
FROM [Tags] AS [t]
WHERE [t].[Text] LIKE N'%.NET%'

A consulta também pode usar filtros mais complexos, incluindo navegações para outros tipos. Por exemplo, para excluir tags somente de postagens de blog antigas:

await context.Tags.Where(t => t.Posts.All(e => e.PublishedOn.Year < 2022)).ExecuteDeleteAsync();

Que executa:

DELETE FROM [t]
FROM [Tags] AS [t]
WHERE NOT EXISTS (
    SELECT 1
    FROM [PostTag] AS [p]
    INNER JOIN [Posts] AS [p0] ON [p].[PostsId] = [p0].[Id]
    WHERE [t].[Id] = [p].[TagsId] AND NOT (DATEPART(year, [p0].[PublishedOn]) < 2022))

Exemplos ExecuteUpdate básicos

Dica

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

ExecuteUpdate e ExecuteUpdateAsync se comportam de maneira muito semelhante aos métodos ExecuteDelete. A principal diferença é que uma atualização requer saber quais propriedades atualizar e como atualizá-las. Isso é feito usando uma ou mais chamadas para SetProperty. Por exemplo, para atualizar o Name de cada blog:

await context.Blogs.ExecuteUpdateAsync(
    s => s.SetProperty(b => b.Name, b => b.Name + " *Featured!*"));

O primeiro parâmetro de SetProperty especifica qual propriedade atualizar; neste caso, Blog.Name. O segundo parâmetro especifica como o novo valor deve ser calculado; neste caso, tomando o valor existente e anexando "*Featured!*". O SQL resultante é:

UPDATE [b]
SET [b].[Name] = [b].[Name] + N' *Featured!*'
FROM [Blogs] AS [b]

Assim como acontece com ExecuteDelete, a consulta pode ser usada para filtrar quais entidades são atualizadas. Além disso, várias chamadas para SetProperty podem ser usadas para atualizar mais de uma propriedade na entidade de destino. Por exemplo, para atualizar o Title e Content de todas as postagens publicadas antes de 2022:

await context.Posts
    .Where(p => p.PublishedOn.Year < 2022)
    .ExecuteUpdateAsync(s => s
        .SetProperty(b => b.Title, b => b.Title + " (" + b.PublishedOn.Year + ")")
        .SetProperty(b => b.Content, b => b.Content + " ( This content was published in " + b.PublishedOn.Year + ")"));

Neste caso, o SQL gerado é um pouco mais complicado:

UPDATE [p]
SET [p].[Content] = (([p].[Content] + N' ( This content was published in ') + COALESCE(CAST(DATEPART(year, [p].[PublishedOn]) AS nvarchar(max)), N'')) + N')',
    [p].[Title] = (([p].[Title] + N' (') + COALESCE(CAST(DATEPART(year, [p].[PublishedOn]) AS nvarchar(max)), N'')) + N')'
FROM [Posts] AS [p]
WHERE DATEPART(year, [p].[PublishedOn]) < 2022

Finalmente, novamente como acontece com ExecuteDelete, o filtro pode fazer referência a outras tabelas. Por exemplo, para atualizar todas as tags de postagens antigas:

await context.Tags
    .Where(t => t.Posts.All(e => e.PublishedOn.Year < 2022))
    .ExecuteUpdateAsync(s => s.SetProperty(t => t.Text, t => t.Text + " (old)"));

O que gera:

UPDATE [t]
SET [t].[Text] = [t].[Text] + N' (old)'
FROM [Tags] AS [t]
WHERE NOT EXISTS (
    SELECT 1
    FROM [PostTag] AS [p]
    INNER JOIN [Posts] AS [p0] ON [p].[PostsId] = [p0].[Id]
    WHERE [t].[Id] = [p].[TagsId] AND NOT (DATEPART(year, [p0].[PublishedOn]) < 2022))

Para obter mais informações e exemplos de código sobre ExecuteUpdate e ExecuteDelete, consulte ExecuteUpdate e ExecuteDelete.

Herança e várias tabelas

ExecuteUpdate e ExecuteDelete só podem agir em uma única tabela. Isso tem implicações ao trabalhar com diferentes estratégias mapeamento de herança. Geralmente, não há problemas ao usar a estratégia de mapeamento TPH, uma vez que há apenas uma tabela para modificar. Por exemplo, excluindo todas as entidades FeaturedPost:

await context.Set<FeaturedPost>().ExecuteDeleteAsync();

Gera o seguinte SQL ao usar o mapeamento TPH:

DELETE FROM [p]
FROM [Posts] AS [p]
WHERE [p].[Discriminator] = N'FeaturedPost'

Também não há problemas para esse caso ao usar a estratégia de mapeamento TPC, já que, novamente, apenas alterações em uma única tabela são necessárias:

DELETE FROM [f]
FROM [FeaturedPosts] AS [f]

No entanto, tentar isso ao usar a estratégia de mapeamento TPT falhará, pois exigiria a exclusão de linhas de duas tabelas diferentes.

Adicionar um filtro à consulta geralmente significa que a operação falhará com as estratégias TPC e TPT. Isso ocorre novamente porque as linhas podem precisar ser excluídas de várias tabelas. Por exemplo, esta consulta:

await context.Posts.Where(p => p.Author!.Name.StartsWith("Arthur")).ExecuteDeleteAsync();

Gera o seguinte SQL ao usar TPH:

DELETE FROM [p]
FROM [Posts] AS [p]
LEFT JOIN [Authors] AS [a] ON [p].[AuthorId] = [a].[Id]
WHERE [a].[Name] IS NOT NULL AND ([a].[Name] LIKE N'Arthur%')

Mas falha ao usar TPC ou TPT.

Dica

O problema #10879 rastreia a adição de suporte para o envio automático de vários comandos nesses cenários. Vote nesta questão se for algo que você gostaria de ver implementado.

ExecuteDelete e relacionamentos

Como mencionado acima, pode ser necessário excluir ou atualizar entidades dependentes antes que a entidade de segurança de uma relação possa ser excluído. Por exemplo, cada Post é dependente de seus Author associados. Isso significa que um autor não pode ser excluído se um post ainda fizer referência a ele; isso violará a restrição de chave estrangeira no banco de dados. Por exemplo, tentando isso:

await context.Authors.ExecuteDeleteAsync();

Resultará na seguinte exceção no SQL Server:

Microsoft.Data.SqlClient.SqlException (0x80131904): A instrução DELETE entrou em conflito com a restrição REFERENCE "FK_Posts_Authors_AuthorId". O conflito ocorreu no banco de dados "TphBlogsContext", tabela "dbo.Posts", coluna 'AuthorId'. A instrução foi finalizada.

Para corrigir isso, devemos primeiro excluir as postagens ou cortar a relação entre cada postagem e seu autor, definindo AuthorId propriedade de chave estrangeira como null. Por exemplo, usando a opção excluir:

await context.Posts.TagWith("Deleting posts...").ExecuteDeleteAsync();
await context.Authors.TagWith("Deleting authors...").ExecuteDeleteAsync();

Dica

TagWith pode ser usado para marcar ExecuteDelete ou ExecuteUpdate da mesma forma que marca consultas normais.

Isso resulta em dois comandos separados; o primeiro a excluir os dependentes:

-- Deleting posts...

DELETE FROM [p]
FROM [Posts] AS [p]

E o segundo para excluir as entidades de segurança:

-- Deleting authors...

DELETE FROM [a]
FROM [Authors] AS [a]

Importante

Vários comandos ExecuteDelete e ExecuteUpdate não estarão contidos em uma única transação por padrão. No entanto, as APIs de transação DbContext podem ser usadas da maneira normal para encapsular esses comandos em uma transação.

Dica

O envio desses comandos em uma única viagem de ida e volta depende do Problema nº 10879. Vote nesta questão se for algo que você gostaria de ver implementado.

Configurar exclusões em cascata no banco de dados pode ser muito útil aqui. Em nosso modelo, a relação entre Blog e Post é necessária, o que faz com que o EF Core configure uma exclusão em cascata por convenção. Isso significa que quando um blog é excluído do banco de dados, todas as suas postagens dependentes também serão excluídas. Segue-se então que para excluir todos os blogs e posts, precisamos apenas excluir os blogs:

await context.Blogs.ExecuteDeleteAsync();

Isso resulta no seguinte SQL:

DELETE FROM [b]
FROM [Blogs] AS [b]

O que, como está excluindo um blog, também fará com que todas as postagens relacionadas sejam excluídas pela exclusão em cascata configurada.

SaveChanges mais rápidos

No FE7, o desempenho de SaveChanges e SaveChangesAsync melhorou significativamente. Em alguns cenários, salvar alterações agora é até quatro vezes mais rápido do que com o EF Core 6.0!

A maioria dessas melhorias vem de:

  • Executar menos viagens de ida e volta para o banco de dados
  • Gerar SQL mais rápido

Alguns exemplos dessas melhorias são mostrados abaixo.

Observação

Consulte Anunciando o Entity Framework Core 7 Preview 6: Performance Edition no Blog do .NET para obter uma discussão detalhada dessas alterações.

Dica

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

Transações desnecessárias são eliminadas

Todos os bancos de dados relacionais modernos garantem a transacionalidade para (a maioria) das instruções SQL únicas. Ou seja, a instrução nunca será apenas parcialmente concluída, mesmo que ocorra um erro. O EF7 evita iniciar uma transação explícita nesses casos.

Por exemplo, examinando o log da seguinte chamada para SaveChanges:

await context.AddAsync(new Blog { Name = "MyBlog" });
await context.SaveChangesAsync();

Mostra que, no EF Core 6.0, o comando INSERT é encapsulado por comandos para iniciar e, em seguida, confirmar uma transação:

dbug: 9/29/2022 11:43:09.196 RelationalEventId.TransactionStarted[20200] (Microsoft.EntityFrameworkCore.Database.Transaction)
      Began transaction with isolation level 'ReadCommitted'.
info: 9/29/2022 11:43:09.265 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (27ms) [Parameters=[@p0='MyBlog' (Nullable = false) (Size = 4000)], CommandType='Text', CommandTimeout='30']
      SET NOCOUNT ON;
      INSERT INTO [Blogs] ([Name])
      VALUES (@p0);
      SELECT [Id]
      FROM [Blogs]
      WHERE @@ROWCOUNT = 1 AND [Id] = scope_identity();
dbug: 9/29/2022 11:43:09.297 RelationalEventId.TransactionCommitted[20202] (Microsoft.EntityFrameworkCore.Database.Transaction)
      Committed transaction.

O EF7 detecta que a transação não é necessária aqui e, portanto, remove essas chamadas:

info: 9/29/2022 11:42:34.776 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (25ms) [Parameters=[@p0='MyBlog' (Nullable = false) (Size = 4000)], CommandType='Text', CommandTimeout='30']
      SET IMPLICIT_TRANSACTIONS OFF;
      SET NOCOUNT ON;
      INSERT INTO [Blogs] ([Name])
      OUTPUT INSERTED.[Id]
      VALUES (@p0);

Isso remove duas viagens de ida e volta do banco de dados, o que pode fazer uma enorme diferença no desempenho geral, especialmente quando a latência das chamadas para o banco de dados é alta. Em sistemas de produção típicos, o banco de dados não está co-localizado na mesma máquina que o aplicativo. Isso significa que a latência geralmente é relativamente alta, tornando essa otimização particularmente eficaz em sistemas de produção do mundo real.

SQL aprimorado para inserção de identidade simples

O caso acima insere uma única linha com uma coluna de chave IDENTITY e nenhum outro valor gerado pelo banco de dados. O EF7 simplifica o SQL nesse caso usando OUTPUT INSERTED. Embora essa simplificação não seja válida para muitos outros casos, ainda é importante melhorar, já que esse tipo de inserção de linha única é muito comum em muitas aplicações.

Inserindo várias linhas

No EF Core 6.0, a abordagem padrão para inserir várias linhas foi orientada por limitações no suporte do SQL Server para tabelas com gatilhos. Queríamos garantir que a experiência padrão funcionasse mesmo para a minoria dos usuários com gatilhos em suas tabelas. Isso significava que não poderíamos usar uma cláusula de OUTPUT simples, porque, no SQL Server, esse não funciona com gatilhos. Em vez disso, ao inserir várias entidades, o EF Core 6.0 gerou um SQL bastante complicado. Por exemplo, esta chamada para SaveChanges:

for (var i = 0; i < 4; i++)
{
    await context.AddAsync(new Blog { Name = "Foo" + i });
}

await context.SaveChangesAsync();

Resulta nas seguintes ações quando executado no SQL Server com EF Core 6.0:

dbug: 9/30/2022 17:19:51.919 RelationalEventId.TransactionStarted[20200] (Microsoft.EntityFrameworkCore.Database.Transaction)
      Began transaction with isolation level 'ReadCommitted'.
info: 9/30/2022 17:19:51.993 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (27ms) [Parameters=[@p0='Foo0' (Nullable = false) (Size = 4000), @p1='Foo1' (Nullable = false) (Size = 4000), @p2='Foo2' (Nullable = false) (Size = 4000), @p3='Foo3' (Nullable = false) (Size = 4000)], CommandType='Text', CommandTimeout='30']
      SET NOCOUNT ON;
      DECLARE @inserted0 TABLE ([Id] int, [_Position] [int]);
      MERGE [Blogs] USING (
      VALUES (@p0, 0),
      (@p1, 1),
      (@p2, 2),
      (@p3, 3)) AS i ([Name], _Position) ON 1=0
      WHEN NOT MATCHED THEN
      INSERT ([Name])
      VALUES (i.[Name])
      OUTPUT INSERTED.[Id], i._Position
      INTO @inserted0;

      SELECT [i].[Id] FROM @inserted0 i
      ORDER BY [i].[_Position];
dbug: 9/30/2022 17:19:52.023 RelationalEventId.TransactionCommitted[20202] (Microsoft.EntityFrameworkCore.Database.Transaction)
      Committed transaction.

Importante

Mesmo que isso seja complicado, o envio em lote de várias inserções como essa ainda é significativamente mais rápido do que o envio de um único comando para cada inserção.

No EF7, você ainda pode obter esse SQL se suas tabelas contiverem gatilhos, mas para o caso comum, agora geramos comandos muito mais eficientes, embora ainda um pouco complexos:

info: 9/30/2022 17:40:37.612 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (4ms) [Parameters=[@p0='Foo0' (Nullable = false) (Size = 4000), @p1='Foo1' (Nullable = false) (Size = 4000), @p2='Foo2' (Nullable = false) (Size = 4000), @p3='Foo3' (Nullable = false) (Size = 4000)], CommandType='Text', CommandTimeout='30']
      SET IMPLICIT_TRANSACTIONS OFF;
      SET NOCOUNT ON;
      MERGE [Blogs] USING (
      VALUES (@p0, 0),
      (@p1, 1),
      (@p2, 2),
      (@p3, 3)) AS i ([Name], _Position) ON 1=0
      WHEN NOT MATCHED THEN
      INSERT ([Name])
      VALUES (i.[Name])
      OUTPUT INSERTED.[Id], i._Position;

A transação desapareceu, como no caso de inserção única, porque MERGE é uma única instrução protegida por uma transação implícita. Além disso, a tabela temporária desapareceu e a cláusula OUTPUT agora envia os IDs gerados diretamente de volta para o cliente. Isso pode ser quatro vezes mais rápido do que no EF Core 6.0, dependendo de fatores ambientais, como a latência entre o aplicativo e o banco de dados.

Gatilhos

Se a tabela tiver gatilhos, a chamada para SaveChanges no código acima lançará uma exceção:

Exceção sem tratamento. Microsoft.EntityFrameworkCore.DbUpdateException:
não foi possível salvar as alterações porque a tabela de destino tem gatilhos de banco de dados. Configure seu tipo de entidade de acordo, consulte https://aka.ms/efcore-docs-sqlserver-save-changes-and-triggers para obter mais informações.
---> Microsoft.Data.SqlClient.SqlException (0x80131904):
a tabela de destino 'BlogsWithTriggers' da instrução DML não pode ter nenhum gatilho habilitado se a instrução contiver uma cláusula OUTPUT sem a cláusula INTO.

O código a seguir pode ser usado para informar ao EF Core que a tabela tem um gatilho:

modelBuilder
    .Entity<BlogWithTrigger>()
    .ToTable(tb => tb.HasTrigger("TRG_InsertUpdateBlog"));

O EF7 será revertido para o EF Core 6.0 SQL ao enviar comandos de inserção e atualização para esta tabela.

Para obter mais informações, incluindo uma convenção para configurar automaticamente todas as tabelas mapeadas com disparadores, consulte Tabelas do SQL Server com gatilhos agora exigem configuração especial do EF Core na documentação de alterações interruptivas do EF7.

Menos viagens de ida e volta para inserir gráficos

Considere inserir um gráfico de entidades contendo uma nova entidade principal e também novas entidades dependentes com chaves estrangeiras que fazem referência à nova entidade de segurança. Por exemplo:

await context.AddAsync(
    new Blog { Name = "MyBlog", Posts = { new() { Title = "My first post" }, new() { Title = "My second post" } } });
await context.SaveChangesAsync();

Se a chave primária da entidade de segurança for gerada pelo banco de dados, o valor a ser definido para a chave estrangeira no dependente não será conhecido até que a entidade de segurança tenha sido inserida. O EF Core gera duas viagens de ida e volta para isso - uma para inserir a entidade de segurança e recuperar a nova chave primária e uma segunda para inserir os dependentes com o valor de chave estrangeira definido. E como existem duas declarações para isso, é necessária uma transação, ou seja, há no total quatro viagens de ida e volta:

dbug: 10/1/2022 13:12:02.517 RelationalEventId.TransactionStarted[20200] (Microsoft.EntityFrameworkCore.Database.Transaction)
      Began transaction with isolation level 'ReadCommitted'.
info: 10/1/2022 13:12:02.517 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (0ms) [Parameters=[@p0='MyBlog' (Nullable = false) (Size = 4000)], CommandType='Text', CommandTimeout='30']
      SET IMPLICIT_TRANSACTIONS OFF;
      SET NOCOUNT ON;
      INSERT INTO [Blogs] ([Name])
      OUTPUT INSERTED.[Id]
      VALUES (@p0);
info: 10/1/2022 13:12:02.529 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (5ms) [Parameters=[@p1='6', @p2='My first post' (Nullable = false) (Size = 4000), @p3='6', @p4='My second post' (Nullable = false) (Size = 4000)], CommandType='Text', CommandTimeout='30']
      SET IMPLICIT_TRANSACTIONS OFF;
      SET NOCOUNT ON;
      MERGE [Post] USING (
      VALUES (@p1, @p2, 0),
      (@p3, @p4, 1)) AS i ([BlogId], [Title], _Position) ON 1=0
      WHEN NOT MATCHED THEN
      INSERT ([BlogId], [Title])
      VALUES (i.[BlogId], i.[Title])
      OUTPUT INSERTED.[Id], i._Position;
dbug: 10/1/2022 13:12:02.531 RelationalEventId.TransactionCommitted[20202] (Microsoft.EntityFrameworkCore.Database.Transaction)
      Committed transaction.

No entanto, em alguns casos, o valor da chave primária é conhecido antes da entidade de segurança ser inserida. Isso inclui:

  • Valores-chave que não são gerados automaticamente
  • Valores de chave gerados no cliente, como chaves Guid
  • Valores de chave gerados no servidor em lotes, como ao usar um gerador de valores hi-lo

No EF7, esses casos agora são otimizados em uma única viagem de ida e volta. Por exemplo, no caso acima no SQL Server, a chave primária Blog.Id pode ser configurada para usar a estratégia de geração hi-lo:

modelBuilder.Entity<Blog>().Property(e => e.Id).UseHiLo();
modelBuilder.Entity<Post>().Property(e => e.Id).UseHiLo();

A chamada SaveChanges acima agora é otimizada para uma única viagem de ida e volta para as inserções.

dbug: 10/1/2022 21:51:55.805 RelationalEventId.TransactionStarted[20200] (Microsoft.EntityFrameworkCore.Database.Transaction)
      Began transaction with isolation level 'ReadCommitted'.
info: 10/1/2022 21:51:55.806 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (0ms) [Parameters=[@p0='9', @p1='MyBlog' (Nullable = false) (Size = 4000), @p2='10', @p3='9', @p4='My first post' (Nullable = false) (Size = 4000), @p5='11', @p6='9', @p7='My second post' (Nullable = false) (Size = 4000)], CommandType='Text', CommandTimeout='30']
      SET NOCOUNT ON;
      INSERT INTO [Blogs] ([Id], [Name])
      VALUES (@p0, @p1);
      INSERT INTO [Posts] ([Id], [BlogId], [Title])
      VALUES (@p2, @p3, @p4),
      (@p5, @p6, @p7);
dbug: 10/1/2022 21:51:55.807 RelationalEventId.TransactionCommitted[20202] (Microsoft.EntityFrameworkCore.Database.Transaction)
      Committed transaction.

Observe que uma transação ainda é necessária aqui. Isso ocorre porque as inserções estão sendo feitas em duas tabelas separadas.

O EF7 também usa um único lote em outros casos em que o EF Core 6.0 criaria mais de um. Por exemplo, ao excluir e inserir linhas na mesma tabela.

O valor de SaveChanges

Como alguns dos exemplos aqui mostram, salvar resultados no banco de dados pode ser um negócio complexo. É aqui que o uso de algo como o EF Core realmente mostra seu valor. EF Core:

  • Agrupa vários comandos de inserção, atualização e exclusão para reduzir viagens de ida e volta
  • Descobre se uma transação explícita é necessária ou não
  • Determina a ordem de inserção, atualização e exclusão de entidades para que as restrições de banco de dados não sejam violadas
  • Garante que os valores gerados pelo banco de dados sejam retornados com eficiência e propagados de volta para entidades
  • Define automaticamente valores de chave estrangeira usando os valores gerados para chaves primárias
  • Detectar conflitos de simultaneidade

Além disso, diferentes sistemas de banco de dados exigem SQL diferente para muitos desses casos. O provedor de banco de dados EF Core trabalha com o EF Core para garantir que comandos corretos e eficientes sejam enviados para cada caso.

Mapeamento de herança de TPC (tabela por tipo concreto)

Por padrão, o EF Core mapeia uma hierarquia de herança de tipos .NET para uma única tabela de banco de dados. Isso é conhecido como a estratégia de mapeamento tabela por hierarquia (TPH) matemática. O EF Core 5.0 introduziu a estratégia tabela por tipo (TPT), que oferece suporte ao mapeamento de cada tipo .NET para uma tabela de banco de dados diferente. O EF7 apresenta a estratégia TPC (tabela por tipo concreto). A TPC também mapeia tipos .NET para tabelas diferentes, mas de uma forma que resolve alguns problemas comuns de desempenho com a estratégia TPT.

Dica

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

Dica

A equipe do EF demonstrou e falou detalhadamente sobre o mapeamento de TPC em um episódio do Standup da Comunidade de Dados do .NET. Assim como acontece com todos os episódios do Community Standup, você pode assistir ao episódio TPC agora no YouTube.

Esquema de banco de dados TPC

A estratégia TPC é semelhante à estratégia TPT, exceto que uma tabela diferente é criada para cada tipo concreto na hierarquia, mas as tabelas são não criadas para tipos abstratos - daí o nome "tabela-por-tipo-concreto". Assim como na TPT, a própria tabela indica o tipo do objeto salvo. No entanto, ao contrário do mapeamento TPT, cada tabela contém colunas para cada propriedade no tipo concreto e seus tipos de base. Os esquemas de banco de dados TPC são desnormalizados.

Por exemplo, considere mapear essa hierarquia:

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

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

    public Food? Food { get; set; }
}

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

    public string? Vet { get; set; }

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

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

    public override string Species { get; }

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

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

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

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

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

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

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

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

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

    public override string Species => "Homo sapiens";

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

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

Ao usar o SQL Server, as tabelas criadas para essa hierarquia são:

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

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

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

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

Observe que:

  • Não há tabelas para os tipos Animal ou Pet, pois eles são abstract no modelo de objeto. Lembre-se de que o C# não permite instâncias de tipos abstratos e, portanto, não há nenhuma situação em que uma instância de tipo abstrato será salva no banco de dados.

  • O mapeamento das propriedades em tipos base é repetido para cada tipo de concreto. Por exemplo, cada tabela tem uma coluna Name, e Gatos e Cães têm uma coluna Vet.

  • Salvar alguns dados nesse banco de dados resulta no seguinte:

Tabela de gatos

ID Nome FoodId Vet EducationLevel
1 Alice 99ca3e98-b26d-4a0c-d4ae-08da7aca624f Cupcake MBA
2 Mac 99ca3e98-b26d-4a0c-d4ae-08da7aca624f Cupcake Pré-escolar
8 Baxter 5dc5019e-6f72-454b-d4b0-08da7aca624f Clínica Veterinária Miau Bacharel em Ciência

Tabela de cães

ID Nome FoodId Vet FavoriteToy
3 Notificação do sistema 011aaf6f-d588-4fad-d4ac-08da7aca624f Cupcake Sr. Esquilo

Tabela FarmAnimals

ID Nome FoodId Valor Espécie
4 Chico 1d495075-f527-4498-d4af-08da7aca624f 100.00 Equus africanus asinus

Tabela de humanos

ID Nome FoodId FavoriteAnimalId
5 Vanda 5418fd81-7660-432f-d4b1-08da7aca624f 2
6 Arthur 59b495d4-0414-46bf-d4ad-08da7aca624f 1
9 Karina nulo 8

Observe que, ao contrário do mapeamento TPT, todas as informações de um único objeto estão contidas em uma única tabela. E, ao contrário do mapeamento TPH, não há combinação de coluna e linha em nenhuma tabela em que isso nunca seja usado pelo modelo. Veremos abaixo como essas características podem ser importantes para consultas e armazenamento.

Configurando a herança TPC

Todos os tipos em uma hierarquia de herança devem ser explicitamente incluídos no modelo ao mapear a hierarquia com o EF Core. Isso pode ser feito criando propriedades DbSet em seu DbContext para cada tipo:

public DbSet<Animal> Animals => Set<Animal>();
public DbSet<Pet> Pets => Set<Pet>();
public DbSet<FarmAnimal> FarmAnimals => Set<FarmAnimal>();
public DbSet<Cat> Cats => Set<Cat>();
public DbSet<Dog> Dogs => Set<Dog>();
public DbSet<Human> Humans => Set<Human>();

Ou usando o método Entity em OnModelCreating:

modelBuilder.Entity<Animal>();
modelBuilder.Entity<Pet>();
modelBuilder.Entity<Cat>();
modelBuilder.Entity<Dog>();
modelBuilder.Entity<FarmAnimal>();
modelBuilder.Entity<Human>();

Importante

Isso é diferente do comportamento EF6 herdado, em que tipos derivados de tipos base mapeados seriam descobertos automaticamente se estivessem contidos no mesmo assembly.

Nada mais precisa ser feito para mapear a hierarquia como TPH, já que é a estratégia padrão. No entanto, a partir do EF7, a TPH pode ser explicitada chamando-UseTphMappingStrategy no tipo base da hierarquia:

modelBuilder.Entity<Animal>().UseTphMappingStrategy();

Para usar a TPT, altere para UseTptMappingStrategy:

modelBuilder.Entity<Animal>().UseTptMappingStrategy();

Da mesma forma, UseTpcMappingStrategy é usado para configurar a TPC:

modelBuilder.Entity<Animal>().UseTpcMappingStrategy();

Em cada caso, o nome da tabela usado para cada tipo é retirado do nome da propriedade DbSet em seu DbContext ou pode ser configurado usando o método do construtor ToTable ou o atributo [Table].

Desempenho da consulta TPC

Para consultas, a estratégia TPC é uma melhoria em relação à TPT porque garante que as informações de uma determinada instância de entidade sejam sempre armazenadas em uma única tabela. Isso significa que a estratégia TPC pode ser útil quando a hierarquia mapeada é grande e tem muitos tipos concretos (geralmente folhas), cada um com um grande número de propriedades e onde apenas um pequeno subconjunto de tipos é usado na maioria das consultas.

O SQL gerado para três consultas LINQ simples pode ser usado para observar onde a TPC se sai bem quando comparado à TPH e TPT. Essas consultas são:

  1. Uma consulta que retorna entidades de todos os tipos na hierarquia:

    context.Animals.ToList();
    
  2. Uma consulta que retorna entidades de um subconjunto de tipos na hierarquia:

    context.Pets.ToList();
    
  3. Uma consulta que retorna apenas entidades de um único tipo de folha na hierarquia:

    context.Cats.ToList();
    

Consultas TPH

Ao usar a TPH, todas as três consultas consultam apenas uma única tabela, mas com filtros diferentes na coluna discriminatória:

  1. TPH SQL retornando entidades de todos os tipos na hierarquia:

    SELECT [a].[Id], [a].[Discriminator], [a].[FoodId], [a].[Name], [a].[Species], [a].[Value], [a].[FavoriteAnimalId], [a].[Vet], [a].[EducationLevel], [a].[FavoriteToy]
    FROM [Animals] AS [a]
    
  2. TPH SQL retornando entidades de um subconjunto de tipos na hierarquia:

    SELECT [a].[Id], [a].[Discriminator], [a].[FoodId], [a].[Name], [a].[Vet], [a].[EducationLevel], [a].[FavoriteToy]
    FROM [Animals] AS [a]
    WHERE [a].[Discriminator] IN (N'Cat', N'Dog')
    
  3. TPH SQL retornando apenas entidades de um único tipo de folha na hierarquia:

    SELECT [a].[Id], [a].[Discriminator], [a].[FoodId], [a].[Name], [a].[Vet], [a].[EducationLevel]
    FROM [Animals] AS [a]
    WHERE [a].[Discriminator] = N'Cat'
    

Todas essas consultas devem ter um bom desempenho, especialmente com um índice de banco de dados apropriado na coluna discriminatória.

Consultas TPT

Ao usar a TPT, todas essas consultas exigem a união de várias tabelas, já que os dados de qualquer tipo concreto específico são divididos em muitas tabelas:

  1. TPT SQL retornando entidades de todos os tipos na hierarquia:

    SELECT [a].[Id], [a].[FoodId], [a].[Name], [f].[Species], [f].[Value], [h].[FavoriteAnimalId], [p].[Vet], [c].[EducationLevel], [d].[FavoriteToy], CASE
        WHEN [d].[Id] IS NOT NULL THEN N'Dog'
        WHEN [c].[Id] IS NOT NULL THEN N'Cat'
        WHEN [h].[Id] IS NOT NULL THEN N'Human'
        WHEN [f].[Id] IS NOT NULL THEN N'FarmAnimal'
    END AS [Discriminator]
    FROM [Animals] AS [a]
    LEFT JOIN [FarmAnimals] AS [f] ON [a].[Id] = [f].[Id]
    LEFT JOIN [Humans] AS [h] ON [a].[Id] = [h].[Id]
    LEFT JOIN [Pets] AS [p] ON [a].[Id] = [p].[Id]
    LEFT JOIN [Cats] AS [c] ON [a].[Id] = [c].[Id]
    LEFT JOIN [Dogs] AS [d] ON [a].[Id] = [d].[Id]
    
  2. TPT SQL retornando entidades de um subconjunto de tipos na hierarquia:

    SELECT [a].[Id], [a].[FoodId], [a].[Name], [p].[Vet], [c].[EducationLevel], [d].[FavoriteToy], CASE
        WHEN [d].[Id] IS NOT NULL THEN N'Dog'
        WHEN [c].[Id] IS NOT NULL THEN N'Cat'
    END AS [Discriminator]
    FROM [Animals] AS [a]
    INNER JOIN [Pets] AS [p] ON [a].[Id] = [p].[Id]
    LEFT JOIN [Cats] AS [c] ON [a].[Id] = [c].[Id]
    LEFT JOIN [Dogs] AS [d] ON [a].[Id] = [d].[Id]
    
  3. TPT SQL retornando apenas entidades de um único tipo de folha na hierarquia:

    SELECT [a].[Id], [a].[FoodId], [a].[Name], [p].[Vet], [c].[EducationLevel]
    FROM [Animals] AS [a]
    INNER JOIN [Pets] AS [p] ON [a].[Id] = [p].[Id]
    INNER JOIN [Cats] AS [c] ON [a].[Id] = [c].[Id]
    

Observação

O EF Core usa a "síntese discriminatória" para determinar de qual tabela os dados vêm e, portanto, o tipo correto a ser usado. Isso funciona porque o LEFT JOIN retorna nulos para a coluna ID dependente (as "subtabelas") que não são do tipo correto. Assim, para um cachorro, [d].[Id] será não-nulo e todos os outros IDs (concretos) serão nulos.

Todas essas consultas podem sofrer problemas de desempenho devido às junções de tabela. É por isso que a TPT nunca é uma boa opção para o desempenho da consulta.

Consultas TPC

A TPC melhora em relação à TPT para todas essas consultas porque o número de tabelas que precisam ser consultadas é reduzido. Além disso, os resultados de cada tabela são combinados usando UNION ALL, que pode ser consideravelmente mais rápido do que uma junção de tabela, uma vez que não precisa realizar nenhuma correspondência entre linhas ou eliminação de duplicação de linhas.

  1. TPC SQL retornando entidades de todos os tipos na hierarquia:

    SELECT [f].[Id], [f].[FoodId], [f].[Name], [f].[Species], [f].[Value], NULL AS [FavoriteAnimalId], NULL AS [Vet], NULL AS [EducationLevel], NULL AS [FavoriteToy], N'FarmAnimal' AS [Discriminator]
    FROM [FarmAnimals] AS [f]
    UNION ALL
    SELECT [h].[Id], [h].[FoodId], [h].[Name], NULL AS [Species], NULL AS [Value], [h].[FavoriteAnimalId], NULL AS [Vet], NULL AS [EducationLevel], NULL AS [FavoriteToy], N'Human' AS [Discriminator]
    FROM [Humans] AS [h]
    UNION ALL
    SELECT [c].[Id], [c].[FoodId], [c].[Name], NULL AS [Species], NULL AS [Value], NULL AS [FavoriteAnimalId], [c].[Vet], [c].[EducationLevel], NULL AS [FavoriteToy], N'Cat' AS [Discriminator]
    FROM [Cats] AS [c]
    UNION ALL
    SELECT [d].[Id], [d].[FoodId], [d].[Name], NULL AS [Species], NULL AS [Value], NULL AS [FavoriteAnimalId], [d].[Vet], NULL AS [EducationLevel], [d].[FavoriteToy], N'Dog' AS [Discriminator]
    FROM [Dogs] AS [d]
    
  2. TPC SQL retornando entidades de um subconjunto de tipos na hierarquia:

    SELECT [c].[Id], [c].[FoodId], [c].[Name], [c].[Vet], [c].[EducationLevel], NULL AS [FavoriteToy], N'Cat' AS [Discriminator]
    FROM [Cats] AS [c]
    UNION ALL
    SELECT [d].[Id], [d].[FoodId], [d].[Name], [d].[Vet], NULL AS [EducationLevel], [d].[FavoriteToy], N'Dog' AS [Discriminator]
    FROM [Dogs] AS [d]
    
  3. TPC SQL retornando apenas entidades de um único tipo de folha na hierarquia:

    SELECT [c].[Id], [c].[FoodId], [c].[Name], [c].[Vet], [c].[EducationLevel]
    FROM [Cats] AS [c]
    

Embora a TPC seja melhor do que a TPT para todas essas consultas, as consultas TPH ainda são melhores ao retornar instâncias de vários tipos. Essa é uma das razões pelas quais a TPH é a estratégia padrão usada pelo EF Core.

Como mostra o SQL para consulta #3, a TPC realmente se destaca ao consultar entidades de um único tipo de folha. A consulta usa apenas uma única tabela e não precisa de filtragem.

Inserções e atualizações de TPC

A TPC também funciona bem ao salvar uma nova entidade, pois isso requer a inserção de apenas uma única linha em uma única tabela. Isso também é verdade para TPH. Com a TPT, as linhas devem ser inseridas em muitas tabelas, que terão um desempenho menos bom.

O mesmo geralmente é verdadeiro para atualizações, embora neste caso se todas as colunas que estão sendo atualizadas estiverem na mesma tabela, mesmo para TPT, a diferença pode não ser significativa.

Considerações sobre o espaço

Tanto a TPT quanto a TPC podem usar menos armazenamento do que a TPH quando há muitos subtipos com muitas propriedades que geralmente não são usadas. Isso ocorre porque cada linha na tabela TPH deve armazenar um NULL para cada uma dessas propriedades não utilizadas. Na prática, isso raramente é um problema, mas pode valer a pena considerar ao armazenar grandes quantidades de dados com essas características.

Dica

Se o seu sistema de banco de dados oferecer suporte a ela (por exemplo.SQL Server), considere usar "colunas esparsas" para colunas TPH que raramente serão preenchidas.

Geração de chave

A estratégia de mapeamento de herança escolhida tem consequências sobre como os valores de chave primária são gerados e gerenciados. As chaves na TPH são fáceis, pois cada instância de entidade é representada por uma única linha em uma única tabela. Qualquer tipo de geração de valor chave pode ser usado, e nenhuma restrição adicional é necessária.

Para a estratégia TPT, há sempre uma linha na tabela mapeada para o tipo base da hierarquia. Qualquer tipo de geração de chave pode ser usado nessa linha, e as chaves para outras tabelas são vinculadas a essa tabela usando restrições de chave estrangeira.

As coisas ficam um pouco mais complicadas para a TPC. Primeiro, é importante entender que o EF Core exige que todas as entidades em uma hierarquia tenham um valor de chave exclusivo, mesmo que as entidades tenham tipos diferentes. Então, usando nosso modelo de exemplo, um Cachorro não pode ter o mesmo valor de chave ID que um Gato. Em segundo lugar, ao contrário da TPT, não há uma tabela comum que possa atuar como o único lugar onde os valores-chave vivem e podem ser gerados. Isso significa que uma coluna Identity simples não pode ser usada.

Para bancos de dados que oferecem suporte a sequências, os valores de chave podem ser gerados usando uma única sequência referenciada na restrição padrão para cada tabela. Esta é a estratégia utilizada nas tabelas TPC mostradas acima, onde cada tabela tem o seguinte:

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

AnimalSequence é uma sequência de banco de dados criada pelo EF Core. Essa estratégia é usada por padrão para hierarquias TPC ao usar o provedor de banco de dados EF Core para SQL Server. Os provedores de banco de dados para outros bancos de dados que oferecem suporte a sequências devem ter um padrão semelhante. Outras estratégias de geração chave que usam sequências, como padrões Hi-Lo, também podem ser usadas com TPC.

Embora as colunas de Identidade padrão não funcionem com TPC, é possível usar colunas de Identidade se cada tabela estiver configurada com uma semente e incremento apropriados para que os valores gerados para cada tabela nunca entrem em conflito. Por exemplo:

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

O SQLite não oferece suporte a sequências ou semente/incremento de identidade e, portanto, a geração de valor de chave inteira não é suportada ao usar o SQLite com a estratégia TPC. No entanto, a geração do lado do cliente ou chaves globalmente exclusivas - por exemplo, chaves GUID - são suportadas em qualquer banco de dados, incluindo SQLite.

Restrições de chave estrangeira

A estratégia de mapeamento TPC cria um esquema SQL desnormalizado - essa é uma razão pela qual alguns puristas de banco de dados são contra. Por exemplo, considere a coluna de chave estrangeira FavoriteAnimalId. O valor nesta coluna deve corresponder ao valor da chave primária de algum animal. Isso pode ser imposto no banco de dados com uma restrição FK simples ao usar TPH ou TPT. Por exemplo:

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

Mas ao usar TPC, a chave primária para um animal é armazenada na tabela para o tipo de concreto desse animal. Por exemplo, a chave primária de um gato é armazenada na coluna Cats.Id, enquanto a chave primária de um cachorro é armazenada na coluna Dogs.Id e assim por diante. Isso significa que uma restrição FK não pode ser criada para essa relação.

Na prática, isso não é um problema, desde que o aplicativo não tente inserir dados inválidos. Por exemplo, se todos os dados forem inseridos pelo EF Core e usarem navegações para relacionar entidades, será garantido que a coluna FK conterá o valor PK válido em todos os momentos.

Resumo e diretrizes

Em resumo, a TPC é uma boa estratégia de mapeamento para usar quando seu código consultar principalmente entidades de um único tipo de folha. Isso ocorre porque os requisitos de armazenamento são menores e não há nenhuma coluna discriminatória que possa precisar de um índice. Inserções e atualizações também são eficientes.

Dito isso, a TPH geralmente é bom para a maioria dos aplicativos e é um bom padrão para uma ampla gama de cenários, portanto, não adicione a complexidade da TPC se você não precisar dela. Especificamente, se o seu código consultar principalmente entidades de muitos tipos, como escrever consultas no tipo base, escolha TPH ao invés da TPC.

Use a TPT somente se for restringido a fazê-lo por fatores externos.

Modelos de engenharia reversa personalizados

Agora você pode personalizar o código scaffolded ao fazer engenharia reversa de um modelo EF a partir de um banco de dados. Comece adicionando os modelos padrão ao seu projeto:

dotnet new install Microsoft.EntityFrameworkCore.Templates
dotnet new ef-templates

Os modelos podem ser personalizados e serão usados automaticamente por dotnet ef dbcontext scaffold e Scaffold-DbContext.

Para obter mais detalhes, consulte Modelos de engenharia reversa personalizados.

Dica

A equipe do EF demonstrou e falou detalhadamente sobre modelos de engenharia reversa em um episódio do Standup da Comunidade de Dados do .NET. Assim como acontece com todos os episódios de Standup da Comunidade, você pode assistir ao episódio de modelos T4 agora no YouTube.

Convenções de criação de modelo

O EF Core usa um "modelo" de metadados para descrever como os tipos de entidade do aplicativo são mapeados para o banco de dados subjacente. Este modelo é construído usando um conjunto de cerca de 60 "convenções". O modelo criado por convenções pode então ser personalizado usando atributos de mapeamento (também conhecidos como "anotações de dados") e/ou chamadas para a API DbModelBuilder em OnModelCreating.

A partir do EF7, os aplicativos agora podem remover ou substituir qualquer uma dessas convenções, bem como adicionar novas convenções. As convenções de construção de modelos são uma maneira poderosa de controlar a configuração do modelo, mas podem ser complexas e difíceis de acertar. Em muitos casos, a configuração existente do modelo pré-convenção pode ser usada para especificar facilmente uma configuração comum para propriedades e tipos.

As alterações nas convenções usadas por um DbContext são feitas substituindo o método DbContext.ConfigureConventions. Por exemplo:

protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
{
    configurationBuilder.Conventions.Remove(typeof(ForeignKeyIndexConvention));
}

Dica

Para localizar todas as convenções internas de criação de modelos, procure cada classe que implementa a interface IConvention.

Dica

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

Removendo uma convenção existente

Às vezes, uma das convenções internas pode não ser apropriada para seu aplicativo, caso em que pode ser removida.

Exemplo: não criar índices para colunas de chave estrangeira

Normalmente, faz sentido criar índices para colunas de chave estrangeira (FK) e, portanto, há uma convenção interna para isso: ForeignKeyIndexConvention. Observando o modelo exibição de depuração para um tipo de entidade Post com relações com Blog e Author, podemos ver que dois índices são criados - um para o FK BlogId e outro para o FK AuthorId.

  EntityType: Post
    Properties:
      Id (int) Required PK AfterSave:Throw ValueGenerated.OnAdd
      AuthorId (no field, int?) Shadow FK Index
      BlogId (no field, int) Shadow Required FK Index
    Navigations:
      Author (Author) ToPrincipal Author Inverse: Posts
      Blog (Blog) ToPrincipal Blog Inverse: Posts
    Keys:
      Id PK
    Foreign keys:
      Post {'AuthorId'} -> Author {'Id'} ToDependent: Posts ToPrincipal: Author ClientSetNull
      Post {'BlogId'} -> Blog {'Id'} ToDependent: Posts ToPrincipal: Blog Cascade
    Indexes:
      AuthorId
      BlogId

No entanto, os índices têm sobrecarga e, como perguntado aqui, nem sempre pode ser apropriado criá-los para todas as colunas FK. Para conseguir isso, a ForeignKeyIndexConvention pode ser removida ao construir o modelo:

protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
{
    configurationBuilder.Conventions.Remove(typeof(ForeignKeyIndexConvention));
}

Olhando para a exibição de depuração do modelo por Post agora, vemos que os índices em FKs não foram criados:

  EntityType: Post
    Properties:
      Id (int) Required PK AfterSave:Throw ValueGenerated.OnAdd
      AuthorId (no field, int?) Shadow FK
      BlogId (no field, int) Shadow Required FK
    Navigations:
      Author (Author) ToPrincipal Author Inverse: Posts
      Blog (Blog) ToPrincipal Blog Inverse: Posts
    Keys:
      Id PK
    Foreign keys:
      Post {'AuthorId'} -> Author {'Id'} ToDependent: Posts ToPrincipal: Author ClientSetNull
      Post {'BlogId'} -> Blog {'Id'} ToDependent: Posts ToPrincipal: Blog Cascade

Quando desejado, os índices ainda podem ser criados explicitamente para colunas de chave estrangeira, usando o IndexAttribute:

[Index("BlogId")]
public class Post
{
    // ...
}

Ou com configuração em OnModelCreating:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Post>(entityTypeBuilder => entityTypeBuilder.HasIndex("BlogId"));
}

Examinando o tipo de entidade Post novamente, ele agora contém o índice BlogId, mas não o índice AuthorId:

  EntityType: Post
    Properties:
      Id (int) Required PK AfterSave:Throw ValueGenerated.OnAdd
      AuthorId (no field, int?) Shadow FK
      BlogId (no field, int) Shadow Required FK Index
    Navigations:
      Author (Author) ToPrincipal Author Inverse: Posts
      Blog (Blog) ToPrincipal Blog Inverse: Posts
    Keys:
      Id PK
    Foreign keys:
      Post {'AuthorId'} -> Author {'Id'} ToDependent: Posts ToPrincipal: Author ClientSetNull
      Post {'BlogId'} -> Blog {'Id'} ToDependent: Posts ToPrincipal: Blog Cascade
    Indexes:
      BlogId

Dica

Se o modelo não usar atributos de mapeamento (também conhecidos como anotações de dados) para configuração, todas as convenções terminadas em AttributeConvention poderão ser removidas com segurança para acelerar a construção do modelo.

Adicionando uma nova convenção

Remover convenções existentes é um começo, mas que tal adicionar convenções de criação de modelos completamente novas? O EF7 também suporta isso!

Exemplo: restringir o comprimento das propriedades discriminatórias

A estratégia de mapeamento de herança tabela por hierarquia requer uma coluna discriminatória para especificar qual tipo é representado em uma determinada linha. Por padrão, o EF usa uma coluna de cadeia de caracteres não associada para o discriminador, o que garante que ela funcione por qualquer comprimento discriminatório. No entanto, restringir o comprimento máximo das cadeias de caracteres do discriminador pode tornar o armazenamento e as consultas mais eficientes. Vamos criar uma nova convenção que faça isso.

As convenções de criação do modelo EF Core são acionadas com base nas alterações feitas no modelo à medida que ele está sendo criado. Isso mantém o modelo atualizado à medida que a configuração explícita é feita, os atributos de mapeamento são aplicados e outras convenções são executadas. Para participar disso, cada convenção implementa uma ou mais interfaces que determinam quando a convenção será acionada. Por exemplo, uma convenção que implementa IEntityTypeAddedConvention será acionada sempre que um novo tipo de entidade for adicionado ao modelo. Da mesma forma, uma convenção que implementa IForeignKeyAddedConvention e IKeyAddedConvention será acionada sempre que uma chave ou uma chave estrangeira for adicionada ao modelo.

Saber quais interfaces implementar pode ser complicado, já que a configuração feita no modelo em um ponto pode ser alterada ou removida em um ponto posterior. Por exemplo, uma chave pode ser criada por convenção, mas posteriormente substituída quando uma chave diferente é configurada explicitamente.

Vamos tornar isso um pouco mais concreto, fazendo uma primeira tentativa de implementar a convenção do comprimento discriminador:

public class DiscriminatorLengthConvention1 : IEntityTypeBaseTypeChangedConvention
{
    public void ProcessEntityTypeBaseTypeChanged(
        IConventionEntityTypeBuilder entityTypeBuilder,
        IConventionEntityType? newBaseType,
        IConventionEntityType? oldBaseType,
        IConventionContext<IConventionEntityType> context)
    {
        var discriminatorProperty = entityTypeBuilder.Metadata.FindDiscriminatorProperty();
        if (discriminatorProperty != null
            && discriminatorProperty.ClrType == typeof(string))
        {
            discriminatorProperty.Builder.HasMaxLength(24);
        }
    }
}

Essa convenção implementa IEntityTypeBaseTypeChangedConvention, o que significa que ela será acionada sempre que a hierarquia de herança mapeada para um tipo de entidade for alterada. Em seguida, a convenção localiza e configura a propriedade discriminador de cadeia de caracteres para a hierarquia.

Essa convenção é então usada chamando Add em ConfigureConventions:

protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
{
    configurationBuilder.Conventions.Add(_ =>  new DiscriminatorLengthConvention1());
}

Dica

Em vez de adicionar uma instância da convenção diretamente, o método Add aceita uma fábrica para criar instâncias da convenção. Isso permite que a convenção use dependências do provedor de serviços interno do EF Core. Como essa convenção não tem dependências, o parâmetro do provedor de serviços é denominado _, indicando que ele nunca é usado.

Compilar o modelo e examinar o tipo de entidade Post mostra que isso funcionou - a propriedade discriminatória agora está configurada para com um comprimento máximo de 24:

 Discriminator (no field, string) Shadow Required AfterSave:Throw MaxLength(24)

Mas o que acontece se agora configurarmos explicitamente uma propriedade discriminatória diferente? Por exemplo:

modelBuilder.Entity<Post>()
    .HasDiscriminator<string>("PostTypeDiscriminator")
    .HasValue<Post>("Post")
    .HasValue<FeaturedPost>("Featured");

Olhando para a visualização de depuração do modelo, descobrimos que o comprimento discriminatório não está mais configurado!

 PostTypeDiscriminator (no field, string) Shadow Required AfterSave:Throw

Isso ocorre porque a propriedade discriminatória que configuramos em nossa convenção foi removida posteriormente quando o discriminador personalizado foi adicionado. Poderíamos tentar corrigir isso implementando outra interface em nossa convenção para reagir às mudanças discriminatórias, mas descobrir qual interface implementar não é fácil.

Felizmente, há uma maneira diferente de abordar isso que torna as coisas muito mais fáceis. Muitas vezes, não importa como o modelo se parece enquanto ele está sendo construído, desde que o modelo final esteja correto. Além disso, a configuração que queremos aplicar muitas vezes não precisa acionar outras convenções para reagir. Portanto, nossa convenção pode implementar IModelFinalizingConvention. As convenções de finalização do modelo são executadas depois que todas as outras construções do modelo são concluídas e, portanto, têm acesso ao estado final do modelo. Uma convenção de finalização de modelo normalmente iterará sobre todo o modelo configurando elementos de modelo à medida que ele avança. Então, neste caso, vamos encontrar todos os discriminadores no modelo e configurá-lo:

public class DiscriminatorLengthConvention2 : IModelFinalizingConvention
{
    public void ProcessModelFinalizing(IConventionModelBuilder modelBuilder, IConventionContext<IConventionModelBuilder> context)
    {
        foreach (var entityType in modelBuilder.Metadata.GetEntityTypes()
                     .Where(entityType => entityType.BaseType == null))
        {
            var discriminatorProperty = entityType.FindDiscriminatorProperty();
            if (discriminatorProperty != null
                && discriminatorProperty.ClrType == typeof(string))
            {
                discriminatorProperty.Builder.HasMaxLength(24);
            }
        }
    }
}

Depois de construir o modelo com essa nova convenção, descobrimos que o comprimento discriminatório agora está configurado corretamente, embora tenha sido personalizado:

PostTypeDiscriminator (no field, string) Shadow Required AfterSave:Throw MaxLength(24)

Só por diversão, vamos mais longe e configurar o comprimento máximo para ter o comprimento do valor discriminatório mais longo.

public class DiscriminatorLengthConvention3 : IModelFinalizingConvention
{
    public void ProcessModelFinalizing(IConventionModelBuilder modelBuilder, IConventionContext<IConventionModelBuilder> context)
    {
        foreach (var entityType in modelBuilder.Metadata.GetEntityTypes()
                     .Where(entityType => entityType.BaseType == null))
        {
            var discriminatorProperty = entityType.FindDiscriminatorProperty();
            if (discriminatorProperty != null
                && discriminatorProperty.ClrType == typeof(string))
            {
                var maxDiscriminatorValueLength =
                    entityType.GetDerivedTypesInclusive().Select(e => ((string)e.GetDiscriminatorValue()!).Length).Max();

                discriminatorProperty.Builder.HasMaxLength(maxDiscriminatorValueLength);
            }
        }
    }
}

Agora, o comprimento máximo da coluna discriminatória é 8, que é o comprimento de "Destaque", o valor discriminatório mais longo em uso.

PostTypeDiscriminator (no field, string) Shadow Required AfterSave:Throw MaxLength(8)

Dica

Você pode estar se perguntando se a convenção também deve criar um índice para a coluna discriminatória. Há uma discussão sobre isso no GitHub. A resposta curta é que às vezes um índice pode ser útil, mas na maioria das vezes provavelmente não será. Portanto, é melhor criar índices apropriados aqui conforme necessário, em vez de ter uma convenção para fazê-lo sempre. Mas se você discorda disso, então a convenção acima pode ser facilmente modificada para criar um índice também.

Exemplo: comprimento padrão para todas as propriedades de cadeia de caracteres

Vejamos outro exemplo em que uma convenção de finalização pode ser usada - desta vez, definindo um comprimento máximo padrão para qualquer propriedade de cadeia de caracteres, conforme solicitado no GitHub. A convenção é bastante semelhante ao exemplo anterior:

public class MaxStringLengthConvention : IModelFinalizingConvention
{
    public void ProcessModelFinalizing(IConventionModelBuilder modelBuilder, IConventionContext<IConventionModelBuilder> context)
    {
        foreach (var property in modelBuilder.Metadata.GetEntityTypes()
                     .SelectMany(
                         entityType => entityType.GetDeclaredProperties()
                             .Where(
                                 property => property.ClrType == typeof(string))))
        {
            property.Builder.HasMaxLength(512);
        }
    }
}

Essa convenção é bem simples. Ele localiza cada propriedade de cadeia de caracteres no modelo e define seu comprimento máximo como 512. Olhando na exibição de depuração nas propriedades de Post, vemos que todas as propriedades de cadeia de caracteres agora têm um comprimento máximo de 512.

EntityType: Post
  Properties:
    Id (int) Required PK AfterSave:Throw ValueGenerated.OnAdd
    AuthorId (no field, int?) Shadow FK Index
    BlogId (no field, int) Shadow Required FK Index
    Content (string) Required MaxLength(512)
    Discriminator (no field, string) Shadow Required AfterSave:Throw MaxLength(512)
    PublishedOn (DateTime) Required
    Title (string) Required MaxLength(512)

Mas a propriedade Content provavelmente deve permitir mais de 512 caracteres, ou todos os nossos posts serão bem curtos! Isso pode ser feito sem alterar nossa convenção configurando explicitamente o comprimento máximo apenas para essa propriedade, usando um atributo de mapeamento:

[MaxLength(4000)]
public string Content { get; set; }

Ou com código em OnModelCreating:

modelBuilder.Entity<Post>()
    .Property(post => post.Content)
    .HasMaxLength(4000);

Agora, todas as propriedades têm um comprimento máximo de 512, exceto Content que foi explicitamente configurado com 4000:

EntityType: Post
  Properties:
    Id (int) Required PK AfterSave:Throw ValueGenerated.OnAdd
    AuthorId (no field, int?) Shadow FK Index
    BlogId (no field, int) Shadow Required FK Index
    Content (string) Required MaxLength(4000)
    Discriminator (no field, string) Shadow Required AfterSave:Throw MaxLength(512)
    PublishedOn (DateTime) Required
    Title (string) Required MaxLength(512)

Então, por que nossa convenção não substituiu o comprimento máximo explicitamente configurado? A resposta é que o EF Core acompanha como cada parte da configuração foi feita. Isso é representado pela enumeração ConfigurationSource. Os diferentes tipos de configuração são:

  • Explicit: o elemento de modelo foi explicitamente configurado no OnModelCreating
  • DataAnnotation: o elemento de modelo foi configurado usando um atributo de mapeamento (também conhecido como anotação de dados) no tipo CLR
  • Convention: o elemento de modelo foi configurado por uma convenção de construção de modelo

As convenções nunca substituem a configuração marcada como DataAnnotation ou Explicit. Isso é conseguido usando um "construtor de convenções", por exemplo, o IConventionPropertyBuilder, que é obtido a partir da propriedade Builder. Por exemplo:

property.Builder.HasMaxLength(512);

Chamar HasMaxLength no construtor de convenções só definirá o comprimento máximo se ele ainda não tiver sido configurado por um atributo de mapeamento ou em OnModelCreating.

Métodos de construtor como este também têm um segundo parâmetro: fromDataAnnotation. Defina isso como true se a convenção estiver fazendo a configuração em nome de um atributo de mapeamento. Por exemplo:

property.Builder.HasMaxLength(512, fromDataAnnotation: true);

Isso define o ConfigurationSource como DataAnnotation, o que significa que o valor agora pode ser substituído por mapeamento explícito em OnModelCreating, mas não por convenções de atributos que não sejam de mapeamento.

Finalmente, antes de deixarmos este exemplo, o que acontece se usarmos o MaxStringLengthConvention e o DiscriminatorLengthConvention3 ao mesmo tempo? A resposta é que depende de qual ordem eles são adicionados, já que as convenções de finalização de modelo são executadas na ordem em que são adicionadas. Portanto, se MaxStringLengthConvention for adicionado por último, ele será executado por último e definirá o comprimento máximo da propriedade discriminatória como 512. Portanto, nesse caso, é melhor adicionar DiscriminatorLengthConvention3 último para que ele possa substituir o comprimento máximo padrão para apenas propriedades discriminatórias, deixando todas as outras propriedades de cadeia de caracteres como 512.

Substituindo uma convenção existente

Às vezes, em vez de remover completamente uma convenção existente, queremos substituí-la por uma convenção que faz basicamente a mesma coisa, mas com comportamento alterado. Isso é útil porque a convenção existente já implementará as interfaces necessárias para ser acionada adequadamente.

Exemplo: mapeamento de propriedades de aceitação

O EF Core mapeia todas as propriedades públicas de leitura/gravação por convenção. Isso pode não ser apropriado para a maneira como seus tipos de entidade são definidos. Para alterar isso, podemos substituir o PropertyDiscoveryConvention por nossa própria implementação que não mapeia nenhuma propriedade, a menos que seja explicitamente mapeada em OnModelCreating ou marcada com um novo atributo chamado Persist:

[AttributeUsage(AttributeTargets.Property | AttributeTargets.Field)]
public sealed class PersistAttribute : Attribute
{
}

Essa é a nova convenção:

public class AttributeBasedPropertyDiscoveryConvention : PropertyDiscoveryConvention
{
    public AttributeBasedPropertyDiscoveryConvention(ProviderConventionSetBuilderDependencies dependencies)
        : base(dependencies)
    {
    }

    public override void ProcessEntityTypeAdded(
        IConventionEntityTypeBuilder entityTypeBuilder,
        IConventionContext<IConventionEntityTypeBuilder> context)
        => Process(entityTypeBuilder);

    public override void ProcessEntityTypeBaseTypeChanged(
        IConventionEntityTypeBuilder entityTypeBuilder,
        IConventionEntityType? newBaseType,
        IConventionEntityType? oldBaseType,
        IConventionContext<IConventionEntityType> context)
    {
        if ((newBaseType == null
             || oldBaseType != null)
            && entityTypeBuilder.Metadata.BaseType == newBaseType)
        {
            Process(entityTypeBuilder);
        }
    }

    private void Process(IConventionEntityTypeBuilder entityTypeBuilder)
    {
        foreach (var memberInfo in GetRuntimeMembers())
        {
            if (Attribute.IsDefined(memberInfo, typeof(PersistAttribute), inherit: true))
            {
                entityTypeBuilder.Property(memberInfo);
            }
            else if (memberInfo is PropertyInfo propertyInfo
                     && Dependencies.TypeMappingSource.FindMapping(propertyInfo) != null)
            {
                entityTypeBuilder.Ignore(propertyInfo.Name);
            }
        }

        IEnumerable<MemberInfo> GetRuntimeMembers()
        {
            var clrType = entityTypeBuilder.Metadata.ClrType;

            foreach (var property in clrType.GetRuntimeProperties()
                         .Where(p => p.GetMethod != null && !p.GetMethod.IsStatic))
            {
                yield return property;
            }

            foreach (var property in clrType.GetRuntimeFields())
            {
                yield return property;
            }
        }
    }
}

Dica

Ao substituir uma convenção interna, a nova implementação de convenção deve herdar da classe de convenção existente. Observe que algumas convenções têm implementações relacionais ou específicas do provedor, caso em que a nova implementação de convenção deve herdar da classe de convenção existente mais específica para o provedor de banco de dados em uso.

A convenção é então registrada usando o método Replace em ConfigureConventions:

protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
{
    configurationBuilder.Conventions.Replace<PropertyDiscoveryConvention>(
        serviceProvider => new AttributeBasedPropertyDiscoveryConvention(
            serviceProvider.GetRequiredService<ProviderConventionSetBuilderDependencies>()));
}

Dica

Este é um caso em que a convenção existente tem dependências, representadas pelo objeto de dependência ProviderConventionSetBuilderDependencies. Eles são obtidos do provedor de serviços interno usando GetRequiredService e passados para o construtor da convenção.

Essa convenção funciona obtendo todas as propriedades e campos legíveis do tipo de entidade fornecido. Se o membro for atribuído a [Persist], ele será mapeado chamando:

entityTypeBuilder.Property(memberInfo);

Por outro lado, se o membro é uma propriedade que de outra forma teria sido mapeada, então ele é excluído do modelo usando:

entityTypeBuilder.Ignore(propertyInfo.Name);

Observe que essa convenção permite que os campos sejam mapeados (além das propriedades) desde que sejam marcados com [Persist]. Isso significa que podemos usar campos privados como chaves ocultas no modelo.

Por exemplo, considere os seguintes tipos de entidade:

public class LaundryBasket
{
    [Persist]
    [Key]
    private readonly int _id;

    [Persist]
    public int TenantId { get; init; }

    public bool IsClean { get; set; }

    public List<Garment> Garments { get; } = new();
}

public class Garment
{
    public Garment(string name, string color)
    {
        Name = name;
        Color = color;
    }

    [Persist]
    [Key]
    private readonly int _id;

    [Persist]
    public int TenantId { get; init; }

    [Persist]
    public string Name { get; }

    [Persist]
    public string Color { get; }

    public bool IsClean { get; set; }

    public LaundryBasket? Basket { get; set; }
}

O modelo criado a partir desses tipos de entidade é:

Model:
  EntityType: Garment
    Properties:
      _id (_id, int) Required PK AfterSave:Throw ValueGenerated.OnAdd
      Basket_id (no field, int?) Shadow FK Index
      Color (string) Required
      Name (string) Required
      TenantId (int) Required
    Navigations:
      Basket (LaundryBasket) ToPrincipal LaundryBasket Inverse: Garments
    Keys:
      _id PK
    Foreign keys:
      Garment {'Basket_id'} -> LaundryBasket {'_id'} ToDependent: Garments ToPrincipal: Basket ClientSetNull
    Indexes:
      Basket_id
  EntityType: LaundryBasket
    Properties:
      _id (_id, int) Required PK AfterSave:Throw ValueGenerated.OnAdd
      TenantId (int) Required
    Navigations:
      Garments (List<Garment>) Collection ToDependent Garment Inverse: Basket
    Keys:
      _id PK

Observe que, normalmente, IsClean teria sido mapeada, mas como não está marcada com [Perist] (presumivelmente porque a limpeza não é uma propriedade persistente da lavanderia), agora é tratada como uma propriedade não mapeada.

Dica

Essa convenção não pôde ser implementada como uma convenção de finalização de modelo porque o mapeamento de uma propriedade aciona muitas outras convenções a serem executadas para configurar ainda mais a propriedade mapeada.

Mapeamento de procedimento armazenado

Por padrão, o EF Core gera comandos de inserção, atualização e exclusão que funcionam diretamente com tabelas ou exibições atualizáveis. O EF7 apresenta suporte para mapeamento desses comandos para procedimentos armazenados.

Dica

O EF Core sempre deu suporte à consulta por meio de procedimentos armazenados. O novo suporte no EF7 é explicitamente sobre o uso de procedimentos armazenados para inserções, atualizações e exclusões.

Importante

O suporte para mapeamento de procedimento armazenado não implica que procedimentos armazenados sejam recomendados.

Os procedimentos armazenados são mapeados em OnModelCreating usando InsertUsingStoredProcedure, UpdateUsingStoredProcedure e DeleteUsingStoredProcedure. Por exemplo, para mapear procedimentos armazenados para um tipo de entidade Person:

modelBuilder.Entity<Person>()
    .InsertUsingStoredProcedure(
        "People_Insert",
        storedProcedureBuilder =>
        {
            storedProcedureBuilder.HasParameter(a => a.Name);
            storedProcedureBuilder.HasResultColumn(a => a.Id);
        })
    .UpdateUsingStoredProcedure(
        "People_Update",
        storedProcedureBuilder =>
        {
            storedProcedureBuilder.HasOriginalValueParameter(person => person.Id);
            storedProcedureBuilder.HasOriginalValueParameter(person => person.Name);
            storedProcedureBuilder.HasParameter(person => person.Name);
            storedProcedureBuilder.HasRowsAffectedResultColumn();
        })
    .DeleteUsingStoredProcedure(
        "People_Delete",
        storedProcedureBuilder =>
        {
            storedProcedureBuilder.HasOriginalValueParameter(person => person.Id);
            storedProcedureBuilder.HasOriginalValueParameter(person => person.Name);
            storedProcedureBuilder.HasRowsAffectedResultColumn();
        });

Essa configuração mapeia para os seguintes procedimentos armazenados ao usar o SQL Server:

Para inserções

CREATE PROCEDURE [dbo].[People_Insert]
    @Name [nvarchar](max)
AS
BEGIN
      INSERT INTO [People] ([Name])
      OUTPUT INSERTED.[Id]
      VALUES (@Name);
END

Para atualizações

CREATE PROCEDURE [dbo].[People_Update]
    @Id [int],
    @Name_Original [nvarchar](max),
    @Name [nvarchar](max)
AS
BEGIN
    UPDATE [People] SET [Name] = @Name
    WHERE [Id] = @Id AND [Name] = @Name_Original
    SELECT @@ROWCOUNT
END

Para exclusões

CREATE PROCEDURE [dbo].[People_Delete]
    @Id [int],
    @Name_Original [nvarchar](max)
AS
BEGIN
    DELETE FROM [People]
    OUTPUT 1
    WHERE [Id] = @Id AND [Name] = @Name_Original;
END

Dica

Os procedimentos armazenados não precisam ser usados para cada tipo no modelo ou para todas as operações em um determinado tipo. Por exemplo, se apenas DeleteUsingStoredProcedure for especificado para um determinado tipo, o EF Core gerará SQL normalmente para operações de inserção e atualização e usará apenas o procedimento armazenado para exclusões.

O primeiro argumento passado para cada método é o nome do procedimento armazenado. Isso pode ser omitido, caso em que o EF Core usará o nome da tabela anexado com "_Insert", "_Update" ou "_Delete". Assim, no exemplo acima, como a tabela é chamada de "Pessoas", os nomes de procedimentos armazenados podem ser removidos sem alteração na funcionalidade.

O segundo argumento é um construtor usado para configurar a entrada e a saída do procedimento armazenado, incluindo parâmetros, valores de retorno e colunas de resultado.

Parâmetros

Os parâmetros devem ser adicionados ao construtor na mesma ordem em que aparecem na definição do procedimento armazenado.

Observação

Os parâmetros podem ser nomeados, mas o EF Core sempre chama procedimentos armazenados usando argumentos posicionais em vez de argumentos nomeados. Vote em Permitir configurar o mapeamento sproc para usar nomes de parâmetros para invocação se chamar pelo nome for algo em que você esteja interessado(a).

O primeiro argumento para cada método do construtor de parâmetros especifica a propriedade no modelo ao qual o parâmetro está vinculado. Esta pode ser uma expressão lambda:

storedProcedureBuilder.HasParameter(a => a.Name);

Ou uma cadeia de caracteres, que é particularmente útil ao mapear propriedades de sombra:

storedProcedureBuilder.HasParameter("Name");

Os parâmetros são, por padrão, configurados para "entrada". Os parâmetros "saída" ou "entrada/saída" podem ser configurados usando um construtor aninhado. Por exemplo:

storedProcedureBuilder.HasParameter(
    document => document.RetrievedOn, 
    parameterBuilder => parameterBuilder.IsOutput());

Existem três métodos de construtor diferentes para diferentes sabores de parâmetros:

  • HasParameter especifica um parâmetro normal vinculado ao valor atual da propriedade fornecida.
  • HasOriginalValueParameter especifica um parâmetro vinculado ao valor original da propriedade fornecida. O valor original é o valor que a propriedade tinha quando foi consultada a partir do banco de dados, se conhecido. Se esse valor não for conhecido, o valor atual será usado. Os parâmetros de valor originais são úteis para tokens de simultaneidade.
  • HasRowsAffectedParameter especifica um parâmetro usado para retornar o número de linhas afetadas pelo procedimento armazenado.

Dica

Os parâmetros de valor originais devem ser usados para valores de chave em procedimentos armazenados "update" e "delete". Isso garante que a linha correta será atualizada em versões futuras do EF Core que oferecem suporte a valores de chave mutáveis.

Retornando valores

O EF Core oferece suporte a três mecanismos para retornar valores de procedimentos armazenados:

  • Parâmetros de saída, como mostrado acima.
  • Colunas de resultado, que são especificadas usando o método construtor HasResultColumn.
  • O valor de retorno, que é limitado a retornar o número de linhas afetadas e é especificado usando o método construtor HasRowsAffectedReturnValue.

Os valores retornados de procedimentos armazenados geralmente são usados para valores gerados, padrão ou computados, como de uma chave Identity ou de uma coluna calculada. Por exemplo, a configuração a seguir especifica quatro colunas de resultado:

entityTypeBuilder.InsertUsingStoredProcedure(
        storedProcedureBuilder =>
        {
            storedProcedureBuilder.HasParameter(document => document.Title);
            storedProcedureBuilder.HasResultColumn(document => document.Id);
            storedProcedureBuilder.HasResultColumn(document => document.FirstRecordedOn);
            storedProcedureBuilder.HasResultColumn(document => document.RetrievedOn);
            storedProcedureBuilder.HasResultColumn(document => document.RowVersion);
        });

Estes são usados para retornar:

  • O valor da chave gerada para a propriedade Id.
  • O valor padrão gerado pelo banco de dados para a propriedade FirstRecordedOn.
  • O valor calculado gerado pelo banco de dados para a propriedade RetrievedOn.
  • O token de simultaneidade de rowversion gerado automaticamente para a propriedade RowVersion.

Essa configuração mapeia para o seguinte procedimento armazenado ao usar o SQL Server:

CREATE PROCEDURE [dbo].[Documents_Insert]
    @Title [nvarchar](max)
AS
BEGIN
    INSERT INTO [Documents] ([Title])
    OUTPUT INSERTED.[Id], INSERTED.[FirstRecordedOn], INSERTED.[RetrievedOn], INSERTED.[RowVersion]
    VALUES (@Title);
END

Simultaneidade otimista

A simultaneidade otimista funciona da mesma forma com procedimentos armazenados do que sem os mesmos. O procedimento armazenado deve:

  • Use um token de simultaneidade em uma cláusula WHERE para garantir que a linha só seja atualizada se tiver um token válido. O valor usado para o token de simultaneidade é normalmente, mas não precisa ser, o valor original da propriedade de token de simultaneidade.
  • Retorne o número de linhas afetadas para que o EF Core possa comparar isso com o número esperado de linhas afetadas e lançar um DbUpdateConcurrencyException se os valores não corresponderem.

Por exemplo, o seguinte procedimento armazenado do SQL Server usa um token de simultaneidade automática rowversion:

CREATE PROCEDURE [dbo].[Documents_Update]
    @Id [int],
    @RowVersion_Original [rowversion],
    @Title [nvarchar](max),
    @RowVersion [rowversion] OUT
AS
BEGIN
    DECLARE @TempTable table ([RowVersion] varbinary(8));
    UPDATE [Documents] SET
        [Title] = @Title
    OUTPUT INSERTED.[RowVersion] INTO @TempTable
    WHERE [Id] = @Id AND [RowVersion] = @RowVersion_Original
    SELECT @@ROWCOUNT;
    SELECT @RowVersion = [RowVersion] FROM @TempTable;
END

Isso é configurado no EF Core usando:

.UpdateUsingStoredProcedure(
    storedProcedureBuilder =>
    {
        storedProcedureBuilder.HasOriginalValueParameter(document => document.Id);
        storedProcedureBuilder.HasOriginalValueParameter(document => document.RowVersion);
        storedProcedureBuilder.HasParameter(document => document.Title);
        storedProcedureBuilder.HasParameter(document => document.RowVersion, parameterBuilder => parameterBuilder.IsOutput());
        storedProcedureBuilder.HasRowsAffectedResultColumn();
    });

Observe que:

  • O valor original do token de simultaneidade RowVersion é usado.
  • O procedimento armazenado usa uma cláusula WHERE para garantir que a linha seja atualizada somente se o valor original RowVersion corresponder.
  • O novo valor gerado para o RowVersion é inserido em uma tabela temporária.
  • O número de linhas afetadas (@@ROWCOUNT) e o valor RowVersion gerado são retornados.

Mapeando hierarquias de herança para procedimentos armazenados

O EF Core exige que os procedimentos armazenados sigam o layout da tabela para tipos em uma hierarquia. Isso significa que:

  • Uma hierarquia mapeada usando TPH deve ter um único procedimento armazenado de inserção, atualização e/ou exclusão direcionado à única tabela mapeada. Os procedimentos armazenados de inserção e atualização devem ter um parâmetro para o valor discriminatório.
  • Uma hierarquia mapeada usando a TPT deve ter um procedimento armazenado de inserção, atualização e/ou exclusão para cada tipo, incluindo tipos abstratos. O EF Core fará várias chamadas conforme necessário para atualizar, inserir e excluir linhas em todas as tabelas.
  • Uma hierarquia mapeada usando a TPC deve ter um procedimento armazenado de inserção, atualização e/ou exclusão para cada tipo concreto, mas não para tipos abstratos.

Observação

Se o uso de um único procedimento armazenado por tipo concreto, independentemente da estratégia de mapeamento, for algo em que você esteja interessado, vote em Suporte usando um único sproc por tipo concreto, independentemente da estratégia de mapeamento de herança.

Mapeando tipos próprios para procedimentos armazenados

A configuração de procedimentos armazenados para tipos próprios é feita no construtor de tipos de propriedade aninhada. Por exemplo:

modelBuilder.Entity<Person>(
    entityTypeBuilder =>
    {
        entityTypeBuilder.OwnsOne(
            author => author.Contact,
            ownedNavigationBuilder =>
            {
                ownedNavigationBuilder.ToTable("Contacts");
                ownedNavigationBuilder
                    .InsertUsingStoredProcedure(
                        storedProcedureBuilder =>
                        {
                            storedProcedureBuilder.HasParameter("PersonId");
                            storedProcedureBuilder.HasParameter(contactDetails => contactDetails.Phone);
                        })
                    .UpdateUsingStoredProcedure(
                        storedProcedureBuilder =>
                        {
                            storedProcedureBuilder.HasOriginalValueParameter("PersonId");
                            storedProcedureBuilder.HasParameter(contactDetails => contactDetails.Phone);
                            storedProcedureBuilder.HasRowsAffectedResultColumn();
                        })
                    .DeleteUsingStoredProcedure(
                        storedProcedureBuilder =>
                        {
                            storedProcedureBuilder.HasOriginalValueParameter("PersonId");
                            storedProcedureBuilder.HasRowsAffectedResultColumn();
            });
    });

Observação

Atualmente, os procedimentos armazenados para inserir, atualizar e excluir somente tipos de propriedade de suporte devem ser mapeados para tabelas separadas. Ou seja, o tipo de propriedade não pode ser representado por colunas na tabela de proprietário. Vote em Adicionar suporte de divisão de "tabela" ao mapeamento sproc CUD se essa for uma limitação que você gostaria de ver removida.

Mapeando entidades de junção de muitos para muitos para procedimentos armazenados

A configuração de entidades de junção de muitos para muitos de procedimentos armazenados pode ser executada como parte da configuração de muitos para muitos. Por exemplo:

modelBuilder.Entity<Book>(
    entityTypeBuilder =>
    {
        entityTypeBuilder
            .HasMany(document => document.Authors)
            .WithMany(author => author.PublishedWorks)
            .UsingEntity<Dictionary<string, object>>(
                "BookPerson",
                builder => builder.HasOne<Person>().WithMany().OnDelete(DeleteBehavior.Cascade),
                builder => builder.HasOne<Book>().WithMany().OnDelete(DeleteBehavior.ClientCascade),
                joinTypeBuilder =>
                {
                    joinTypeBuilder
                        .InsertUsingStoredProcedure(
                            storedProcedureBuilder =>
                            {
                                storedProcedureBuilder.HasParameter("AuthorsId");
                                storedProcedureBuilder.HasParameter("PublishedWorksId");
                            })
                        .DeleteUsingStoredProcedure(
                            storedProcedureBuilder =>
                            {
                                storedProcedureBuilder.HasOriginalValueParameter("AuthorsId");
                                storedProcedureBuilder.HasOriginalValueParameter("PublishedWorksId");
                                storedProcedureBuilder.HasRowsAffectedResultColumn();
                            });
                });
    });

Interceptores e eventos novos e aprimorados

Os interceptores do EF Core permitem a interceptação, modificação e/ou supressão de operações do EF Core. O EF Core também inclui eventos .NET tradicionais e registro em log.

O EF7 inclui os seguintes aprimoramentos nos interceptadores:

Além disso, o EF7 inclui novos eventos tradicionais do .NET para:

As seções a seguir mostram alguns exemplos de uso desses novos recursos de interceptação.

Ações simples na criação de entidades

Dica

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

O novo IMaterializationInterceptor oferece suporte à interceptação antes e depois da criação de uma instância de entidade e antes e depois que as propriedades dessa instância são inicializadas. O interceptador pode alterar ou substituir a instância da entidade em cada ponto. Permite:

  • Definir propriedades não mapeadas ou métodos de chamada necessários para validação, valores computados ou sinalizadores.
  • Usando uma fábrica para criar instâncias.
  • A criação de uma instância de entidade diferente da que o EF normalmente criaria, como uma instância de um cache ou de um tipo de proxy.
  • Injetando serviços em uma instância de entidade.

Por exemplo, imagine que queremos controlar o tempo em que uma entidade foi recuperada do banco de dados, talvez para que ela possa ser exibida para um usuário que edita os dados. Para conseguir isso, primeiro definimos uma interface:

public interface IHasRetrieved
{
    DateTime Retrieved { get; set; }
}

O uso de uma interface é comum com interceptadores, pois permite que o mesmo interceptador trabalhe com muitos tipos de entidades diferentes. Por exemplo:

public class Customer : IHasRetrieved
{
    public int Id { get; set; }
    public string Name { get; set; } = null!;
    public string? PhoneNumber { get; set; }

    [NotMapped]
    public DateTime Retrieved { get; set; }
}

Observe que o atributo [NotMapped] é usado para indicar que essa propriedade é usada somente durante o trabalho com a entidade e não deve ser persistente no banco de dados.

O interceptador deve então implementar o método apropriado a partir de IMaterializationInterceptor e definir o tempo recuperado:

public class SetRetrievedInterceptor : IMaterializationInterceptor
{
    public object InitializedInstance(MaterializationInterceptionData materializationData, object instance)
    {
        if (instance is IHasRetrieved hasRetrieved)
        {
            hasRetrieved.Retrieved = DateTime.UtcNow;
        }

        return instance;
    }
}

Uma instância desse interceptador é registrada ao configurar o DbContext:

public class CustomerContext : DbContext
{
    private static readonly SetRetrievedInterceptor _setRetrievedInterceptor = new();

    public DbSet<Customer> Customers
        => Set<Customer>();

    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
        => optionsBuilder
            .AddInterceptors(_setRetrievedInterceptor)
            .UseSqlite("Data Source = customers.db");
}

Dica

Esse interceptador é sem monitoração de estado, o que é comum, portanto, uma única instância é criada e compartilhada entre todas as instâncias DbContext.

Agora, sempre que um Customer for consultado a partir do banco de dados, a propriedade Retrieved será definida automaticamente. Por exemplo:

await using (var context = new CustomerContext())
{
    var customer = await context.Customers.SingleAsync(e => e.Name == "Alice");
    Console.WriteLine($"Customer '{customer.Name}' was retrieved at '{customer.Retrieved.ToLocalTime()}'");
}

Produz saída:

Customer 'Alice' was retrieved at '9/22/2022 5:25:54 PM'

Injetar serviços em entidades

Dica

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

O EF Core já tem suporte interno para injetar alguns serviços especiais em instâncias de contexto; por exemplo, consulte Carregamento lento sem proxies, que funciona injetando o serviço ILazyLoader.

Um IMaterializationInterceptor pode ser usado para generalizar isso para qualquer serviço. O exemplo a seguir mostra como injetar um ILogger em entidades para que elas possam executar seu próprio registro em log.

Observação

A injeção de serviços em entidades acopla esses tipos de entidades aos serviços injetados, o que algumas pessoas consideram um antipadrão.

Como antes, uma interface é usada para definir o que pode ser feito.

public interface IHasLogger
{
    ILogger? Logger { get; set; }
}

E os tipos de entidade que registrarão em log devem implementar essa interface. Por exemplo:

public class Customer : IHasLogger
{
    private string? _phoneNumber;

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

    public string? PhoneNumber
    {
        get => _phoneNumber;
        set
        {
            Logger?.LogInformation(1, $"Updating phone number for '{Name}' from '{_phoneNumber}' to '{value}'.");

            _phoneNumber = value;
        }
    }

    [NotMapped]
    public ILogger? Logger { get; set; }
}

Desta vez, o interceptador deve implementar IMaterializationInterceptor.InitializedInstance, que é chamado depois que cada instância de entidade foi criada e seus valores de propriedade foram inicializados. O interceptor obtém uma ILogger do contexto e inicializa IHasLogger.Logger com ele:

public class LoggerInjectionInterceptor : IMaterializationInterceptor
{
    private ILogger? _logger;

    public object InitializedInstance(MaterializationInterceptionData materializationData, object instance)
    {
        if (instance is IHasLogger hasLogger)
        {
            _logger ??= materializationData.Context.GetService<ILoggerFactory>().CreateLogger("CustomersLogger");
            hasLogger.Logger = _logger;
        }

        return instance;
    }
}

Desta vez, uma nova instância do interceptor é usada para cada instância DbContext, uma vez que o ILogger obtido pode mudar por DbContext instância e o ILogger é armazenado em cache no interceptador:

protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    => optionsBuilder.AddInterceptors(new LoggerInjectionInterceptor());

Agora, sempre que o Customer.PhoneNumber for alterado, essa alteração será registrada no log do aplicativo. Por exemplo:

info: CustomersLogger[1]
      Updating phone number for 'Alice' from '+1 515 555 0123' to '+1 515 555 0125'.

Interceptação da árvore de expressão LINQ

Dica

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

O EF Core usa consultas LINQ do .NET. Isso normalmente envolve o uso do compilador C#, VB ou F# para criar uma árvore de expressões que é convertida pelo EF Core no SQL apropriado. Por exemplo, considere um método que retorna uma página de clientes:

Task<List<Customer>> GetPageOfCustomers(string sortProperty, int page)
{
    using var context = new CustomerContext();

    return context.Customers
        .OrderBy(e => EF.Property<object>(e, sortProperty))
        .Skip(page * 20).Take(20).ToListAsync();
}

Dica

Essa consulta usa o método EF.Property para especificar a propriedade pela qual classificar. Isso permite que o aplicativo passe dinamicamente o nome da propriedade, permitindo a classificação por qualquer propriedade do tipo de entidade. Lembre-se de que a classificação por colunas não indexadas pode ser lenta.

Isso funcionará bem, desde que a propriedade usada para classificação sempre retorne uma ordem estável. Mas nem sempre é assim. Por exemplo, a consulta LINQ acima gera o seguinte no SQLite ao ordenar por Customer.City:

SELECT "c"."Id", "c"."City", "c"."Name", "c"."PhoneNumber"
FROM "Customers" AS "c"
ORDER BY "c"."City"
LIMIT @__p_1 OFFSET @__p_0

Se houver vários clientes com o mesmo City, a ordem dessa consulta não será estável. Isso pode levar a resultados ausentes ou duplicados à medida que o usuário percorre os dados.

Uma maneira comum de corrigir esse problema é executar uma classificação secundária por chave primária. No entanto, em vez de adicionar manualmente isso a cada consulta, o EF7 permite a interceptação da árvore de expressão de consulta onde a ordem secundária pode ser adicionada dinamicamente. Para facilitar isso, usaremos novamente uma interface, desta vez para qualquer entidade que tenha uma chave primária inteira:

public interface IHasIntKey
{
    int Id { get; }
}

Essa interface é implementada pelos tipos de entidade de interesse:

public class Customer : IHasIntKey
{
    public int Id { get; set; }
    public string Name { get; set; } = null!;
    public string? City { get; set; }
    public string? PhoneNumber { get; set; }
}

Precisamos, então, de um interceptor que implemente IQueryExpressionInterceptor

public class KeyOrderingExpressionInterceptor : IQueryExpressionInterceptor
{
    public Expression QueryCompilationStarting(Expression queryExpression, QueryExpressionEventData eventData)
        => new KeyOrderingExpressionVisitor().Visit(queryExpression);

    private class KeyOrderingExpressionVisitor : ExpressionVisitor
    {
        private static readonly MethodInfo ThenByMethod
            = typeof(Queryable).GetMethods()
                .Single(m => m.Name == nameof(Queryable.ThenBy) && m.GetParameters().Length == 2);

        protected override Expression VisitMethodCall(MethodCallExpression? methodCallExpression)
        {
            var methodInfo = methodCallExpression!.Method;
            if (methodInfo.DeclaringType == typeof(Queryable)
                && methodInfo.Name == nameof(Queryable.OrderBy)
                && methodInfo.GetParameters().Length == 2)
            {
                var sourceType = methodCallExpression.Type.GetGenericArguments()[0];
                if (typeof(IHasIntKey).IsAssignableFrom(sourceType))
                {
                    var lambdaExpression = (LambdaExpression)((UnaryExpression)methodCallExpression.Arguments[1]).Operand;
                    var entityParameterExpression = lambdaExpression.Parameters[0];

                    return Expression.Call(
                        ThenByMethod.MakeGenericMethod(
                            sourceType,
                            typeof(int)),
                        base.VisitMethodCall(methodCallExpression),
                        Expression.Lambda(
                            typeof(Func<,>).MakeGenericType(entityParameterExpression.Type, typeof(int)),
                            Expression.Property(entityParameterExpression, nameof(IHasIntKey.Id)),
                            entityParameterExpression));
                }
            }

            return base.VisitMethodCall(methodCallExpression);
        }
    }
}

Isso provavelmente parece muito complicado - e é! Trabalhar com árvores de expressão normalmente não é fácil. Vejamos o que está acontecendo:

  • Fundamentalmente, o interceptor encapsula um ExpressionVisitor. O visitante substitui VisitMethodCall, que será chamado sempre que houver uma chamada para um método na árvore de expressão de consulta.

  • O visitante verifica se esta é ou não uma chamada para o método OrderBy que nos interessa.

  • Se for, o visitante verifica ainda mais se a chamada de método genérico é para um tipo que implementa nossa interface IHasIntKey.

  • Neste ponto, sabemos que a chamada de método é da forma OrderBy(e => ...). Extraímos a expressão lambda dessa chamada e obtemos o parâmetro usado nessa expressão, ou seja, o e.

  • Agora criamos um novo MethodCallExpression usando o método construtor Expression.Call. Nesse caso, o método que está sendo chamado é ThenBy(e => e.Id). Nós construímos isso usando o parâmetro extraído acima e uma propriedade de acesso à propriedade Id da interface IHasIntKey.

  • A entrada nessa chamada é o OrderBy(e => ...) original e, portanto, o resultado final é uma expressão para OrderBy(e => ...).ThenBy(e => e.Id).

  • Essa expressão modificada é retornada do visitante, o que significa que a consulta LINQ agora foi modificada adequadamente para incluir uma chamada ThenBy.

  • O EF Core continua e compila essa expressão de consulta no SQL apropriado para o banco de dados que está sendo usado.

Esse interceptador é registrado da mesma forma que fizemos no primeiro exemplo. A execução GetPageOfCustomers agora gera o seguinte SQL:

SELECT "c"."Id", "c"."City", "c"."Name", "c"."PhoneNumber"
FROM "Customers" AS "c"
ORDER BY "c"."City", "c"."Id"
LIMIT @__p_1 OFFSET @__p_0

Isso agora sempre produzirá um pedido estável, mesmo que haja vários clientes com o mesmo City.

Ufa! Isso é muito código para fazer uma alteração simples em uma consulta. E pior ainda, pode nem funcionar para todas as consultas. É notoriamente difícil escrever uma expressão que reconheça todas as formas de consulta que deveria, e nenhuma das que não deveria. Por exemplo, isso provavelmente não funcionará se o pedido for feito em uma subconsulta.

Isso nos leva a um ponto crítico sobre interceptadores – sempre se pergunte se há uma maneira mais fácil de fazer o que você quer. Os interceptores são poderosos, mas é fácil errar as coisas. São, como diz o ditado, uma maneira fácil de dar um tiro no próprio pé.

Por exemplo, imagine se, em vez disso, mudássemos nosso método GetPageOfCustomers assim:

Task<List<Customer>> GetPageOfCustomers2(string sortProperty, int page)
{
    using var context = new CustomerContext();

    return context.Customers
        .OrderBy(e => EF.Property<object>(e, sortProperty))
        .ThenBy(e => e.Id)
        .Skip(page * 20).Take(20).ToListAsync();
}

Nesse caso, o ThenBy é simplesmente adicionado à consulta. Sim, pode ser necessário fazer separadamente para cada consulta, mas é simples, fácil de entender e sempre funcionará.

Interceptação de simultaneidade otimista

Dica

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

O EF Core oferece suporte ao padrão de simultaneidade otimista verificando se o número de linhas realmente afetadas por uma atualização ou exclusão é o mesmo que o número de linhas que se espera que sejam afetadas. Isso geralmente é acoplado a um token de simultaneidade; ou seja, um valor de coluna que só corresponderá ao seu valor esperado se a linha não tiver sido atualizada desde que o valor esperado foi lido.

O EF sinaliza uma violação da simultaneidade otimista ao lançar um DbUpdateConcurrencyException. No EF7, ISaveChangesInterceptor tem novos métodos ThrowingConcurrencyException e ThrowingConcurrencyExceptionAsync que são chamados antes que o DbUpdateConcurrencyException seja lançado. Esses pontos de interceptação permitem que a exceção seja suprimida, possivelmente juntamente com alterações de banco de dados assíncronas para resolver a violação.

Por exemplo, se duas solicitações tentarem excluir a mesma entidade quase ao mesmo tempo, a segunda exclusão poderá falhar porque a linha no banco de dados não existe mais. Isso pode ser bom - o resultado final é que a entidade foi excluída de qualquer maneira. O interceptor a seguir demonstra como isso pode ser feito:

public class SuppressDeleteConcurrencyInterceptor : ISaveChangesInterceptor
{
    public InterceptionResult ThrowingConcurrencyException(
        ConcurrencyExceptionEventData eventData,
        InterceptionResult result)
    {
        if (eventData.Entries.All(e => e.State == EntityState.Deleted))
        {
            Console.WriteLine("Suppressing Concurrency violation for command:");
            Console.WriteLine(((RelationalConcurrencyExceptionEventData)eventData).Command.CommandText);

            return InterceptionResult.Suppress();
        }

        return result;
    }

    public ValueTask<InterceptionResult> ThrowingConcurrencyExceptionAsync(
        ConcurrencyExceptionEventData eventData,
        InterceptionResult result,
        CancellationToken cancellationToken = default)
        => new(ThrowingConcurrencyException(eventData, result));
}

Há várias coisas que valem a pena observar sobre este interceptor:

  • Os métodos de interceptação síncrona e assíncrona são implementados. Isso é importante se o aplicativo pode chamar SaveChanges ou SaveChangesAsync. No entanto, se todo o código do aplicativo for assíncrono, somente ThrowingConcurrencyExceptionAsync precisará ser implementado. Da mesma forma, se o aplicativo nunca usa métodos de banco de dados assíncronos, apenas ThrowingConcurrencyException precisa ser implementado. Isso geralmente é verdadeiro para todos os interceptores com métodos de sincronização e assíncronos. (Talvez valha a pena implementar o método que seu aplicativo não usa para lançar, apenas no caso de algum código de sincronização/assíncrono entrar.)
  • O interceptador tem acesso a objetos EntityEntry para as entidades que estão sendo salvas. Nesse caso, isso é usado para verificar se a violação de simultaneidade está acontecendo ou não para uma operação de exclusão.
  • Se o aplicativo estiver usando um provedor de banco de dados relacional, o objeto ConcurrencyExceptionEventData poderá ser convertido em um objeto RelationalConcurrencyExceptionEventData. Isso fornece informações adicionais específicas relacionais sobre a operação de banco de dados que está sendo executada. Nesse caso, o texto do comando relacional é impresso no console.
  • O retorno InterceptionResult.Suppress() diz à EF Core para suprimir a ação que estava prestes a tomar - neste caso, lançando o DbUpdateConcurrencyException. Essa capacidade de alterar o comportamento do EF Core, em vez de apenas observar o que o EF Core está fazendo, é um dos recursos mais poderosos dos interceptadores.

Inicialização lenta de uma cadeia de conexão

Dica

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

As cadeias de conexão geralmente são ativos estáticos lidos de um arquivo de configuração. Eles podem ser facilmente passados para UseSqlServer ou similar ao configurar um DbContext. No entanto, às vezes, a cadeia de conexão pode ser alterada para cada instância de contexto. Por exemplo, cada locatário em um sistema multilocatário pode ter uma cadeia de conexão diferente.

O EF7 facilita o manuseio de conexões dinâmicas e cadeias de conexão por meio de melhorias no IDbConnectionInterceptor. Isso começa com a capacidade de configurar o DbContext sem qualquer cadeia de conexão. Por exemplo:

services.AddDbContext<CustomerContext>(
    b => b.UseSqlServer());

Um dos métodos IDbConnectionInterceptor pode ser implementado para configurar a conexão antes de ser usado. ConnectionOpeningAsync é uma boa escolha, já que pode executar uma operação assíncrona para obter a cadeia de conexão, encontrar um token de acesso e assim por diante. Por exemplo, imagine um serviço com escopo para a solicitação atual que compreenda o locatário atual:

services.AddScoped<ITenantConnectionStringFactory, TestTenantConnectionStringFactory>();

Aviso

Executar uma pesquisa assíncrona para uma cadeia de conexão, token de acesso ou similar toda vez que for necessário pode ser muito lento. Considere armazenar essas coisas em cache e atualizar apenas a cadeia de caracteres ou o token armazenado em cache periodicamente. Por exemplo, os tokens de acesso geralmente podem ser usados por um período significativo de tempo antes de precisarem ser atualizados.

Isso pode ser injetado em cada instância DbContext usando a injeção do construtor:

public class CustomerContext : DbContext
{
    private readonly ITenantConnectionStringFactory _connectionStringFactory;

    public CustomerContext(
        DbContextOptions<CustomerContext> options,
        ITenantConnectionStringFactory connectionStringFactory)
        : base(options)
    {
        _connectionStringFactory = connectionStringFactory;
    }

    // ...
}

Esse serviço é usado ao construir a implementação do interceptor para o contexto:

protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    => optionsBuilder.AddInterceptors(
        new ConnectionStringInitializationInterceptor(_connectionStringFactory));

Finalmente, o interceptador usa esse serviço para obter a cadeia de conexão de forma assíncrona e defini-la na primeira vez que a conexão é usada:

public class ConnectionStringInitializationInterceptor : DbConnectionInterceptor
{
    private readonly IClientConnectionStringFactory _connectionStringFactory;

    public ConnectionStringInitializationInterceptor(IClientConnectionStringFactory connectionStringFactory)
    {
        _connectionStringFactory = connectionStringFactory;
    }

    public override InterceptionResult ConnectionOpening(
        DbConnection connection,
        ConnectionEventData eventData,
        InterceptionResult result)
        => throw new NotSupportedException("Synchronous connections not supported.");

    public override async ValueTask<InterceptionResult> ConnectionOpeningAsync(
        DbConnection connection, ConnectionEventData eventData, InterceptionResult result,
        CancellationToken cancellationToken = new())
    {
        if (string.IsNullOrEmpty(connection.ConnectionString))
        {
            connection.ConnectionString = (await _connectionStringFactory.GetConnectionStringAsync(cancellationToken));
        }

        return result;
    }
}

Observação

A cadeia de conexão só é obtida na primeira vez que uma conexão é usada. Depois disso, a cadeia de conexão armazenada no DbConnection será usada sem procurar uma nova cadeia de conexão.

Dica

Esse interceptador substitui o método de ConnectionOpening não assíncrono a ser lançado, já que o serviço para obter a cadeia de conexão deve ser chamado de um caminho de código assíncrono.

Registrando estatísticas de consulta do SQL Server

Dica

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

Finalmente, vamos criar dois interceptadores que trabalham juntos para enviar estatísticas de consulta do SQL Server para o log do aplicativo. Para gerar as estatísticas, precisamos de uma IDbCommandInterceptor para fazer duas coisas.

Primeiro, o interceptador prefixará comandos com SET STATISTICS IO ON, que informa ao SQL Server para enviar estatísticas ao cliente depois que um conjunto de resultados for consumido:

public override ValueTask<InterceptionResult<DbDataReader>> ReaderExecutingAsync(
    DbCommand command,
    CommandEventData eventData,
    InterceptionResult<DbDataReader> result,
    CancellationToken cancellationToken = default)
{
    command.CommandText = "SET STATISTICS IO ON;" + Environment.NewLine + command.CommandText;

    return new(result);
}

Em segundo lugar, o interceptador implementará o novo método DataReaderClosingAsync, que é chamado depois que o DbDataReader terminou de consumir resultados, mas antes foi fechado. Quando o SQL Server está enviando estatísticas, ele as coloca em um segundo resultado no leitor, portanto, neste ponto, o interceptador lê esse resultado chamando NextResultAsync que preenche estatísticas na conexão.

public override async ValueTask<InterceptionResult> DataReaderClosingAsync(
    DbCommand command,
    DataReaderClosingEventData eventData,
    InterceptionResult result)
{
    await eventData.DataReader.NextResultAsync();

    return result;
}

O segundo interceptador é necessário para obter as estatísticas da conexão e gravá-las no log do aplicativo. Para isso, usaremos um IDbConnectionInterceptor, implementando o novo método ConnectionCreated. ConnectionCreated é chamado imediatamente após o EF Core ter criado uma conexão e, portanto, pode ser usado para executar configuração adicional dessa conexão. Nesse caso, o interceptador obtém um ILogger e, em seguida, conecta-se ao evento SqlConnection.InfoMessage para registrar as mensagens.

public override DbConnection ConnectionCreated(ConnectionCreatedEventData eventData, DbConnection result)
{
    var logger = eventData.Context!.GetService<ILoggerFactory>().CreateLogger("InfoMessageLogger");
    ((SqlConnection)eventData.Connection).InfoMessage += (_, args) =>
    {
        logger.LogInformation(1, args.Message);
    };
    return result;
}

Importante

Os métodos ConnectionCreating e ConnectionCreated só são chamados quando o EF Core cria um DbConnection. Eles não serão chamados se o aplicativo criar o DbConnection e passá-lo para o EF Core.

A execução de algum código que usa esses interceptores mostra estatísticas de consulta do SQL Server no log:

info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (4ms) [Parameters=[@p0='?' (Size = 4000), @p1='?' (Size = 4000), @p2='?' (Size = 4000), @p3='?' (Size = 4000)], CommandType='Text', CommandTimeout='30']
      SET STATISTICS IO ON;
      SET IMPLICIT_TRANSACTIONS OFF;
      SET NOCOUNT ON;
      MERGE [Customers] USING (
      VALUES (@p0, @p1, 0),
      (@p2, @p3, 1)) AS i ([Name], [PhoneNumber], _Position) ON 1=0
      WHEN NOT MATCHED THEN
      INSERT ([Name], [PhoneNumber])
      VALUES (i.[Name], i.[PhoneNumber])
      OUTPUT INSERTED.[Id], i._Position;
info: InfoMessageLogger[1]
      Table 'Customers'. Scan count 0, logical reads 5, physical reads 0, page server reads 0, read-ahead reads 0, page server read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob page server reads 0, lob read-ahead reads 0, lob page server read-ahead reads 0.
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (2ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
      SET STATISTICS IO ON;
      SELECT TOP(2) [c].[Id], [c].[Name], [c].[PhoneNumber]
      FROM [Customers] AS [c]
      WHERE [c].[Name] = N'Alice'
info: InfoMessageLogger[1]
      Table 'Customers'. Scan count 1, logical reads 2, physical reads 0, page server reads 0, read-ahead reads 0, page server read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob page server reads 0, lob read-ahead reads 0, lob page server read-ahead reads 0.

Aprimoramentos de consulta

O EF7 contém muitas melhorias na tradução de consultas LINQ.

GroupBy como operador final

Dica

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

O EF7 oferece suporte ao uso de GroupBy como operador final em uma consulta. Por exemplo, esta consulta LINQ:

var query = context.Books.GroupBy(s => s.Price);

Converte para o seguinte SQL ao usar o SQL Server:

SELECT [b].[Price], [b].[Id], [b].[AuthorId]
FROM [Books] AS [b]
ORDER BY [b].[Price]

Observação

Esse tipo de GroupBy não é convertido diretamente para SQL, portanto, o EF Core faz o agrupamento nos resultados retornados. No entanto, isso não resulta em nenhum dado adicional sendo transferido do servidor.

GroupJoin como operador final

Dica

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

O EF7 oferece suporte ao uso de GroupJoin como operador final em uma consulta. Por exemplo, esta consulta LINQ:

var query = context.Customers.GroupJoin(
    context.Orders, c => c.Id, o => o.CustomerId, (c, os) => new { Customer = c, Orders = os });

Converte para o seguinte SQL ao usar o SQL Server:

SELECT [c].[Id], [c].[Name], [t].[Id], [t].[Amount], [t].[CustomerId]
FROM [Customers] AS [c]
OUTER APPLY (
    SELECT [o].[Id], [o].[Amount], [o].[CustomerId]
    FROM [Orders] AS [o]
    WHERE [c].[Id] = [o].[CustomerId]
) AS [t]
ORDER BY [c].[Id]

Tipo de entidade GroupBy

Dica

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

O EF7 oferece suporte ao agrupamento por um tipo de entidade. Por exemplo, esta consulta LINQ:

var query = context.Books
    .GroupBy(s => s.Author)
    .Select(s => new { Author = s.Key, MaxPrice = s.Max(p => p.Price) });

Converte para o seguinte SQL ao usar SQLite:

SELECT [a].[Id], [a].[Name], MAX([b].[Price]) AS [MaxPrice]
FROM [Books] AS [b]
INNER JOIN [Author] AS [a] ON [b].[AuthorId] = [a].[Id]
GROUP BY [a].[Id], [a].[Name]

Lembre-se de que o agrupamento por uma propriedade exclusiva, como a chave primária, sempre será mais eficiente do que o agrupamento por um tipo de entidade. No entanto, o agrupamento por tipos de entidade pode ser usado para tipos de entidade com e sem chave.

Além disso, o agrupamento por um tipo de entidade com uma chave primária sempre resultará em um grupo por instância de entidade, já que cada entidade deve ter um valor de chave exclusivo. Às vezes, vale a pena alternar a origem da consulta para que o agrupamento não seja necessário. Por exemplo, a consulta a seguir retorna os mesmos resultados da consulta anterior:

var query = context.Authors
    .Select(a => new { Author = a, MaxPrice = a.Books.Max(b => b.Price) });

Essa consulta se converte no seguinte SQL ao usar o SQLite:

SELECT [a].[Id], [a].[Name], (
    SELECT MAX([b].[Price])
    FROM [Books] AS [b]
    WHERE [a].[Id] = [b].[AuthorId]) AS [MaxPrice]
FROM [Authors] AS [a]

As subconsultas não fazem referência a colunas desagrupadas da consulta externa

Dica

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

No EF Core 6.0, uma cláusula GROUP BY faria referência a colunas na consulta externa, que falha em alguns bancos de dados e é ineficiente em outros. Por exemplo, considere a seguinte consulta:

var query = from s in (from i in context.Invoices
                       group i by i.History.Month
                       into g
                       select new { Month = g.Key, Total = g.Sum(p => p.Amount), })
            select new
            {
                s.Month, s.Total, Payment = context.Payments.Where(p => p.History.Month == s.Month).Sum(p => p.Amount)
            };

No EF Core 6.0 no SQL Server, isso foi traduzido para:

SELECT DATEPART(month, [i].[History]) AS [Month], COALESCE(SUM([i].[Amount]), 0.0) AS [Total], (
    SELECT COALESCE(SUM([p].[Amount]), 0.0)
    FROM [Payments] AS [p]
    WHERE DATEPART(month, [p].[History]) = DATEPART(month, [i].[History])) AS [Payment]
FROM [Invoices] AS [i]
GROUP BY DATEPART(month, [i].[History])

No EF7, a tradução é:

SELECT [t].[Key] AS [Month], COALESCE(SUM([t].[Amount]), 0.0) AS [Total], (
    SELECT COALESCE(SUM([p].[Amount]), 0.0)
    FROM [Payments] AS [p]
    WHERE DATEPART(month, [p].[History]) = [t].[Key]) AS [Payment]
FROM (
    SELECT [i].[Amount], DATEPART(month, [i].[History]) AS [Key]
    FROM [Invoices] AS [i]
) AS [t]
GROUP BY [t].[Key]

Coleções somente leitura podem ser usadas para Contains

Dica

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

O EF7 oferece suporte ao uso de Contains quando os itens a serem pesquisados estão contidos em um IReadOnlySet ou IReadOnlyCollection ou IReadOnlyList. Por exemplo, esta consulta LINQ:

IReadOnlySet<int> searchIds = new HashSet<int> { 1, 3, 5 };
var query = context.Customers.Where(p => p.Orders.Any(l => searchIds.Contains(l.Id)));

Converte para o seguinte SQL ao usar o SQL Server:

SELECT [c].[Id], [c].[Name]
FROM [Customers] AS [c]
WHERE EXISTS (
    SELECT 1
    FROM [Orders] AS [o]
    WHERE [c].[Id] = [o].[Customer1Id] AND [o].[Id] IN (1, 3, 5))

Traduções para funções agregadas

O EF7 introduz melhor extensibilidade para os provedores traduzirem funções de agregação. Este e outros trabalhos nesta área resultaram em várias novas traduções entre provedores, incluindo:

Observação

As funções agregadas que atuam em IEnumerable argumento normalmente são traduzidas apenas em consultas GroupBy. Vote em Tipos espaciais de suporte em colunas JSON se você estiver interessado(a) em remover essa limitação.

Funções de agregação de cadeia de caracteres

Dica

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

As consultas que usam Join e Concat agora são traduzidas quando apropriado. Por exemplo:

var query = context.Posts
    .GroupBy(post => post.Author)
    .Select(grouping => new { Author = grouping.Key, Books = string.Join("|", grouping.Select(post => post.Title)) });

Essa consulta se traduz no seguinte ao usar o SQL Server:

SELECT [a].[Id], [a].[Name], COALESCE(STRING_AGG([p].[Title], N'|'), N'') AS [Books]
FROM [Posts] AS [p]
LEFT JOIN [Authors] AS [a] ON [p].[AuthorId] = [a].[Id]
GROUP BY [a].[Id], [a].[Name]

Quando combinadas com outras funções de cadeia de caracteres, essas traduções permitem alguma manipulação complexa de cadeia de caracteres no servidor. Por exemplo:

var query = context.Posts
    .GroupBy(post => post.Author!.Name)
    .Select(
        grouping =>
            new
            {
                PostAuthor = grouping.Key,
                Blogs = string.Concat(
                    grouping
                        .Select(post => post.Blog.Name)
                        .Distinct()
                        .Select(postName => "'" + postName + "' ")),
                ContentSummaries = string.Join(
                    " | ",
                    grouping
                        .Where(post => post.Content.Length >= 10)
                        .Select(post => "'" + post.Content.Substring(0, 10) + "' "))
            });

Essa consulta se traduz no seguinte ao usar o SQL Server:

SELECT [t].[Name], (N'''' + [t0].[Name]) + N''' ', [t0].[Name], [t].[c]
FROM (
    SELECT [a].[Name], COALESCE(STRING_AGG(CASE
        WHEN CAST(LEN([p].[Content]) AS int) >= 10 THEN COALESCE((N'''' + COALESCE(SUBSTRING([p].[Content], 0 + 1, 10), N'')) + N''' ', N'')
    END, N' | '), N'') AS [c]
    FROM [Posts] AS [p]
    LEFT JOIN [Authors] AS [a] ON [p].[AuthorId] = [a].[Id]
    GROUP BY [a].[Name]
) AS [t]
OUTER APPLY (
    SELECT DISTINCT [b].[Name]
    FROM [Posts] AS [p0]
    LEFT JOIN [Authors] AS [a0] ON [p0].[AuthorId] = [a0].[Id]
    INNER JOIN [Blogs] AS [b] ON [p0].[BlogId] = [b].[Id]
    WHERE [t].[Name] = [a0].[Name] OR ([t].[Name] IS NULL AND [a0].[Name] IS NULL)
) AS [t0]
ORDER BY [t].[Name]

Funções de agregação espacial

Dica

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

Agora é possível para provedores de banco de dados que oferecem suporte ao NetTopologySuite traduzir as seguintes funções de agregação espacial:

Dica

Essas traduções foram implementadas pela equipe do SQL Server e SQLite. Para outros provedores, entre em contato com o mantenedor do provedor para adicionar suporte se ele tiver sido implementado para esse provedor.

Por exemplo:

var query = context.Caches
    .Where(cache => cache.Location.X < -90)
    .GroupBy(cache => cache.Owner)
    .Select(
        grouping => new { Id = grouping.Key, Combined = GeometryCombiner.Combine(grouping.Select(cache => cache.Location)) });

Essa consulta é convertida para o seguinte SQL ao usar o SQL Server:

SELECT [c].[Owner] AS [Id], geography::CollectionAggregate([c].[Location]) AS [Combined]
FROM [Caches] AS [c]
WHERE [c].[Location].Long < -90.0E0
GROUP BY [c].[Owner]

Funções de agregação estatística

Dica

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

As traduções do SQL Server foram implementadas para as seguintes funções estatísticas:

Dica

Essas traduções foram implementadas pela equipe do SQL Server. Para outros provedores, entre em contato com o mantenedor do provedor para adicionar suporte se ele tiver sido implementado para esse provedor.

Por exemplo:

var query = context.Downloads
    .GroupBy(download => download.Uploader.Id)
    .Select(
        grouping => new
        {
            Author = grouping.Key,
            TotalCost = grouping.Sum(d => d.DownloadCount),
            AverageViews = grouping.Average(d => d.DownloadCount),
            VariancePopulation = EF.Functions.VariancePopulation(grouping.Select(d => d.DownloadCount)),
            VarianceSample = EF.Functions.VarianceSample(grouping.Select(d => d.DownloadCount)),
            StandardDeviationPopulation = EF.Functions.StandardDeviationPopulation(grouping.Select(d => d.DownloadCount)),
            StandardDeviationSample = EF.Functions.StandardDeviationSample(grouping.Select(d => d.DownloadCount))
        });

Essa consulta é convertida para o seguinte SQL ao usar o SQL Server:

SELECT [u].[Id] AS [Author], COALESCE(SUM([d].[DownloadCount]), 0) AS [TotalCost], AVG(CAST([d].[DownloadCount] AS float)) AS [AverageViews], VARP([d].[DownloadCount]) AS [VariancePopulation], VAR([d].[DownloadCount]) AS [VarianceSample], STDEVP([d].[DownloadCount]) AS [StandardDeviationPopulation], STDEV([d].[DownloadCount]) AS [StandardDeviationSample]
FROM [Downloads] AS [d]
INNER JOIN [Uploader] AS [u] ON [d].[UploaderId] = [u].[Id]
GROUP BY [u].[Id]

Tradução de string.IndexOf

Dica

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

O EF7 agora converte String.IndexOf em consultas LINQ. Por exemplo:

var query = context.Posts
    .Select(post => new { post.Title, IndexOfEntity = post.Content.IndexOf("Entity") })
    .Where(post => post.IndexOfEntity > 0);

Essa consulta é convertida no seguinte SQL ao usar o SQL Server:

SELECT [p].[Title], CAST(CHARINDEX(N'Entity', [p].[Content]) AS int) - 1 AS [IndexOfEntity]
FROM [Posts] AS [p]
WHERE (CAST(CHARINDEX(N'Entity', [p].[Content]) AS int) - 1) > 0

Tradução de GetType para tipos de entidade

Dica

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

O EF7 agora converte Object.GetType() em consultas LINQ. Por exemplo:

var query = context.Posts.Where(post => post.GetType() == typeof(Post));

Essa consulta se converte no seguinte SQL ao usar o SQL Server com herança TPH:

SELECT [p].[Id], [p].[AuthorId], [p].[BlogId], [p].[Content], [p].[Discriminator], [p].[PublishedOn], [p].[Title], [p].[PromoText]
FROM [Posts] AS [p]
WHERE [p].[Discriminator] = N'Post'

Observe que essa consulta retorna apenas Post instâncias que são realmente do tipo Post, e não aquelas de quaisquer tipos derivados. Isso é diferente de uma consulta que usa is ou OfType, que também retornará instâncias de quaisquer tipos derivados. Por exemplo, considere a consulta:

var query = context.Posts.OfType<Post>();

O que se traduz em SQL diferente:

      SELECT [p].[Id], [p].[AuthorId], [p].[BlogId], [p].[Content], [p].[Discriminator], [p].[PublishedOn], [p].[Title], [p].[PromoText]
      FROM [Posts] AS [p]

E retornará entidades Post e FeaturedPost.

Compatível com AT TIME ZONE

Dica

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

O EF7 introduz novas funções AtTimeZone para DateTime e DateTimeOffset. Essas funções são convertidas em cláusulas AT TIME ZONE no SQL gerado. Por exemplo:

var query = context.Posts
    .Select(
        post => new
        {
            post.Title,
            PacificTime = EF.Functions.AtTimeZone(post.PublishedOn, "Pacific Standard Time"),
            UkTime = EF.Functions.AtTimeZone(post.PublishedOn, "GMT Standard Time"),
        });

Essa consulta é convertida no seguinte SQL ao usar o SQL Server:

SELECT [p].[Title], [p].[PublishedOn] AT TIME ZONE 'Pacific Standard Time' AS [PacificTime], [p].[PublishedOn] AT TIME ZONE 'GMT Standard Time' AS [UkTime]
FROM [Posts] AS [p]

Dica

Essas traduções foram implementadas pela equipe do SQL Server. Para outros provedores, entre em contato com o mantenedor do provedor para adicionar suporte se ele tiver sido implementado para esse provedor.

Inclusão filtrada em navegações ocultas

Dica

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

Os métodos Include agora podem ser usados com EF.Property. Isso permite filtragem e ordenação mesmo para propriedades de navegação privadas ou navegações privadas representadas por campos. Por exemplo:

var query = context.Blogs.Include(
    blog => EF.Property<ICollection<Post>>(blog, "Posts")
        .Where(post => post.Content.Contains(".NET"))
        .OrderBy(post => post.Title));

Isso é o mesmo que:

var query = context.Blogs.Include(
    blog => Posts
        .Where(post => post.Content.Contains(".NET"))
        .OrderBy(post => post.Title));

Mas não exige que Blog.Posts seja acessível ao público.

Ao usar o SQL Server, ambas as consultas acima são convertidas em:

SELECT [b].[Id], [b].[Name], [t].[Id], [t].[AuthorId], [t].[BlogId], [t].[Content], [t].[Discriminator], [t].[PublishedOn], [t].[Title], [t].[PromoText]
FROM [Blogs] AS [b]
LEFT JOIN (
    SELECT [p].[Id], [p].[AuthorId], [p].[BlogId], [p].[Content], [p].[Discriminator], [p].[PublishedOn], [p].[Title], [p].[PromoText]
    FROM [Posts] AS [p]
    WHERE [p].[Content] LIKE N'%.NET%'
) AS [t] ON [b].[Id] = [t].[BlogId]
ORDER BY [b].[Id], [t].[Title]

Tradução do Cosmos para Regex.IsMatch

Dica

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

O EF7 dá suporte ao uso de Regex.IsMatch em consultas LINQ no Azure Cosmos DB. Por exemplo:

var containsInnerT = await context.Triangles
    .Where(o => Regex.IsMatch(o.Name, "[a-z]t[a-z]", RegexOptions.IgnoreCase))
    .ToListAsync();

Traduz para o seguinte SQL:

SELECT c
FROM root c
WHERE ((c["Discriminator"] = "Triangle") AND RegexMatch(c["Name"], "[a-z]t[a-z]", "i"))

API DbContext e aprimoramentos de comportamento

O EF7 contém uma variedade de pequenas melhorias para DbContext e classes relacionadas.

Dica

O código para exemplos nesta seção vem de DbContextApiSample.cs.

Supressor para propriedades DbSet não inicializadas

As propriedades DbSet públicas e configuráveis em um DbContext são inicializadas automaticamente pelo EF Core quando o DbContext é construído. Por exemplo, considere a seguinte definição DbContext:

public class SomeDbContext : DbContext
{
    public DbSet<Blog> Blogs { get; set; }
}

A propriedade Blogs será definida como uma instância DbSet<Blog> como parte da construção da instância DbContext. Isso permite que o contexto seja usado para consultas sem etapas adicionais.

No entanto, após a introdução de tipos de referência anuláveis C#, o compilador agora avisa que a propriedade não anulável Blogs não foi inicializada:

[CS8618] Non-nullable property 'Blogs' must contain a non-null value when exiting constructor. Consider declaring the property as nullable.

Este é um aviso falso; a propriedade é definida como um valor não nulo pelo EF Core. Além disso, declarar a propriedade como anulável fará com que o aviso desapareça, mas isso não é uma boa ideia porque, conceitualmente, a propriedade não é anulável e nunca será nula.

O EF7 contém um DiagnosticSuppressor para propriedades DbSet em um DbContext que impede o compilador de gerar esse aviso.

Dica

Esse padrão se originou na época em que as propriedades automáticas do C# eram muito limitadas. Com o C# moderno, considere tornar as propriedades automáticas somente leitura e, em seguida, inicializá-las explicitamente no construtor DbContext ou obter a instância DbSet armazenada em cache do contexto quando necessário. Por exemplo, public DbSet<Blog> Blogs => Set<Blog>().

Distinguir cancelamento de falha em logs

Às vezes, um aplicativo cancelará explicitamente uma consulta ou outra operação de banco de dados. Isso geralmente é feito usando um CancellationToken passado para o método que executa a operação.

No EF Core 6, os eventos registrados quando uma operação é cancelada são os mesmos registrados quando a operação falha por algum outro motivo. O EF7 introduz novos eventos de log especificamente para operações de banco de dados canceladas. Esses novos eventos são, por padrão, registrados no nível Debug. A tabela a seguir mostra os eventos relevantes e seus níveis de log padrão:

Evento Descrição Nível de log padrão
CoreEventId.QueryIterationFailed Ocorreu um erro ao processar os resultados de uma consulta. LogLevel.Error
CoreEventId.SaveChangesFailed Ocorreu um erro ao tentar salvar as alterações no banco de dados. LogLevel.Error
RelationalEventId.CommandError Ocorreu um erro durante a execução de um comando de banco de dados. LogLevel.Error
CoreEventId.QueryCanceled Uma consulta foi cancelada. LogLevel.Debug
CoreEventId.SaveChangesCanceled O comando do banco de dados foi cancelado ao tentar salvar as alterações. LogLevel.Debug
RelationalEventId.CommandCanceled A execução de um DbCommand foi cancelada. LogLevel.Debug

Observação

O cancelamento é detectado observando a exceção em vez de verificar o token de cancelamento. Isso significa que cancelamentos não acionados por meio do token de cancelamento ainda serão detectados e registrados dessa forma.

Novas sobrecargas de IProperty e INavigation para métodos EntityEntry

O código que trabalha com o modelo EF geralmente terá um IProperty ou INavigation representando metadados de propriedade ou navegação. Um EntityEntry é usado para obter a propriedade/valor de navegação ou consultar seu estado. No entanto, antes do EF7, isso exigia passar o nome da propriedade ou navegação para métodos do EntityEntry, que então pesquisaria novamente o IProperty ou INavigation. No EF7, o IProperty ou INavigation pode ser passado diretamente, evitando a pesquisa adicional.

Por exemplo, considere um método para localizar todos os irmãos de uma determinada entidade:

public static IEnumerable<TEntity> FindSiblings<TEntity>(
    this DbContext context, TEntity entity, string navigationToParent)
    where TEntity : class
{
    var parentEntry = context.Entry(entity).Reference(navigationToParent);

    return context.Entry(parentEntry.CurrentValue!)
        .Collection(parentEntry.Metadata.Inverse!)
        .CurrentValue!
        .OfType<TEntity>()
        .Where(e => !ReferenceEquals(e, entity));
}

Esse método localiza o pai de uma determinada entidade e, em seguida, passa o INavigation inverso para o método Collection da entrada pai. Esses metadados são usados para retornar todos os irmãos do pai ou da mãe. Aqui está um exemplo de seu uso:


Console.WriteLine($"Siblings to {post.Id}: '{post.Title}' are...");
foreach (var sibling in context.FindSiblings(post, nameof(post.Blog)))
{
    Console.WriteLine($"    {sibling.Id}: '{sibling.Title}'");
}

E a saída:

Siblings to 1: 'Announcing Entity Framework 7 Preview 7: Interceptors!' are...
    5: 'Productivity comes to .NET MAUI in Visual Studio 2022'
    6: 'Announcing .NET 7 Preview 7'
    7: 'ASP.NET Core updates in .NET 7 Preview 7'

EntityEntry para tipos de entidade de tipo compartilhado

O EF Core pode usar o mesmo tipo de CLR para vários tipos de entidade diferentes. Eles são conhecidos como "tipos de entidade de tipo compartilhado" e são frequentemente usados para mapear um tipo de dicionário com pares chave/valor usados para as propriedades do tipo de entidade. Por exemplo, um tipo de entidade BuildMetadata pode ser definido sem definir um tipo CLR dedicado:

modelBuilder.SharedTypeEntity<Dictionary<string, object>>(
    "BuildMetadata", b =>
    {
        b.IndexerProperty<int>("Id");
        b.IndexerProperty<string>("Tag");
        b.IndexerProperty<Version>("Version");
        b.IndexerProperty<string>("Hash");
        b.IndexerProperty<bool>("Prerelease");
    });

Observe que o tipo de entidade de tipo compartilhado deve ser nomeado - nesse caso, o nome é BuildMetadata. Esses tipos de entidade são acessados usando um DbSet para o tipo de entidade que é obtido usando o nome. Por exemplo:

public DbSet<Dictionary<string, object>> BuildMetadata
    => Set<Dictionary<string, object>>("BuildMetadata");

Esse DbSet pode ser usado para rastrear instâncias de entidade:

await context.BuildMetadata.AddAsync(
    new Dictionary<string, object>
    {
        { "Tag", "v7.0.0-rc.1.22426.7" },
        { "Version", new Version(7, 0, 0) },
        { "Prerelease", true },
        { "Hash", "dc0f3e8ef10eb1464b27f0fd4704f53c01226036" }
    });

E executar consultas:

var builds = await context.BuildMetadata
    .Where(metadata => !EF.Property<bool>(metadata, "Prerelease"))
    .OrderBy(metadata => EF.Property<string>(metadata, "Tag"))
    .ToListAsync();

Agora, no EF7, também há um método Entry em DbSet que pode ser usado para obter o estado de uma instância, mesmo que ainda não esteja rastreada. Por exemplo:

var state = context.BuildMetadata.Entry(build).State;

ContextInitialized agora está registrado como Debug

No EF7, o evento ContextInitialized é registrado no nível Debug. Por exemplo:

dbug: 10/7/2022 12:27:52.379 CoreEventId.ContextInitialized[10403] (Microsoft.EntityFrameworkCore.Infrastructure)
      Entity Framework Core 7.0.0 initialized 'BlogsContext' using provider 'Microsoft.EntityFrameworkCore.SqlServer:7.0.0' with options: SensitiveDataLoggingEnabled using NetTopologySuite

Em versões anteriores, ele foi registrado no nível Information. Por exemplo:

info: 10/7/2022 12:30:34.757 CoreEventId.ContextInitialized[10403] (Microsoft.EntityFrameworkCore.Infrastructure)
      Entity Framework Core 7.0.0 initialized 'BlogsContext' using provider 'Microsoft.EntityFrameworkCore.SqlServer:7.0.0' with options: SensitiveDataLoggingEnabled using NetTopologySuite

Se desejar, o nível de log pode ser alterado novamente para Information:

optionsBuilder.ConfigureWarnings(
    builder =>
    {
        builder.Log((CoreEventId.ContextInitialized, LogLevel.Information));
    });

IEntityEntryGraphIterator é utilizável publicamente

No EF7, o serviço IEntityEntryGraphIterator pode ser usado por aplicativos. Este é o serviço usado internamente ao descobrir um gráfico de entidades para rastrear, e também por TrackGraph. Aqui está um exemplo que itera em todas as entidades acessíveis a partir de alguma entidade inicial:

var blogEntry = context.ChangeTracker.Entries<Blog>().First();
var found = new HashSet<object>();
var iterator = context.GetService<IEntityEntryGraphIterator>();
iterator.TraverseGraph(new EntityEntryGraphNode<HashSet<object>>(blogEntry, found, null, null), node =>
{
    if (node.NodeState.Contains(node.Entry.Entity))
    {
        return false;
    }

    Console.Write($"Found with '{node.Entry.Entity.GetType().Name}'");

    if (node.InboundNavigation != null)
    {
        Console.Write($" by traversing '{node.InboundNavigation.Name}' from '{node.SourceEntry!.Entity.GetType().Name}'");
    }

    Console.WriteLine();

    node.NodeState.Add(node.Entry.Entity);

    return true;
});

Console.WriteLine();
Console.WriteLine($"Finished iterating. Found {found.Count} entities.");
Console.WriteLine();

Aviso:

  • O iterador pára de atravessar de um determinado nó quando o delegado de retorno de chamada retorna false. Este exemplo controla as entidades visitadas e retorna false quando a entidade já foi visitada. Isso evita loops infinitos resultantes de ciclos no gráfico.
  • O objeto EntityEntryGraphNode<TState> permite que o estado seja transmitido sem capturá-lo para o delegado.
  • Para cada nó visitado que não seja o primeiro, o nó do qual ele foi descoberto e a navegação pela qual ele foi descoberto são passados para o retorno de chamada.

Aprimoramentos na construção do modelo

O EF7 contém uma variedade de pequenas melhorias na construção de modelos.

Dica

O código para exemplos nesta seção vem de ModelBuildingSample.cs.

Os índices podem ser ascendentes ou descendentes

Por padrão, o EF Core cria índices ascendentes. O EF7 também suporta a criação de índices descendentes. Por exemplo:

modelBuilder
    .Entity<Post>()
    .HasIndex(post => post.Title)
    .IsDescending();

Ou, usando o atributo de mapeamento Index:

[Index(nameof(Title), AllDescending = true)]
public class Post
{
    public int Id { get; set; }

    [MaxLength(64)]
    public string? Title { get; set; }
}

Isso raramente é útil para índices em uma única coluna, já que o banco de dados pode usar o mesmo índice para ordenar em ambas as direções. No entanto, esse não é o caso para índices compostos em várias colunas, onde a ordem em cada coluna pode ser importante. O EF Core oferece suporte a isso permitindo que várias colunas tenham uma ordem diferente definida para cada coluna. Por exemplo:

modelBuilder
    .Entity<Blog>()
    .HasIndex(blog => new { blog.Name, blog.Owner })
    .IsDescending(false, true);

Ou, usando um atributo de mapeamento:

[Index(nameof(Name), nameof(Owner), IsDescending = new[] { false, true })]
public class Blog
{
    public int Id { get; set; }

    [MaxLength(64)]
    public string? Name { get; set; }

    [MaxLength(64)]
    public string? Owner { get; set; }

    public List<Post> Posts { get; } = new();
}

Isso resulta no seguinte SQL ao usar o SQL Server:

CREATE INDEX [IX_Blogs_Name_Owner] ON [Blogs] ([Name], [Owner] DESC);

Finalmente, vários índices podem ser criados sobre o mesmo conjunto ordenado de colunas, dando nomes aos índices. Por exemplo:

modelBuilder
    .Entity<Blog>()
    .HasIndex(blog => new { blog.Name, blog.Owner }, "IX_Blogs_Name_Owner_1")
    .IsDescending(false, true);

modelBuilder
    .Entity<Blog>()
    .HasIndex(blog => new { blog.Name, blog.Owner }, "IX_Blogs_Name_Owner_2")
    .IsDescending(true, true);

Ou, usando atributos de mapeamento:

[Index(nameof(Name), nameof(Owner), IsDescending = new[] { false, true }, Name = "IX_Blogs_Name_Owner_1")]
[Index(nameof(Name), nameof(Owner), IsDescending = new[] { true, true }, Name = "IX_Blogs_Name_Owner_2")]
public class Blog
{
    public int Id { get; set; }

    [MaxLength(64)]
    public string? Name { get; set; }

    [MaxLength(64)]
    public string? Owner { get; set; }

    public List<Post> Posts { get; } = new();
}

Isso gera o seguinte SQL no SQL Server:

CREATE INDEX [IX_Blogs_Name_Owner_1] ON [Blogs] ([Name], [Owner] DESC);
CREATE INDEX [IX_Blogs_Name_Owner_2] ON [Blogs] ([Name] DESC, [Owner] DESC);

Atributo de mapeamento para chaves compostas

O EF7 introduz um novo atributo de mapeamento (também conhecido como "anotação de dados") para especificar a propriedade de chave primária ou propriedades de qualquer tipo de entidade. Ao contrário System.ComponentModel.DataAnnotations.KeyAttribute, PrimaryKeyAttribute é colocado na classe de tipo de entidade em vez de na propriedade de chave. Por exemplo:

[PrimaryKey(nameof(PostKey))]
public class Post
{
    public int PostKey { get; set; }
}

Isso o torna um ajuste natural para definir chaves compostas:

[PrimaryKey(nameof(PostId), nameof(CommentId))]
public class Comment
{
    public int PostId { get; set; }
    public int CommentId { get; set; }
    public string CommentText { get; set; } = null!;
}

Definir o índice na classe também significa que ele pode ser usado para especificar propriedades ou campos privados como chaves, mesmo que eles geralmente sejam ignorados ao criar o modelo EF. Por exemplo:

[PrimaryKey(nameof(_id))]
public class Tag
{
    private readonly int _id;
}

DeleteBehavior atributo de mapeamento

O EF7 apresenta um atributo de mapeamento (também conhecido como "anotação de dados") para especificar o DeleteBehavior para uma relação. Por exemplo, relações necessárias são criadas com DeleteBehavior.Cascade por padrão. Isso pode ser alterado para DeleteBehavior.NoAction por padrão usando DeleteBehaviorAttribute:

public class Post
{
    public int Id { get; set; }
    public string? Title { get; set; }

    [DeleteBehavior(DeleteBehavior.NoAction)]
    public Blog Blog { get; set; } = null!;
}

Isso desabilitará exclusões em cascata para a relação Blog-Posts.

Propriedades mapeadas para nomes de colunas diferentes

Alguns padrões de mapeamento resultam na mesma propriedade CLR sendo mapeada para uma coluna em cada uma das várias tabelas diferentes. O EF7 permite que essas colunas tenham nomes diferentes. Por exemplo, considere uma hierarquia de herança simples:

public abstract class Animal
{
    public int Id { get; set; }
    public string Breed { get; set; } = null!;
}

public class Cat : Animal
{
    public string? EducationalLevel { get; set; }
}

public class Dog : Animal
{
    public string? FavoriteToy { get; set; }
}

Com a estratégia de mapeamento de herança TPT, esses tipos serão mapeados para três tabelas. No entanto, a coluna de chave primária em cada tabela pode ter um nome diferente. Por exemplo:

CREATE TABLE [Animals] (
    [Id] int NOT NULL IDENTITY,
    [Breed] nvarchar(max) NOT NULL,
    CONSTRAINT [PK_Animals] PRIMARY KEY ([Id])
);

CREATE TABLE [Cats] (
    [CatId] int NOT NULL,
    [EducationalLevel] nvarchar(max) NULL,
    CONSTRAINT [PK_Cats] PRIMARY KEY ([CatId]),
    CONSTRAINT [FK_Cats_Animals_CatId] FOREIGN KEY ([CatId]) REFERENCES [Animals] ([Id]) ON DELETE CASCADE
);

CREATE TABLE [Dogs] (
    [DogId] int NOT NULL,
    [FavoriteToy] nvarchar(max) NULL,
    CONSTRAINT [PK_Dogs] PRIMARY KEY ([DogId]),
    CONSTRAINT [FK_Dogs_Animals_DogId] FOREIGN KEY ([DogId]) REFERENCES [Animals] ([Id]) ON DELETE CASCADE
);

O EF7 permite que esse mapeamento seja configurado usando um construtor de tabelas aninhado:

modelBuilder.Entity<Animal>().ToTable("Animals");

modelBuilder.Entity<Cat>()
    .ToTable(
        "Cats",
        tableBuilder => tableBuilder.Property(cat => cat.Id).HasColumnName("CatId"));

modelBuilder.Entity<Dog>()
    .ToTable(
        "Dogs",
        tableBuilder => tableBuilder.Property(dog => dog.Id).HasColumnName("DogId"));

Com o mapeamento de herança TPC, a propriedade Breed também pode ser mapeada para nomes de colunas diferentes em tabelas diferentes. Por exemplo, considere as seguintes tabelas TPC:

CREATE TABLE [Cats] (
    [CatId] int NOT NULL DEFAULT (NEXT VALUE FOR [AnimalSequence]),
    [CatBreed] nvarchar(max) NOT NULL,
    [EducationalLevel] nvarchar(max) NULL,
    CONSTRAINT [PK_Cats] PRIMARY KEY ([CatId])
);

CREATE TABLE [Dogs] (
    [DogId] int NOT NULL DEFAULT (NEXT VALUE FOR [AnimalSequence]),
    [DogBreed] nvarchar(max) NOT NULL,
    [FavoriteToy] nvarchar(max) NULL,
    CONSTRAINT [PK_Dogs] PRIMARY KEY ([DogId])
);

O EF7 dá suporte a este mapeamento de tabela:

modelBuilder.Entity<Animal>().UseTpcMappingStrategy();

modelBuilder.Entity<Cat>()
    .ToTable(
        "Cats",
        builder =>
        {
            builder.Property(cat => cat.Id).HasColumnName("CatId");
            builder.Property(cat => cat.Breed).HasColumnName("CatBreed");
        });

modelBuilder.Entity<Dog>()
    .ToTable(
        "Dogs",
        builder =>
        {
            builder.Property(dog => dog.Id).HasColumnName("DogId");
            builder.Property(dog => dog.Breed).HasColumnName("DogBreed");
        });

Relações unidirecionais muitos para muitos

O EF7 dá suporte a relações muitos para muitos em que um lado ou outro não tem uma propriedade de navegação. Por exemplo, considere tipos Post e Tag:

public class Post
{
    public int Id { get; set; }
    public string? Title { get; set; }
    public Blog Blog { get; set; } = null!;
    public List<Tag> Tags { get; } = new();
}
public class Tag
{
    public int Id { get; set; }
    public string TagName { get; set; } = null!;
}

Observe que o tipo Post tem uma propriedade de navegação para uma lista de marcas, mas o tipo Tag não tem uma propriedade de navegação para postagens. No EF7, isso ainda pode ser configurado como uma relação muitos para muitos, permitindo que o mesmo objeto Tag seja usado para muitas postagens diferentes. Por exemplo:

modelBuilder
    .Entity<Post>()
    .HasMany(post => post.Tags)
    .WithMany();

Isso resulta no mapeamento para a tabela de junção apropriada:

CREATE TABLE [Tags] (
    [Id] int NOT NULL IDENTITY,
    [TagName] nvarchar(max) NOT NULL,
    CONSTRAINT [PK_Tags] PRIMARY KEY ([Id])
);

CREATE TABLE [Posts] (
    [Id] int NOT NULL IDENTITY,
    [Title] nvarchar(64) NULL,
    [BlogId] int NOT NULL,
    CONSTRAINT [PK_Posts] PRIMARY KEY ([Id]),
    CONSTRAINT [FK_Posts_Blogs_BlogId] FOREIGN KEY ([BlogId]) REFERENCES [Blogs] ([Id])
);

CREATE TABLE [PostTag] (
    [PostId] int NOT NULL,
    [TagsId] int NOT NULL,
    CONSTRAINT [PK_PostTag] PRIMARY KEY ([PostId], [TagsId]),
    CONSTRAINT [FK_PostTag_Posts_PostId] FOREIGN KEY ([PostId]) REFERENCES [Posts] ([Id]) ON DELETE CASCADE,
    CONSTRAINT [FK_PostTag_Tags_TagsId] FOREIGN KEY ([TagsId]) REFERENCES [Tags] ([Id]) ON DELETE CASCADE
);

E a relação pode ser usada como muitos para muitos da maneira normal. Por exemplo, inserir algumas postagens que compartilham várias marcas de um conjunto comum:

var tags = new Tag[] { new() { TagName = "Tag1" }, new() { TagName = "Tag2" }, new() { TagName = "Tag2" }, };

await context.AddRangeAsync(new Blog { Posts =
{
    new Post { Tags = { tags[0], tags[1] } },
    new Post { Tags = { tags[1], tags[0], tags[2] } },
    new Post()
} });

await context.SaveChangesAsync();

Divisão de entidade

A divisão de entidade mapeia um único tipo de entidade para várias tabelas. Por exemplo, considere um banco de dados com três tabelas que contêm dados do cliente:

  • Uma tabela Customers para obter informações do cliente
  • Uma tabela PhoneNumbers para o número de telefone do cliente
  • Uma tabela Addresses para o endereço do cliente

Aqui estão as definições para essas tabelas no SQL Server:

CREATE TABLE [Customers] (
    [Id] int NOT NULL IDENTITY,
    [Name] nvarchar(max) NOT NULL,
    CONSTRAINT [PK_Customers] PRIMARY KEY ([Id])
);
    
CREATE TABLE [PhoneNumbers] (
    [CustomerId] int NOT NULL,
    [PhoneNumber] nvarchar(max) NULL,
    CONSTRAINT [PK_PhoneNumbers] PRIMARY KEY ([CustomerId]),
    CONSTRAINT [FK_PhoneNumbers_Customers_CustomerId] FOREIGN KEY ([CustomerId]) REFERENCES [Customers] ([Id]) ON DELETE CASCADE
);

CREATE TABLE [Addresses] (
    [CustomerId] int NOT NULL,
    [Street] nvarchar(max) NOT NULL,
    [City] nvarchar(max) NOT NULL,
    [PostCode] nvarchar(max) NULL,
    [Country] nvarchar(max) NOT NULL,
    CONSTRAINT [PK_Addresses] PRIMARY KEY ([CustomerId]),
    CONSTRAINT [FK_Addresses_Customers_CustomerId] FOREIGN KEY ([CustomerId]) REFERENCES [Customers] ([Id]) ON DELETE CASCADE
);

Cada uma dessas tabelas normalmente seria mapeada para seu próprio tipo de entidade, com relações entre os tipos. No entanto, se todas as três tabelas são sempre usadas juntas, então pode ser mais conveniente mapeá-las todas para um único tipo de entidade. Por exemplo:

public class Customer
{
    public Customer(string name, string street, string city, string? postCode, string country)
    {
        Name = name;
        Street = street;
        City = city;
        PostCode = postCode;
        Country = country;
    }

    public int Id { get; set; }
    public string Name { get; set; }
    public string? PhoneNumber { get; set; }
    public string Street { get; set; }
    public string City { get; set; }
    public string? PostCode { get; set; }
    public string Country { get; set; }
}

Isso é obtido no EF7 chamando SplitToTable para cada divisão no tipo de entidade. Por exemplo, o código a seguir divide o tipo de entidade Customer para as tabelas Customers, PhoneNumberse Addresses mostradas acima:

modelBuilder.Entity<Customer>(
    entityBuilder =>
    {
        entityBuilder
            .ToTable("Customers")
            .SplitToTable(
                "PhoneNumbers",
                tableBuilder =>
                {
                    tableBuilder.Property(customer => customer.Id).HasColumnName("CustomerId");
                    tableBuilder.Property(customer => customer.PhoneNumber);
                })
            .SplitToTable(
                "Addresses",
                tableBuilder =>
                {
                    tableBuilder.Property(customer => customer.Id).HasColumnName("CustomerId");
                    tableBuilder.Property(customer => customer.Street);
                    tableBuilder.Property(customer => customer.City);
                    tableBuilder.Property(customer => customer.PostCode);
                    tableBuilder.Property(customer => customer.Country);
                });
    });

Observe também que, se necessário, diferentes nomes de coluna de chave primária podem ser especificados para cada uma das tabelas.

Cadeias de caracteres UTF-8 do SQL Server

As cadeias de caracteres Unicode do SQL Server representadas pelos tipos de dados nchar e nvarchar são armazenadas como UTF-16. Além disso, os char e varchartipos de dados são usados para armazenar cadeias de caracteres não Unicode com suporte para vários conjuntos de caracteres.

A partir do SQL Server 2019, os tipos de dados char e varchar podem ser usados para armazenar cadeias de caracteres Unicode com codificação UTF-8. Isso é feito definindo uma das ordenações UTF-8. Por exemplo, o código a seguir configura uma cadeia de caracteres UTF-8 do SQL Server de comprimento variável para a coluna CommentText :

modelBuilder
    .Entity<Comment>()
    .Property(comment => comment.CommentText)
    .HasColumnType("varchar(max)")
    .UseCollation("LATIN1_GENERAL_100_CI_AS_SC_UTF8");

Essa configuração gera a seguinte definição de coluna do SQL Server:

CREATE TABLE [Comment] (
    [PostId] int NOT NULL,
    [CommentId] int NOT NULL,
    [CommentText] varchar(max) COLLATE LATIN1_GENERAL_100_CI_AS_SC_UTF8 NOT NULL,
    CONSTRAINT [PK_Comment] PRIMARY KEY ([PostId], [CommentId])
);

As tabelas temporais dão suporte a entidades de propriedade

O mapeamento de tabelas temporais do EF Core SQL Server foi aprimorado no EF7 para dar suporte ao compartilhamento de tabela. Mais notavelmente, o mapeamento padrão para entidades individuais de propriedade usa o compartilhamento de tabelas.

Por exemplo, considere um tipo de entidade de proprietário Employee e seu tipo de entidade de propriedade EmployeeInfo:

public class Employee
{
    public Guid EmployeeId { get; set; }
    public string Name { get; set; } = null!;

    public EmployeeInfo Info { get; set; } = null!;
}

public class EmployeeInfo
{
    public string Position { get; set; } = null!;
    public string Department { get; set; } = null!;
    public string? Address { get; set; }
    public decimal? AnnualSalary { get; set; }
}

Se esses tipos forem mapeados para a mesma tabela, no EF7 essa tabela poderá ser feita uma tabela temporária:

modelBuilder
    .Entity<Employee>()
    .ToTable(
        "Employees",
        tableBuilder =>
        {
            tableBuilder.IsTemporal();
            tableBuilder.Property<DateTime>("PeriodStart").HasColumnName("PeriodStart");
            tableBuilder.Property<DateTime>("PeriodEnd").HasColumnName("PeriodEnd");
        })
    .OwnsOne(
        employee => employee.Info,
        ownedBuilder => ownedBuilder.ToTable(
            "Employees",
            tableBuilder =>
            {
                tableBuilder.IsTemporal();
                tableBuilder.Property<DateTime>("PeriodStart").HasColumnName("PeriodStart");
                tableBuilder.Property<DateTime>("PeriodEnd").HasColumnName("PeriodEnd");
            }));

Observação

Tornar essa configuração mais fácil é controlada pelo Problema nº 29303. Vote nesta questão se for algo que você gostaria de ver implementado.

Geração de valor aprimorada

O EF7 inclui duas melhorias significativas na geração automática de valores para propriedades de chave.

Dica

O código para exemplos nesta seção vem de ModelBuildingSample.cs.

Geração de valor para tipos protegidos por DDD

No DDD (design controlado pelo domínio), as "chaves protegidas" podem melhorar a segurança do tipo das propriedades de chave. Isso é feito encapsulando o tipo de chave em outro tipo específico ao uso da chave. Por exemplo, o código a seguir define um tipo de ProductId para chaves de produto e um tipo de CategoryId para chaves de categoria.

public readonly struct ProductId
{
    public ProductId(int value) => Value = value;
    public int Value { get; }
}

public readonly struct CategoryId
{
    public CategoryId(int value) => Value = value;
    public int Value { get; }
}

Em seguida, eles são usados em tipos de entidade Product e Category :

public class Product
{
    public Product(string name) => Name = name;
    public ProductId Id { get; set; }
    public string Name { get; set; }
    public CategoryId CategoryId { get; set; }
    public Category Category { get; set; } = null!;
}

public class Category
{
    public Category(string name) => Name = name;
    public CategoryId Id { get; set; }
    public string Name { get; set; }
    public List<Product> Products { get; } = new();
}

Isso torna impossível atribuir acidentalmente a ID de uma categoria a um produto ou vice-versa.

Aviso

Assim como acontece com muitos conceitos de DDD, essa segurança de tipo aprimorada vem em detrimento da complexidade adicional do código. Vale a pena considerar se, por exemplo, atribuir uma ID de produto a uma categoria é algo que provavelmente acontecerá. Manter as coisas simples pode ser mais benéfico para a base de código.

Os tipos de chave protegida mostrados aqui encapsulam int valores de chave, o que significa que valores inteiros serão usados nas tabelas de banco de dados mapeadas. Isso é obtido definindo conversores de valor para os tipos:

protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
{
    configurationBuilder.Properties<ProductId>().HaveConversion<ProductIdConverter>();
    configurationBuilder.Properties<CategoryId>().HaveConversion<CategoryIdConverter>();
}

private class ProductIdConverter : ValueConverter<ProductId, int>
{
    public ProductIdConverter()
        : base(v => v.Value, v => new(v))
    {
    }
}

private class CategoryIdConverter : ValueConverter<CategoryId, int>
{
    public CategoryIdConverter()
        : base(v => v.Value, v => new(v))
    {
    }
}

Observação

O código aqui usa tipos de struct. Isso significa que eles têm semântica de tipo de valor apropriada para uso como chaves. Se class tipos forem usados, eles precisarão substituir a semântica de igualdade ou também especificar um comparador de valor.

No EF7, os tipos de chave com base em conversores de valor podem usar valores de chave gerados automaticamente, desde que o tipo subjacente dê suporte a isso. Isso é configurado da maneira normal usando ValueGeneratedOnAdd:

modelBuilder.Entity<Product>().Property(product => product.Id).ValueGeneratedOnAdd();
modelBuilder.Entity<Category>().Property(category => category.Id).ValueGeneratedOnAdd();

Por padrão, isso resulta em colunas de IDENTITY quando usadas com o SQL Server:

CREATE TABLE [Categories] (
    [Id] int NOT NULL IDENTITY,
    [Name] nvarchar(max) NOT NULL,
    CONSTRAINT [PK_Categories] PRIMARY KEY ([Id]));

CREATE TABLE [Products] (
    [Id] int NOT NULL IDENTITY,
    [Name] nvarchar(max) NOT NULL,
    [CategoryId] int NOT NULL,
    CONSTRAINT [PK_Products] PRIMARY KEY ([Id]),
    CONSTRAINT [FK_Products_Categories_CategoryId] FOREIGN KEY ([CategoryId]) REFERENCES [Categories] ([Id]) ON DELETE CASCADE);

Que são usados da maneira normal para gerar valores de chave ao inserir entidades:

MERGE [Categories] USING (
VALUES (@p0, 0),
(@p1, 1)) AS i ([Name], _Position) ON 1=0
WHEN NOT MATCHED THEN
INSERT ([Name])
VALUES (i.[Name])
OUTPUT INSERTED.[Id], i._Position;

Geração de chave baseada em sequência para SQL Server

O EF Core dá suporte à geração de valor de chave usando de colunasIDENTITYdo SQL Server ou um padrão Hi-Lo com base em blocos de chaves gerados por uma sequência de banco de dados. O EF7 apresenta suporte para uma sequência de banco de dados anexada à restrição padrão da coluna da chave. Em sua forma mais simples, isso requer apenas dizer ao EF Core para usar uma sequência para a propriedade de chave:

modelBuilder.Entity<Product>().Property(product => product.Id).UseSequence();

Isso resulta em uma sequência sendo definida no banco de dados:

CREATE SEQUENCE [ProductSequence] START WITH 1 INCREMENT BY 1 NO MINVALUE NO MAXVALUE NO CYCLE;

Em seguida, é usado na restrição padrão da coluna de chave:

CREATE TABLE [Products] (
    [Id] int NOT NULL DEFAULT (NEXT VALUE FOR [ProductSequence]),
    [Name] nvarchar(max) NOT NULL,
    [CategoryId] int NOT NULL,
    CONSTRAINT [PK_Products] PRIMARY KEY ([Id]),
    CONSTRAINT [FK_Products_Categories_CategoryId] FOREIGN KEY ([CategoryId]) REFERENCES [Categories] ([Id]) ON DELETE CASCADE);

Observação

Essa forma de geração de chave é usada por padrão para chaves geradas em hierarquias de tipo de entidade usando a estratégia de mapeamento TPC.

Se desejar, a sequência poderá receber um nome e um esquema diferentes. Por exemplo:

modelBuilder
    .Entity<Product>()
    .Property(product => product.Id)
    .UseSequence("ProductsSequence", "northwind");

A configuração adicional da sequência é formada configurando-a explicitamente no modelo. Por exemplo:

modelBuilder
    .HasSequence<int>("ProductsSequence", "northwind")
    .StartsAt(1000)
    .IncrementsBy(2);

Melhorias nas ferramentas de migrações

O EF7 inclui duas melhorias significativas ao usar as ferramentas de linha de comando EF Core Migrations.

UseSqlServer etc. aceite nulo

É muito comum ler uma cadeia de conexão de um arquivo de configuração e passar essa cadeia de conexão para UseSqlServer, UseSqliteou o método equivalente para outro provedor. Por exemplo:

services.AddDbContext<BloggingContext>(options =>
    options.UseSqlServer(Configuration.GetConnectionString("BloggingDatabase")));

Também é comum passar uma cadeia de conexão ao aplicar migrações. Por exemplo:

dotnet ef database update --connection "Server=(localdb)\mssqllocaldb;Database=MyAppDb"

Ou ao usar um Pacote de migrações.

./bundle.exe --connection "Server=(localdb)\mssqllocaldb;Database=MyAppDb"

Nesse caso, mesmo que a cadeia de conexão lida da configuração não seja usada, o código de inicialização do aplicativo ainda tenta lê-lo da configuração e passá-lo para UseSqlServer. Se a configuração não estiver disponível, isso resultará em passar nulo para UseSqlServer. No EF7, isso é permitido, desde que a cadeia de conexão seja definida posteriormente, como passando --connection para a ferramenta de linha de comando.

Observação

Essa alteração foi feita para UseSqlServer e UseSqlite. Para outros provedores, entre em contato com o mantenedor do provedor para fazer uma alteração equivalente se ainda não tiver sido feito para esse provedor.

Detectar quando as ferramentas estão em execução

O EF Core executa o código do aplicativo quando os comandos dotnet-ef ou do PowerShell estão sendo usados. Às vezes, pode ser necessário detectar essa situação para impedir que código inadequado seja executado em tempo de design. Por exemplo, o código que aplica migrações automaticamente na inicialização provavelmente não deve fazer isso em tempo de design. No EF7, isso pode ser detectado usando o sinalizador EF.IsDesignTime:

if (!EF.IsDesignTime)
{
    await context.Database.MigrateAsync();
}

O EF Core define o IsDesignTime como true quando o código do aplicativo está em execução em nome das ferramentas.

Aprimoramentos de desempenho para proxies

O EF Core dá suporte a proxies gerados dinamicamente para carregamento lento e controle de alterações. O EF7 contém duas melhorias de desempenho ao usar esses proxies:

  • Os tipos de proxy agora são criados de forma lenta. Isso significa que o tempo inicial de criação do modelo ao usar proxies pode ser massivamente mais rápido com o EF7 do que com o EF Core 6.0.
  • Os proxies agora podem ser usados com modelos compilados.

Aqui estão alguns resultados de desempenho para um modelo com 449 tipos de entidade, 6390 propriedades e 720 relações.

Cenário Método Média Erro StdDev
EF Core 6.0 sem proxies TimeToFirstQuery 1,085 s 0,0083 s 0,0167 s
EF Core 6.0 com proxies de controle de alterações TimeToFirstQuery 13,01 s 0,2040 s 0,4110 s
EF Core 7.0 sem proxies TimeToFirstQuery 1,442 s 0,0134 s 0,0272 s
EF Core 7.0 com proxies de controle de alterações TimeToFirstQuery 1,446 s 0,0160 s 0,0323 s
EF Core 7.0 com proxies de controle de alterações e modelo compilado TimeToFirstQuery 0,162 s 0,0062 s 0,0125 s

Portanto, nesse caso, um modelo com proxies de controle de alterações pode estar pronto para executar a primeira consulta 80 vezes mais rápido no EF7 do que era possível com o EF Core 6.0.

Associação de dados do Windows Forms de primeira classe

A equipe do Windows Forms vem fazendo algumas grandes melhorias na experiência do Visual Studio Designer. Isso inclui novas experiências para associação de dados que se integram bem ao EF Core.

Resumindo, a nova experiência fornece o Visual Studio U.I. para criar um ObjectDataSource:

Choose Category data source type

Em seguida, isso pode ser associado a um DbSet do EF Core com algum código simples:

public partial class MainForm : Form
{
    private ProductsContext? dbContext;

    public MainForm()
    {
        InitializeComponent();
    }

    protected override void OnLoad(EventArgs e)
    {
        base.OnLoad(e);

        this.dbContext = new ProductsContext();

        this.dbContext.Categories.Load();
        this.categoryBindingSource.DataSource = dbContext.Categories.Local.ToBindingList();
    }

    protected override void OnClosing(CancelEventArgs e)
    {
        base.OnClosing(e);

        this.dbContext?.Dispose();
        this.dbContext = null;
    }
}

Consulte Introdução ao Windows Forms para obter um passo a passo completo e aplicativo de exemplo WinForms para download.