Nouveautés d’EF Core 8

EF Core 8.0 (EF8) a été publié en novembre 2023.

Conseil

Vous pouvez exécuter et déboguer dans les exemples en en téléchargeant l’exemple de code à partir de GitHub. Chaque section établit un lien vers le code source spécifique à cette section.

EF8 nécessite le kit de développement logiciel (SDK) .NET 8 pour générer et nécessite l’exécution du runtime .NET 8. EF8 ne s’exécute pas sur les versions antérieures de .NET, et ne s’exécute pas sur le .NET Framework.

Objets valeur utilisant des types complexes

Les objets enregistrés dans la base de données peuvent être divisés en trois grandes catégories :

  • Objets non structurés et contenant une seule valeur. Par exemple int, Guid, string, IPAddress. Ils sont appelés (de manière plus ou moins large) « types primitifs ».
  • Objets structurés pour contenir plusieurs valeurs, et où l’identité de l’objet est définie par une valeur de clé. Par exemple, Blog, Post, Customer. Ils sont appelés « types d’entités ».
  • Objets structurés pour contenir plusieurs valeurs, mais dont l’objet n’a aucune clé définissant l’identité. Par exemple, Address, Coordinate.

Avant EF8, il n’existait aucun moyen efficace de mapper le troisième type d’objet. Vous pouvez utiliser des types détenus, mais dans la mesure où les types détenus sont en fait des types d’entités, ils ont une sémantique basée sur une valeur de clé, même quand cette valeur de clé est masquée.

EF8 prend désormais en charge les « types complexes » pour couvrir ce troisième type d’objet. Les objets de types complexes :

  • Ne sont pas identifiés ou suivis par valeur de clé.
  • Doivent être définis dans le cadre d’un type d’entité. (En d’autres termes, vous ne pouvez pas avoir de DbSet de type complexe.)
  • Il peut s’agir de types valeur ou de types référence .NET.
  • Les instances peuvent être partagées par plusieurs propriétés.

Exemple simple

Par exemple, prenons un type Address :

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

Address est ensuite utilisé à trois emplacements dans un modèle client/commandes simple :

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

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

Créons et enregistrons un client avec son adresse :

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

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

Cela se traduit par l’insertion de la ligne suivante dans la base de données :

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

Notez que les types complexes n’obtiennent pas leurs propres tables. À la place, elles sont enregistrées inline dans les colonnes de la table Customers. Cela correspond au comportement de partage de table des types détenus.

Remarque

Nous ne prévoyons pas d’autoriser le mappage des types complexes à leur propre table. Toutefois, dans une prochaine version, nous prévoyons d’autoriser l’enregistrement du type complexe en tant que document JSON dans une seule colonne. Votez pour le Problème 31252 s’il est important pour vous.

Supposons à présent que nous souhaitions expédier une commande à un client, et utiliser l’adresse du client en tant qu’adresse de facturation et adresse d’expédition par défaut. La solution naturelle consiste à copier l’objet Address de Customer vers Order. Par exemple :

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

await context.SaveChangesAsync();

Avec les types complexes, cela fonctionne comme prévu, et l’adresse est insérée dans la table Orders :

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

Vous vous dites peut-être « mais je pourrais le faire avec des types détenus ! » Toutefois, la sémantique du « type d’entité » des types détenus devient rapidement un obstacle. Par exemple, l’exécution du code ci-dessus avec des types détenus entraîne une série d’avertissements, puis une erreur :

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

En effet, une seule instance du type d’entité Address (avec la même valeur de clé masquée) est utilisée pour trois instances d’entité différentes. En revanche, le partage de la même instance entre des propriétés complexes est autorisé. Ainsi, le code fonctionne comme prévu quand vous utilisez des types complexes.

Configuration des types complexes

Les types complexes doivent être configurés dans le modèle à l’aide d’attributs de mappage ou en appelant l’API ComplexProperty dans OnModelCreating. Les types complexes ne sont pas découverts par convention.

Par exemple, le type Address peut être configuré à l’aide de ComplexTypeAttribute :

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

Ou dans OnModelCreating :

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

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

Mutabilité

Dans l’exemple ci-dessus, nous nous sommes retrouvés avec la même instance de Address utilisée à trois emplacements. Cela est autorisé et ne pose aucun problème pour EF Core quand vous utilisez des types complexes. Toutefois, le partage d’instances du même type référence signifie que si une valeur de propriété de l’instance est modifiée, ce changement sera reflété dans les trois utilisations. Par exemple, dans le cadre de ce qui précède, changeons le Line1 de l’adresse du client, puis enregistrons les changements :

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

Cela entraîne la mise à jour suivante de la base de données quand vous utilisez SQL Server :

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

Notez que les trois colonnes de Line1 ont changé, car elles partagent toutes la même instance. Cela n’est généralement pas ce que nous souhaitons.

Conseil

Si les adresses de commande doivent changer automatiquement quand l’adresse du client change, mappez l’adresse en tant que type d’entité. Order et Customer peuvent ensuite référencer sans problème la même instance d’adresse (qui est désormais identifiée par une clé) via une propriété de navigation.

Un bon moyen de gérer les problèmes de ce genre consiste à rendre le type immuable. En effet, cette immuabilité est souvent naturelle quand un type est un bon candidat pour être un type complexe. Ainsi, il est généralement judicieux de fournir un nouvel objet Address complexe au lieu de changer simplement, par exemple, le pays en laissant le reste intact.

Les types référence et les types valeur peuvent être rendus immuables. Nous allons examiner quelques exemples dans les sections suivantes.

Types référence en tant que types complexes

Classe immuable

Nous avons utilisé un simple class mutable dans l’exemple ci-dessus. Pour éviter les problèmes de mutation accidentelle décrits ci-dessus, nous pouvons rendre la classe immuable. Par exemple :

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

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

Conseil

Avec C# 12 ou une version ultérieure, cette définition de classe peut être simplifiée à l’aide d’un constructeur principal :

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

Il n’est désormais plus possible de changer la valeur de Line1 pour une adresse existante. À la place, nous devons créer une instance avec la valeur changée. Par exemple :

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

await context.SaveChangesAsync();

Cette fois, l’appel à SaveChangesAsync met à jour uniquement l’adresse du client :

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

Notez que même si l’objet Address est immuable, et que même si l’objet entier a été changé, EF effectue toujours le suivi des changements apportés aux propriétés individuelles. Ainsi, seules les colonnes dont les valeurs ont changé sont mises à jour.

Enregistrement immuable

C# 9 a introduit les types d’enregistrements, ce qui facilite la création et l’utilisation d’objets immuables. Par exemple, l’objet Address peut devenir un type d’enregistrement :

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

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

Conseil

Cette définition d’enregistrement peut être simplifiée à l’aide d’un constructeur principal :

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

Le remplacement de l’objet mutable et l’appel de SaveChanges nécessitent désormais moins de code :

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

await context.SaveChangesAsync();

Types valeur en tant que types complexes

Struct mutable

Un type valeur mutable simple peut être utilisé en tant que type complexe. Par exemple, Address peut être défini en tant que struct en C# :

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

L’affectation de l’objet client Address aux propriétés d’expédition et de facturation Address permet à chaque propriété d’obtenir une copie de Address, car c’est ainsi que les types valeur fonctionnent. Cela signifie que la modification de Address pour le client ne change pas les instances de Address en ce qui concerne l’expédition ou la facturation. Ainsi, les structs mutables n’ont pas les mêmes problèmes de partage d’instance que les classes mutables.

Toutefois, les structs mutables sont généralement déconseillés en C#. Réfléchissez donc très attentivement avant de les utiliser.

Struct immuable

Les structs immuables fonctionnent aussi bien que types complexes, tout comme les classes immuables. Par exemple, Address peut être défini de manière à ne pas pouvoir être modifié :

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

Le code permettant de changer l’adresse est désormais identique à celui d’une classe immuable :

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

await context.SaveChangesAsync();

Enregistrement de struct immuable

C# 10 a introduit les types struct record, ce qui facilite la création et l’utilisation d’enregistrements de structs immuables, comme avec les enregistrements de classes immuables. Par exemple, nous pouvons définir Address en tant qu’enregistrement de struct immuable :

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

