Novedades de EF Core 7.0

EF Core 7.0 (EF7) se publicó en noviembre de 2022.

Sugerencia

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

EF7 tiene como destino .NET 6 y, por tanto, se puede usar con .NET 6 (LTS) o .NET 7.

Modelo de ejemplo

Muchos de los ejemplos siguientes usan un modelo sencillo con blogs, publicaciones, etiquetas y 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();
}

Algunos de los ejemplos también usan tipos agregados, que se asignan de maneras diferentes en distintos ejemplos. Hay un tipo agregado para los contactos:

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

Y un segundo tipo agregado para los metadatos de publicación:

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

Sugerencia

El modelo de ejemplo se puede encontrar en BlogsContext.cs.

Columnas JSON

La mayoría de las bases de datos relacionales admiten columnas que contienen documentos JSON. El JSON de estas columnas se puede explorar en profundidad con consultas. Esto permite, por ejemplo, filtrar y ordenar por los elementos de los documentos, así como la proyección de elementos fuera de los documentos en los resultados. Las columnas JSON permiten a las bases de datos relacionales asumir algunas de las características de las bases de datos de documentos, lo que crea un híbrido útil entre los dos.

EF7 contiene compatibilidad independiente del proveedor para las columnas JSON, con una implementación para SQL Server. Esta compatibilidad permite la asignación de agregados creados a partir de tipos de .NET a documentos JSON. Las consultas LINQ normales se pueden usar en los agregados y se traducirán a las construcciones de consulta adecuadas necesarias para profundizar en el JSON. EF7 también admite la actualización y guardado de cambios en documentos JSON.

Nota:

La compatibilidad con SQLite para JSON está planeada para la publicación de EF7. Los proveedores de PostgreSQL y Pomelo MySQL ya contienen cierta compatibilidad con columnas JSON. Trabajaremos con los autores de esos proveedores para alinear la compatibilidad con JSON en todos los proveedores.

Asignación a columnas JSON

En EF Core, los tipos de agregado se definen mediante OwnsOne y OwnsMany. Por ejemplo, considere el tipo agregado de nuestro modelo de ejemplo que se usa para almacenar información de contacto:

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

A continuación, se puede usar en un tipo de entidad "propietario", por ejemplo, para almacenar los detalles de contacto de un autor:

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

El tipo de agregado se configura en OnModelCreating mediante OwnsOne:

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

Sugerencia

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

De forma predeterminada, los proveedores de bases de datos relacionales asignan tipos agregados como esta a la misma tabla que el tipo de entidad propietario. Es decir, cada propiedad de las ContactDetails clases y Address se asigna a una columna de la Authors tabla.

Algunos autores guardados con detalles de contacto tendrán este aspecto:

Autores

Identificador Nombre Contact_Address_Street Contact_Address_City Contact_Address_Postcode Contact_Address_Country Contact_Phone
1 Maddy Montaquila 1 Main St Camberwick Green CW1 5ZH Reino Unido 01632 12345
2 Jeremy Likness 2 Main St Chigley CW1 5ZH Reino Unido 01632 12346
3 Daniel Roth 3 Main St Camberwick Green CW1 5ZH Reino Unido 01632 12347
4 Arthur Vickers 15a Main St Chigley CW1 5ZH Reino Unido 01632 22345
5 Brice Lambson 4 Main St Chigley CW1 5ZH Reino Unido 01632 12349

Si lo desea, cada tipo de entidad que compone el agregado se puede asignar a su propia tabla en su lugar:

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

A continuación, los mismos datos se almacenan en tres tablas:

Autores

Identificador Nombre
1 Maddy Montaquila
2 Jeremy Likness
3 Daniel Roth
4 Arthur Vickers
5 Brice Lambson

Contactos

AuthorId Teléfono
1 01632 12345
2 01632 12346
3 01632 12347
4 01632 22345
5 01632 12349

Direcciones

ContactDetailsAuthorId Calle Ciudad Código postal Country
1 1 Main St Camberwick Green CW1 5ZH Reino Unido
2 2 Main St Chigley CW1 5ZH Reino Unido
3 3 Main St Camberwick Green CW1 5ZH Reino Unido
4 15a Main St Chigley CW1 5ZH Reino Unido
5 4 Main St Chigley CW1 5ZH Reino Unido

Ahora viene la parte interesante. En EF7, el ContactDetails tipo de agregado se puede asignar a una columna JSON. Esto solo requiere una llamada a al ToJson() configurar el tipo de agregado:

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

La Authors tabla contendrá ahora una columna JSON para ContactDetails rellenarla con un documento JSON para cada autor:

Autores

Identificador Nombre Contacto
1 Maddy Montaquila {
  "Teléfono":"01632 12345",
  "Dirección": {
    "Coiudad":"Camberwick Green",
    "País":"UK",
    "Código postal":"CW1 5ZH",
    "Calle":"1 Main St"
  }
}
2 Jeremy Likness {
  "Teléfono":"01632 12346",
  "Dirección": {
    "Ciudad":"Chigley",
    "País":"UK",
    "Código postal":"CH1 5ZH",
    "Calle":"2 Main St"
  }
}
3 Daniel Roth {
  "Teléfono":"01632 12347",
  "Dirección": {
    "Coiudad":"Camberwick Green",
    "País":"UK",
    "Código postal":"CW1 5ZH",
    "Calle":"3 Main St"
  }
}
4 Arthur Vickers {
  "Teléfono":"01632 12348",
  "Dirección": {
    "Ciudad":"Chigley",
    "País":"UK",
    "Código postal":"CH1 5ZH",
    "Calle":"15a Main St"
  }
}
5 Brice Lambson {
  "Teléfono":"01632 12349",
  "Dirección": {
    "Ciudad":"Chigley",
    "País":"UK",
    "Código postal":"CH1 5ZH",
    "Calle":"4 Main St"
  }
}

Sugerencia

Este uso de agregados es muy similar a la forma en que se asignan los documentos JSON al usar el proveedor de EF Core para Azure Cosmos DB. Las columnas JSON proporcionan las funcionalidades de uso de EF Core en bases de datos de documentos a documentos incrustados en una base de datos relacional.

Los documentos JSON mostrados anteriormente son muy sencillos, pero esta funcionalidad de asignación también se puede usar con estructuras de documentos más complejas. Por ejemplo, considere otro tipo de agregado del modelo de ejemplo, que se usa para representar metadatos sobre una publicación:

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

Este tipo de agregado contiene varios tipos y colecciones anidados. Las llamadas a OwnsOne y OwnsMany se usan para asignar este tipo de agregado:

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

Sugerencia

ToJson solo es necesario en la raíz de agregado para asignar todo el agregado a un documento JSON.

Con esta asignación, EF7 puede crear y consultar en un documento JSON complejo como este:

{
  "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"
        }
      ]
    }
  ]
}

Nota:

Todavía no se admite la asignación de tipos espaciales a JSON. El documento anterior usa double valores como solución alternativa. Vote por Admitir tipos espaciales en columnas JSON si esto es algo que le interesa.

Nota:

Todavía no se admite la asignación de colecciones de tipos primitivos a JSON. El documento anterior usa un convertidor de valores para transformar la colección en una cadena separada por comas. Vote por Json: agregue compatibilidad con la recopilación de tipos primitivos si esto es algo que le interesa.

Nota:

La asignación de tipos de propiedad a JSON aún no se admite junto con la herencia de TPT o TPC. Vote por Las propiedades JSON de soporte técnico con la asignación de herencia de TPT/TPC si esto es algo que le interesa.

Consultas en columnas JSON

Las consultas en columnas JSON funcionan igual que las consultas en cualquier otro tipo de agregado en EF Core. Es decir, use LINQ! A continuación se muestran algunos ejemplos.

Una consulta para todos los autores que residen en Chigley:

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

Esta consulta genera el siguiente SQL cuando se utiliza 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 el uso de JSON_VALUE para obtener desde City dentro del Address documento JSON.

Select se puede usar para extraer y proyectar elementos del documento JSON:

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

Esta consulta genera el siguiente 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'

Este es un ejemplo que hace un poco más en el filtro y la proyección, y también ordena por el número de teléfono en el 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();

Esta consulta genera el siguiente 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))

Y cuando el documento JSON contiene colecciones, estos se pueden proyectar en los 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();

Esta consulta genera el siguiente 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

Nota:

Las consultas más complejas que implican colecciones JSON requieren compatibilidad con jsonpath. Vote por la consulta jsonpath de soporte técnico si esto es algo que le interesa.

Sugerencia

Considere la posibilidad de crear índices para mejorar el rendimiento de las consultas en documentos JSON. Por ejemplo, consulte Indexación de datos Json al usar SQL Server.

Actualización de columnas JSON

SaveChanges y SaveChangesAsync funcionan de la manera normal de realizar actualizaciones en una columna JSON. Para realizar cambios exhaustivos, se actualizará todo el documento. Por ejemplo, reemplazando la mayoría del Contact documento para un 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();

En este caso, se pasa todo el nuevo documento como 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']

A continuación, se usa en SQL UPDATE:

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

Sin embargo, si solo se cambia un subdocumento, EF Core usará un JSON_MODIFY comando para actualizar solo el subdocumento. Por ejemplo, cambiar el elemento Address dentro de un Contact documento:

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

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

await context.SaveChangesAsync();

Genera los parámetros siguientes:

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 se usa en a UPDATE través de una JSON_MODIFY llamada:

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

Por último, si solo se cambia una sola propiedad, EF Core volverá a usar un comando "JSON_MODIFY", esta vez para aplicar revisiones solo al valor de propiedad cambiado. Por ejemplo:

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

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

await context.SaveChangesAsync();

Genera los parámetros siguientes:

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 se usan de nuevo con una JSON_MODIFY:

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

ExecuteUpdate y ExecuteDelete (actualización masiva)

De forma predeterminada, EF Core realiza un seguimiento de los cambios en las entidadesy, a continuación , envía actualizaciones a la base de datos cuando se llama a uno de los SaveChanges métodos. Los cambios solo se envían para propiedades y relaciones que realmente han cambiado. Además, las entidades con seguimiento permanecen sincronizadas con los cambios enviados a la base de datos. Este mecanismo es una manera eficaz y cómoda de enviar inserciones, actualizaciones y eliminaciones de uso general a la base de datos. Estos cambios también se procesan por lotes para reducir el número de recorridos de ida y vuelta de la base de datos.

Sin embargo, a veces resulta útil ejecutar comandos de actualización o eliminación en la base de datos sin implicar el seguimiento de cambios. EF7 lo habilita con los nuevos ExecuteUpdate métodos y ExecuteDelete. Estos métodos se aplican a una consulta LINQ y actualizarán o eliminarán entidades en la base de datos en función de los resultados de esa consulta. Muchas entidades se pueden actualizar con un solo comando y las entidades no se cargan en la memoria, lo que significa que esto puede dar lugar a actualizaciones y eliminaciones más eficaces.

Sin embargo, tenga en cuenta que:

  • Los cambios específicos que se deben realizar deben especificarse explícitamente; EF Core no los detecta automáticamente.
  • Las entidades con seguimiento no se mantendrán sincronizadas.
  • Es posible que sea necesario enviar comandos adicionales en el orden correcto para no infringir las restricciones de la base de datos. Por ejemplo, eliminar dependientes antes de que se pueda eliminar una entidad de seguridad.

Todo esto significa que los ExecuteUpdate métodos y ExecuteDelete complementan, en lugar de reemplazar, el mecanismo existente SaveChanges.

Ejemplos básicos ExecuteDelete

Sugerencia

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

Al llamar a ExecuteDelete o en una DbSet instancia inmediatamente se eliminan todas las entidades de esa DbSet de la ExecuteDeleteAsync base de datos. Por ejemplo, para eliminar todas las Tag entidades:

