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
etPhoneNumber
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 DateOnly
nullable. 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 HierarchyId
type 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 :
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 leHierarchyId
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é deHierarchyId
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 auDbContext
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 | [ |
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 lorsque vous faisiez de l’ingénierie inverse d’une base de données SQLite pour générer un modèle EF Core à l’aide de scaffold, les types d’entités résultants n’incluaient que des propriétés de type long
, double
, string
, et 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 | |
SMALLINT | |
INT | |
bigint | long |
STRING |
Format de données | Type .NET |
---|---|
« 0.0 » | |
« 1970-01-01 » | |
« 1970-01-01 00:00:00 » | |
« 00:00:00 » | |
« 00000000-0000-0000-0000-000000000000 » |
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 reste 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 Customer
avec 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)
.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 :
- Compatibilité nativeAOT/découpage pour Microsoft.Data.Sqlite
- Autoriser le multi-régions ou les régions préférées des applications dans EF Core Cosmos
- SQLite : ajouter EF. Functions.Unhex
- Options d’index SQL Server SortInTempDB et DataCompression
- Autoriser la connexion « unsharing » entre les contextes
- Ajouter une version générique de l’attribut EntityTypeConfiguration
- Requête : ajouter la prise en charge de la projection d’entités JSON qui ont été composées
- Supprimer la sous-requête et la projection inutiles lors de l’utilisation de l’ordre sans limite/décalage dans les opérations ensemblistes
- Autoriser le regroupement de DbContext avec des services singleton
- RestartSequenceOperation.StartValue facultatif
- Autoriser UseSequence et HiLo sur les propriétés non clés
- Fournissez plus d’informations lorsque l’erreur « Aucun DbContext a été trouvé » est générée
- Passer le comportement de suivi des requêtes aud’intercepteur de matérialisation
- Utiliser des comparaisons de clés de chaîne non sensibles à la casse sur SQL Server
- Autoriser les convertisseurs de valeurs à modifier le DbType
- Résoudre les services d’application dans les services EF
- Autoriser le transfert de propriété de DbConnection depuis l’application vers dbContext