Le code permettant de changer l’adresse ressemble désormais à celui d’un enregistrement de classe immuable :

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

await context.SaveChangesAsync();

Types complexes imbriqués

Un type complexe peut contenir les propriétés d’autres types complexes. Par exemple, utilisons notre type complexe Address ci-dessus avec un type complexe PhoneNumber, et imbriquons-les tous les deux dans un autre type complexe :

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

public record PhoneNumber(int CountryCode, long Number);

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

Nous utilisons ici des enregistrements immuables, car ils correspondent bien à la sémantique de nos types complexes. Toutefois, l’imbrication de types complexes peut être effectuée avec n’importe quelle saveur de type .NET.

Remarque

Nous n’utilisons pas de constructeur principal pour le type Contact, car EF Core ne prend pas encore en charge l’injection de constructeurs des valeurs de types complexes. Votez pour le Problème 31621 s’il est important pour vous.

Nous allons ajouter Contact en tant que propriété de Customer :

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

Et PhoneNumber en tant que propriétés de Order :

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

Vous pouvez à nouveau configurer des types complexes imbriqués à l’aide de ComplexTypeAttribute :

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

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

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

Ou dans OnModelCreating :

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

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

Requêtes

Les propriétés de types complexes sur les types d’entités sont traitées comme toute autre propriété qui n’est pas une propriété de navigation du type d’entité. Cela signifie qu’elles sont toujours chargées quand le type d’entité est chargé. Cela est également vrai pour toutes les propriétés de types complexes imbriquées. Par exemple, l’interrogation d’un client :

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

Se traduit par le code SQL suivant quand vous utilisez SQL Server :

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

Notez deux éléments dans ce code SQL :

  • Tout est retourné pour remplir le client ainsi que tous les types complexes Contact, Address et PhoneNumber imbriqués.
  • Toutes les valeurs de types complexes sont stockées sous forme de colonnes dans la table pour le type d’entité. Les types complexes ne sont jamais mappés à des tables distinctes.

Projections

Les types complexes peuvent être projetés à partir d’une requête. Par exemple, la sélection de l’adresse d’expédition uniquement dans une commande :

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

Se traduit par ce qui suit quand vous utilisez SQL Server :

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

Notez que les projections de types complexes ne peuvent pas faire l’objet d’un suivi, car les objets de types complexes n’ont aucune identité à utiliser pour le suivi.

Utilisation dans les prédicats

Les membres de types complexes peuvent être utilisés dans les prédicats. Par exemple, la recherche de toutes les commandes à destination d’une ville spécifique :

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

Se traduit par le code SQL suivant sur SQL Server :

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

Une instance de type complexe complète peut également être utilisée dans les prédicats. Par exemple, la recherche de tous les clients ayant un numéro de téléphone donné :

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

Se traduit par le code SQL suivant quand vous utilisez SQL Server :

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

Notez que l’égalité est effectuée via le développement de chaque membre du type complexe. Cette pratique est cohérente avec les caractéristiques des types complexes, qui n’ont aucune clé pour l’identité. Ainsi, une instance de type complexe est égale à une autre instance de type complexe, si et seulement si tous leurs membres sont égaux. Cela est cohérent également avec l’égalité définie par .NET pour les types d’enregistrements.

Manipulation des valeurs de types complexes

EF8 permet d’accéder aux informations de suivi, par exemple les valeurs actuelles et d’origine des types complexes, et indique si une valeur de propriété a été modifiée ou non. L’API relative aux types complexes est une extension de l’API de suivi des changements, déjà utilisée pour les types d’entités.

Les méthodes ComplexProperty de EntityEntry retournent une entrée pour un objet complexe entier. Par exemple, pour obtenir la valeur actuelle de Order.BillingAddress :

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

Vous pouvez ajouter un appel à Property pour accéder à une propriété de type complexe. Par exemple, pour obtenir la valeur actuelle du code postal de facturation uniquement :

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

Les types complexes imbriqués sont accessibles à l’aide d’appels imbriqués à ComplexProperty. Par exemple, si vous souhaitez obtenir la ville à partir de l’Address imbriqué de Contact pour un Customer :

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

D’autres méthodes sont disponibles pour la lecture et le changement d’état. Par exemple, vous pouvez utiliser PropertyEntry.IsModified pour définir une propriété de type complexe comme étant modifiée :

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

Limites actuelles

Les types complexes représentent un investissement important dans la pile EF. Nous n’avons pas réussi à tout faire fonctionner dans cette version, mais nous prévoyons de combler certaines des lacunes dans une prochaine version. Veillez à voter (👍) pour les problèmes GitHub appropriés si la correction de l’une de ces limitations est importante pour vous.

Les limitations des types complexes dans EF8 sont les suivantes :

  • Prise en charge des collections de types complexes. (Problème 31237)
  • Affectation de la valeur null aux propriétés de types complexes. (Problème 31376)
  • Mappage des propriétés de types complexes aux colonnes JSON. (Problème 31252)
  • Injection de constructeurs pour les types complexes. (Problème 31621)
  • Ajout de la prise en charge des données initiales pour les types complexes. (Problème 31254)
  • Mappage des propriétés de types complexes pour le fournisseur Cosmos. (Problème 31253)
  • Implémentation des types complexes pour la base de données en mémoire. (Problème 31464)

Collections primitives

Une question persistante lors de l’utilisation de bases de données relationnelles est ce qu’il faut faire avec les collections de types primitifs ; c’est-à-dire des listes ou des tableaux d’entiers, de dates/heures, de chaînes, et ainsi de suite. Si vous utilisez PostgreSQL, il est facile de stocker ces éléments à l’aide du type de tableau intégré PostgreSQL. Pour d’autres bases de données, il existe deux approches courantes :

  • Créez une table avec une colonne pour la valeur de type primitif et une autre colonne pour agir en tant que clé étrangère liant chaque valeur à son propriétaire de la collection.
  • Sérialisez la collection primitive dans un type de colonne géré par la base de données, par exemple, sérialisez vers et à partir d’une chaîne.

La première option présente des avantages dans de nombreuses situations : nous allons examiner rapidement cette option à la fin de cette section. Toutefois, il ne s’agit pas d’une représentation naturelle des données dans le modèle, et si ce que vous avez vraiment est une collection d’un type primitif, la deuxième option peut être plus efficace.

À compter de Preview 4, EF8 inclut désormais la prise en charge intégrée de la deuxième option, à l’aide de JSON comme format de sérialisation. JSON fonctionne bien pour cela, car les bases de données relationnelles modernes incluent des mécanismes intégrés pour l’interrogation et la manipulation de JSON, de sorte que la colonne JSON peut, efficacement, être traitée comme une table si nécessaire, sans la surcharge de création de cette table. Ces mêmes mécanismes permettent au JSON d’être transmis dans des paramètres, puis utilisés de la même façon que les paramètres table dans les requêtes, plus loin.

Conseil

Le code présenté ici provient de PrimitiveCollectionsSample.cs.

Propriétés de la collection primitive

EF Core peut mapper n’importe quelle propriété IEnumerable<T>, où T est un type primitif, à une colonne JSON dans la base de données. Cela est effectué par convention pour les propriétés publiques qui ont à la fois un getter et un setter. Par exemple, toutes les propriétés du type d’entité suivant sont mappées aux colonnes JSON par convention :

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

Remarque

Qu’entendons-nous par « type primitif » dans ce contexte ? Essentiellement, quelque chose que le fournisseur de base de données sait mapper, en utilisant un type de conversion de valeur si nécessaire. Par exemple, dans le type d’entité ci-dessus, les types int, string, DateTime, DateOnly et bool sont tous gérés sans conversion par le fournisseur de base de données. SQL Server n’a pas de prise en charge native des URI ou des ints non signés, mais uint et Uri sont toujours traités comme des types primitifs, car il existe convertisseurs de valeurs intégrés pour ces types.

Par défaut, EF Core utilise un type de colonne de chaîne Unicode non contrainte pour contenir le JSON, car cela protège contre la perte de données avec de grandes collections. Toutefois, sur certains systèmes de base de données, tels que SQL Server, la spécification d’une longueur maximale pour la chaîne peut améliorer les performances. Cela, ainsi que d’autres configurations de colonne, peuvent être effectués de la manière normale. Par exemple :

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