await context.Tags.ExecuteDeleteAsync();

Esto ejecuta el siguiente SQL cuando se utiliza SQL Server:

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

Y lo que es más interesante, la consulta puede contener un filtro. Por ejemplo:

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

Esto ejecuta el siguiente código SQL:

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

La consulta también puede usar filtros más complejos, incluidas las navegaciones a otros tipos. Por ejemplo, para eliminar etiquetas solo de las entradas de blog antiguas:

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

Que se ejecuta:

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

Ejemplos básicos ExecuteUpdate

Sugerencia

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

ExecuteUpdate y ExecuteUpdateAsync se comportan de una manera muy similar a los ExecuteDelete métodos. La principal diferencia es que una actualización requiere saber qué propiedades actualizar y cómo actualizarlas. Esto se logra mediante una o varias llamadas a SetProperty. Por ejemplo, para actualizar el Name de cada blog:

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

El primer parámetro de SetProperty especifica la propiedad que se va a actualizar; en este caso, Blog.Name. El segundo parámetro especifica cómo se debe calcular el nuevo valor; en este caso, tomando el valor existente y anexando "*Featured!*". El código SQL resultante es:

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

Al igual que con ExecuteDelete, la consulta se puede usar para filtrar qué entidades se actualizan. Además, se pueden usar varias llamadas a SetProperty para actualizar más de una propiedad en la entidad de destino. Por ejemplo, para actualizar y TitleContent de todas las publicaciones 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 + ")"));

En este caso, el SQL generado es un poco más 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

Por último, de nuevo como con ExecuteDelete, el filtro puede hacer referencia a otras tablas. Por ejemplo, para actualizar todas las etiquetas de las publicaciones antiguas:

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

Lo que genera:

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 obtener más información y ejemplos de código en ExecuteUpdate y ExecuteDelete, consulte ExecuteUpdate y ExecuteDelete .

Herencia y varias tablas

ExecuteUpdate y ExecuteDelete solo pueden actuar en una sola tabla. Esto tiene implicaciones al trabajar con diferentes estrategias de asignación de herencia. Por lo general, no hay problemas al usar la estrategia de asignación de TPH, ya que solo hay una tabla que modificar. Por ejemplo, eliminar todas las FeaturedPost entidades:

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

Genera el siguiente código SQL al usar la asignación de TPH:

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

Tampoco hay problemas para este caso al usar la estrategia de asignación de TPC, ya que solo se necesitan cambios en una sola tabla:

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

Sin embargo, al intentarlo al usar la estrategia de asignación de TPT se producirá un error, ya que requeriría eliminar filas de dos tablas diferentes.

Agregar un filtro a la consulta a menudo significa que se producirá un error en la operación con las estrategias TPC y TPT. Esto es de nuevo porque es posible que las filas deba eliminarse de varias tablas. Fijémonos en esta consulta:

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

Genera el siguiente código SQL al 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%')

Pero se produce un error al usar TPC o TPT.

Sugerencia

El problema 10879 realiza un seguimiento de la adición de compatibilidad para enviar automáticamente varios comandos en estos escenarios. Vote por este problema si es algo que le gustaría ver implementado.

ExecuteDelete y relaciones

Como se mencionó anteriormente, puede ser necesario eliminar o actualizar entidades dependientes antes de que se pueda eliminar la entidad de seguridad de una relación. Por ejemplo, cada uno de ellos Post depende de su asociado Author. Esto significa que un autor no se puede eliminar si una publicación todavía hace referencia a él; si lo hace, se infringirá la restricción de clave externa en la base de datos. Por ejemplo, intentando:

await context.Authors.ExecuteDeleteAsync();

Dará como resultado la siguiente excepción en SQL Server:

Microsoft.Data.SqlClient.SqlException (0x80131904): la instrucción DELETE entra en conflicto con la restricción REFERENCE "FK_Posts_Authors_AuthorId". El conflicto se produjo en la base de datos "TphBlogsContext", tabla "dbo. Publicaciones", columna 'AuthorId'. Se terminó la instrucción.

Para corregir esto, primero debemos eliminar las publicaciones o anular la relación entre cada publicación y su autor estableciendo AuthorId la propiedad de clave externa en null. Por ejemplo, con la opción eliminar:

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

Sugerencia

TagWith se puede usar para etiquetar ExecuteDelete o ExecuteUpdate de la misma manera que las consultas normales.

Esto da como resultado dos comandos independientes; primero para eliminar los dependientes:

-- Deleting posts...

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

Y el segundo para eliminar las entidades de seguridad:

-- Deleting authors...

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

Importante

Varios ExecuteDelete comandos y ExecuteUpdate no se incluirán en una sola transacción de forma predeterminada. Sin embargo, las API de transacción dbContext se pueden usar de la manera normal de encapsular estos comandos en una transacción.

Sugerencia

El envío de estos comandos en un solo recorrido de ida y vuelta depende del problema 10879. Vote por este problema si es algo que le gustaría ver implementado.

La configuración de eliminaciones en cascada en la base de datos puede ser muy útil aquí. En nuestro modelo, la relación entre Blog y Post es necesaria, lo que hace que EF Core configure una eliminación en cascada por convención. Esto significa que cuando se elimina un blog de la base de datos, también se eliminarán todas sus entradas dependientes. A continuación, se indica que para eliminar todos los blogs y publicaciones solo necesitamos eliminar los blogs:

await context.Blogs.ExecuteDeleteAsync();

El resultado es el siguiente SQL:

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

Lo que, a medida que elimina un blog, también hará que todas las entradas relacionadas se eliminen mediante la eliminación en cascada configurada.

SaveChanges más rápido

En EF7, el rendimiento de SaveChanges y SaveChangesAsync se ha mejorado significativamente. En algunos escenarios, guardar los cambios ahora es hasta cuatro veces más rápido que con EF Core 6.0.

La mayoría de estas mejoras proceden de:

  • Realizar menos recorridos de ida y vuelta a la base de datos
  • Generación de SQL más rápido

A continuación se muestran algunos ejemplos de estas mejoras.

Nota:

Consulte Anuncio de Entity Framework Core 7 Preview 6: Performance Edition en el blog de .NET para obtener una explicación detallada de estos cambios.

Sugerencia

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

Se eliminan las transacciones innecesarias

Todas las bases de datos relacionales modernas garantizan la transaccionalidad de las instrucciones SQL únicas (la mayoría). Es decir, la instrucción nunca se completará parcialmente, incluso si se produce un error. EF7 evita iniciar una transacción explícita en estos casos.

Por ejemplo, examine el registro de la siguiente llamada a SaveChanges:

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

Muestra que en EF Core 6.0, el INSERT comando se ajusta mediante comandos para comenzar y, a continuación, confirmar una transacción:

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.

EF7 detecta que la transacción no es necesaria aquí y, por tanto, quita estas llamadas:

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

Esto elimina dos recorridos de ida y vuelta de base de datos, lo que puede hacer una gran diferencia en el rendimiento general, especialmente cuando la latencia de las llamadas a la base de datos es alta. En los sistemas de producción típicos, la base de datos no se encuentra en la misma máquina que la aplicación. Esto significa que la latencia suele ser relativamente alta, lo que hace que esta optimización sea especialmente eficaz en los sistemas de producción del mundo real.

SQL mejorado para la inserción de identidad simple

El caso anterior inserta una sola fila con una IDENTITY columna de clave y ningún otro valor generado por la base de datos. EF7 simplifica el CÓDIGO SQL en este caso mediante OUTPUT INSERTED. Aunque esta simplificación no es válida para muchos otros casos, todavía es importante mejorar, ya que este tipo de inserción de una sola fila es muy común en muchas aplicaciones.

Insertar varias filas

En EF Core 6.0, el enfoque predeterminado para insertar varias filas estaba controlado por limitaciones en la compatibilidad de SQL Server con tablas con desencadenadores. Queríamos asegurarnos de que la experiencia predeterminada funcionaba incluso para la minoría de usuarios con desencadenadores en sus tablas. Esto significaba que no se podía usar una cláusula simple OUTPUT, porque, en SQL Server, esto no funciona con desencadenadores. En su lugar, al insertar varias entidades, EF Core 6.0 generó algo bastante complicado de SQL. Por ejemplo, esta llamada a SaveChanges:

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

await context.SaveChangesAsync();

Da como resultado las siguientes acciones cuando se ejecuta en SQL Server con 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

Aunque esto es complicado, el procesamiento por lotes de varias inserciones como esta sigue siendo significativamente más rápido que enviar un solo comando para cada inserción.

En EF7, todavía puede obtener este CÓDIGO SQL si las tablas contienen desencadenadores, pero en el caso común ahora se genera mucho más eficaz, si aún es algo complejo, comandos:

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;

La transacción ha desaparecido, como en el caso de inserción único, porque MERGE es una sola instrucción protegida por una transacción implícita. Además, la tabla temporal ha desaparecido y la cláusula OUTPUT ahora envía los identificadores generados directamente al cliente. Esto puede ser cuatro veces más rápido que en EF Core 6.0, en función de factores de entorno como la latencia entre la aplicación y la base de datos.

Desencadenadores

Si la tabla tiene desencadenadores, la llamada a SaveChanges en el código anterior producirá una excepción:

Excepción no controlada. Microsoft.EntityFrameworkCore.DbUpdateException:
no se pudieron guardar los cambios porque la tabla de destino tiene desencadenadores de base de datos. Configure el tipo de entidad en consecuencia, consulte https://aka.ms/efcore-docs-sqlserver-save-changes-and-triggers para obtener más información.
---> Microsoft.Data.SqlClient.SqlException (0x80131904):
la tabla de destino "BlogsWithTriggers" de la instrucción DML no puede tener ningún desencadenador habilitado si la instrucción contiene una cláusula OUTPUT sin cláusula INTO.

El código siguiente se puede usar para informar a EF Core de que la tabla tiene un desencadenador:

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

EF7 revertirá a EF Core 6.0 SQL al enviar comandos de inserción y actualización para esta tabla.

Para obtener más información, incluida una convención para configurar automáticamente todas las tablas asignadas con desencadenadores, consulte Tablas de SQL Server con desencadenadores que ahora requieren una configuración especial de EF Core en la documentación de cambios importantes de EF7.

Menos recorridos de ida y vuelta para insertar gráficos

Considere la posibilidad de insertar un gráfico de entidades que contengan una nueva entidad principal y también nuevas entidades dependientes con claves externas que hagan referencia a la nueva entidad de seguridad. Por ejemplo:

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

Si la base de datos genera la clave principal de la entidad de seguridad, el valor que se va a establecer para la clave externa en el dependiente no se conoce hasta que se haya insertado la entidad de seguridad. EF Core genera dos recorridos de ida y vuelta para que este-uno inserte la entidad de seguridad y devuelva la nueva clave principal y un segundo para insertar los dependientes con el valor de clave externa establecido. Y como hay dos instrucciones para esto, se necesita una transacción, lo que significa que hay en total cuatro recorridos de ida y vuelta:

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.

Sin embargo, en algunos casos, se conoce el valor de clave principal antes de insertar la entidad de seguridad. Esto incluye:

  • Valores de clave que no se generan automáticamente
  • Valores de clave que se generan en el cliente, como Guid claves
  • Valores de clave que se generan en el servidor en lotes, como cuando se usa un generador de valores hi-lo

En EF7, estos casos ahora están optimizados en un solo recorrido de ida y vuelta. Por ejemplo, en el caso anterior en SQL Server, la Blog.Id clave principal se puede configurar para usar la estrategia de generación hi-lo:

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

La SaveChanges llamada anterior ahora está optimizada para un solo recorrido de ida y vuelta para las inserciones.

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.

Tenga en cuenta que todavía se necesita una transacción aquí. Esto se debe a que las inserciones se realizan en dos tablas independientes.