Ou, à l’aide d’attributs de mappage :

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

Une configuration de colonne par défaut peut être utilisée pour toutes les propriétés d’un certain type à l’aide de configuration de modèle de pré-convention. Par exemple :

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

Requêtes avec des collections primitives

Examinons certaines des requêtes qui utilisent des collections de types primitifs. Pour cela, nous aurons besoin d’un modèle simple avec deux types d’entités. Le premier représente une maison publique britannique, ou « pub »:

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

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

Le type Pub contient deux collections primitives :

  • Beers est un tableau de chaînes représentant les marques de bière disponibles au pub.
  • DaysVisited est une liste des dates sur lesquelles le pub a été visité.

Conseil

Dans une application réelle, il serait probablement plus judicieux de créer un type d’entité pour la bière, et d’avoir une table pour les bières. Nous affichons ici une collection primitive pour illustrer leur fonctionnement. Mais rappelez-vous, juste parce que vous pouvez modéliser quelque chose comme une collection primitive ne signifie pas nécessairement que vous devez nécessairement.

Le deuxième type d’entité représente une promenade de chiens dans la campagne britannique :

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

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

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

Comme Pub, DogWalk contient également une collection de dates visitées et un lien vers le pub le plus proche depuis, vous savez, parfois, le chien a besoin d’une sauce de bière après une longue promenade.

À l’aide de ce modèle, la première requête que nous allons effectuer est une requête simple Contains pour trouver toutes les promenades avec l’un des différents terrains :

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

Cela est déjà traduit par les versions actuelles d’EF Core en inlinant les valeurs à rechercher. Par exemple, lors de l’utilisation de SQL Server :

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

Toutefois, cette stratégie ne fonctionne pas correctement avec la mise en cache des requêtes de base de données. Consultez Annonce d’EF8 Preview 4 sur le blog .NET pour accéder à une discussion sur le sujet.

Important

L’incorporation de valeurs ici est effectuée de telle sorte qu’il n’y a aucune chance d’attaque par injection SQL. La modification à utiliser JSON décrite ci-dessous concerne toutes les performances et rien à voir avec la sécurité.

Pour EF Core 8, la valeur par défaut consiste maintenant à passer la liste des terrains en tant que paramètre unique contenant une collection JSON. Par exemple :

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

La requête utilise ensuite OpenJson sur SQL Server :

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

Ou json_each sur SQLite :

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

Remarque

OpenJson est disponible uniquement sur SQL Server 2016 (niveau de compatibilité 130) et versions ultérieures. Vous pouvez indiquer à SQL Server que vous utilisez une ancienne version en configurant le niveau de compatibilité dans le cadre de UseSqlServer. Par exemple :

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

Essayons un autre type de requête Contains. Dans ce cas, nous allons rechercher une valeur de la collection de paramètres dans la colonne. Par exemple, n’importe quel pub qui stocke Heineken :

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

La documentation existante de Nouveautés dans EF7 fournit des informations détaillées sur le mappage, les requêtes et les mises à jour JSON. Cette documentation s’applique désormais également à SQLite.

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

OpenJson est maintenant utilisé pour extraire des valeurs de la colonne JSON afin que chaque valeur puisse être mises en correspondance avec le paramètre passé.

Nous pouvons combiner l’utilisation de OpenJson sur le paramètre avec OpenJson sur la colonne. Par exemple, pour trouver des pubs qui stockent l’une d’une variété de lagers :

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

Cela se traduit par ce qui suit sur SQL Server :

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

La valeur du paramètre @__beers_0 ici est ["Carling","Heineken","Stella Artois","Carlsberg"].

Examinons une requête qui utilise la colonne contenant une collection de dates. Par exemple, pour trouver des pubs visités cette année :

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

Cela se traduit par ce qui suit sur SQL Server :

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

Notez que la requête utilise la fonction spécifique à date DATEPART ici, car EF sait que la collection primitive contient des dates. Il peut ne pas sembler comme ça, mais c’est vraiment important. Étant donné qu’EF sait ce qui se trouve dans la collection, il peut générer des valeurs SQL appropriées pour utiliser les valeurs typées avec des paramètres, des fonctions, d’autres colonnes, etc.

Nous allons utiliser à nouveau la collection de dates pour commander correctement les valeurs de type et de projet extraites de la collection. Par exemple, nous allons répertorier les pubs dans l’ordre où ils ont été visités pour la première fois, et avec la première et la dernière date à laquelle chaque pub a été visité :

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

Cela se traduit par ce qui suit sur SQL Server :

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

Enfin, combien de fois finissons-nous par nous rendre au pub le plus proche lorsque nous promenons notre chien ? C’est ce que nous allons voir :

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

Cela se traduit par ce qui suit sur SQL Server :

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

Et révèle les données suivantes :

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

On dirait que la bière et la promenade des chiens sont une combinaison gagnante !

Collections primitives dans des documents JSON

Dans tous les exemples ci-dessus, la colonne de la collection primitive contient JSON. Toutefois, ce n’est pas le même que le mappage un type d’entité appartenant à une colonne contenant un document JSON, qui a été introduit dans EF7. Mais que se passe-t-il si ce document JSON lui-même contient une collection primitive ? Eh bien, toutes les requêtes ci-dessus fonctionnent toujours de la même façon ! Par exemple, imaginez que nous allons déplacer les jours visités données dans un type appartenant Visits mappé à un document JSON :

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

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

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

Conseil

Le code présenté ici provient de PrimitiveCollectionsInJsonSample.cs.

Nous pouvons maintenant exécuter une variante de notre requête finale qui, cette fois, extrait les données du document JSON, y compris les requêtes dans les collections primitives contenues dans le document :

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

Cela se traduit par ce qui suit sur SQL Server :

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

Et à une requête similaire lors de l’utilisation de SQLite :

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

Conseil

Notez que sur SQLite EF Core utilise désormais l’opérateur ->>, ce qui entraîne des requêtes plus faciles à lire et souvent plus performantes.

Mappage de collections primitives à une table

Nous avons mentionné ci-dessus qu’une autre option pour les collections primitives consiste à les mapper à une autre table. La prise en charge de première classe est suivie par Problème #25163 ; veillez à voter pour cette question s’il est important pour vous. Jusqu’à ce qu’elle soit implémentée, la meilleure approche consiste à créer un type d’habillage pour la primitive. Par exemple, nous allons créer un type pour Beer:

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

    public string Name { get; private set; }
}

Notez que le type encapsule simplement la valeur primitive: il n’a pas de clé primaire ni de clés étrangères définies. Ce type peut ensuite être utilisé dans la classe Pub :

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

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

EF crée désormais une table Beer , synthétisant les colonnes de clé primaire et de clé étrangère vers la table Pubs. Par exemple, sur SQL Server :

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

Améliorations apportées au mappage de colonnes JSON

EF8 inclut des améliorations apportées à la prise en charge du mappage de colonnes JSON introduite dans EF7.

Conseil

Le code présenté ici provient de JsonColumnsSample.cs.

Traduire l’accès aux éléments en tableaux JSON

EF8 prend en charge l’indexation dans les tableaux JSON lors de l’exécution de requêtes. Par exemple, la requête suivante vérifie si les deux premières mises à jour ont été effectuées avant une date donnée.

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

Cela se traduit par le code SQL suivant lors de l’utilisation de SQL Server :

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

Remarque

Cette requête réussit même si un billet donné n’a pas de mises à jour ou n’a qu’une seule mise à jour. Dans ce cas, JSON_VALUE retourne NULL et le prédicat n’est pas mis en correspondance.

L’indexation dans des tableaux JSON peut également être utilisée pour projeter des éléments d’un tableau dans les résultats finaux. Par exemple, la requête suivante projette la date de UpdatedOn pour les premières et deuxième mises à jour de chaque publication.

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

Cela se traduit par le code SQL suivant lors de l’utilisation de SQL Server :

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

Comme indiqué ci-dessus, JSON_VALUE retourne null si l’élément du tableau n’existe pas. Cela est géré dans la requête en cas de conversion de la valeur projetée en DateOnlynullable. Une alternative au cast de la valeur consiste à filtrer les résultats de la requête afin que JSON_VALUE ne retourne jamais null. Par exemple :

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