EF7 también usa un único lote en otros casos en los que EF Core 6.0 crearía más de uno. Por ejemplo, al eliminar e insertar filas en la misma tabla.

Valor de SaveChanges

Como se muestra en algunos de los ejemplos que se muestran aquí, guardar los resultados en la base de datos puede ser un negocio complejo. Aquí es donde el uso de algo como EF Core muestra realmente su valor. EF Core:

  • Agrupa varios comandos de inserción, actualización y eliminación para reducir los recorridos de ida y vuelta
  • Averiguar si se necesita o no una transacción explícita
  • Determina el orden de insertar, actualizar y eliminar entidades para que no se infrinjan las restricciones de base de datos
  • Garantiza que los valores generados por la base de datos se devuelvan de forma eficaz y se propaguen a entidades
  • Establece automáticamente los valores de clave externa mediante los valores generados para las claves principales
  • Prueba los conflictos de simultaneidad

Además, los distintos sistemas de base de datos requieren un SQL diferente para muchos de estos casos. El proveedor de bases de datos de EF Core funciona con EF Core para asegurarse de que se envían comandos correctos y eficaces para cada caso.

Asignación de herencia de tipo tabla por concreto (TPC)

De forma predeterminada, EF Core asigna una jerarquía de herencia de tipos .NET a una tabla de base de datos única. Esto se conoce como estrategia de asignación de tabla por jerarquía (TPH). EF Core 5.0 introdujo la estrategia de tabla por tipo (TPT), que admite la asignación de cada tipo de .NET a una tabla de base de datos diferente. EF7 presenta la estrategia de tipo tabla por concreto (TPC). TPC también asigna tipos de .NET a tablas diferentes, pero de una manera que soluciona algunos problemas comunes de rendimiento con la estrategia de TPT.

Sugerencia

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

Sugerencia

El equipo de EF mostró y habló en profundidad sobre la asignación de TPC en un episodio del .NET Data Community Standup. Al igual que con todos los episodiosde Community Standup, puedes ver el episodio de TPC ahora mismo en YouTube.

El esquema de base de datos

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

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

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

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

    public Food? Food { get; set; }
}

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

    public string? Vet { get; set; }

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

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

    public override string Species { get; }

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

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

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

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

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

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

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

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

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

    public override string Species => "Homo sapiens";

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

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

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

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

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

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

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

Tenga en lo siguiente:

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

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

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

Tabla de gatos

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

Mesa de perros

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

Tabla FarmAnimals

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

Tabla de seres humanos

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

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

Configuración de la herencia de TPC

Todos los tipos de una jerarquía de herencia deben incluirse explícitamente en el modelo al asignar la jerarquía con EF Core. Esto se puede hacer mediante la creación de propiedades DbSet en su 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>();

O bien mediante el método Entity en OnModelCreating:

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

Importante

Esto es diferente del comportamiento heredado de EF6, donde los tipos derivados de tipos base asignados se detectarían automáticamente si estuvieran contenidos en el mismo ensamblado.

No es necesario hacer nada más para asignar la jerarquía como TPH, ya que es la estrategia predeterminada. Sin embargo, a partir de EF7, el TPH se puede hacer explícito llamando UseTphMappingStrategy al tipo base de la jerarquía:

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

Para usar TPT en su lugar, cambie esto a UseTptMappingStrategy:

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

Del mismo modo, UseTpcMappingStrategy se usa para configurar TPC:

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

En cada caso, el nombre de tabla usado para cada tipo se toma del nombre de propiedad DbSet en su DbContext, o se puede configurar mediante el método builder ToTable o el atributo [Table].

Rendimiento de consultas SQL

En el caso de las consultas, la estrategia de TPC es una mejora de TPT porque garantiza que la información de una instancia de entidad determinada siempre se almacena en una sola tabla. Esto significa que la estrategia de TPC puede ser útil cuando la jerarquía asignada es grande y tiene muchos tipos concretos (normalmente hoja), cada uno con un gran número de propiedades y donde solo se usa un pequeño subconjunto de tipos en la mayoría de las consultas.

El CÓDIGO SQL generado para tres consultas LINQ simples se puede usar para observar dónde funciona bien TPC en comparación con TPH y TPT. Estas consultas son:

  1. Consulta que devuelve entidades de todos los tipos de la jerarquía:

    context.Animals.ToList();
    
  2. Consulta que devuelve entidades de un subconjunto de tipos en la jerarquía:

    context.Pets.ToList();
    
  3. Consulta que devuelve solo entidades de un tipo hoja único en la jerarquía:

    context.Cats.ToList();
    

Consultas TPH

Cuando se usa TPH, las tres consultas solo consultan una sola tabla, pero con filtros diferentes en la columna discriminante:

  1. TPH SQL que devuelve entidades de todos los tipos de la jerarquía:

    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 que devuelve entidades de un subconjunto de tipos en la jerarquía:

    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 que devuelve solo entidades de un tipo hoja único en la jerarquía:

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

Todas estas consultas deben funcionar bien, especialmente con un índice de base de datos adecuado en la columna discriminador.

Consultas TPT

Al usar TPT, todas estas consultas requieren combinar varias tablas, ya que los datos de cualquier tipo concreto determinado se dividen en muchas tablas:

  1. TPT SQL que devuelve entidades de todos los tipos de la jerarquía:

    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 que devuelve entidades de un subconjunto de tipos en la jerarquía:

    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 que devuelve solo entidades de un tipo hoja único en la jerarquía:

    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]
    

Nota:

EF Core usa "síntesis discriminatoria" para determinar de qué tabla proceden los datos y, por tanto, el tipo correcto que se va a usar. Esto funciona porque LEFT JOIN devuelve valores NULL para la columna id. dependiente (las "sub tablas") que no son el tipo correcto. Por lo tanto, para un perro, [d].[Id] será distinto de NULL, y todos los demás identificadores (concretos) serán NULL.

Todas estas consultas pueden sufrir problemas de rendimiento debido a las combinaciones de tablas. Este es el motivo por el que TPT nunca es una buena opción para el rendimiento de las consultas.

Consultas TPC

TPC mejora el TPT para todas estas consultas porque se reduce el número de tablas que se deben consultar. Además, los resultados de cada tabla se combinan mediante UNION ALL, que puede ser considerablemente más rápido que una combinación de tabla, ya que no es necesario realizar ninguna coincidencia entre filas o desduplicación de filas.

  1. TPC SQL que devuelve entidades de todos los tipos de la jerarquía:

    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 que devuelve entidades de un subconjunto de tipos en la jerarquía:

    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 que devuelve solo entidades de un tipo hoja único en la jerarquía:

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

Aunque TPC es mejor que TPT para todas estas consultas, las consultas TPH siguen siendo mejores al devolver instancias de varios tipos. Esta es una de las razones por las que el TPH es la estrategia predeterminada que usa EF Core.

Como muestra SQL para la consulta 3, TPC realmente destaca al consultar entidades de un solo tipo hoja. La consulta solo usa una sola tabla y no necesita ningún filtrado.

Inserciones y actualizaciones TPC

TPC también funciona bien al guardar una nueva entidad, ya que esto requiere insertar solo una sola fila en una sola tabla. Esto también es cierto para TPH. Con TPT, las filas deben insertarse en muchas tablas, lo que funcionará menos bien.

A menudo, lo mismo sucede con las actualizaciones, aunque en este caso si todas las columnas que se actualizan están en la misma tabla, incluso para TPT, es posible que la diferencia no sea significativa.

Consideraciones acerca del espacio

Tanto TPT como TPC pueden usar menos almacenamiento que TPH cuando hay muchos subtipos con muchas propiedades que a menudo no se usan. Esto se debe a que todas las filas de la tabla TPH deben almacenar un NULL para cada una de estas propiedades sin usar. En la práctica, esto rara vez es un problema, pero podría ser conveniente tener en cuenta al almacenar grandes cantidades de datos con estas características.

Sugerencia

Si el sistema de base de datos lo admite (por ejemplo, SQL Server), considere la posibilidad de usar "columnas dispersas" para las columnas TPH que rara vez se rellenarán.

Generación de la clave

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

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

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

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

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

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

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

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

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

Restricciones de clave externa

La estrategia de asignación de TPC crea un esquema SQL desnormalizado; este es un motivo por el que algunos puristas de base de datos están en su contra. Por ejemplo, considere la columna FavoriteAnimalIdde clave externa. El valor de esta columna debe coincidir con el valor de clave principal de algún animal. Esto se puede aplicar en la base de datos con una restricción FK simple cuando se usa TPH o TPT. Por ejemplo:

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

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

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

Resumen e instrucciones

En resumen, TPC es una buena estrategia de asignación que se usará cuando el código consulte principalmente las entidades de un solo tipo hoja. Esto se debe a que los requisitos de almacenamiento son más pequeños y no hay ninguna columna discriminante que pueda necesitar un índice. Las inserciones y actualizaciones también son eficaces.

Dicho esto, TPH suele ser adecuado para la mayoría de las aplicaciones y es un buen valor predeterminado para una amplia gama de escenarios, por lo que no agregue la complejidad de TPC si no lo necesita. En concreto, si el código consultará principalmente las entidades de muchos tipos, como escribir consultas en el tipo base, después se inclinará hacia TPH sobre TPC.

Use TPT solo si está restringido para hacerlo por factores externos.

Plantillas personalizadas de ingeniería inversa

Ahora puede personalizar el código andamiaje cuando realice ingeniería inversa de un modelo EF a partir de una base de datos. Para empezar, agregue las plantillas predeterminadas al proyecto:

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

Las plantillas pueden personalizarse y serán utilizadas automáticamente por dotnet ef dbcontext scaffold y Scaffold-DbContext.

Para más detalles, consulte Plantillas personalizadas de ingeniería inversa.

Sugerencia

El equipo de EF mostró y habló en profundidad sobre las plantillas de ingeniería inversa en un episodio del .NET Data Community Standup. Al igual que con todos los episodiosde Community Standup, puedes ver el episodio de plantillas T4 ahora en YouTube.

Convenciones de compilación de modelos

EF Core usa un modelo de metadatos para describir cómo se asignan los tipos de entidad de la aplicación a la base de datos subyacente. Este modelo se crea con un conjunto de alrededor de 60 "convenciones". Después, el modelo compilado mediante convenciones se puede personalizar mediante atributos de asignación (también conocidos como "anotaciones de datos") o llamadas a la DbModelBuilder API en OnModelCreating.

A partir de EF7, las aplicaciones ahora pueden quitar o reemplazar cualquiera de estas convenciones, así como agregar nuevas convenciones. Las convenciones de creación de modelos son una forma eficaz de controlar la configuración del modelo, pero pueden ser complejas y difíciles de obtener correctamente. En muchos casos, la configuración del modelo anterior a la convención existente se puede usar en su lugar para especificar fácilmente una configuración común para las propiedades y los tipos.

Los cambios en las convenciones usadas por se DbContext realizan reemplazando el DbContext.ConfigureConventions método. Por ejemplo:

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

Sugerencia

Para buscar todas las convenciones de creación de modelos integradas, busque cada clase que implemente la IConvention interfaz.

Sugerencia

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

Eliminación de una convención existente

A veces, es posible que una de las convenciones integradas no sea adecuada para la aplicación, en cuyo caso se puede quitar.

Ejemplo: no cree índices para columnas de clave externa

Normalmente tiene sentido crear índices para columnas de clave externa (FK) y, por lo tanto, hay una convención integrada para este proceso: ForeignKeyIndexConvention. Al examinar la vista de depuración del modelo para obtener un tipo de entidad Post con relaciones con Blog y Author, podemos ver que se crean dos índices: uno para el elemento BlogId de FK y el otro para el elemento AuthorId de FK.

  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

Los índices tienen sobrecarga y, como se pregunta aquí, puede que no siempre sea apropiado crearlos para todas las columnas FK. Para lograrlo, se puede quitar ForeignKeyIndexConvention al compilar el modelo:

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

Al examinar ahora la vista de depuración del modelo para Post, vemos que no se han creado los índices en los elementos FK:

  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

Cuando se desee, los índices pueden seguir creándose explícitamente para las columnas de clave foránea, ya sea utilizando la función IndexAttribute:

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

O bien con la configuración en OnModelCreating:

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

Al volver a examinar el Post tipo de entidad, ahora contiene el BlogId índice, pero no el AuthorId índice:

  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

Sugerencia

Si el modelo no usa atributos de asignación (también conocidos como anotaciones de datos) para la configuración, todas las convenciones cuyo nombre termine en AttributeConvention se pueden quitar de forma segura para acelerar la creación de modelos.

Adición de una nueva convención

Quitar las convenciones existentes es un inicio, pero ¿qué ocurre con la adición de convenciones de creación de modelos completamente nuevas? EF7 también admite esto.

Ejemplo: Restricción de la longitud de las propiedades discriminatorias

La estrategia de asignación de herencia de tabla por jerarquía requiere una columna discriminatoria para especificar qué tipo se representa en cualquier fila determinada. De forma predeterminada, EF usa una columna de cadena sin enlazar para el discriminador, lo que garantiza que funcionará para cualquier longitud de discriminador. Sin embargo, restringir la longitud máxima de las cadenas discriminatorias puede hacer que el almacenamiento y las consultas sean más eficaces. Vamos a crear una nueva convención que lo hará.

Las convenciones de compilación de modelos de EF Core se desencadenan en función de los cambios realizados en el modelo a medida que se compila. Esto mantiene actualizado el modelo a medida que se realiza una configuración explícita, se aplican los atributos de asignación y se ejecutan otras convenciones. Para participar en esto, cada convención implementa una o varias interfaces que determinan cuándo se desencadenará la convención. Por ejemplo, una convención que implementa IEntityTypeAddedConvention se desencadenará cada vez que se agregue un nuevo tipo de entidad al modelo. Del mismo modo, una convención que implementa IForeignKeyAddedConvention y IKeyAddedConvention se desencadenará cada vez que se agregue una clave o una clave externa al modelo.

Saber qué interfaces implementar pueden ser complicadas, ya que la configuración realizada en el modelo en un punto puede cambiarse o quitarse en un momento posterior. Por ejemplo, una clave se puede crear por convención, pero posteriormente se reemplaza cuando se configura explícitamente una clave diferente.

Vamos a hacer esto un poco más concreto mediante un primer intento de implementar la convención de longitud discriminadora:

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

Esta convención implementa IEntityTypeBaseTypeChangedConvention, lo que significa que se desencadenará cada vez que se cambie la jerarquía de herencia asignada para un tipo de entidad. A continuación, la convención busca y configura la propiedad de discriminador de cadena para la jerarquía.

A continuación, se usa esta convención llamando a Add en ConfigureConventions:

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

Sugerencia

En lugar de agregar una instancia de la convención directamente, el Add método acepta un generador para crear instancias de la convención. Esto permite que la convención use dependencias del proveedor de servicios interno de EF Core. Dado que esta convención no tiene dependencias, el parámetro del proveedor de servicios se denomina _, lo que indica que nunca se usa.

Al crear el modelo y examinar el Post tipo de entidad se muestra que esta propiedad ha funcionado; la propiedad discriminadora ahora está configurada para con una longitud máxima de 24:

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

¿Pero qué ocurre si ahora configuramos explícitamente una propiedad discriminador diferente? Por ejemplo:

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

Al examinar la vista de depuración del modelo, encontramos que la longitud del discriminador ya no está configurada.

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

Esto se debe a que la propiedad discriminador que configuramos en nuestra convención se quitó posteriormente cuando se agregó el discriminador personalizado. Podríamos intentar corregirlo implementando otra interfaz en nuestra convención para reaccionar a los cambios discriminatorios, pero averiguar qué interfaz implementar no es fácil.

Afortunadamente, hay una manera diferente de abordar esto que hace que las cosas sean mucho más fáciles. Mucho tiempo, no importa el aspecto del modelo mientras se compila, siempre y cuando el modelo final sea correcto. Además, la configuración que queremos aplicar a menudo no necesita desencadenar otras convenciones para reaccionar. Por lo tanto, nuestra convención puede implementar IModelFinalizingConvention. Las convenciones de finalización del modelo se ejecutan una vez completada la compilación del modelo y, por tanto, tienen acceso al estado final del modelo. Normalmente, una convención de finalización de modelos recorre en iteración todos los elementos del modelo que configuran los elementos del modelo a medida que va. Por lo tanto, en este caso, encontraremos todos los discriminadores en el modelo y los configuraremos:

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

Después de compilar el modelo con esta nueva convención, encontramos que la longitud del discriminador ahora está configurada correctamente aunque se haya personalizado:

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

Solo para divertirse, vamos a ir un paso más allá y configurar la longitud máxima para que sea la longitud del valor discriminador más largo.

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

Ahora la longitud máxima de la columna discriminadora es 8, que es la longitud de "Destacado", el valor discriminador más largo en uso.

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

Sugerencia

Es posible que se pregunte si la convención también debe crear un índice para la columna discriminante. Hay una discusión sobre esto en GitHub. La respuesta corta es que a veces un índice podría ser útil, pero la mayoría de las veces probablemente no lo será. Por lo tanto, es mejor crear índices adecuados aquí según sea necesario, en lugar de tener una convención para hacerlo siempre. Pero si no está de acuerdo con esto, la convención anterior también se puede modificar para crear un índice.

Ejemplo: Longitud predeterminada para todas las propiedades de cadena

Echemos un vistazo a otro ejemplo en el que se puede usar una convención de finalización: esta vez, estableciendo una longitud máxima predeterminada para cualquier propiedad de cadena, tal como se solicita en GitHub. La convención es bastante similar al ejemplo 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);
        }
    }
}

Esta convención es bastante sencilla. Busca cada propiedad de cadena en el modelo y establece su longitud máxima en 512. Al examinar la vista de depuración en las propiedades de Post, vemos que todas las propiedades de cadena ahora tienen una longitud máxima 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)

Pero la propiedad Content probablemente debería permitir más de 512 caracteres, o todas nuestras publicaciones serán bastante cortas! Esto se puede hacer sin cambiar nuestra convención configurando explícitamente la longitud máxima para esta propiedad, ya sea mediante un atributo de asignación:

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

O con código en OnModelCreating:

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

Ahora todas las propiedades tienen una longitud máxima de 512, excepto Content la que se configuró explícitamente con 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)

¿Por qué no invalidó nuestra convención la longitud máxima configurada explícitamente? La respuesta es que EF Core realiza un seguimiento de cómo se realizó cada parte de la configuración. Esto se representa mediante la enumeración ConfigurationSource. Los distintos tipos de configuración son:

  • Explicit: el elemento de modelo se configuró explícitamente en OnModelCreating
  • DataAnnotation: el elemento de modelo se configuró mediante un atributo de asignación (también conocido como anotación de datos) en el tipo CLR
  • Convention: el elemento de modelo se configuró mediante una convención de creación de modelos

Las convenciones nunca invalidan la configuración marcada como DataAnnotation o Explicit. Esto se consigue utilizando un "constructor de convenciones", por ejemplo, el IConventionPropertyBuilder, que se obtiene de la propiedad Builder. Por ejemplo:

property.Builder.HasMaxLength(512);

Al llamar HasMaxLength a en el generador de convenciones solo se establecerá la longitud máxima si aún no se configuró mediante un atributo de asignación o en OnModelCreating.

Los métodos del generador como este también tienen un segundo parámetro: fromDataAnnotation. Establézcalo true en si la convención realiza la configuración en nombre de un atributo de asignación. Por ejemplo:

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

Esto establece el ConfigurationSource en DataAnnotation, lo que significa que el valor ahora se puede invalidar mediante la asignación explícita en OnModelCreating, pero no mediante convenciones de atributo que no son de asignación.

Por último, antes de dejar este ejemplo, ¿qué ocurre si usamos MaxStringLengthConvention y DiscriminatorLengthConvention3 al mismo tiempo? La respuesta es que depende del orden en que se agregan, ya que las convenciones de finalización del modelo se ejecutan en el orden en que se agregan. Por lo tanto, si MaxStringLengthConvention se agrega por último, se ejecutará en último lugar y establecerá la longitud máxima de la propiedad discriminadora en 512. Por lo tanto, en este caso, es mejor agregar DiscriminatorLengthConvention3 el último para que pueda invalidar la longitud máxima predeterminada para solo las propiedades discriminadora, al tiempo que deja todas las demás propiedades de cadena como 512.

Eliminación de una convención existente

A veces, en lugar de quitar una convención existente por completo, queremos reemplazarla por una convención que hace básicamente lo mismo, pero con el comportamiento cambiado. Esto resulta útil porque la convención existente ya implementará las interfaces que necesita para que se desencadene correctamente.

Ejemplo: asignación de propiedades de participación

EF Core asigna todas las propiedades públicas de lectura y escritura por convención. Esto podría no ser adecuado para la forma en que se definen los tipos de entidad. Para cambiar esto, podemos reemplazar por PropertyDiscoveryConvention nuestra propia implementación que no asigna ninguna propiedad a menos que se asigne explícitamente en OnModelCreating o se marque con un nuevo atributo denominado Persist:

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

Esta es la nueva convención:

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

Sugerencia

Al reemplazar una convención integrada, la nueva implementación de convención debe heredar de la clase de convención existente. Tenga en cuenta que algunas convenciones tienen implementaciones relacionales o específicas del proveedor, en cuyo caso la nueva implementación de convención debe heredar de la clase de convención más específica para el proveedor de base de datos en uso.

A continuación, la convención se registra mediante el Replace método en ConfigureConventions:

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

Sugerencia

Este es un caso en el que la convención existente tiene dependencias, representadas por el ProviderConventionSetBuilderDependencies objeto de dependencia. Estos se obtienen del proveedor de servicios interno mediante GetRequiredService y se pasan al constructor de convención.

Esta convención funciona obteniendo todas las propiedades y campos legibles del tipo de entidad especificado. Si el miembro se atribuye a [Persist], se asigna mediante una llamada a:

entityTypeBuilder.Property(memberInfo);

Por otro lado, si el miembro es una propiedad que, de lo contrario, se habría asignado, se excluye del modelo mediante:

entityTypeBuilder.Ignore(propertyInfo.Name);

Tenga en cuenta que esta convención permite asignar campos (además de las propiedades) siempre que estén marcados con [Persist]. Esto significa que podemos usar campos privados como claves ocultas en el modelo.

Por ejemplo, considere los tipos de entidad siguientes:

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

El modelo creado a partir de estos tipos de entidad es:

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 se habría asignado, pero como no está marcado con [Perist] (presumiblemente porque la limpieza no es una propiedad persistente de lavandería), ahora se trata como una propiedad sin asignar.

Sugerencia

Esta convención no se pudo implementar como una convención de finalización del modelo porque la asignación de una propiedad desencadena muchas otras convenciones para ejecutarse para configurar aún más la propiedad asignada.

Asignación de procedimientos almacenados

De forma predeterminada, EF Core genera comandos de inserción, actualización y eliminación que funcionan directamente con tablas o vistas actualizables. EF7 presenta compatibilidad con la asignación de estos comandos a procedimientos almacenados.

Sugerencia

EF Core siempre admite consultas a través de procedimientos almacenados. La nueva compatibilidad con EF7 consiste explícitamente en usar procedimientos almacenados para inserciones, actualizaciones y eliminaciones.

Importante