Cela se traduit par le code SQL suivant lors de l’utilisation de SQL Server :

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

Traduire des requêtes en collections incorporées

EF8 prend en charge les requêtes sur des collections de types primitifs (décrits ci-dessus) et non primitifs incorporés dans le document JSON. Par exemple, la requête suivante retourne toutes les publications avec une liste arbitraire de termes de recherche :

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

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

Cela se traduit par le code SQL suivant lors de l’utilisation de SQL Server :

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

Colonnes JSON pour SQLite

EF7 a introduit la prise en charge du mappage aux colonnes JSON lors de l’utilisation d’Azure SQL/SQL Server. EF8 étend cette prise en charge aux bases de données SQLite. En ce qui concerne la prise en charge de SQL Server, cela inclut les éléments suivants :

  • Mappage d’agrégats générés à partir de types .NET vers des documents JSON stockés dans des colonnes SQLite
  • Requêtes dans des colonnes JSON, telles que le filtrage et le tri par les éléments des documents
  • Requêtes qui projettent des éléments hors du document JSON en résultats
  • Mise à jour et enregistrement des modifications dans des documents JSON

La documentation existante de Nouveautés dans EF7 fournit des informations détaillées sur le mappage, les requêtes et les mises à jour JSON. Cette documentation s’applique désormais également à SQLite.

Conseil

Le code présenté dans la documentation EF7 a été mis à jour pour s’exécuter également sur SQLite est disponible dans JsonColumnsSample.cs.

Requêtes dans des colonnes JSON

Les requêtes dans des colonnes JSON sur SQLite utilisent la fonction json_extract. Par exemple, la requête « auteurs dans Chigley » de la documentation référencée ci-dessus :

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

Est traduit en SQL suivant lors de l’utilisation de SQLite :

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

Mise à jour des colonnes JSON

Pour les mises à jour, EF utilise la fonction json_set sur SQLite. Par exemple, lors de la mise à jour d’une propriété unique dans un document :

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

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

await context.SaveChangesAsync();

EF génère les paramètres suivants :

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

Qui utilise la fonction json_set sur SQLite :

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

HierarchyId dans .NET et EF Core

Azure SQL et SQL Server ont un type de données spécial appelé hierarchyid utilisé pour stocker données hiérarchiques. Dans ce cas, les « données hiérarchiques » signifient essentiellement des données qui forment une structure d’arborescence, où chaque élément peut avoir un parent et/ou des enfants. Voici quelques exemples de ces données :

  • Structure d'organisation
  • Système de fichiers
  • Ensemble de tâches dans un projet
  • Taxonomie de termes langagiers
  • Graphique de liens entre pages Web

La base de données peut ensuite exécuter des requêtes sur ces données à l’aide de sa structure hiérarchique. Par exemple, une requête peut trouver des ancêtres et des dépendants d’éléments donnés, ou rechercher tous les éléments à une certaine profondeur dans la hiérarchie.

Prise en charge dans .NET et EF Core

La prise en charge officielle du type hierarchyid SQL Server n’a été prise en charge que récemment sur les plateformes .NET modernes (c’est-à-dire « . NET Core »). Cette prise en charge se présente sous la forme du package NuGet Microsoft.SqlServer.Types, qui apporte des types spécifiques à SQL Server de bas niveau. Dans ce cas, le type de bas niveau est appelé SqlHierarchyId.

Au niveau suivant, un nouveau package Microsoft.EntityFrameworkCore.SqlServer.Abstractions a été introduit, qui inclut un type HierarchyId de niveau supérieur destiné à être utilisé dans les types d’entités.

Conseil

Le HierarchyIdtype est plus idiomatique aux normes de .NET que SqlHierarchyId, qui est plutôt modélisé après la façon dont les types .NET Framework sont hébergés à l’intérieur du moteur de base de données SQL Server. HierarchyId est conçu pour fonctionner avec EF Core, mais il peut également être utilisé en dehors d’EF Core dans d’autres applications. Le package Microsoft.EntityFrameworkCore.SqlServer.Abstractions ne référence aucun autre package, et a donc un impact minimal sur la taille et les dépendances des applications déployées.

L’utilisation de HierarchyId pour les fonctionnalités EF Core, telles que les requêtes et les mises à jour, nécessite le package Microsoft.EntityFrameworkCore.SqlServer.HierarchyId. Ce package apporte Microsoft.EntityFrameworkCore.SqlServer.Abstractions et Microsoft.SqlServer.Types en tant que dépendances transitives, et il est donc souvent le seul package nécessaire. Une fois le package installé, l’utilisation de HierarchyId est activée en appelant UseHierarchyId dans le cadre de l’appel de l’application à UseSqlServer. Par exemple :

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

Remarque

La prise en charge non officielle de hierarchyid dans EF Core a été disponible depuis de nombreuses années via le package EntityFrameworkCore.SqlServer.HierarchyId. Ce package a été maintenu en tant que collaboration entre la communauté et l’équipe EF. Maintenant qu’il existe une prise en charge officielle de hierarchyid dans .NET, le code de ce package de communauté forme, avec l’autorisation des contributeurs d’origine, la base du package officiel décrit ici. Merci beaucoup à tous ceux impliqués au fil des années, y compris @aljones, @cutig3r, @huan086, @kmataru, @mehdihaghshenas, et @vyrotek

Modélisation des hiérarchies

Le type HierarchyId peut être utilisé pour les propriétés d’un type d’entité. Par exemple, supposons que nous voulons modéliser l’arbre familial paternel de certains halflings. Dans le type d’entité pour Halfling, une propriété HierarchyId peut être utilisée pour localiser chaque halfling dans l’arborescence de la famille.

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

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

Conseil

Le code présenté ici et dans les exemples ci-dessous provient de HierarchyIdSample.cs.

Conseil

Si vous le souhaitez, HierarchyId convient à une utilisation comme type de propriété de clé.

Dans ce cas, l’arbre familial est enraciné avec le patriarche de la famille. Chaque halfling peut être tracé du patriarche vers le bas de l’arbre à l’aide de sa propriétéPathFromPatriarch. SQL Server utilise un format binaire compact pour ces chemins d’accès, mais il est courant d’analyser et à partir d’une représentation sous forme de chaîne lisible par l’homme lors de l’utilisation du code. Dans cette représentation, la position à chaque niveau est séparée par un caractère /. Par exemple, considérez l’arborescence familiale dans le diagramme ci-dessous :

Arbre généalogique des Halfelins

Dans cette arborescence :

  • Balbo est à la racine de l’arbre, représenté par /.
  • Balbo a cinq enfants, représentés par /1/, /2/, /3/, /4/et /5/.
  • Le premier enfant de Balbo, Mungo, a également cinq enfants, représentés par /1/1/, /1/2/, /1/3/, /1/4/et /1/5/. Notez que le HierarchyId pour Balbo (/1/) est le préfixe de tous ses enfants.
  • De même, le troisième enfant de Balbo, Ponto, a deux enfants, représentés par /3/1/ et /3/2/. Là encore, chacun de ces enfants est précédé de HierarchyId pour Ponto, qui est représenté comme /3/.
  • Et ainsi de suite sur le bas de l’arbre...

Le code suivant insère cette arborescence familiale dans une base de données à l’aide d’EF Core :

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

await SaveChangesAsync();

Conseil

Si nécessaire, les valeurs décimales peuvent être utilisées pour créer de nouveaux nœuds entre deux nœuds existants. Par exemple, /3/2.5/2/ passe entre /3/2/2/ et /3/3/2/.

Interrogation des hiérarchies

HierarchyId expose plusieurs méthodes qui peuvent être utilisées dans les requêtes LINQ.

Méthode Description
GetAncestor(int n) Obtient le nœud n niveaux de l’arborescence hiérarchique.
GetDescendant(HierarchyId? child1, HierarchyId? child2) Obtient la valeur d’un nœud descendant supérieur à child1 et inférieur à child2.
GetLevel() Obtient le niveau de ce nœud dans l’arborescence hiérarchique.
GetReparentedValue(HierarchyId? oldRoot, HierarchyId? newRoot) Obtient une valeur représentant l’emplacement d’un nouveau nœud qui a un chemin d’accès de newRoot égal au chemin d’accès de oldRoot jusqu’à cela, en déplaçant cela vers le nouvel emplacement.
IsDescendantOf(HierarchyId? parent) Obtient une valeur indiquant si ce nœud est un descendant de parent.

En outre, les opérateurs ==, !=, <, <=, > et >= peuvent être utilisés.

Voici des exemples d’utilisation de ces méthodes dans les requêtes LINQ.

Obtenir des entités à un niveau donné dans l’arborescence

La requête suivante utilise GetLevel pour retourner tous les demi-points à un niveau donné dans l’arborescence de la famille :

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

Cela se traduit par le code SQL suivant :

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

En exécutant cela dans une boucle, nous pouvons obtenir les demi-points pour chaque génération :

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

Obtenir l’ancêtre direct d’une entité

La requête suivante utilise GetAncestor pour trouver l’ancêtre direct d’un halfling, compte tenu du nom de ce demi-point :

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

Cela se traduit par le code SQL suivant :

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

L’exécution de cette requête pour la moitié de « Bilbo » retourne « Bungo ».

Obtenir les descendants directs d’une entité

La requête suivante utilise également GetAncestor, mais cette fois pour trouver les descendants directs d’un halfelin, étant donné le nom de ce demi-point :

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

Cela se traduit par le code SQL suivant :

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

L’exécution de cette requête pour le halfling de « Mungo » retourne « Bungo », « Belba », « Longo » et « Linda ».

Obtenir tous les ancêtres d’une entité

GetAncestor est utile pour rechercher un niveau unique ou, en effet, un nombre spécifié de niveaux. En revanche, IsDescendantOf est utile pour trouver tous les ancêtres ou dépendants. Par exemple, la requête suivante utilise IsDescendantOf pour rechercher tous les ancêtres d’un halfling, compte tenu du nom de ce halfling :

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

Important

IsDescendantOf retourne la valeur true pour elle-même, c’est pourquoi elle est filtrée dans la requête ci-dessus.

Cela se traduit par le code SQL suivant :

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

L’exécution de cette requête pour le halfelin de « Bilbo » retourne « Bungo », « Mungo » et « Balbo ».

Obtenir toutes les décroissantes d’une entité

La requête suivante utilise également IsDescendantOf, mais cette fois-ci pour tous les descendants d’un halfelin, compte tenu du nom de ce demi-point :

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

Cela se traduit par le code SQL suivant :

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

L’exécution de cette requête pour le halfling de « Mungo » retourne « Bungo », « Belba », « Longo », « Linda », « Bingo », « Bilbo », « Otho », « Falco », « Lotho », « Lotho », et « Poppy ».

Trouver un ancêtre commun

L’une des questions les plus courantes posées sur cet arbre familial particulier est « qui est l’ancêtre commun de Frodo et Bilbo ? » Nous pouvons utiliser IsDescendantOf pour écrire une telle requête :

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

Cela se traduit par le code SQL suivant :

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

L’exécution de cette requête avec « Bilbo » et « Frodo » nous indique que leur ancêtre commun est « Balbo ».

Mise à jour des hiérarchies

Les mécanismes de SaveChanges et SaveChanges peuvent être utilisés pour mettre à jour les colonnes hierarchyid.

Re-parenter une sous-hiérarchie

Par exemple, je suis sûr que nous nous souvenons tous du scandale de SR 1752 (a.k.a. « LongoGate ») quand des tests d’ADN ont révélé que Longo n’était pas en fait le fils de Mungo, mais en fait le fils de Ponto ! L’une des retombées de ce scandale était que l’arbre familial devait être réécrit. En particulier, Longo et tous ses descendants devaient être re-parentés de Mungo à Ponto. GetReparentedValue peut être utilisé pour ce faire. Par exemple, tout d’abord « Longo » et tous ses descendants sont interrogés :

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

Ensuite, GetReparentedValue est utilisée pour mettre à jour le HierarchyId pour Longo et chaque descendant, suivi d’un appel à SaveChangesAsync:

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

await context.SaveChangesAsync();

Cela entraîne la mise à jour de base de données suivante :

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

À l’aide de ces paramètres :

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

Remarque

Les valeurs des paramètres des propriétés HierarchyId sont envoyées à la base de données dans leur format binaire compact et binaire.

Après la mise à jour, l’interrogation des descendants de « Mungo » retourne « Bungo », « Belba », « Linda », « Bingo », « Bilbo », « Falco », et « Poppy », tout en interrogeant pour les descendants de « Ponto » retourne « Longo », « Rosa », « Polo », « Otho », « Posco », « Prisca », « Lotho », « Ponto », « Porto », « Peony », et « Angelica ».

Requêtes SQL brutes pour les types non mappés

EF7 a introduit requêtes SQL brutes retournant des types scalaires. Cela est amélioré dans EF8 pour inclure des requêtes SQL brutes retournant n’importe quel type CLR mappable, sans inclure ce type dans le modèle EF.

Conseil

Le code présenté ici provient de RawSqlSample.cs.

Les requêtes utilisant des types non mappés sont exécutées à l’aide de SqlQuery ou de SqlQueryRaw. L’ancien utilise l’interpolation de chaîne pour paramétrer la requête, ce qui permet de s’assurer que toutes les valeurs non constantes sont paramétrées. Par exemple, considérez la table de base de données suivante :

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

SqlQuery pouvez être utilisé pour interroger cette table et retourner des instances d’un type BlogPost avec des propriétés correspondant aux colonnes de la table :

Par exemple :

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

Par exemple :

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

Cette requête est paramétrable et exécutée comme suit :

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

Le type utilisé pour les résultats de requête peut contenir des constructions de mappage courantes prises en charge par EF Core, telles que des constructeurs paramétrés et des attributs de mappage. Par exemple :

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

    public int Id { get; private set; }

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

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

Remarque

Les types utilisés de cette façon n’ont pas de clés définies et ne peuvent pas avoir de relations avec d’autres types. Les types avec des relations doivent être mappés dans le modèle.

Le type utilisé doit avoir une propriété pour chaque valeur du jeu de résultats, mais il n’est pas nécessaire de faire correspondre une table dans la base de données. Par exemple, le type suivant représente uniquement un sous-ensemble d’informations pour chaque billet et inclut le nom du blog, qui provient de la table Blogs :

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

Et peut être interrogé à l’aide de SqlQuery de la même façon que précédemment :


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

Une caractéristique intéressante de SqlQuery est qu’elle retourne une IQueryable qui peut être composée à l’aide de LINQ. Par exemple, une clause « Where » peut être ajoutée à la requête ci-dessus :

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

Ceci est exécuté comme suit :

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

À ce stade, il est important de rappeler que toutes les opérations ci-dessus peuvent être effectuées complètement dans LINQ sans avoir à écrire un SQL. Cela inclut le retour d’instances d’un type non mappé comme PostSummary. Par exemple, la requête précédente peut être écrite dans LINQ comme suit :

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

Ce qui se traduit par un SQL beaucoup plus propre :

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

Conseil

EF est en mesure de générer un SQL plus propre lorsqu’il est responsable de l’ensemble de la requête que lors de la composition sur SQL fourni par l’utilisateur, car, dans l’ancien cas, la sémantique complète de la requête est disponible pour EF.

Jusqu’à présent, toutes les requêtes ont été exécutées directement sur des tables. SqlQuery pouvez également être utilisé pour retourner des résultats à partir d’une vue sans mapper le type de vue dans le modèle EF. Par exemple :

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

De même, SqlQuery pouvez être utilisé pour les résultats d’une fonction :

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

Le IQueryable retourné peut être composé lorsqu’il s’agit du résultat d’une vue ou d’une fonction, tout comme pour le résultat d’une requête de table. Les procédures stockées peuvent également être exécutées à l’aide de SqlQuery, mais la plupart des bases de données ne prennent pas en charge la composition. Par exemple :

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

Améliorations apportées au chargement différé

Chargement différé pour les requêtes sans suivi

EF8 ajoute la prise en charge de chargement différé des navigations sur les entités qui ne sont pas suivies par le DbContext. Cela signifie qu’une requête sans suivi peut être suivie du chargement différé des navigations sur les entités retournées par la requête sans suivi.