La compatibilidad con la asignación de procedimientos almacenados no implica que se recomiendan procedimientos almacenados.

Los procedimientos almacenados se asignan OnModelCreating mediante InsertUsingStoredProcedure, UpdateUsingStoredProcedure, y DeleteUsingStoredProcedure. Por ejemplo, para asignar procedimientos almacenados para un Person tipo de entidad:

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

Esta configuración se asigna a los siguientes procedimientos almacenados cuando se usa SQL Server:

Para inserciones

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

Para actualizaciones

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 las eliminaciones

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

Sugerencia

No es necesario usar procedimientos almacenados para cada tipo del modelo o para todas las operaciones de un tipo determinado. Por ejemplo, si solo DeleteUsingStoredProcedure se especifica para un tipo determinado, EF Core generará SQL como normal para las operaciones de inserción y actualización y solo usará el procedimiento almacenado para las eliminaciones.

El primer argumento pasado a cada método es el nombre del procedimiento almacenado. Esto se puede omitir, en cuyo caso EF Core usará el nombre de tabla anexado con "_Insert", "_Update" o "_Delete". Por lo tanto, en el ejemplo anterior, dado que la tabla se denomina "People", los nombres de procedimiento almacenado se pueden quitar sin ningún cambio en la funcionalidad.

El segundo argumento es un generador que se usa para configurar la entrada y salida del procedimiento almacenado, incluidos los parámetros, los valores devueltos y las columnas de resultados.

Parámetros

Los parámetros se deben agregar al generador en el mismo orden que aparecen en la definición del procedimiento almacenado.

Nota:

Los parámetros se pueden denominar, pero EF Core siempre llama a procedimientos almacenados mediante argumentos posicionales en lugar de argumentos con nombre. Vote a favor de Permitir configurar el mapeo sproc para utilizar nombres de parámetros para la invocación si llamar por nombre es algo que le interesa.

El primer argumento para cada método del generador de parámetros especifica la propiedad del modelo al que está enlazado el parámetro. Puede ser una expresión lambda:

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

O una cadena, que es especialmente útil al asignar propiedades de sombra:

storedProcedureBuilder.HasParameter("Name");

Los parámetros están configurados de forma predeterminada para "input". Los parámetros "Output" o "input/output" se pueden configurar mediante un generador anidado. Por ejemplo:

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

Hay tres métodos de generador diferentes para diferentes tipos de parámetros:

  • HasParameter especifica un parámetro normal enlazado al valor actual de la propiedad especificada.
  • HasOriginalValueParameter especifica un parámetro enlazado al valor original de la propiedad especificada. El valor original es el valor que tenía la propiedad cuando se consultaba desde la base de datos, si se conoce. Si no se conoce este valor, se usa en su lugar el valor actual. Los parámetros de valor originales son útiles para los tokens de simultaneidad.
  • HasRowsAffectedParameter especifica un parámetro utilizado para devolver el número de filas afectadas por el procedimiento almacenado.

Sugerencia

Los parámetros de valor originales deben usarse para los valores de clave en los procedimientos almacenados "update" y "delete". Esto garantiza que la fila correcta se actualizará en versiones futuras de EF Core que admiten valores de clave mutables.

Valores devueltos

EF Core admite tres mecanismos para devolver valores de procedimientos almacenados:

  • Parámetros de salida, como se muestra anteriormente.
  • Columnas de resultados, que se especifican mediante el HasResultColumn método generador.
  • Valor devuelto, que se limita a devolver el número de filas afectadas y se especifica mediante el HasRowsAffectedReturnValue método generador.

Los valores devueltos a partir de procedimientos almacenados a menudo se usan para valores generados, predeterminados o calculados, como desde una Identity clave o una columna calculada. Por ejemplo, la siguiente configuración especifica cuatro columnas 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);
        });

Se usan para devolver:

  • Valor de clave generado para la Id propiedad.
  • Valor predeterminado generado por la base de datos para la FirstRecordedOn propiedad.
  • Valor calculado generado por la base de datos para la RetrievedOn propiedad.
  • Token de simultaneidad generado rowversion automáticamente para la RowVersion propiedad.

Esta configuración se asigna al siguiente procedimiento almacenado cuando se usa 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

Simultaneidad optimista

La simultaneidad optimista funciona de la misma manera con los procedimientos almacenados que no. El procedimiento almacenado debe:

  • Use un token de simultaneidad en una WHERE cláusula para asegurarse de que la fila solo se actualiza si tiene un token válido. El valor usado para el token de simultaneidad suele ser, pero no es necesario, el valor original de la propiedad del token de simultaneidad.
  • Devuelve el número de filas afectadas para que EF Core pueda compararlo con el número esperado de filas afectadas y producir un DbUpdateConcurrencyException si los valores no coinciden.

Por ejemplo, el siguiente procedimiento almacenado de SQL Server usa un rowversion token de simultaneidad automática:

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

Esto se configura en EF Core mediante:

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

Tenga en lo siguiente:

  • Se usa el valor original del RowVersion token de simultaneidad.
  • El procedimiento almacenado usa una WHERE cláusula para asegurarse de que la fila solo se actualiza si el RowVersion valor original coincide.
  • El nuevo valor generado para RowVersion se inserta en una tabla temporal.
  • Se devuelve el número de filas afectadas (@@ROWCOUNT) y el valor generado RowVersion.

Asignación de jerarquías de herencia a procedimientos almacenados

EF Core requiere que los procedimientos almacenados sigan el diseño de tabla para los tipos de una jerarquía. Esto significa que:

  • Una jerarquía asignada mediante TPH debe tener un único procedimiento almacenado de inserción, actualización o eliminación destinado a la tabla asignada única. Los procedimientos almacenados de inserción y actualización deben tener un parámetro para el valor discriminador.
  • Una jerarquía asignada mediante TPT debe tener un procedimiento almacenado de inserción, actualización o eliminación para cada tipo, incluidos los tipos abstractos. EF Core realizará varias llamadas según sea necesario para actualizar, insertar y eliminar filas en todas las tablas.
  • Una jerarquía asignada mediante TPC debe tener un procedimiento almacenado de inserción, actualización o eliminación para cada tipo concreto, pero no tipos abstractos.

Nota:

Si usa un único procedimiento almacenado por tipo concreto, independientemente de la estrategia de asignación, es algo que le interesa, vote por Soporte técnico mediante un único sproc por tipo concreto, independientemente de la estrategia de asignación de herencia.

Asignación de tipos de propiedad a procedimientos almacenados

La configuración de procedimientos almacenados para los tipos de propiedad se realiza en el generador de tipos de propiedad anidada. Por ejemplo:

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

Nota:

Actualmente, los procedimientos almacenados para insertar, actualizar y eliminar solo admiten tipos de propiedad deben asignarse a tablas independientes. Es decir, el tipo de propiedad no se puede representar mediante columnas de la tabla de propietarios. Vote por Agregar compatibilidad de división de "tabla" a la asignación de sproc de CUD si se trata de una limitación que le gustaría ver quitada.

Asignación de entidades unidas muchos-a-muchos a procedimientos almacenados

La configuración de procedimientos almacenados de entidades de combinación de varios a varios se puede realizar como parte de la configuración de varios a varios. Por ejemplo:

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

Nuevos y mejorados interceptores y eventos

Los interceptores de EF Core habilitan la intercepción, la modificación o la supresión de operaciones de EF Core. EF Core también incluye eventos tradicionales de .NET y registro.

EF7 incluye las siguientes mejoras para interceptores:

Además, EF7 incluye nuevos eventos de .NET tradicionales para:

En las secciones siguientes se muestran algunos ejemplos de uso de estas nuevas funcionalidades de interceptación.

Acciones simples en la creación de entidades

Sugerencia

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

El nuevo IMaterializationInterceptor admite la interceptación antes y después de crear una instancia de entidad, y antes y después de inicializar las propiedades de esa instancia. El interceptor puede cambiar o reemplazar la instancia de entidad en cada punto. Esto permite:

  • Establecer propiedades no asignadas o métodos de llamada necesarios para la validación, los valores calculados o las marcas.
  • Uso de un generador para crear instancias.
  • La creación de una instancia de entidad diferente a la de EF normalmente crearía, como una instancia de una memoria caché o de un tipo de proxy.
  • Insertar servicios en una instancia de entidad.

Por ejemplo, imagine que queremos realizar un seguimiento del tiempo en que se recuperó una entidad de la base de datos, quizás para que se pueda mostrar a un usuario editando los datos. Para ello, primero definimos una interfaz:

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

El uso de una interfaz es común con interceptores, ya que permite que el mismo interceptor funcione con muchos tipos de entidad diferentes. Por ejemplo:

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 el [NotMapped] atributo se usa para indicar que esta propiedad solo se usa mientras se trabaja con la entidad y no debe conservarse en la base de datos.

A continuación, el interceptor debe implementar el método adecuado desde IMaterializationInterceptor y establecer el tiempo recuperado:

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

        return instance;
    }
}

Se registra una instancia de este interceptor al configurar el 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");
}

Sugerencia

Este interceptor no tiene estado, lo que es común, por lo que se crea y se comparte una sola instancia entre todas las instancias DbContext.

Ahora, cada vez que se consulta desde Customer la base de datos, la Retrieved propiedad se establecerá automáticamente. Por ejemplo:

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

Genera la salida:

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

Inserción de servicios en entidades

Sugerencia

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

EF Core ya tiene compatibilidad integrada para insertar algunos servicios especiales en instancias de contexto; Por ejemplo, consulte Carga diferida sin servidores proxy, que funciona insertando el ILazyLoader servicio.

IMaterializationInterceptor Se puede usar para generalizar esto en cualquier servicio. En el ejemplo siguiente se muestra cómo insertar una clase ILogger en entidades de modo que puedan realizar su propio registro.

Nota:

La inserción de servicios en entidades acopla esos tipos de entidad a los servicios insertados, que algunas personas consideran ser antipatrones.

Como antes, se usa una interfaz para definir lo que se puede hacer.

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

Y los tipos de entidad que registrarán deben implementar esta interfaz. Por ejemplo:

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

Esta vez, el interceptor debe implementar IMaterializationInterceptor.InitializedInstance, al que se llama después de que se haya creado cada instancia de entidad y se hayan inicializado sus valores de propiedad. El interceptor obtiene un ILogger elemento del contexto e inicializa IHasLogger.Logger con él:

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

Esta vez se usa una nueva instancia del interceptor para cada DbContext instancia, ya que el ILogger objeto obtenido puede cambiar por DbContext instancia y ILogger se almacena en caché en el interceptor:

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

Ahora, siempre que Customer.PhoneNumber se cambie, este cambio se registrará en el registro de la aplicación. Por ejemplo:

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

Interceptación de árbol de expresión LINQ

Sugerencia

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

EF Core usa consultas LINQ de .NET. Normalmente, esto implica el uso del compilador de C#, VB o F# para crear un árbol de expresiones que EF Core traduce a continuación en el SQL adecuado. Por ejemplo, considere un método que devuelve una 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();
}

Sugerencia

Esta consulta usa el EF.Property método para especificar la propiedad por la que se va a ordenar. Esto permite que la aplicación pase dinámicamente el nombre de propiedad, lo que permite ordenar por cualquier propiedad del tipo de entidad. Tenga en cuenta que la ordenación por columnas no indexadas puede ser lenta.

Esto funcionará bien siempre que la propiedad utilizada para ordenar siempre devuelva una ordenación estable. Pero esto no siempre puede ser el caso. Por ejemplo, la consulta LINQ anterior genera lo siguiente en SQLite al 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

Si hay varios clientes con el mismo City, la ordenación de esta consulta no es estable. Esto podría dar lugar a resultados que faltan o se duplican a medida que las páginas de usuario a través de los datos.

Una manera común de solucionar este problema es realizar una ordenación secundaria por clave principal. Sin embargo, en lugar de agregarlo manualmente a cada consulta, EF7 permite la interceptación del árbol de expresiones de consulta donde se puede agregar dinámicamente la ordenación secundaria. Para facilitar esto, volveremos a usar una interfaz, esta vez para cualquier entidad que tenga una clave principal de entero:

public interface IHasIntKey
{
    int Id { get; }
}

Esta interfaz se implementa mediante los tipos de entidad de interés:

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

A continuación, necesitamos un 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);
        }
    }
}

Esto probablemente se ve bastante complicado---y lo es! Trabajar con árboles de expresión normalmente no es fácil. Echemos un vistazo a lo que está sucediendo:

  • Fundamentalmente, el interceptor encapsula un ExpressionVisitor. El visitante invalida VisitMethodCall, al que se llamará cada vez que haya una llamada a un método en el árbol de expresiones de consulta.

  • El visitante comprueba si se trata de una llamada al método en el OrderBy que estamos interesados.

  • Si es así, el visitante comprueba aún más si la llamada al método genérico es para un tipo que implementa nuestra IHasIntKey interfaz.

  • En este punto sabemos que la llamada al método es de la forma OrderBy(e => ...). Extraemos la expresión lambda de esta llamada y obtenemos el parámetro usado en esa expresión, es decir, el e.

  • Ahora compilamos un nuevo MethodCallExpression mediante el Expression.Call método builder. En este caso, el método al que se llama es ThenBy(e => e.Id). Se compila mediante el parámetro extraído anteriormente y un acceso de propiedad a la Id propiedad de la IHasIntKey interfaz.

  • La entrada en esta llamada es la original OrderBy(e => ...)y, por tanto, el resultado final es una expresión para OrderBy(e => ...).ThenBy(e => e.Id).

  • Esta expresión modificada se devuelve del visitante, lo que significa que la consulta LINQ se ha modificado correctamente para incluir una ThenBy llamada.

  • EF Core continúa y compila esta expresión de consulta en el SQL adecuado para la base de datos que se usa.

Este interceptor se registra de la misma manera que lo hicimos en el primer ejemplo. GetPageOfCustomers La ejecución ahora genera el siguiente código 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

Esto siempre producirá una ordenación estable, incluso si hay varios clientes con el mismo City.

Por suerte, Este es un montón de código para realizar un cambio sencillo en una consulta. Y aún peor, es posible que ni siquiera funcione para todas las consultas. Es notoriamente difícil escribir un visitante de expresiones que reconozca todas las formas de consulta que debe y ninguna de las que no debería. Por ejemplo, es probable que esto no funcione si la ordenación se realiza en una subconsulta.

Esto nos lleva a un punto crítico sobre los interceptores, siempre se pregunta si hay una manera más fácil de hacer lo que quiere. Los interceptores son potentes, pero es fácil hacer que las cosas se mal. Son, como dices, una manera fácil de dispararte en el pie.

Por ejemplo, imagine si en su lugar cambiamos nuestro método de la GetPageOfCustomers siguiente manera:

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

En este caso, simplemente ThenBy se agrega a la consulta. Sí, es posible que tenga que realizarse por separado en todas las consultas, pero es sencillo, fácil de entender y siempre funcionará.

Interceptación de simultaneidad optimista

Sugerencia

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

EF Core admite el patrón de simultaneidad optimista comprobando que el número de filas realmente afectados por una actualización o eliminación es el mismo que el número de filas que se espera que se vean afectados. A menudo, esto se combina con un token de simultaneidad; es decir, un valor de columna que solo coincidirá con su valor esperado si la fila no se ha actualizado desde que se leyó el valor esperado.

EF indica una infracción de la simultaneidad optimista iniciando una DbUpdateConcurrencyException. En EF7, ISaveChangesInterceptor tiene nuevos métodos ThrowingConcurrencyException y ThrowingConcurrencyExceptionAsync a los que se llama antes DbUpdateConcurrencyException de que se produzca. Estos puntos de interceptación permiten suprimir la excepción, posiblemente junto con cambios asincrónicos en la base de datos para resolver la infracción.

Por ejemplo, si dos solicitudes intentan eliminar la misma entidad casi al mismo tiempo, la segunda eliminación puede producir un error porque la fila de la base de datos ya no existe. Esto puede ser correcto: el resultado final es que la entidad se ha eliminado de todos modos. El interceptor siguiente muestra cómo se puede hacer esto:

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

Hay varias cosas que merece la pena tener en cuenta sobre este interceptor:

  • Se implementan los métodos de interceptación sincrónica y asincrónica. Esto es importante si la aplicación puede llamar a SaveChanges o SaveChangesAsync. Sin embargo, si todo el código de aplicación es asincrónico, solo ThrowingConcurrencyExceptionAsync debe implementarse. Del mismo modo, si la aplicación nunca usa métodos de base de datos asincrónicos, solo ThrowingConcurrencyException debe implementarse. Esto suele ser cierto para todos los interceptores con métodos sincronizados y asincrónicos. (Es posible que valga la pena implementar el método en el que la aplicación no se usa para iniciar, solo en caso de que algún código sincronizado o asincrónico se cree en).
  • El interceptor tiene acceso a EntityEntry objetos para las entidades que se guardan. En este caso, se usa para comprobar si se está produciendo o no la infracción de simultaneidad para una operación de eliminación.
  • Si la aplicación usa un proveedor de bases de datos relacionales, el ConcurrencyExceptionEventData objeto se puede convertir en un RelationalConcurrencyExceptionEventData objeto. Esto proporciona información adicional específica de las relaciones relacionales sobre la operación de base de datos que se está realizando. En este caso, el texto del comando relacional se imprime en la consola.
  • Devolver InterceptionResult.Suppress() indica a EF Core que suprima la acción que estaba a punto de realizar; en este caso, iniciando DbUpdateConcurrencyException. Esta capacidad para cambiar el comportamiento de EF Core, en lugar de simplemente observar lo que hace EF Core, es una de las características más eficaces de los interceptores.

Inicialización diferida de una cadena de conexión

Sugerencia

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

Las cadenas de conexión suelen ser recursos estáticos leídos desde un archivo de configuración. Estos se pueden pasar fácilmente a o de forma similar al UseSqlServer configurar un DbContext. Sin embargo, a veces la cadena de conexión puede cambiar para cada instancia de contexto. Por ejemplo, cada inquilino de un sistema multiinquilino puede tener una cadena de conexión diferente.

EF7 facilita el control de las conexiones dinámicas y las cadenas de conexión mediante mejoras en IDbConnectionInterceptor. Esto comienza con la capacidad de configurar sin DbContext ninguna cadena de conexión. Por ejemplo:

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

Uno de los IDbConnectionInterceptor métodos se puede implementar para configurar la conexión antes de usarse. ConnectionOpeningAsync es una buena opción, ya que puede realizar una operación asincrónica para obtener la cadena de conexión, buscar un token de acceso, etc. Por ejemplo, imagine un servicio con ámbito de la solicitud actual que comprende el inquilino actual:

services.AddScoped<ITenantConnectionStringFactory, TestTenantConnectionStringFactory>();

Advertencia

Realizar una búsqueda asincrónica para una cadena de conexión, un token de acceso o similar cada vez que sea necesario puede ser muy lento. Considere la posibilidad de almacenar en caché estos elementos y actualizar periódicamente la cadena o el token almacenados en caché. Por ejemplo, los tokens de acceso a menudo se pueden usar durante un período de tiempo significativo antes de tener que actualizarse.

Esto se puede insertar en cada DbContext instancia mediante la inserción de constructores:

public class CustomerContext : DbContext
{
    private readonly ITenantConnectionStringFactory _connectionStringFactory;

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

    // ...
}

A continuación, este servicio se usa al construir la implementación del interceptor para el contexto:

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

Por último, el interceptor usa este servicio para obtener la cadena de conexión de forma asincrónica y establecerla la primera vez que se usa la conexión:

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

Nota:

La cadena de conexión solo se obtiene la primera vez que se usa una conexión. Después de eso, la cadena de conexión almacenada en DbConnection se usará sin buscar una nueva cadena de conexión.

Sugerencia

Este interceptor invalida el método no asincrónico para iniciar, ya que el servicio para obtener la cadena de conexión debe llamarse desde una ruta de acceso de código asincrónica ConnectionOpening.

Registro de estadísticas de consulta de SQL Server

Sugerencia

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

Por último, vamos a crear dos interceptores que funcionan conjuntamente para enviar estadísticas de consulta de SQL Server al registro de la aplicación. Para generar las estadísticas, necesitamos IDbCommandInterceptor hacer dos cosas.

En primer lugar, el interceptor prefijo comandos con SET STATISTICS IO ON, que indica a SQL Server que envíe estadísticas al cliente después de que se haya consumido un conjunto de resultados:

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

En segundo lugar, el interceptor implementará el nuevo DataReaderClosingAsync método, al que se llama después DbDataReader de que haya terminado de consumir resultados, pero antes de que se haya cerrado. Cuando SQL Server envía estadísticas, las coloca en un segundo resultado en el lector, por lo que, en este momento, el interceptor lee ese resultado mediante una llamada a NextResultAsync la que rellena las estadísticas en la conexión.

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

    return result;
}

El segundo interceptor es necesario para obtener las estadísticas de la conexión y escribirlas en el registrador de la aplicación. Para ello, usaremos un IDbConnectionInterceptor, implementando el nuevo ConnectionCreated método. ConnectionCreated se llama inmediatamente después de que EF Core haya creado una conexión, por lo que se puede usar para realizar una configuración adicional de esa conexión. En este caso, el interceptor obtiene y ILogger, a continuación, se enlaza al SqlConnection.InfoMessage evento para registrar los mensajes.

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

Solo se llama a los métodos ConnectionCreating y ConnectionCreated cuando EF Core crea un DbConnection. No se llamará si la aplicación crea DbConnection y la pasa a EF Core.

Al ejecutar código que usa estos interceptores se muestran las estadísticas de consulta de SQL Server en el registro:

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.

Mejoras de consultas

EF7 contiene muchas mejoras en la traducción de consultas LINQ.

GroupBy como operador final

Sugerencia

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

EF7 admite el uso GroupBy como operador final en una consulta. Por ejemplo, esta consulta LINQ:

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

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

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

Nota:

Este tipo de GroupBy no se traduce directamente en SQL, por lo que EF Core realiza la agrupación en los resultados devueltos. Sin embargo, esto no da lugar a que se transfieran datos adicionales desde el servidor.

GroupJoin como operador final

Sugerencia

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

EF7 admite el uso GroupJoin como operador final en una consulta. Por ejemplo, esta consulta LINQ:

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

Esto se traduce en el siguiente código SQL cuando se usa 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 entidad GroupBy

Sugerencia

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

EF7 admite la agrupación por un tipo de entidad. Por ejemplo, esta consulta LINQ:

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

Se traduce al siguiente código SQL al 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]

Tenga en cuenta que la agrupación por una propiedad única, como la clave principal, siempre será más eficaz que la agrupación por un tipo de entidad. Sin embargo, la agrupación por tipos de entidad se puede usar para los tipos de entidad con clave y sin clave.

Además, la agrupación por un tipo de entidad con una clave principal siempre dará como resultado un grupo por instancia de entidad, ya que cada entidad debe tener un valor de clave único. A veces vale la pena cambiar el origen de la consulta para que la agrupación en no sea necesaria. Por ejemplo, la consulta siguiente devuelve los mismos resultados que la consulta anterior:

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

Esta consulta se traduce en el siguiente SQL cuando se utiliza 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]

Las subconsultas no hacen referencia a columnas sin agrupar de consultas externas

Sugerencia

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