Conseil

Le code des exemples de chargement différé ci-dessous provient de LazyLoadingSample.cs.

Par exemple, considérez une requête sans suivi pour les blogs :

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

Si Blog.Posts est configuré pour le chargement différé, par exemple, à l’aide de proxys de chargement différé, l’accès à Posts entraîne son chargement à partir de la base de données :

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

EF8 indique également si une navigation donnée est chargée pour les entités non suivies par le contexte. Par exemple :

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

Il existe quelques considérations importantes lors de l’utilisation du chargement différé de cette façon :

  • Le chargement différé réussit uniquement jusqu’à ce que la DbContext utilisée pour interroger l’entité soit supprimée.
  • Les entités interrogées de cette façon gardent une référence à leur DbContext, même si elles ne sont pas suivies par celui-ci. Veillez à éviter les fuites de mémoire si les instances d’entité auront des durées de vie longues.
  • Détacher explicitement l’entité en définissant son état sur EntityState.Detached détache la référence au DbContext et le chargement différé ne fonctionnera plus.
  • N’oubliez pas que tous les chargements différés utilisent des E/S synchrones, car il n’existe aucun moyen d’accéder à une propriété de manière asynchrone.

Le chargement différé à partir d’entités non tracées fonctionne pour les deux proxys de chargement différé et le chargement différé sans proxys.

Chargement explicite à partir d’entités non tracées

EF8 prend en charge le chargement de navigations sur des entités non tracées, même lorsque l’entité ou la navigation n’est pas configurée pour le chargement différé. Contrairement au chargement différé, cette chargement explicite peut être effectuée de manière asynchrone. Par exemple :

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

Refuser le chargement différé pour des navigations spécifiques

EF8 permet de configurer des navigations spécifiques pour ne pas se charger paresseux, même si tout le reste est configuré pour le faire. Par exemple, pour configurer la navigation Post.Author pour ne pas se charger paresseux, procédez comme suit :

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

La désactivation du chargement différé comme celui-ci fonctionne pour les deux proxys de chargement différé et paresseux-chargement sans proxys.

Les proxys de chargement différé fonctionnent en remplaçant les propriétés de navigation virtuelle. Dans les applications EF6 classiques, une source courante de bogues oublie de rendre une navigation virtuelle, car la navigation ne se chargera pas silencieusement. Par conséquent, les proxys EF Core lèvent par défaut lorsqu’une navigation n’est pas virtuelle.

Cela peut être modifié dans EF8 pour opter pour le comportement EF6 classique afin qu’une navigation puisse être effectuée pour ne pas charger simplement paresseux en rendant la navigation non virtuelle. Cette option est configurée dans le cadre de l’appel à UseLazyLoadingProxies. Par exemple :

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

Accès aux entités suivies

Rechercher des entités suivies par clé primaire, alternative ou étrangère

En interne, EF gère les structures de données pour rechercher des entités suivies par clé primaire, alternative ou étrangère. Ces structures de données sont utilisées pour corriger efficacement les entités associées lorsque de nouvelles entités sont suivies ou que les relations changent.

EF8 contient de nouvelles API publiques afin que les applications puissent désormais utiliser ces structures de données pour rechercher efficacement des entités suivies. Ces API sont accessibles via le LocalView<TEntity> du type d’entité. Par exemple, pour rechercher une entité suivie par sa clé primaire :

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

Conseil

Le code présenté ici provient de LookupByKeySample.cs.

La méthode FindEntry retourne leEntityEntry<TEntity> de l’entité suivie, ou null si aucune entité avec la clé donnée n’est suivie. Comme toutes les méthodes sur LocalView, la base de données n’est jamais interrogée, même si l’entité est introuvable. L’entrée retournée contient l’entité elle-même, ainsi que les informations de suivi. Par exemple :

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

La recherche d’une entité à l’autre que la clé primaire nécessite que le nom de la propriété soit spécifié. Par exemple, pour rechercher par une autre clé :

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

Ou pour rechercher par une clé étrangère unique :

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

Jusqu’à présent, les recherches ont toujours retourné une seule entrée, ou null. Toutefois, certaines recherches peuvent retourner plusieurs entrées, par exemple lors de la recherche par une clé étrangère non unique. La méthode GetEntries doit être utilisée pour ces recherches. Par exemple :

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

Dans tous ces cas, la valeur utilisée pour la recherche est une clé primaire, une autre clé ou une valeur de clé étrangère. EF utilise ses structures de données internes pour ces recherches. Toutefois, les recherches par valeur peuvent également être utilisées pour la valeur de n’importe quelle propriété ou combinaison de propriétés. Par exemple, pour rechercher tous les billets archivés :

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

Cette recherche nécessite une analyse de toutes les instances de Post suivies et sera donc moins efficace que les recherches clés. Toutefois, il est généralement plus rapide que les requêtes naïves utilisant ChangeTracker.Entries<TEntity>().

Enfin, il est également possible d’effectuer des recherches sur des clés composites, d’autres combinaisons de plusieurs propriétés ou lorsque le type de propriété n’est pas connu au moment de la compilation. Par exemple :

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

Génération de modèles

Les colonnes de discrimination ont une longueur maximale

Dans EF8, les colonnes de discrimination de chaîne utilisées pour mappage d’héritage TPH sont désormais configurées avec une longueur maximale. Cette longueur est calculée comme le plus petit nombre Fibonacci qui couvre toutes les valeurs de discrimination définies. Par exemple, considérez la hiérarchie suivante :

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

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

public class PaperbackEdition : Book
{
}

public class HardbackEdition : Book
{
}

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

Avec la convention d’utilisation des noms de classes pour les valeurs de discriminateur, les valeurs possibles ici sont « PaperbackEdition », « HardbackEdition » et « Magazine », et par conséquent, la colonne de discrimination est configurée pour une longueur maximale de 21. Par exemple, lors de l’utilisation de SQL Server :

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

Conseil

Les nombres Fibonacci sont utilisés pour limiter le nombre de fois qu’une migration est générée pour modifier la longueur de colonne lorsque de nouveaux types sont ajoutés à la hiérarchie.

DateOnly/TimeOnly pris en charge sur SQL Server

Les types DateOnly et TimeOnly ont été introduits dans .NET 6 et ont été pris en charge pour plusieurs fournisseurs de base de données (par exemple SQLite, MySQL et PostgreSQL) depuis leur introduction. Pour SQL Server, la version récente d’un package Microsoft.Data.SqlClient ciblant .NET 6 a permis ErikEJ d’ajouter la prise en charge de ces types au niveau ADO.NET. Cela a permis de prendre en charge EF8 pour DateOnly et TimeOnly comme propriétés dans les types d’entités.

Conseil

DateOnly et TimeOnly peuvent être utilisés dans EF Core 6 et 7 à l’aide du package de communauté ErikEJ.EntityFrameworkCore.SqlServer.DateOnlyTimeOnly de @ErikEJ.

Par exemple, considérez le modèle EF suivant pour les écoles britanniques :

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

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

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

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

Conseil

Le code présenté ici provient de DateOnlyTimeOnlySample.cs.

Remarque

Ce modèle représente uniquement les écoles britanniques et stocke les heures en tant que heures locales (GMT). La gestion de différents fuseaux horaires compliquerait considérablement ce code. Notez que l’utilisation de DateTimeOffset n’aide pas ici, car les heures d’ouverture et de fermeture ont des décalages différents selon que l’heure d’été est active ou non.

Ces types d’entités correspondent aux tableaux suivants lors de l’utilisation de SQL Server. Notez que les propriétés DateOnly correspondent à des colonnes date et que les propriétés TimeOnly correspondent à time colonnes.

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

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

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

Les requêtes utilisant DateOnly et TimeOnly fonctionnent de la manière attendue. Par exemple, la requête LINQ suivante recherche les écoles actuellement ouvertes :

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

Cette requête se traduit par le code SQL suivant, comme indiqué par ToQueryString:

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

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

DateOnly et TimeOnly peuvent également être utilisés dans les colonnes JSON. Par exemple, OpeningHours peut être enregistré en tant que document JSON, ce qui entraîne des données qui ressemblent à ceci :