En EF Core 6.0, una GROUP BY cláusula haría referencia a columnas de la consulta externa, lo que produce un error con algunas bases de datos y es ineficaz en otras. Por ejemplo, considere la siguiente 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)
            };

En EF Core 6.0 en SQL Server, esto se ha traducido a:

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

En EF7, la traducción es:

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]

Las colecciones de solo lectura se pueden usar para Contains

Sugerencia

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

EF7 admite el uso Contains cuando los elementos que se van a buscar están contenidos en IReadOnlySet o IReadOnlyCollection, o IReadOnlyList. Por ejemplo, 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)));

Esto se traduce en el siguiente código SQL cuando se usa 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))

Traducciones para funciones de agregado

EF7 presenta una mejor extensibilidad para que los proveedores traduzcan funciones de agregado. Este y otro trabajo en esta área han dado lugar a varias traducciones nuevas entre proveedores, entre las que se incluyen:

Nota:

Las funciones de agregado que actúan en IEnumerable el argumento normalmente solo se traducen en GroupBy consultas. Vote por Admitir tipos espaciales en columnas JSON si está interesado en quitar esta limitación.

Funciones de agregación de cadenas

Sugerencia

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

Las consultas que usan Join y Concat ahora se traducen cuando corresponda. Por ejemplo:

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

Esta consulta se traduce en lo siguiente cuando se utiliza 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]

Cuando se combina con otras funciones de cadena, estas traducciones permiten una manipulación compleja de cadenas en el servidor. Por ejemplo:

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

Esta consulta se traduce en lo siguiente cuando se utiliza 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]

Funciones de agregado espacial

Sugerencia

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

Ahora es posible que los proveedores de bases de datos que admiten NetTopologySuite traduzcan las siguientes funciones de agregado espacial:

Sugerencia

El equipo de SQL Server y SQLite han implementado estas traducciones. Para otros proveedores, póngase en contacto con el mantenedor de proveedores para agregar soporte técnico si se ha implementado para ese proveedor.

Por ejemplo:

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

Esta consulta se traduce al siguiente SQL cuando se utiliza 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]

Funciones de agregado estadístico

Sugerencia

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

Las traducciones de SQL Server se han implementado para las siguientes funciones estadísticas:

Sugerencia

El equipo de SQL Server ha implementado estas traducciones. Para otros proveedores, póngase en contacto con el mantenedor de proveedores para agregar soporte técnico si se ha implementado para ese proveedor.

Por ejemplo:

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

Esta consulta se traduce al siguiente SQL cuando se utiliza 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]

Traducción de string.IndexOf

Sugerencia

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

EF7 ahora se traduce String.IndexOf en consultas LINQ. Por ejemplo:

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

Esto se traduce en el siguiente código SQL cuando se usa 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

Traducción de para tipos de GetType entidad

Sugerencia

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

EF7 ahora se traduce Object.GetType() en consultas LINQ. Por ejemplo:

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

Esta consulta se traduce en el siguiente código SQL cuando se usa SQL Server con herencia de 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'

Tenga en cuenta que esta consulta devuelve solo Post instancias que son realmente de tipo Posty no las de ningún tipo derivado. Esto es diferente de una consulta que usa is o OfType, que también devolverá instancias de cualquier tipo derivado. Por ejemplo, considere la consulta:

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

Lo que se traduce en diferentes SQL:

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

Y devolverá las Post entidades y FeaturedPost.

Compatibilidad con AT TIME ZONE

Sugerencia

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

EF7 presenta nuevas AtTimeZone funciones para DateTime y DateTimeOffset. Estas funciones se traducen en AT TIME ZONE cláusulas de SQL generadas. Por ejemplo:

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

Esto se traduce en el siguiente código SQL cuando se usa 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]

Sugerencia

El equipo de SQL Server ha implementado estas traducciones. Para otros proveedores, póngase en contacto con el mantenedor de proveedores para agregar soporte técnico si se ha implementado para ese proveedor.

Inclusión filtrada en las navegaciones ocultas

Sugerencia

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

Los métodos Include ahora se pueden usar con EF.Property. Esto permite filtrar y ordenar incluso para las propiedades de navegación privada o las navegaciones privadas representadas por campos. Por ejemplo:

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

Esto equivale a:

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

Pero no es necesario Blog.Posts que sea accesible públicamente.

Al usar SQL Server, ambas consultas anteriores se traducen en:

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]

Traducción de Cosmos para Regex.IsMatch

Sugerencia

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

EF7 admite el uso Regex.IsMatch en consultas LINQ en Azure Cosmos DB. Por ejemplo:

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

Esto se traduce en el siguiente código SQL:

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

Mejoras en la API y el comportamiento de DbContext

EF7 contiene una variedad de pequeñas mejoras en DbContext y las clases relacionadas.

Sugerencia

El código de los ejemplos de esta sección procede de DbContextApiSample.cs.

Supresor para propiedades dbSet sin inicializar

EF Core inicializa automáticamente las propiedades públicas y configurables DbSet de un DbContext objeto cuando se construye DbContext. Por ejemplo, considere la siguiente definición DbContext:

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

La Blogs propiedad se establecerá en una DbSet<Blog> instancia como parte de la construcción de la DbContext instancia. Esto permite usar el contexto para las consultas sin pasos adicionales.

Sin embargo, después de la introducción de los tipos de referencia que aceptan valores NULL de C#, el compilador advierte ahora de que la propiedad Blogs que no acepta valores NULL no se inicializa:

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

Se trata de una advertencia falsa; el núcleo de EF establece la propiedad en un valor no null. Además, declarar la propiedad como que acepta valores NULL hará que la advertencia desaparezca, pero esto no es una buena idea porque, conceptualmente, la propiedad no acepta valores NULL y nunca será NULL.

EF7 contiene un diagnosticSuppressor para DbSet las propiedades de un objeto DbContext que impide que el compilador genere esta advertencia.

Sugerencia

Este patrón se originó en los días en los que las propiedades automáticas de C# eran muy limitadas. Con C#moderno, considere la posibilidad de hacer que las propiedades automáticas sean de solo lectura y, a continuación, inicializarlas explícitamente en el DbContext constructor o obtener la instancia almacenada DbSet en caché del contexto cuando sea necesario. Por ejemplo, public DbSet<Blog> Blogs => Set<Blog>().

Distinguir la cancelación de errores en los registros

A veces, una aplicación cancelará explícitamente una consulta u otra operación de base de datos. Normalmente, esto se hace mediante un CancellationToken objeto pasado al método que realiza la operación.

En EF Core 6, los eventos registrados cuando se cancela una operación son los mismos que los registrados cuando se produce un error en la operación por algún otro motivo. EF7 presenta nuevos eventos de registro específicamente para las operaciones canceladas de la base de datos. Estos nuevos eventos se registran de forma predeterminada en el nivel Debug. En la tabla siguiente se muestran los eventos pertinentes y sus niveles de registro predeterminados:

Evento Descripción Nivel de registro predeterminado
CoreEventId.QueryIterationFailed Error al procesar los resultados de una consulta. LogLevel.Error
CoreEventId.SaveChangesFailed Error al intentar guardar los cambios en la base de datos. LogLevel.Error
RelationalEventId.CommandError Error al ejecutar un comando de base de datos. LogLevel.Error
CoreEventId.QueryCanceled La consulta se ha cancelado. LogLevel.Debug
CoreEventId.SaveChangesCanceled El comando de base de datos se canceló al intentar guardar los cambios. LogLevel.Debug
RelationalEventId.CommandCanceled Se ha cancelado la ejecución de una DbCommand. LogLevel.Debug

Nota:

La cancelación se detecta examinando la excepción en lugar de comprobar el token de cancelación. Esto significa que las cancelaciones no desencadenadas a través del token de cancelación se seguirán detectando y registrando de esta manera.

Nuevas sobrecargas IProperty y INavigation para métodos EntityEntry

El código que trabaja con el modelo de EF a menudo tendrán un IProperty o INavigation que representan la propiedad o metadatos de navegación. A continuación, se usa EntityEntry para obtener el valor de propiedad o navegación o consultar su estado. Sin embargo, antes de EF7, esto requería pasar el nombre de la propiedad o navegación a los métodos de EntityEntry, que luego volverían a buscar IProperty o INavigation. En EF7, el IProperty o INavigation se puede pasar directamente, evitando la búsqueda adicional.

Por ejemplo, considere un método para buscar todos los elementos del mismo nivel de una entidad determinada:

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

Este método busca el elemento primario de una entidad determinada y, a continuación, pasa el inverso INavigation al Collection método de la entrada primaria. A continuación, estos metadatos se usan para devolver todos los elementos del elemento primario especificado. Este es un ejemplo de su 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}'");
}

Y la salida:

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 entidad de tipo compartido

EF Core puede usar el mismo tipo CLR para varios tipos de entidad diferentes. Estos se conocen como "tipos de entidad de tipo compartido" y a menudo se usan para asignar un tipo de diccionario con pares clave-valor usados para las propiedades del tipo de entidad. Por ejemplo, se puede definir un BuildMetadata tipo de entidad sin definir un 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 el tipo de entidad de tipo compartido debe tener el nombre ; en este caso, el nombre es BuildMetadata. A continuación, se obtiene acceso a estos tipos de entidad mediante un DbSet para el tipo de entidad que se obtiene con el nombre. Por ejemplo:

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

Esto DbSet se puede usar para realizar un seguimiento de las instancias de entidad:

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

Y ejecute consultas:

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

Ahora, en EF7, también hay un Entry método en el DbSet que se puede usar para obtener el estado de una instancia, incluso si aún no se realiza el seguimiento. Por ejemplo:

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

ContextInitialized ahora se registra como Debug

En EF7, el evento ContextInitializedse registra en el nivel Debug. Por ejemplo:

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

En versiones anteriores, se registró en el nivel Information. Por ejemplo:

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

Si lo desea, el nivel de registro se puede volver a cambiar a Information:

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

IEntityEntryGraphIterator es utilizable públicamente

En EF7, las aplicaciones pueden usar el IEntityEntryGraphIterator servicio. Este es el servicio que se usa internamente al detectar un gráfico de entidades para realizar un seguimiento, y también por TrackGraph. Este es un ejemplo que recorre en iteración todas las entidades accesibles desde alguna entidad 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:

  • El iterador deja de atravesar un nodo determinado cuando el delegado de devolución de llamada devuelve false. En este ejemplo se realiza un seguimiento de las entidades visitadas y se devuelve false cuando la entidad ya se ha visitado. Esto evita bucles infinitos resultantes de ciclos en el gráfico.
  • El objeto EntityEntryGraphNode<TState> permite pasar el estado sin capturarlo en el delegado.
  • Para cada nodo visitado distinto del primero, el nodo desde el que se detectó y la navegación desde la que se detectó se pasan a la devolución de llamada.

Mejoras en la creación de modelos

EF7 contiene una variedad de pequeñas mejoras en la creación de modelos.

Sugerencia

El código de los ejemplos de esta sección procede de ModelBuildingSample.cs.

Los índices pueden ser ascendentes o descendentes

De forma predeterminada, EF Core crea índices ascendentes. EF7 también admite la creación de índices descendentes. Por ejemplo:

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

O bien, mediante el atributo de Index asignación:

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

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

Esto rara vez es útil para los índices en una sola columna, ya que la base de datos puede usar el mismo índice para ordenar en ambas direcciones. Sin embargo, esto no es el caso de los índices compuestos en varias columnas donde el orden de cada columna puede ser importante. EF Core admite esto al permitir que varias columnas tengan una ordenación diferente definida para cada columna. Por ejemplo:

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

O bien, mediante un atributo de asignación:

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

Esto da como resultado el siguiente SQL cuando se utiliza SQL Server:

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