Colonne Valeur
Id 2
Nom Lycée Farr
Créé 01/05/1964
OpeningHours
[
{ "DayOfWeek": "Sunday", "ClosesAt": null, "OpensAt": null },
{ "DayOfWeek": "Monday", "ClosesAt": "15:35:00", "OpensAt": "08:45:00" },
{ "DayOfWeek": "Tuesday", "ClosesAt": "15:35:00", "OpensAt": "08:45:00" },
{ "DayOfWeek": "Wednesday", "ClosesAt": "15:35:00", "OpensAt": "08:45:00" },
{ "DayOfWeek": "Thursday", "ClosesAt": "15:35:00", "OpensAt": "08:45:00" },
{ "DayOfWeek": "Friday", "ClosesAt": "12:50:00", "OpensAt": "08:45:00" },
{ "DayOfWeek": "Saturday", "ClosesAt": null, "OpensAt": null }
]

En combinant deux fonctionnalités d’EF8, nous pouvons maintenant interroger les heures d’ouverture en indexant dans la collection JSON. Par exemple :

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

Cette requête se traduit par le code SQL suivant, comme indiqué par ToQueryString:

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

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

Enfin, les mises à jour et les suppressions peuvent être effectuées avec suivi et SaveChanges, ou à l’aide de ExecuteUpdate/ExecuteDelete. Par exemple :

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

Cette mise à jour se traduit par le code SQL suivant :

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

Ingénieur inverse Synapse et Dynamics 365 TDS

L’ingénierie inverse EF8 (génération de modèles automatique à partir d’une base de données existante) prend désormais en charge pool SQL Synapse Serverless et bases de données de point de terminaison TDS Dynamics 365 .

Avertissement

Ces systèmes de base de données présentent des différences par rapport aux bases de données SQL Server et Azure SQL normales. Ces différences signifient que toutes les fonctionnalités EF Core ne sont pas prises en charge lors de l’écriture de requêtes sur ou de l’exécution d’autres opérations avec ces systèmes de base de données.

Améliorations apportées aux traductions mathématiques

Les interfaces mathématiques génériques ont été introduites dans .NET 7. Les types concrets tels que double et float ont implémenté ces interfaces en ajoutant de nouvelles API qui reflètent les fonctionnalités existantes de Math et MathF.

EF Core 8 traduit les appels à ces API mathématiques génériques dans LINQ à l’aide des traductions SQL existantes des fournisseurs pour Math et MathF. Cela signifie que vous êtes désormais libre de choisir entre les appels tels que Math.Sin ou double.Sin dans vos requêtes EF.

Nous avons collaboré avec l’équipe .NET pour ajouter deux nouvelles méthodes mathématiques génériques dans .NET 8, qui sont implémentées sur double et float. Elles sont également traduites en SQL dans EF Core 8.

.NET SQL
DegreesToRadians RADIANS
RadiansToDegrees DEGREES

Enfin, nous avons collaboré avec Eric Sink dans le projet SQLitePCLRaw pour activer les fonctions mathématiques SQLite dans leurs builds de la bibliothèque SQLite native. Cela inclut la bibliothèque native que vous obtenez par défaut quand vous installez le fournisseur EF Core SQLite. Cela permet plusieurs nouvelles traductions SQL dans LINQ, notamment : Acos, Acosh, Asin, Asinh, Atan, Atan2, Atanh, Ceiling, Cos, Cosh, DegreesToRadians, Exp, Floor, Log, Log2, Log10, Pow, RadiansToDegrees, Sign, Sin, Sinh, Sqrt, Tan, Tanh et Truncate.

Vérifier si des modifications de modèle sont en attente

Nous avons ajouté une nouvelle commande dotnet ef pour vérifier si des modifications de modèle ont été apportées depuis la dernière migration. Cet élément peut être utile dans des scénarios CI/CD pour veiller à ce que vous ou un collègue n’ayez pas oublié d’ajouter une migration.

dotnet ef migrations has-pending-model-changes

Vous pouvez également effectuer cette vérification par programmation dans votre application ou par des tests en utilisant la nouvelle méthode dbContext.Database.HasPendingModelChanges().

Améliorations apportées à la génération de modèles automatique SQLite

SQLite prend en charge seulement quatre types de données primitifs--INTEGER, REAL, TEXT et BLOB. Auparavant, cela signifiait que lorsqu’une base de données SQLite faisait l’objet d’une ingénierie à rebours pour générer une structure d’un modèle EF Core, les types d’entités résultants incluaient uniquement des propriétés de type long, double, stringet byte[]. D’autres types .NET sont pris en charge par le fournisseur EF Core SQLite en effectuant une conversion entre eux et l’un des quatre types SQLite primitifs.

Dans EF Core 8, nous utilisons désormais le format de données et le nom du type de colonne en plus du type SQLite afin de déterminer un type .NET plus approprié à utiliser dans le modèle. Les tableaux suivants montrent certains des cas où les informations supplémentaires permettent d’obtenir de meilleurs types de propriétés dans le modèle.

Nom du type de colonne Type .NET
BOOLEAN byte[]bool
SMALLINT longshort
INT longint
bigint long
STRING byte[]string
Format de données Type .NET
« 0.0 » stringdecimal
« 1970-01-01 » stringDateOnly
« 1970-01-01 00:00:00 » stringDateTime
« 00:00:00 » stringTimeSpan
« 00000000-0000-0000-0000-000000000000 » stringGuid

Valeurs sentinelles et valeurs par défaut de base de données

Les bases de données permettent de configurer des colonnes pour générer une valeur par défaut si aucune valeur n’est fournie lors de l’insertion d’une ligne. Cela peut être représenté dans EF en utilisant HasDefaultValue comme constantes :

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

Ou HasDefaultValueSql pour les clauses SQL arbitraires :

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

Conseil

Le code ci-dessous provient de DefaultConstraintSample.cs.

Pour qu’EF puisse utiliser ce paramètre, il doit déterminer quand, et quand ne pas, envoyer de valeur pour la colonne. Par défaut, EF utilise la valeur par défaut du CLR comme sentinelle à cet effet. Autrement dit, quand la valeur de Status ou LeaseDate dans les exemples ci-dessus sont les valeurs par défaut du CLR pour ces types, alors EF interprète cela pour signifier que la propriété n’a pas été définie, et n’envoie donc pas de valeur à la base de données. Cela fonctionne bien pour les types de référence, par exemple, si le Status de la propriété string est null, alors EF n’envoie null pas à la base de données, mais n’inclut pas de valeur afin que valeur par défaut de la base de données ("Hidden") soit utilisée. De même, pour le LeaseDate de la propriété DateTime, EF n’insère pas la valeur par défaut du CLR de 1/1/0001 12:00:00 AM, mais à la place omet cette valeur afin que la valeur par défaut de la base de données soit utilisée.

Toutefois, dans certains cas, la valeur par défaut du CLR est une valeur valide à insérer. EF8 gère cela en autorisant la valeur sentinelle d’une colonne à changer. Prenons l’exemple d’une colonne d’entiers configurée avec une base de données par défaut :

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

Dans ce cas, nous voulons que la nouvelle entité soit insérée avec le nombre donné de crédits, sauf s’il n’est pas spécifié, auquel cas 10 crédits sont attribués. Toutefois, cela signifie que l’insertion d’un enregistrement avec zéro crédits n’est pas possible, car zéro est la valeur par défaut du CLR, et par conséquent, EF n’envoie aucune valeur. Dans EF8, cela peut être résolu en modifiant la sentinelle de la propriété de zéro à -1 :

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

EF utilise désormais uniquement la valeur par défaut de la base de données si Credits est défini sur -1 ; une valeur de zéro est insérée comme n’importe quelle autre quantité.

Il peut souvent être utile de le refléter dans le type d’entité ainsi que dans la configuration EF. Par exemple :

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

Cela signifie que la valeur sentinelle de -1 est définie automatiquement lorsque l’instance est créée, ce qui signifie que la propriété démarre dans son état « non défini ».

Conseil

Si vous souhaitez configurer la contrainte par défaut de base de données à utiliser quand Migrations crée la colonne, mais que vous voulez que EF insère toujours une valeur, configurez la propriété comme non générée. Par exemple : b.Property(e => e.Credits).HasDefaultValueSql(10).ValueGeneratedNever();.

Valeurs par défaut de la base de données pour les booléens

Les propriétés booléennes présentent une forme extrême de ce problème, car la valeur par défaut du CLR (false) est l’une des deux seules valeurs valides. Cela signifie qu’une propriété bool avec une contrainte par défaut de base de données n’aura qu’une valeur insérée si cette valeur est true. Quand la valeur par défaut de la base de données est false, cela signifie que, quand la valeur de la propriété est false, alors la base de données par défaut est utilisée, c’est-à-dire false. Sinon, si la valeur de la propriété est true, alors true sera inséré. Par conséquent, lorsque la valeur par défaut de la base de données est false, la colonne de base de données se termine par la valeur correcte.

En revanche, si la valeur par défaut de la base de données est true, cela signifie que lorsque la valeur de la propriété est false, alors la valeur par défaut de la base de données sera utilisée, c’est à dire true ! Et lorsque la valeur de la propriété est true, alors true sera inséré. Par conséquent, la valeur dans la colonne se terminera toujours true dans la base de données, quelle que soit la valeur de la propriété.

EF8 résout ce problème en définissant la sentinelle pour les propriétés bool sur la même valeur que la valeur par défaut de la base de données. Les deux cas ci-dessus entraînent ensuite l’insertion de la valeur correcte, que la valeur de la base de données par défaut soit true ou false.

Conseil

Lorsque vous créez une structure à partir d’une base de données existante, EF8 analyse, puis inclut des valeurs par défaut simples dans les appels HasDefaultValue. (Auparavant, toutes les valeurs par défaut étaient générées sous forme d’appels HasDefaultValueSql opaques.) Cela signifie que les colonnes bool non nullables avec une valeur de base de données par défaut constante de true ou false ne sont plus générées automatiquement comme nullables.

Valeurs par défaut de la base de données pour les énumérations

Les propriétés d’énumération peuvent présenter des problèmes similaires aux propriétés bool, car les énumérations ont généralement un très petit ensemble de valeurs valides et la valeur par défaut du CLTR peut être l’une de ces valeurs. Prenons l’exemple de ce type d'entité et de cette énumération :

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

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

La propriété Level est ensuite configurée avec une valeur de base de données par défaut :

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

Avec cette configuration, EF exclut l’envoi de la valeur à la base de données lorsqu’elle est définie sur Level.Beginner, et Level.Intermediate est attribué par la base de données à la place. Ce n’est pas ce qui était prévu !

Le problème n’aurait pas eu lieu si l’énumération a été définie avec la valeur « inconnue » ou « non spécifiée » comme étant la valeur par défaut de la base de données :

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

Toutefois, il n’est pas toujours possible de modifier une énumération existante. La sentinelle peut donc à nouveau être spécifiée dans EF8. Par exemple, en revenant à l’énumération d’origine :

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

Désormais, Level.Beginner est inséré normalement, et la valeur par défaut de la base de données sera utilisée uniquement lorsque la valeur de propriété est Level.Unspecified. Il peut à nouveau être utile de refléter cela dans le type d’entité lui-même. Par exemple :

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

Utiliser un champ de stockage nullable

Un moyen plus général de gérer le problème décrit ci-dessus consiste à créer un champ de stockage nullable pour la propriété non nullable. Prenons l’exemple du type d’entité suivant avec une propriété bool :

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

La propriété peut recevoir un champ de stockage nullable :

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

    private bool? _isActive;

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

Le champ de stockage ici restera nullà moins que le setter de propriété ne soit réellement appelé. Autrement dit, la valeur du champ de stockage est un meilleur indicateur que la valeur par défaut du CLR pour savoir si la propriété a été définie ou non. Cela fonctionne de façon prête à l’emploi avec EF, car EF utilise le champ de stockage pour lire et écrire la propriété par défaut.

ExecuteUpdate et ExecuteDelete améliorées

Les commandes SQL qui effectuent des mises à jour et des suppressions, comme celles générées par les méthodes ExecuteUpdate et ExecuteDelete, doivent cibler une table de base de données unique. Toutefois, dans EF7, ExecuteUpdate et ExecuteDelete ne prenaient pas en charge les mises à jour accédant à plusieurs types d’entités, même quand la requête n’a finalement affecté qu’une seule table. EF8 supprime cette limitation. Prenons l’exemple d’un type d’entité Customer avec type possédé CustomerInfo :

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

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

Ces deux types d’entités sont mappés à la table Customers. Toutefois, la mise à jour en bloc suivante échoue sur EF7, car elle utilise les deux types d’entités :

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

Dans EF8, cela se traduit désormais par le code SQL suivant, quand on utilise Azure SQL :

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

De même, les instances retournées depuis une requête Union peuvent être mises à jour tant que les mises à jour ciblent toutes la même table. Par exemple, nous pouvons mettre à jour tout Customeravec une région de France, et dans le même temps, tout Customer ayant visité un magasin dans la région France:

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

Dans EF8, cette requête génère les éléments suivants quand on utilise Azure SQL :

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

En guise d’exemple final, dans EF8, ExecuteUpdate peut être utilisé pour mettre à jour des entités dans une hiérarchie TPT tant que toutes les propriétés mises à jour sont mappées à la même table. Prenons l’exemple de ces types d’entités mappés à l’aide de TPT :

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

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

Avec EF8, la propriété Note peut être mise à jour :

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

Ou la propriété Name peut être mise à jour :

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

Toutefois, EF8 ne peut pas tenter de mettre à jour les propriétés Name et Note, car elles sont mappées à des tables différentes. Par exemple :

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

Lève l’exception suivante :

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

Meilleure utilisation des requêtes IN

Lorsque l’opérateur LINQ Contains est utilisé avec une sous-requête, EF Core génère désormais de meilleures requêtes à l’aide de SQL IN au lieu de EXISTS. En plus de produire du SQL plus lisible, cela peut entraîner, dans certains cas, des requêtes considérablement plus rapides. Prenons l'exemple de la requête LINQ suivante :

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

EF7 génère ce qui suit pour PostgreSQL :

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

La sous-requête faisant référence à la table externe Blogs (via b."Id"), il s’agit d’une sous-requête corrélée, ce qui signifie que la sous-requête Posts doit être exécutée pour chaque ligne de la table Blogs. Dans EF8, le code SQL suivant est généré à la place :

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

La sous-requête ne faisant plus référence à Blogs, elle peut être évaluée une fois, ce qui génère des améliorations massives du niveau de performance sur la plupart des systèmes de base de données. Toutefois, certains systèmes de base de données, notamment SQL Server, peuvent optimiser la première requête sur la deuxième requête afin que les performances soient identiques.

Rowversions numériques pour SQL Azure/SQL Server

L’accès concurrentiel optimiste automatique de SQL Server est gérée à l’aide de colonnes rowversion. rowversion est une valeur opaque de 8 octets passée entre la base de données, le client et le serveur. Par défaut, SqlClient expose les types rowversion comme byte[], bien que les types de référence mutables soient une correspondance incorrecte pour la sémantique rowversion. Dans EF8, il est facile de mapper plutôt des colonnes rowversion à des propriétés long ou ulong. Par exemple :

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

Élimination des parenthèses

Générer du SQL lisible est un objectif important d’EF Core. Dans EF8, le SQL généré est plus lisible grâce à l’élimination automatique des parenthèses inutiles. Par exemple, la requête LINQ suivante :

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

Se traduit par l’Azure SQL suivant quand on utilise EF7 :

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

Qui a été amélioré en ce qui suit quand on utilise EF8 :

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

Opt-out spécifique pour la clause RETURNING/OUTPUT

EF7 a modifié la mise à jour SQL par défaut pour utiliser RETURNING/OUTPUT pour récupérer des colonnes générées par la base de données. Certains cas ont été identifiés où cela ne fonctionne pas, et EF8 introduit des opt-outs explicites pour ce comportement.

Par exemple, pour se désinscrire de OUTPUT quand on utilise le fournisseur SQL Server/Azure SQL :

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

Ou pour se désinscrire de RETURNING quand on utilise le fournisseur SQLite :

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

Autres modifications mineures

Outre les améliorations décrites ci-dessus, de nombreuses modifications de moindre importance ont été apportées à EF8. notamment :