Por último, se pueden crear varios índices en el mismo conjunto ordenado de columnas proporcionando los nombres de los índices. Por ejemplo:

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

O bien, mediante atributos de asignación:

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

Esto genera el siguiente código SQL en 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 asignación para claves compuestas

EF7 presenta un nuevo atributo de asignación (también conocido como "anotación de datos") para especificar la propiedad de clave principal o las propiedades de cualquier tipo de entidad. A diferencia de System.ComponentModel.DataAnnotations.KeyAttribute, PrimaryKeyAttribute se coloca en la clase de tipo de entidad en lugar de en la propiedad de clave. Por ejemplo:

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

Esto hace que sea un ajuste natural para definir claves compuestas:

[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 el índice en la clase también significa que se puede usar para especificar propiedades privadas o campos como claves, aunque normalmente se omitirían al compilar el modelo de EF. Por ejemplo:

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

DeleteBehavior atributo de asignación

EF7 presenta un atributo de asignación (también conocido como "anotación de datos") para especificar para DeleteBehavior una relación. Por ejemplo, las relaciones necesarias se crean con DeleteBehavior.Cascade de forma predeterminada. Esto se puede cambiar a DeleteBehavior.NoAction de forma predeterminada mediante DeleteBehaviorAttribute:

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

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

Esto deshabilitará las eliminaciones en cascada para la relación Blog-Posts.

Propiedades asignadas a nombres de columna diferentes

Algunos patrones de asignación dan lugar a la misma propiedad CLR que se asigna a una columna en cada una de varias tablas diferentes. EF7 permite que estas columnas tengan nombres diferentes. Por ejemplo, considere una jerarquía de herencia simple:

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

Con la estrategia de asignación de herencia de TPT, estos tipos se asignarán a tres tablas. Sin embargo, la columna de clave principal de cada tabla puede tener un nombre diferente. Por ejemplo:

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

EF7 permite configurar esta asignación mediante un generador de tablas anidadas:

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

Con la asignación de herencia de TPC, la propiedad Breed también se puede asignar a nombres de columna diferentes en tablas diferentes. Considere, por ejemplo, las siguientes tablas 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])
);

EF7 admite esta asignación de tablas:

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

Relaciones de varios a varios unidireccionales

EF7 admite relaciones de varios a varios donde un lado u otro no tienen una propiedad de navegación. Por ejemplo, considere los tipos Post y 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 el tipo Post tiene una propiedad de navegación para una lista de etiquetas, pero el tipo Tag no tiene una propiedad de navegación para publicaciones. En EF7, esto todavía se puede configurar como una relación de varios a varios, lo que permite usar el mismo Tag objeto para muchas publicaciones diferentes. Por ejemplo:

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

Esto da como resultado la asignación a la tabla de combinación adecuada:

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

Y la relación se puede usar como un varios a varios de la manera normal. Por ejemplo, insertando algunas publicaciones que comparten varias etiquetas de un conjunto común:

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

División de entidades

La división de entidades asigna un tipo de entidad único a varias tablas. Por ejemplo, considere una base de datos con tres tablas que contienen datos del cliente:

  • Una tabla Customers para obtener información del cliente
  • Una tabla PhoneNumbers para el número de teléfono del cliente
  • Una Addresses tabla para la dirección del cliente

Estas son las definiciones de estas tablas en 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 una de estas tablas normalmente se asignaría a su propio tipo de entidad, con relaciones entre los tipos. Sin embargo, si las tres tablas siempre se usan juntas, puede ser más conveniente asignarlas a un solo tipo de entidad. Por ejemplo:

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

Esto se logra en EF7 llamando SplitToTable a para cada división en el tipo de entidad. Por ejemplo, el código siguiente divide el tipo de entidad Customer en las tablas Customers, PhoneNumbers, y Addresses mostradas anteriormente:

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 también que, si es necesario, se pueden especificar nombres de columna de clave principal diferentes para cada una de las tablas.

Cadenas UTF-8 de SQL Server

Las cadenas Unicode de SQL Server representadas por los nchar tipos de datos ynvarchar se almacenan como UTF-16. Además, los char tipos de datos yvarchar se usan para almacenar cadenas que no son Unicode compatibles con varios juegos de caracteres.

A partir de SQL Server 2019, los tipos de datos char y varchar se pueden usar para almacenar cadenas Unicode con codificación UTF-8. Se logra estableciendo una de las intercalaciones UTF-8. Por ejemplo, el código siguiente configura una cadena UTF-8 de longitud variable de SQL Server para la CommentText columna:

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

Esta configuración genera la siguiente definición de columna de 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])
);

Las tablas temporales admiten entidades propiedad

La asignación de tablas temporales de SQL Server de EF Core se ha mejorado en EF7 para admitir el uso compartido de tablas. En particular, la asignación predeterminada para las entidades propiedad únicas usa el uso compartido de tablas.

Por ejemplo, considere un tipo Employee de entidad propietario y su tipo EmployeeInfo de entidad propiedad:

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

Si estos tipos se asignan a la misma tabla, en EF7 esa tabla se puede crear una tabla temporal:

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

Nota:

Facilitar esta configuración es objeto de seguimiento en el asunto nº 29303. Vote por este problema si es algo que le gustaría ver implementado.

Generación de valores mejorada

EF7 incluye dos mejoras significativas en la generación automática de valores para las propiedades clave.

Sugerencia

El código de los ejemplos de esta sección procede de ValueGenerationSample.cs.

Generación de valores para tipos protegidos DDD

En el diseño controlado por dominio (DDD), las "claves protegidas" pueden mejorar la seguridad del tipo de propiedades clave. Esto se logra ajustando el tipo de clave en otro tipo que es específico del uso de la clave. Por ejemplo, el código siguiente define un tipo ProductId para las claves de producto y un tipo CategoryId para las claves de categoría.

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

A continuación, se usan en los tipos de entidad Product y 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();
}

Esto hace que sea imposible asignar accidentalmente el identificador de una categoría a un producto, o viceversa.

Advertencia

Al igual que con muchos conceptos de DDD, esta seguridad de tipos mejorada conlleva la complejidad adicional del código. Vale la pena considerar si, por ejemplo, asignar un identificador de producto a una categoría es algo que es probable que ocurra. Mantener las cosas sencillas puede ser más beneficiosa para el código base.

Los tipos de clave protegidos que se muestran aquí encapsulan int los valores de clave, lo que significa que los valores enteros se usarán en las tablas de base de datos asignadas. Esto se logra definiendo convertidores de valores para los 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))
    {
    }
}

Nota:

El código aquí usa struct tipos. Esto significa que tienen una semántica de tipo de valor adecuada para su uso como claves. Si class se usan tipos en su lugar, deben invalidar la semántica de igualdad o especificar también un comparador de valores.

En EF7, los tipos de clave basados en convertidores de valores pueden usar valores de clave generados automáticamente siempre que el tipo subyacente admita esto. Esto se configura de la manera normal mediante ValueGeneratedOnAdd:

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

De forma predeterminada, esto da como IDENTITY resultado columnas cuando se usa con 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 se usan de la manera normal de generar valores de clave al insertar 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;

Generación de claves basada en secuencia para SQL Server

EF Core admite la generación de valores de clave mediante columnas IDENTITY de SQL Server o un patrón Hi-Lo basado en bloques de claves generados por una secuencia de base de datos. EF7 presenta compatibilidad con una secuencia de base de datos asociada a la restricción predeterminada de columna de la clave. En su forma más sencilla, esto solo requiere indicar a EF Core que use una secuencia para la propiedad de clave:

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

Esto da como resultado una secuencia definida en la base de datos:

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

A continuación, se usa en la restricción predeterminada de columna de clave:

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

Nota:

Esta forma de generación de claves se usa de forma predeterminada para las claves generadas en jerarquías de tipos de entidad mediante la estrategia de asignación de TPC.

Si lo desea, la secuencia se puede asignar un nombre y un esquema diferentes. Por ejemplo:

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

La configuración adicional de la secuencia se forma configurando explícitamente en el modelo. Por ejemplo:

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

Mejoras en las herramientas de migración

EF7 incluye dos mejoras significativas al usar las herramientas de línea de comandos de migraciones de EF Core.

UseSqlServer, etc. aceptar null

Es muy común leer una cadena de conexión de un archivo de configuración y, a continuación, pasar esa cadena de conexión a UseSqlServer, UseSqliteo el método equivalente para otro proveedor. Por ejemplo:

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

También es habitual pasar una cadena de conexión al aplicar migraciones. Por ejemplo:

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

O cuando se usa una agrupación de migraciones.

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

En este caso, aunque no se use la cadena de conexión leída de la configuración, el código de inicio de la aplicación sigue intentando leerla de la configuración y pasarla a UseSqlServer. Si la configuración no está disponible, esto da como resultado pasar null a UseSqlServer. En EF7, esto se permite, siempre que la cadena de conexión se establezca en última instancia más adelante, como pasar --connection a la herramienta de línea de comandos.

Nota:

Este cambio se ha realizado para UseSqlServer y UseSqlite. Para otros proveedores, póngase en contacto con el mantenedor de proveedores para realizar un cambio equivalente si aún no se ha realizado para ese proveedor.

Detección de cuándo se ejecutan las herramientas

EF Core ejecuta código de aplicación cuando se usan los comandos dotnet-ef o de PowerShell. A veces puede ser necesario detectar esta situación para evitar que el código inapropiado se ejecute en tiempo de diseño. Por ejemplo, el código que aplica automáticamente las migraciones al inicio probablemente no debería hacerlo en tiempo de diseño. En EF7, esto se puede detectar mediante la EF.IsDesignTime marca :

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

EF Core establece en IsDesignTime a true cuando el código de la aplicación se ejecuta en nombre de las herramientas.

Mejoras de rendimiento para servidores proxy

EF Core admite servidores proxy generados dinámicamente para la carga diferida y el seguimiento de cambios. EF7 contiene dos mejoras de rendimiento al usar estos servidores proxy:

  • Los tipos de proxy ahora se crean diferir. Esto significa que el tiempo de creación del modelo inicial cuando se usan servidores proxy puede ser masivamente más rápido con EF7 que con EF Core 6.0.
  • Los servidores proxy ahora se pueden usar con modelos compilados.

Estos son algunos resultados de rendimiento para un modelo con 449 tipos de entidad, 6390 propiedades y 720 relaciones.

Escenario Método Media Error StdDev
EF Core 6.0 sin servidores proxy TimeToFirstQuery 1.085 s 0.0083 s 0.0167 s
EF Core 6.0 con servidores proxy de seguimiento de cambios TimeToFirstQuery 13.01 s 0.2040 s 0.4110 s
EF Core 7.0 sin servidores proxy TimeToFirstQuery 1.442 s 0.0134 s 0.0272 s
EF Core 7.0 con servidores proxy de seguimiento de cambios TimeToFirstQuery 1.446 s 0.0160 s 0.0323 s
EF Core 7.0 con servidores proxy de seguimiento de cambios y modelo compilado TimeToFirstQuery 0.162 s 0.0062 s 0.0125 s

Por lo tanto, en este caso, un modelo con servidores proxy de seguimiento de cambios puede estar listo para ejecutar la primera consulta 80 veces más rápido en EF7 de lo que era posible con EF Core 6.0.

Enlace de datos de Windows Forms de primera clase

El equipo de Windows Forms ha realizado algunas mejoras excelentes en la experiencia del Diseñador de Visual Studio. Esto incluye nuevas experiencias para el enlace de datos que se integra bien con EF Core.

En resumen, la nueva experiencia proporciona Visual Studio U.I. para crear un ObjectDataSource:

Choose Category data source type

Después, esto se puede enlazar a una instancia de EF Core DbSet con código simple:

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

Consulta Introducción a Windows Forms para ver un tutorial completo y una aplicación de ejemplo de WinForms descargable.