Partager via


Nouveautés de EF Core 9

EF Core 9 (EF9) est la prochaine version après EF Core 8 et est prévue pour la publication en novembre 2024.

EF9 est disponible en tant que builds quotidiennes qui contiennent toutes les dernières fonctionnalités EF9 et ajustements d’API. Les exemples ici utilisent ces builds quotidiennes.

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 ci-dessous renvoie au code source propre à cette section.

EF9 cible .NET 8 et peut donc être utilisé avec .NET 8 (LTS) ou une préversion de .NET 9.

Conseil

Les nouveaux documents sont mis à jour pour chaque préversion. Tous les exemples sont configurés pour utiliser les builds quotidiennes EF9, qui ont généralement plusieurs semaines supplémentaires de travail terminées par rapport à la dernière préversion. Nous encourageons vivement l’utilisation des builds quotidiennes lors du test de nouvelles fonctionnalités afin de ne pas effectuer vos tests sur les bits obsolètes.

Azure Cosmos DB pour NoSQL

EF 9.0 apporte des améliorations substantielles au fournisseur EF Core pour Azure Cosmos DB ; des parties importantes du fournisseur ont été réécrites pour fournir de nouvelles fonctionnalités, permettre de nouvelles formes de requêtes, et mieux aligner le fournisseur avec les meilleures pratiques de Cosmos DB Les principales améliorations de haut niveau sont énumérées ci-dessous ; pour une liste complète, voir ce problème épique.

Avertissement

Dans le cadre des améliorations apportées au fournisseur, un certain nombre de ruptures à fort impact ont dû être effectuées ; si vous mettez à jour une application existante, veuillez lire attentivement la section relative aux ruptures.

Amélioration des requêtes avec les clés de partition et les ID de documents

Chaque document stocké dans la base de données Cosmos a un ID de ressource unique. En outre, chaque document peut contenir une « clé de partition » qui détermine le partitionnement logique des données afin que la base de données puisse être mise à l’échelle efficacement. Pour plus d’informations sur le choix des clés de partition, consultez Partitionnement et mise à l’échelle horizontale dans Azure Cosmos DB.

Dans EF 9.0, le fournisseur Cosmos DB identifie beaucoup mieux les comparaisons de clés de partition dans vos requêtes LINQ, et les extrait pour que vos requêtes ne soient envoyées qu'à la partition concernée ; cela peut grandement améliorer les performances de vos requêtes et réduire les coûts. Par exemple :

var sessions = await context.Sessions
    .Where(b => b.PartitionKey == "someValue" && b.Username.StartsWith("x"))
    .ToListAsync();

Dans cette requête, le fournisseur reconnaît automatiquement la comparaison sur PartitionKey ; si nous examinons les journaux, nous verrons ce qui suit :

Executed ReadNext (189.8434 ms, 2.8 RU) ActivityId='8cd669ed-2ca5-4f2b-8923-338899071361', Container='test', Partition='["someValue"]', Parameters=[]
SELECT VALUE c
FROM root c
WHERE STARTSWITH(c["Username"], "x")

Notez que la clause WHERE ne contient pas PartitionKey : cette comparaison a été « levée » et est utilisée pour exécuter la requête uniquement sur la partition concernée. Dans les versions précédentes, la comparaison était laissée dans la clause WHERE dans de nombreuses situations, ce qui entraînait l'exécution de la requête sur toutes les partitions et se traduisait par une augmentation des coûts et une réduction des performances.

En outre, si votre requête fournit également une valeur pour la propriété ID du document et n'inclut aucune autre opération de requête, le fournisseur peut appliquer une optimisation supplémentaire :

var somePartitionKey = "someValue";
var someId = 8;
var sessions = await context.Sessions
    .Where(b => b.PartitionKey == somePartitionKey && b.Id == someId)
    .SingleAsync();

Les journaux montrent ce qui suit pour cette requête :

Executed ReadItem (73 ms, 1 RU) ActivityId='13f0f8b8-d481-47f0-bf41-67f7deb008b2', Container='test', Id='8', Partition='["someValue"]'

Ici, aucune requête SQL n'est envoyée. Au lieu de cela, le fournisseur effectue une lecture ponctuelle extrêmement efficace (API ReadItem), qui récupère directement le document en fonction de la clé de partition et de l'ID. Il s'agit du type de lecture le plus efficace et le plus rentable que vous puissiez effectuer dans Cosmos DB ; consultez la documentation de Cosmos DB pour plus d'informations sur les lectures ponctuelles.

Pour en savoir plus sur l'interrogation à l'aide de clés de partition et de lectures ponctuelles, consultez la page de documentation sur l'interrogation.

Clés de partition hiérarchiques

Conseil

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

Azure Cosmos DB supportait à l'origine une clé de partition unique, mais a depuis élargi ses capacités de partitionnement pour prendre en charge le sous-partitionnement en spécifiant jusqu'à trois niveaux de hiérarchie dans la clé de partition. EF Core 9 apporte une prise en charge complète des clés de partition hiérarchiques, ce qui vous permet de profiter des meilleures performances et des économies associées à cette fonctionnalité.

Les clés de partition sont spécifiées à l’aide de l’API de génération de modèles, généralement dans DbContext.OnModelCreating. Il doit y avoir une propriété mappée dans le type d’entité pour chaque niveau de la clé de partition. Par exemple, considérez un type d’entité UserSession :

public class UserSession
{
    // Item ID
    public Guid Id { get; set; }

    // Partition Key
    public string TenantId { get; set; } = null!;
    public Guid UserId { get; set; }
    public int SessionId { get; set; }

    // Other members
    public string Username { get; set; } = null!;
}

Le code suivant spécifie une clé de partition à trois niveaux à l’aide des propriétés et TenantId, UserId et SessionId :

modelBuilder
    .Entity<UserSession>()
    .HasPartitionKey(e => new { e.TenantId, e.UserId, e.SessionId });

Conseil

Cette définition de clé de partition suit l’exemple donné dans Choisir vos clés de partition hiérarchiques dans la documentation Azure Cosmos DB.

Notez comment, à partir d’EF Core 9, les propriétés d’un type mappé peuvent être utilisées dans la clé de partition. Pour les types bool et numériques, comme la propriété int SessionId, la valeur est utilisée directement dans la clé de partition. D’autres types, comme la propriété Guid UserId, sont automatiquement convertis en chaînes.

Lors des requêtes, EF extrait automatiquement les valeurs des clés de partition des requêtes et les applique à l'API de requêtes Cosmos pour s'assurer que les requêtes sont limitées au plus petit nombre de partitions possible. Par exemple, considérez la requête LINQ suivante qui fournit les valeurs des clés de partition :

var tenantId = "Microsoft";
var sessionId = 7;
var userId = new Guid("99A410D7-E467-4CC5-92DE-148F3FC53F4C");

var sessions = await context.Sessions
    .Where(
        e => e.TenantId == tenantId
             && e.UserId == userId
             && e.SessionId == sessionId
             && e.Username.Contains("a"))
    .ToListAsync();

Lors de l’exécution de cette requête, EF Core extrait les valeurs des paramètres tenantId, userId et sessionId, et les transmet à l’API de requête Cosmos comme valeur de la clé de partition. Par exemple, consultez les journaux d’activité de l’exécution de la requête ci-dessus :

info: 6/10/2024 19:06:00.017 CosmosEventId.ExecutingSqlQuery[30100] (Microsoft.EntityFrameworkCore.Database.Command) 
      Executing SQL query for container 'UserSessionContext' in partition '["Microsoft","99a410d7-e467-4cc5-92de-148f3fc53f4c",7.0]' [Parameters=[]]
      SELECT c
      FROM root c
      WHERE ((c["Discriminator"] = "UserSession") AND CONTAINS(c["Username"], "a"))

Notez que les comparaisons de la clé de partition ont été supprimées de la clause WHERE et sont utilisées comme clé de partition pour une exécution efficace : ["Microsoft","99a410d7-e467-4cc5-92de-148f3fc53f4c",7.0].

Pour plus d'informations, consultez la documentation sur l'interrogation à l'aide de clés de partition.

Des capacités d'interrogation LINQ considérablement améliorées

Dans EF 9.0, les capacités de traduction LINQ du fournisseur Cosmos DB ont été considérablement étendues, et le fournisseur peut maintenant exécuter beaucoup plus de types de requêtes. La liste complète des améliorations apportées aux requêtes est trop longue pour être énumérée, mais en voici les grandes lignes :

  • Le fournisseur Cosmos supporte maintenant entièrement les collections primitives de EF, vous permettant d'effectuer des requêtes LINQ sur des collections d'ints ou de chaînes, par exemple. Pour plus d'informations, reportez-vous à la section Nouveautés de EF8 : collections primitives.
  • La prise en charge de l'interrogation arbitraire sur des collections non primitives a également été ajoutée.
  • De nombreux opérateurs LINQ supplémentaires sont désormais pris en charge : indexation dans les collections, Length/Count, ElementAt, Contains, et bien d'autres.
  • La prise en charge des opérateurs d'agrégation tels que Count et Sum a été ajoutée.
  • De nombreuses traductions de fonctions ont été ajoutées (voir la documentation sur les mappages de fonctions pour la liste complète des traductions prises en charge) :
    • Des traductions pour les composants DateTime et DateTimeOffset (DateTime.Year, DateTimeOffset.Month...) ont été ajoutées.
    • EF.Functions.IsDefined et EF.Functions.CoalesceUndefined permettent désormais de traiter les valeurs undefined.
    • string.Contains, StartsWith et EndsWith prennent désormais en charge StringComparison.OrdinalIgnoreCase.

Pour obtenir la liste complète des améliorations en matière d'interrogation, consultez ce problème :

Modélisation améliorée, alignée sur les standards Cosmos et JSON

EF 9.0 mappe les documents de Cosmos DB d'une manière plus naturelle pour une base de données documentaire basée sur JSON, et aide à l'interopérabilité avec d'autres systèmes accédant à vos documents. Bien que cela implique des changements radicaux, il existe des API qui permettent de revenir au comportement antérieur à la version 9.0 dans tous les cas.

Propriétés id simplifiées sans discriminants

Tout d'abord, les versions précédentes d'EF inséraient la valeur du discriminant dans la propriété JSON id, ce qui produisait des documents tels que les suivants :

{
    "id": "Blog|1099",
    ...
}

Cette modification a été effectuée afin de permettre à des documents de types différents (par exemple Blog et Post) et ayant la même valeur clé (1099) d'exister au sein de la même partition de conteneur. Depuis EF 9.0, la propriété id ne contient que la valeur de la clé :

{
    "id": 1099,
    ...
}

Il s'agit d'une manière plus naturelle de mappage vers JSON, qui facilite l'interaction entre les outils et systèmes externes et les documents JSON générés par EF ; ces systèmes externes ne connaissent généralement pas les valeurs du discriminateur EF, qui sont par défaut dérivées des types .NET.

Notez qu'il s'agit d'un changement radical, puisque EF ne pourra plus interroger les documents existants avec l'ancien format id. Une API a été introduite pour revenir au comportement précédent, voir la note de rupture et la documentation pour plus de détails.

La propriété Discriminator a été renommée $type

La propriété Discriminator par défaut était auparavant nommée Discriminator EF 9.0 modifie la valeur par défaut en $type :

{
    "id": 1099,
    "$type": "Blog",
    ...
}

Ceci suit la norme émergente pour le polymorphisme JSON, permettant une meilleure interopérabilité avec d'autres outils. Par exemple, System.Text.Json de .NET prend également en charge le polymorphisme, en utilisant $type comme nom de propriété discriminante par défaut (docs).

Notez qu'il s'agit d'un changement radical, puisque EF ne pourra plus interroger les documents existants avec l'ancien nom de propriété du discriminateur. Consultez la note de changement pour savoir comment revenir au nommage précédent.

Recherche de similarité vectorielle (preview)

Azure Cosmos DB offre maintenant un support en avant-première pour la recherche de similarité vectorielle. La recherche vectorielle est une partie fondamentale de certains types d'applications, y compris l'IA, la recherche sémantique et d'autres. Le support de Cosmos DB pour la recherche vectorielle permet de stocker vos données et vecteurs et d'effectuer vos requêtes dans une seule base de données, ce qui peut considérablement simplifier votre architecture et supprimer le besoin d'une solution de base de données vectorielle supplémentaire et dédiée dans votre stack. Pour en savoir plus sur la recherche vectorielle de Cosmos DB, consultez la documentation.

Une fois que votre conteneur Cosmos DB est correctement configuré, l'utilisation de la recherche vectorielle via EF est une simple question d'ajout d'une propriété vectorielle et de configuration :

public class Blog
{
    ...

    public float[] Vector { get; set; }
}

public class BloggingContext
{
    ...

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Blog>()
            .Property(b => b.Embeddings)
            .IsVector(DistanceFunction.Cosine, dimensions: 1536);
    }
}

Une fois cela fait, utilisez la fonction EF.Functions.VectorDistance() dans les requêtes LINQ pour effectuer une recherche de similarité vectorielle :

var blogs = await context.Blogs
    .OrderBy(s => EF.Functions.VectorDistance(s.Vector, vector))
    .Take(5)
    .ToListAsync();

Pour plus d'informations, consultez la documentation sur la recherche vectorielle.

Prise en charge la pagination

Le fournisseur de Cosmos DB permet désormais de paginer les résultats des requêtes via les jetons de continuation, ce qui est beaucoup plus efficace et rentable que l'utilisation traditionnelle des jetons Skip et Take :

var firstPage = await context.Posts
    .OrderBy(p => p.Id)
    .ToPageAsync(pageSize: 10, continuationToken: null);

var continuationToken = page.ContinuationToken;
foreach (var post in page.Values)
{
    // Display/send the posts to the user
}

Le nouvel opérateur ToPageAsync renvoie un CosmosPage, qui expose un jeton de continuation qui peut être utilisé pour reprendre efficacement la requête à un moment ultérieur, en récupérant les 10 éléments suivants :

var nextPage = await context.Sessions.OrderBy(s => s.Id).ToPageAsync(10, continuationToken);

Pour plus d'informations, consultez la section de documentation sur la pagination.

FromSql pour des requêtes SQL plus sûres

Le fournisseur de la base de données Cosmos a autorisé les requêtes SQL via FromSqlRaw. Cependant, cette API peut être sujette à des attaques par injection SQL lorsque des données fournies par l'utilisateur sont interpolées ou concaténées dans le code SQL. Dans EF 9.0, vous pouvez désormais utiliser la nouvelle méthode FromSql, qui intègre toujours les données paramétrées en tant que paramètre en dehors du SQL :

var maxAngle = 8;
_ = await context.Blogs
    .FromSql($"SELECT VALUE c FROM root c WHERE c.Angle1 <= {maxAngle}")
    .ToListAsync();

Pour plus d'informations, consultez la section de documentation sur la pagination.

Accès en fonction du rôle

Azure Cosmos DB for NoSQL inclut un système de contrôle d’accès en fonction du rôle (RBAC) intégré. Cela est désormais pris en charge par EF9 pour la gestion et l’utilisation de conteneurs. Aucune modification n’est requise pour le code d’application. Pour plus d’informations, consultez Problème n° 32197.

Les E/S synchrones sont désormais bloquées par défaut

Azure Cosmos DB for NoSQL ne supporte pas les API synchrones (bloquantes) à partir du code de l'application. Auparavant, EF masquait ce problème en bloquant pour vous les appels asynchrones. Cependant, cela encourage l'utilisation d'E/S synchrones, ce qui est une mauvaise pratique, et peut provoquer des blocages. Par conséquent, à partir de EF 9, une exception est levée lorsqu'un accès synchrone est tenté. Par exemple :

Les E/S synchrones peuvent encore être utilisées pour l'instant en configurant le niveau d'avertissement de manière appropriée. Par exemple, dans OnConfiguring sur votre type DbContext :

protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    => optionsBuilder.ConfigureWarnings(b => b.Ignore(CosmosEventId.SyncNotSupported));

Notez cependant que nous prévoyons de supprimer complètement le support de la synchronisation dans EF 11, alors commencez à mettre à jour pour utiliser des méthodes asynchrones comme ToListAsync et SaveChangesAsync dès que possible !

Requêtes AOT et précompilées

Comme mentionné dans l’introduction, il y a beaucoup de travail en arrière-plan pour permettre à EF Core de s’exécuter sans compilation juste-à-temps (JAT). Au lieu de cela, EF compile tout ce qui est nécessaire par compilation AOT (Ahead Of Time) pour exécuter des requêtes dans l’application. Cette compilation AOT et le traitement connexe se produisent dans le cadre de la génération et de la publication de l’application. À ce stade de la version EF9, peu d’informations sont disponibles pour vous, le développeur d’applications. Toutefois, pour ceux qui sont intéressés, les publications terminées dans EF9 qui prennent en charge les requêtes AOT et précompilées sont les suivantes :

Revenez ici pour obtenir des exemples d’utilisation de requêtes précompilées à mesure que l’expérience est mise en place.

Translation LINQ et SQL

Comme chaque version, EF9 inclut un grand nombre d'améliorations des capacités d'interrogation LINQ. De nouvelles requêtes peuvent être traduites, et de nombreuses traductions SQL pour les scénarios supportés ont été améliorées, à la fois pour de meilleures performances et une meilleure lisibilité.

Le nombre d'améliorations est trop important pour les énumérer toutes ici. Vous trouverez ci-dessous quelques-unes des améliorations les plus importantes ; vous trouverez dans ce problème une liste plus complète des travaux réalisés dans la version 9.0.

Nous aimerions remercier Andrea Canciani (@ranma42) pour ses nombreuses et excellentes contributions à l'optimisation du langage SQL généré par EF Core !

Types complexes : Support de GroupBy et ExecuteUpdate

GroupBy

Conseil

Le code affiché ici provient de ComplexTypesSample.cs.

EF9 prend en charge le regroupement par une instance de type complexe. Par exemple :

var groupedAddresses = await context.Stores
    .GroupBy(b => b.StoreAddress)
    .Select(g => new { g.Key, Count = g.Count() })
    .ToListAsync();

EF traduit cela en regroupement pour chaque membre du type complexe, qui s’aligne sur la sémantique des types complexes en tant qu’objets valeur. Par exemple, sur Azure SQL :

SELECT [s].[StoreAddress_City], [s].[StoreAddress_Country], [s].[StoreAddress_Line1], [s].[StoreAddress_Line2], [s].[StoreAddress_PostCode], COUNT(*) AS [Count]
FROM [Stores] AS [s]
GROUP BY [s].[StoreAddress_City], [s].[StoreAddress_Country], [s].[StoreAddress_Line1], [s].[StoreAddress_Line2], [s].[StoreAddress_PostCode]

ExecuteUpdate

Conseil

Le code affiché ici provient de ExecuteUpdateSample.cs.

De même, dans EF9, ExecuteUpdate a également été amélioré pour accepter les propriétés de type complexe. Toutefois, chaque membre du type complexe doit être spécifié explicitement. Par exemple :

var newAddress = new Address("Gressenhall Farm Shop", null, "Beetley", "Norfolk", "NR20 4DR");

await context.Stores
    .Where(e => e.Region == "Germany")
    .ExecuteUpdateAsync(s => s.SetProperty(b => b.StoreAddress, newAddress));

Cela génère du SQL qui met à jour chaque colonne mappée au type complexe :

UPDATE [s]
SET [s].[StoreAddress_City] = @__complex_type_newAddress_0_City,
    [s].[StoreAddress_Country] = @__complex_type_newAddress_0_Country,
    [s].[StoreAddress_Line1] = @__complex_type_newAddress_0_Line1,
    [s].[StoreAddress_Line2] = NULL,
    [s].[StoreAddress_PostCode] = @__complex_type_newAddress_0_PostCode
FROM [Stores] AS [s]
WHERE [s].[Region] = N'Germany'

Auparavant, vous deviez énumérer manuellement les différentes propriétés du type complexe dans votre appel ExecuteUpdate.

Élaguer les éléments inutiles du SQL

Auparavant, EF produisait parfois du code SQL contenant des éléments qui n'étaient pas réellement nécessaires ; dans la plupart des cas, ces éléments étaient éventuellement nécessaires à un stade antérieur du traitement du code SQL et étaient laissés de côté. EF9 élague désormais la plupart de ces éléments, ce qui permet d'obtenir un code SQL plus compact et, dans certains cas, plus efficace.

Élagage des tables

Comme premier exemple, le code SQL généré par EF contenait parfois des JOINs vers des tables qui n'étaient pas réellement nécessaires dans la requête. Considérez le modèle suivant, qui utilise le mappage de l'héritage par table par type (TPT) :

public class Order
{
    public int Id { get; set; }
    ...

    public Customer Customer { get; set; }
}

public class DiscountedOrder : Order
{
    public double Discount { get; set; }
}

public class Customer
{
    public int Id { get; set; }
    ...

    public List<Order> Orders { get; set; }
}

public class BlogContext : DbContext
{
    ...

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Order>().UseTptMappingStrategy();
    }
}

Si nous exécutons la requête suivante pour obtenir tous les clients ayant au moins une commande :

var customers = await context.Customers.Where(o => o.Orders.Any()).ToListAsync();

EF8 a généré le code SQL suivant :

SELECT [c].[Id], [c].[Name]
FROM [Customers] AS [c]
WHERE EXISTS (
    SELECT 1
    FROM [Orders] AS [o]
    LEFT JOIN [DiscountedOrders] AS [d] ON [o].[Id] = [d].[Id]
    WHERE [c].[Id] = [o].[CustomerId])

Notez que la requête contient une jointure avec la table DiscountedOrders même si aucune colonne n'y est référencée. EF9 génère un SQL élagué sans la jointure :

SELECT [c].[Id], [c].[Name]
FROM [Customers] AS [c]
WHERE EXISTS (
    SELECT 1
    FROM [Orders] AS [o]
    WHERE [c].[Id] = [o].[CustomerId])

Élagage par projection

De même, examinons la requête suivante :

var orders = await context.Orders
    .Where(o => o.Amount > 10)
    .Take(5)
    .CountAsync();

Sur EF8, cette requête génère le SQL suivant :

SELECT COUNT(*)
FROM (
    SELECT TOP(@__p_0) [o].[Id]
    FROM [Orders] AS [o]
    WHERE [o].[Amount] > 10
) AS [t]

Notez que la projection [o].[Id] n'est pas nécessaire dans la sous-requête, puisque l'expression SELECT extérieure se contente de compter les lignes. EF9 génère ce qui suit à la place :

SELECT COUNT(*)
FROM (
    SELECT TOP(@__p_0) 1 AS empty
    FROM [Orders] AS [o]
    WHERE [o].[Amount] > 10
) AS [s]

... et la projection est vide. Cela n'a l'air de rien, mais cela peut simplifier considérablement le code SQL dans certains cas ; nous vous invitons à parcourir certaines des modifications du code SQL dans les tests pour en voir l'effet.

Traductions impliquant GREATEST/LEAST

Conseil

Le code présenté ici est tiré de LeastGreatestSample.cs.

Plusieurs nouvelles traductions ont été introduites et utilisent les fonctions SQL GREATEST et LEAST.

Important

Les fonctions GREATEST et LEAST ont été introduites dans la version 2022 des bases de données SQL Server/Azure SQL. Visual Studio 2022 installe SQL Server 2019 par défaut. Nous vous recommandons d’installer SQL Server Développeur Édition 2022 pour essayer ces nouvelles traductions dans EF9.

Par exemple, les requêtes utilisant Math.Max ou Math.Min sont désormais traduites pour Azure SQL par GREATEST et LEAST respectivement. Par exemple :

var walksUsingMin = await context.Walks
    .Where(e => Math.Min(e.DaysVisited.Count, e.ClosestPub.Beers.Length) > 4)
    .ToListAsync();

Cette requête est traduite par le code SQL suivant quand vous utilisez EF9 s’exécutant sur SQL Server 2022 :

SELECT [w].[Id], [w].[ClosestPubId], [w].[DaysVisited], [w].[Name], [w].[Terrain]
FROM [Walks] AS [w]
INNER JOIN [Pubs] AS [p] ON [w].[ClosestPubId] = [p].[Id]
WHERE LEAST((
    SELECT COUNT(*)
    FROM OPENJSON([w].[DaysVisited]) AS [d]), (
    SELECT COUNT(*)
    FROM OPENJSON([p].[Beers]) AS [b])) >

Math.Min et Math.Max peuvent également être utilisés sur les valeurs d’une collection primitive. Par exemple :

var pubsInlineMax = await context.Pubs
    .SelectMany(e => e.Counts)
    .Where(e => Math.Max(e, threshold) > top)
    .ToListAsync();

Cette requête est traduite par le code SQL suivant quand vous utilisez EF9 s’exécutant sur SQL Server 2022 :

SELECT [c].[value]
FROM [Pubs] AS [p]
CROSS APPLY OPENJSON([p].[Counts]) WITH ([value] int '$') AS [c]
WHERE GREATEST([c].[value], @__threshold_0) > @__top_1

Enfin, RelationalDbFunctionsExtensions.Least et RelationalDbFunctionsExtensions.Greatest peuvent être utilisées pour appeler directement la fonction Least ou Greatest dans SQL. Par exemple :

var leastCount = await context.Pubs
    .Select(e => EF.Functions.Least(e.Counts.Length, e.DaysVisited.Count, e.Beers.Length))
    .ToListAsync();

Cette requête est traduite par le code SQL suivant quand vous utilisez EF9 s’exécutant sur SQL Server 2022 :

SELECT LEAST((
    SELECT COUNT(*)
    FROM OPENJSON([p].[Counts]) AS [c]), (
    SELECT COUNT(*)
    FROM OPENJSON([p].[DaysVisited]) AS [d]), (
    SELECT COUNT(*)
    FROM OPENJSON([p].[Beers]) AS [b]))
FROM [Pubs] AS [p]

Forcer ou empêcher la paramétrisation des requêtes

Conseil

Le code présenté ici est tiré de QuerySample.cs.

Sauf dans certains cas spéciaux, EF Core paramétrise les variables utilisées dans une requête LINQ, mais ajoute des constantes dans le code SQL généré. Prenons l’exemple de méthode de requête suivant :

async Task<List<Post>> GetPosts(int id)
    => await context.Posts
        .Where(e => e.Title == ".NET Blog" && e.Id == id)
        .ToListAsync();

Cela se traduit par le code SQL et les paramètres suivants quand vous utilisez Azure SQL :

Executed DbCommand (1ms) [Parameters=[@__id_0='1'], CommandType='Text', CommandTimeout='30']
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 [p].[Title] = N'.NET Blog' AND [p].[Id] = @__id_0

Notez qu’EF a créé une constante dans le code SQL pour « .NET Blog », car cette valeur ne change pas d’une requête à l’autre. L’utilisation d’une constante permet au moteur de base de données d’examiner cette valeur pendant la création d’un plan de requête, ce qui peut produire une requête plus efficace.

En revanche, la valeur de id est paramétrisée, car la même requête peut être exécutée avec plusieurs valeurs différentes de id. La création d'une constante dans ce cas entraînerait la pollution du cache des requêtes par un grand nombre de requêtes qui ne diffèrent que par des valeurs id Cela est très mauvais pour les performances générales de la base de données.

En règle générale, ces valeurs par défaut ne doivent pas être changées. Toutefois, EF Core 8.0.2 introduit une méthode EF.Constant qui force EF à utiliser une constante même si un paramètre est utilisé par défaut. Par exemple :

async Task<List<Post>> GetPostsForceConstant(int id)
    => await context.Posts
        .Where(e => e.Title == ".NET Blog" && e.Id == EF.Constant(id))
        .ToListAsync();

La traduction contient maintenant une constante pour la valeur id :

Executed DbCommand (1ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
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 [p].[Title] = N'.NET Blog' AND [p].[Id] = 1

EF9 introduit la méthode EF.Parameter pour faire l’inverse. Autrement dit, forcer EF à utiliser un paramètre même si la valeur est une constante dans le code. Par exemple :

async Task<List<Post>> GetPostsForceParameter(int id)
    => await context.Posts
        .Where(e => e.Title == EF.Parameter(".NET Blog") && e.Id == id)
        .ToListAsync();

La traduction contient désormais un paramètre pour la chaîne « .NET Blog » :

Executed DbCommand (1ms) [Parameters=[@__p_0='.NET Blog' (Size = 4000), @__id_1='1'], CommandType='Text', CommandTimeout='30']
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 [p].[Title] = @__p_0 AND [p].[Id] = @__id_1

Sous-requêtes inlined non liées

Conseil

Le code présenté ici est tiré de QuerySample.cs.

Dans EF8, un IQueryable référencé dans une autre requête peut être exécuté en tant qu’aller-retour de base de données distinct. Prenons l'exemple de la requête LINQ suivante :

var dotnetPosts = context
    .Posts
    .Where(p => p.Title.Contains(".NET"));

var results = dotnetPosts
    .Where(p => p.Id > 2)
    .Select(p => new { Post = p, TotalCount = dotnetPosts.Count() })
    .Skip(2).Take(10)
    .ToArray();

Dans EF8, la requête pour dotnetPosts est exécutée en un aller-retour, puis les résultats finaux sont exécutés en tant que deuxième requête. Par exemple, sur SQL Server :

SELECT COUNT(*)
FROM [Posts] AS [p]
WHERE [p].[Title] LIKE N'%.NET%'

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 [p].[Title] LIKE N'%.NET%' AND [p].[Id] > 2
ORDER BY (SELECT 1)
OFFSET @__p_1 ROWS FETCH NEXT @__p_2 ROWS ONLY

Dans EF9, le IQueryable dans le dotnetPosts est souligné, ce qui entraîne un seul aller-retour dans la base de données :

SELECT [p].[Id], [p].[Archived], [p].[AuthorId], [p].[BlogId], [p].[Content], [p].[Discriminator], [p].[PublishedOn], [p].[Title], [p].[PromoText], [p].[Metadata], (
    SELECT COUNT(*)
    FROM [Posts] AS [p0]
    WHERE [p0].[Title] LIKE N'%.NET%')
FROM [Posts] AS [p]
WHERE [p].[Title] LIKE N'%.NET%' AND [p].[Id] > 2
ORDER BY (SELECT 1)
OFFSET @__p_0 ROWS FETCH NEXT @__p_1 ROWS ONLY

Les requêtes utilisant Count != 0 sont optimisées

Conseil

Le code présenté ici est tiré de QuerySample.cs.

Dans EF8, la requête LINQ suivante a été traduite pour utiliser la fonction SQL COUNT :

var blogsWithPost = await context.Blogs
    .Where(b => b.Posts.Count > 0)
    .ToListAsync();

EF9 génère désormais une traduction plus efficace à l’aide de EXISTS :

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

Sémantique C# pour les opérations de comparaison sur les valeurs nullables

Dans la version EF8, les comparaisons entre des éléments nullables n'étaient pas effectuées correctement dans certains cas. En C#, si l'un des opérandes ou les deux sont nuls, le résultat d'une opération de comparaison est faux ; dans le cas contraire, les valeurs contenues dans les opérandes sont comparées. Dans EF8, nous traduisions les comparaisons en utilisant la sémantique null de la base de données. Cela produisait des résultats différents d'une requête similaire utilisant LINQ to Objects. De plus, nous obtenions des résultats différents lorsque la comparaison était effectuée dans le filtre ou dans la projection. Certaines requêtes produisaient également des résultats différents entre SQL Server et Sqlite/Postgres.

Par exemple, la requête :

var negatedNullableComparisonFilter = await context.Entities
    .Where(x => !(x.NullableIntOne > x.NullableIntTwo))
    .Select(x => new { x.NullableIntOne, x.NullableIntTwo }).ToListAsync();

générerait le SQL suivant :

SELECT [e].[NullableIntOne], [e].[NullableIntTwo]
FROM [Entities] AS [e]
WHERE NOT ([e].[NullableIntOne] > [e].[NullableIntTwo])

qui filtre les entités dont la valeur de NullableIntOne ou NullableIntTwo est nulle.

Dans EF9, nous produisons :

SELECT [e].[NullableIntOne], [e].[NullableIntTwo]
FROM [Entities] AS [e]
WHERE CASE
    WHEN [e].[NullableIntOne] > [e].[NullableIntTwo] THEN CAST(0 AS bit)
    ELSE CAST(1 AS bit)
END = CAST(1 AS bit)

Une comparaison similaire est effectuée dans une projection :

var negatedNullableComparisonProjection = await context.Entities.Select(x => new
{
    x.NullableIntOne,
    x.NullableIntTwo,
    Operation = !(x.NullableIntOne > x.NullableIntTwo)
}).ToListAsync();

a donné le résultat SQL suivant :

SELECT [e].[NullableIntOne], [e].[NullableIntTwo], CASE
    WHEN NOT ([e].[NullableIntOne] > [e].[NullableIntTwo]) THEN CAST(1 AS bit)
    ELSE CAST(0 AS bit)
END AS [Operation]
FROM [Entities] AS [e]

qui renvoie false pour les entités dont NullableIntOne ou NullableIntTwo sont définies comme nulles (au lieu de true attendu en C#). L'exécution du même scénario sur Sqlite a généré :

SELECT "e"."NullableIntOne", "e"."NullableIntTwo", NOT ("e"."NullableIntOne" > "e"."NullableIntTwo") AS "Operation"
FROM "Entities" AS "e"

ce qui entraîne une exception Nullable object must have a value, car la traduction produit une valeur null dans les cas où NullableIntOne ou NullableIntTwo sont nuls.

EF9 gère désormais correctement ces scénarios, produisant des résultats cohérents avec LINQ to Objects et à travers différents fournisseurs.

Cette amélioration a été apportée par @ranma42. Merci !

Amélioration de la traduction de l'opérateur de négation logique (!)

EF9 apporte de nombreuses optimisations autour de SQL CASE/WHEN, COALESCE, de la négation, et de diverses autres constructions ; la plupart d'entre elles ont été apportées par Andrea Canciani (@ranma42) - merci beaucoup pour tout cela ! Ci-dessous, nous allons détailler quelques-unes de ces optimisations autour de la négation logique.

Examinons la requête suivante :

var negatedContainsSimplification = await context.Posts
    .Where(p => !p.Content.Contains("Announcing"))
    .Select(p => new { p.Content }).ToListAsync();

Dans EF8, nous produirions le code SQL suivant :

SELECT "p"."Content"
FROM "Posts" AS "p"
WHERE NOT (instr("p"."Content", 'Announcing') > 0)

Dans EF9, nous « poussons » l'opération NOT dans la comparaison :

SELECT "p"."Content"
FROM "Posts" AS "p"
WHERE instr("p"."Content", 'Announcing') <= 0

Un autre exemple, applicable à SQL Server, est celui d'une opération conditionnelle annulée.

var caseSimplification = await context.Blogs
    .Select(b => !(b.Id > 5 ? false : true))
    .ToListAsync();

Dans EF8, cela se traduisait par des blocs CASE imbriqués :

SELECT CASE
    WHEN CASE
        WHEN [b].[Id] > 5 THEN CAST(0 AS bit)
        ELSE CAST(1 AS bit)
    END = CAST(0 AS bit) THEN CAST(1 AS bit)
    ELSE CAST(0 AS bit)
END
FROM [Blogs] AS [b]

Dans EF9, nous avons supprimé l'imbrication :

SELECT CASE
    WHEN [b].[Id] > 5 THEN CAST(1 AS bit)
    ELSE CAST(0 AS bit)
END
FROM [Blogs] AS [b]

Sur SQL Server, lors de la projection d'une propriété bool négativée :

var negatedBoolProjection = await context.Posts.Select(x => new { x.Title, Active = !x.Archived }).ToListAsync();

EF8 générerait un bloc CASE car les comparaisons ne peuvent pas apparaître dans la projection directement dans les requêtes SQL Server :

SELECT [p].[Title], CASE
   WHEN [p].[Archived] = CAST(0 AS bit) THEN CAST(1 AS bit)
   ELSE CAST(0 AS bit)
END AS [Active]
FROM [Posts] AS [p]

Dans EF9, cette traduction a été simplifiée et utilise désormais l'expression exclusive or (^) :

SELECT [p].[Title], [p].[Archived] ^ CAST(1 AS bit) AS [Active]
FROM [Posts] AS [p]

Autres améliorations des requêtes

  • Le support d'interrogation des collections primitives introduit dans EF8 a été étendu à tous les types ICollection<T>. Notez que cela ne s'applique qu'aux collections de paramètres et aux collections en ligne - les collections primitives qui font partie d'entités sont toujours limitées aux tableaux, aux listes et, dans EF9, aux tableaux/listes en lecture seule.
  • Nouvelles fonctions ToHashSetAsync pour renvoyer les résultats d'une requête sous forme de HashSet (#30033, contribution de @wertzui).
  • TimeOnly.FromDateTime et FromTimeSpan sont désormais traduits sur SQL Server (#33678).
  • ToString over enums est maintenant traduit (#33706, contribué par @Danevandy99).
  • string.Join se traduit désormais par CONCAT_WS dans un contexte non agrégé sur SQL Server (#28899).
  • EF.Functions.PatIndex se traduit maintenant par la fonction PATINDEX de SQL Server, qui renvoie la position de départ de la première occurrence d'un motif (#33702, @smnsht).
  • Sum et Average fonctionnent maintenant pour les décimales sur SQLite (#33721, contribué par @ranma42).
  • Corrections et optimisations pour string.StartsWith et EndsWith (#31482).
  • Les méthodes Convert.To* peuvent désormais accepter des arguments de type object (#33891, contribution de @imangd).

Les améliorations ci-dessus ne sont que quelques-unes des plus importantes apportées à EF9 en matière de requêtes; vous trouverez une liste plus complète dans ce problème.

Migrations

Amélioration des migrations de table temporelle

La migration créée pendant la conversion d’une table existante en table temporelle a été réduite de taille pour EF9. Par exemple, dans EF8, la conversion d’une seule table existante en table temporelle entraîne la migration suivante :

protected override void Up(MigrationBuilder migrationBuilder)
{
    migrationBuilder.AlterTable(
        name: "Blogs")
        .Annotation("SqlServer:IsTemporal", true)
        .Annotation("SqlServer:TemporalHistoryTableName", "BlogsHistory")
        .Annotation("SqlServer:TemporalHistoryTableSchema", null)
        .Annotation("SqlServer:TemporalPeriodEndColumnName", "PeriodEnd")
        .Annotation("SqlServer:TemporalPeriodStartColumnName", "PeriodStart");

    migrationBuilder.AlterColumn<string>(
        name: "SiteUri",
        table: "Blogs",
        type: "nvarchar(max)",
        nullable: false,
        oldClrType: typeof(string),
        oldType: "nvarchar(max)")
        .Annotation("SqlServer:IsTemporal", true)
        .Annotation("SqlServer:TemporalHistoryTableName", "BlogsHistory")
        .Annotation("SqlServer:TemporalHistoryTableSchema", null)
        .Annotation("SqlServer:TemporalPeriodEndColumnName", "PeriodEnd")
        .Annotation("SqlServer:TemporalPeriodStartColumnName", "PeriodStart");

    migrationBuilder.AlterColumn<string>(
        name: "Name",
        table: "Blogs",
        type: "nvarchar(max)",
        nullable: false,
        oldClrType: typeof(string),
        oldType: "nvarchar(max)")
        .Annotation("SqlServer:IsTemporal", true)
        .Annotation("SqlServer:TemporalHistoryTableName", "BlogsHistory")
        .Annotation("SqlServer:TemporalHistoryTableSchema", null)
        .Annotation("SqlServer:TemporalPeriodEndColumnName", "PeriodEnd")
        .Annotation("SqlServer:TemporalPeriodStartColumnName", "PeriodStart");

    migrationBuilder.AlterColumn<int>(
        name: "Id",
        table: "Blogs",
        type: "int",
        nullable: false,
        oldClrType: typeof(int),
        oldType: "int")
        .Annotation("SqlServer:Identity", "1, 1")
        .Annotation("SqlServer:IsTemporal", true)
        .Annotation("SqlServer:TemporalHistoryTableName", "BlogsHistory")
        .Annotation("SqlServer:TemporalHistoryTableSchema", null)
        .Annotation("SqlServer:TemporalPeriodEndColumnName", "PeriodEnd")
        .Annotation("SqlServer:TemporalPeriodStartColumnName", "PeriodStart")
        .OldAnnotation("SqlServer:Identity", "1, 1");

    migrationBuilder.AddColumn<DateTime>(
        name: "PeriodEnd",
        table: "Blogs",
        type: "datetime2",
        nullable: false,
        defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified))
        .Annotation("SqlServer:IsTemporal", true)
        .Annotation("SqlServer:TemporalHistoryTableName", "BlogsHistory")
        .Annotation("SqlServer:TemporalHistoryTableSchema", null)
        .Annotation("SqlServer:TemporalPeriodEndColumnName", "PeriodEnd")
        .Annotation("SqlServer:TemporalPeriodStartColumnName", "PeriodStart");

    migrationBuilder.AddColumn<DateTime>(
        name: "PeriodStart",
        table: "Blogs",
        type: "datetime2",
        nullable: false,
        defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified))
        .Annotation("SqlServer:IsTemporal", true)
        .Annotation("SqlServer:TemporalHistoryTableName", "BlogsHistory")
        .Annotation("SqlServer:TemporalHistoryTableSchema", null)
        .Annotation("SqlServer:TemporalPeriodEndColumnName", "PeriodEnd")
        .Annotation("SqlServer:TemporalPeriodStartColumnName", "PeriodStart");
}

Dans EF9, la même opération entraîne désormais une migration beaucoup plus petite :

protected override void Up(MigrationBuilder migrationBuilder)
{
    migrationBuilder.AlterTable(
        name: "Blogs")
        .Annotation("SqlServer:IsTemporal", true)
        .Annotation("SqlServer:TemporalHistoryTableName", "BlogsHistory")
        .Annotation("SqlServer:TemporalHistoryTableSchema", null)
        .Annotation("SqlServer:TemporalPeriodEndColumnName", "PeriodEnd")
        .Annotation("SqlServer:TemporalPeriodStartColumnName", "PeriodStart");

    migrationBuilder.AddColumn<DateTime>(
        name: "PeriodEnd",
        table: "Blogs",
        type: "datetime2",
        nullable: false,
        defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified))
        .Annotation("SqlServer:TemporalIsPeriodEndColumn", true);

    migrationBuilder.AddColumn<DateTime>(
        name: "PeriodStart",
        table: "Blogs",
        type: "datetime2",
        nullable: false,
        defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified))
        .Annotation("SqlServer:TemporalIsPeriodStartColumn", true);
}

Génération de modèles

Modèles compilés automatiquement

Conseil

Le code présenté ici provient de l’exemple NewInEFCore9.CompiledModels.

Les modèles compilés peuvent améliorer le temps de démarrage des applications avec des modèles volumineux, c’est-à-dire des centaines, voire des milliers de nombres de types d’entités. Dans les versions précédentes d’EF Core, un modèle compilé devait être généré manuellement à l’aide de la ligne de commande. Par exemple :

dotnet ef dbcontext optimize

Après avoir exécuté la commande, une ligne de ce type .UseModel(MyCompiledModels.BlogsContextModel.Instance) doit être ajoutée à OnConfiguring pour indiquer à EF Core d’utiliser le modèle compilé.

À compter d’EF9, cette ligne .UseModel n’est plus nécessaire lorsque le type DbContext de l’application se trouve dans le même projet/assembly que le modèle compilé. Au lieu de cela, le modèle compilé est détecté et utilisé automatiquement. Vous pouvez le constater en disposant d’un journal EF chaque fois qu’il génère le modèle. L’exécution d’une application simple montre ensuite EF qui génère le modèle au démarrage de l’application :

Starting application...
>> EF is building the model...
Model loaded with 2 entity types.

La sortie de l’exécution de dotnet ef dbcontext optimize sur le projet de modèle est la suivante :

PS D:\code\EntityFramework.Docs\samples\core\Miscellaneous\NewInEFCore9.CompiledModels\Model> dotnet ef dbcontext optimize

Build succeeded in 0.3s

Build succeeded in 0.3s
Build started...
Build succeeded.
>> EF is building the model...
>> EF is building the model...
Successfully generated a compiled model, it will be discovered automatically, but you can also call 'options.UseModel(BlogsContextModel.Instance)'. Run this command again when the model is modified.
PS D:\code\EntityFramework.Docs\samples\core\Miscellaneous\NewInEFCore9.CompiledModels\Model> 

Notez que la sortie du journal indique que le modèle a été généré lors de l’exécution de la commande. Si nous réexécutons l’application, après la régénération, mais sans apporter de modifications de code, la sortie est :

Starting application...
Model loaded with 2 entity types.

Notez que le modèle n’a pas été généré lors du démarrage de l’application, car le modèle compilé a été détecté et utilisé automatiquement.

Intégration de MSBuild

Avec l’approche ci-dessus, le modèle compilé doit toujours être régénéré manuellement lorsque les types d’entités ou la configuration DbContext sont modifiés. Toutefois, EF9 est fourni avec MSBuild et cible un package qui peut mettre à jour automatiquement le modèle compilé lorsque le projet de modèle est généré. Pour commencer, installez le package NuGet Microsoft.EntityFrameworkCore.Tasks. Par exemple :

dotnet add package Microsoft.EntityFrameworkCore.Tasks --version 9.0.0-preview.4.24205.3

Conseil

Utilisez la version du package dans la commande ci-dessus qui correspond à la version d’EF Core que vous utilisez.

Activez ensuite l’intégration en définissant la propriété EFOptimizeContext sur votre fichier .csproj. Par exemple :

<PropertyGroup>
    <EFOptimizeContext>true</EFOptimizeContext>
</PropertyGroup>

Il existe des propriétés MSBuild supplémentaires facultatives pour contrôler la façon dont le modèle est généré, ce qui équivaut aux options transmises sur la ligne de commande à dotnet ef dbcontext optimize. Il s’agit notamment des paramètres suivants :

Propriété MSBuild Description
EFOptimizeContext Définissez cette option sur true pour activer les modèles compilés automatiquement.
DbContextName Classe DbContext à utiliser. Nom de classe uniquement ou qualifié complet avec des espaces de noms. Si cette option est omise, EF Core trouve la classe de contexte. S’il existe plusieurs classes de contexte, cette option est requise.
EFStartupProject Chemin relatif du projet de démarrage. La valeur par défaut est le dossier actif.
EFTargetNamespace Espace de noms à utiliser pour toutes les classes générées. Valeurs par défaut à générer à partir de l’espace de noms racine et du répertoire de sortie plus CompiledModels.

Dans notre exemple, nous devons spécifier le projet de démarrage :

<PropertyGroup>
  <EFOptimizeContext>true</EFOptimizeContext>
  <EFStartupProject>..\App\App.csproj</EFStartupProject>
</PropertyGroup>

Maintenant, si nous générons le projet, nous pouvons voir la journalisation au moment de la génération indiquant que le modèle compilé est généré :

Optimizing DbContext...
dotnet exec --depsfile D:\code\EntityFramework.Docs\samples\core\Miscellaneous\NewInEFCore9.CompiledModels\App\bin\Release\net8.0\App.deps.json
  --additionalprobingpath G:\packages 
  --additionalprobingpath "C:\Program Files (x86)\Microsoft Visual Studio\Shared\NuGetPackages" 
  --runtimeconfig D:\code\EntityFramework.Docs\samples\core\Miscellaneous\NewInEFCore9.CompiledModels\App\bin\Release\net8.0\App.runtimeconfig.json G:\packages\microsoft.entityframeworkcore.tasks\9.0.0-preview.4.24205.3\tasks\net8.0\..\..\tools\netcoreapp2.0\ef.dll dbcontext optimize --output-dir D:\code\EntityFramework.Docs\samples\core\Miscellaneous\NewInEFCore9.CompiledModels\Model\obj\Release\net8.0\ 
  --namespace NewInEfCore9 
  --suffix .g 
  --assembly D:\code\EntityFramework.Docs\samples\core\Miscellaneous\NewInEFCore9.CompiledModels\Model\bin\Release\net8.0\Model.dll --startup-assembly D:\code\EntityFramework.Docs\samples\core\Miscellaneous\NewInEFCore9.CompiledModels\App\bin\Release\net8.0\App.dll 
  --project-dir D:\code\EntityFramework.Docs\samples\core\Miscellaneous\NewInEFCore9.CompiledModels\Model 
  --root-namespace NewInEfCore9 
  --language C# 
  --nullable 
  --working-dir D:\code\EntityFramework.Docs\samples\core\Miscellaneous\NewInEFCore9.CompiledModels\App 
  --verbose 
  --no-color 
  --prefix-output 

De plus, l’exécution de l’application montre que le modèle compilé a été détecté et, par conséquent, le modèle n’est pas généré à nouveau :

Starting application...
Model loaded with 2 entity types.

À présent, chaque fois que le modèle change, le modèle compilé sera automatiquement régénéré dès que le projet est généré.

[REMARQUE!] Nous travaillons sur des problèmes de performances en apportant des modifications apportées au modèle compilé dans EF8 et EF9. Pour plus d’informations, consultez Problème n° 33483.

Collections primitives en lecture seule

Conseil

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

EF8 a introduit la prise en charge du mappage des tableaux et listes mutables de types primitifs. Cela a été étendu dans EF9 pour inclure des collections/listes en lecture seule. Plus précisément, EF9 prend en charge les collections de type IReadOnlyList, IReadOnlyCollection ou ReadOnlyCollection. Par exemple, dans le code suivant, DaysVisited sera mappé par convention en tant que collection primitive de dates :

public class DogWalk
{
    public int Id { get; set; }
    public string Name { get; set; }
    public ReadOnlyCollection<DateOnly> DaysVisited { get; set; }
}

La collection en lecture seule peut être sauvegardée par une collection normale et mutable si vous le souhaitez. Par exemple, dans le code suivant, DaysVisited peut être mappé en tant que collection primitive de dates, tout en autorisant le code de la classe à manipuler la liste sous-jacente.

    public class Pub
    {
        public int Id { get; set; }
        public string Name { get; set; }
        public IReadOnlyCollection<string> Beers { get; set; }

        private List<DateOnly> _daysVisited = new();
        public IReadOnlyList<DateOnly> DaysVisited => _daysVisited;
    }

Ces collections peuvent ensuite être utilisées dans les requêtes de la manière standard : Par exemple, cette requête LINQ :

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

Se traduit par le code SQL suivant sur SQLite :

SELECT "w"."Name" AS "WalkName", "p"."Name" AS "PubName", (
    SELECT COUNT(*)
    FROM json_each("w"."DaysVisited") AS "d"
    WHERE "d"."value" IN (
        SELECT "d0"."value"
        FROM json_each("p"."DaysVisited") AS "d0"
    )) AS "Count", json_array_length("w"."DaysVisited") AS "TotalCount"
FROM "Walks" AS "w"
INNER JOIN "Pubs" AS "p" ON "w"."ClosestPubId" = "p"."Id"

Spécifier le facteur de remplissage pour les clés et les index

Conseil

Le code présenté ici est tiré de ModelBuildingSample.cs.

EF9 prend en charge la spécification du facteur de remplissage SQL Server lors de l’utilisation des migrations EF Core pour créer des clés et des index. À partir de la documentation SQL Server, « Lorsqu’un index est créé ou reconstruit, la valeur du facteur de remplissage détermine le pourcentage d’espace sur chaque page de niveau feuille à remplir avec des données, en réservant le reste sur chaque page comme espace libre pour une croissance future ».

Le facteur de remplissage peut être défini sur un seul ou composite principal et des clés et des index de remplacement. Par exemple :

modelBuilder.Entity<User>()
    .HasKey(e => e.Id)
    .HasFillFactor(80);

modelBuilder.Entity<User>()
    .HasAlternateKey(e => new { e.Region, e.Ssn })
    .HasFillFactor(80);

modelBuilder.Entity<User>()
    .HasIndex(e => new { e.Name })
    .HasFillFactor(80);

modelBuilder.Entity<User>()
    .HasIndex(e => new { e.Region, e.Tag })
    .HasFillFactor(80);

En cas d’application aux tables existantes, cela modifie les tables en facteur de remplissage par la contrainte :

ALTER TABLE [User] DROP CONSTRAINT [AK_User_Region_Ssn];
ALTER TABLE [User] DROP CONSTRAINT [PK_User];
DROP INDEX [IX_User_Name] ON [User];
DROP INDEX [IX_User_Region_Tag] ON [User];

ALTER TABLE [User] ADD CONSTRAINT [AK_User_Region_Ssn] UNIQUE ([Region], [Ssn]) WITH (FILLFACTOR = 80);
ALTER TABLE [User] ADD CONSTRAINT [PK_User] PRIMARY KEY ([Id]) WITH (FILLFACTOR = 80);
CREATE INDEX [IX_User_Name] ON [User] ([Name]) WITH (FILLFACTOR = 80);
CREATE INDEX [IX_User_Region_Tag] ON [User] ([Region], [Tag]) WITH (FILLFACTOR = 80);

Cette amélioration a été apportée par @deano-chasseur. Merci !

Rendre les conventions de création de modèles existantes plus extensibles

Conseil

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

Des conventions de création de modèles publiques pour les applications ont été introduites dans EF7. Dans EF9, nous avons facilité l’extension de certaines conventions existantes. Par exemple, le code permettant de mapper les propriétés par attribut dans EF7 est le suivant :

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

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

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

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

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

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

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

Dans EF9, cela peut être simplifié de la façon suivante :

public class AttributeBasedPropertyDiscoveryConvention(ProviderConventionSetBuilderDependencies dependencies)
    : PropertyDiscoveryConvention(dependencies)
{
    protected override bool IsCandidatePrimitiveProperty(
        MemberInfo memberInfo, IConventionTypeBase structuralType, out CoreTypeMapping? mapping)
    {
        if (base.IsCandidatePrimitiveProperty(memberInfo, structuralType, out mapping))
        {
            if (Attribute.IsDefined(memberInfo, typeof(PersistAttribute), inherit: true))
            {
                return true;
            }

            structuralType.Builder.Ignore(memberInfo.Name);
        }

        mapping = null;
        return false;
    }
}

Mettre à jour ApplyConfigurationsFromAssembly pour appeler des constructeurs non publics

Dans les versions précédentes d’EF Core, la méthode ApplyConfigurationsFromAssembly instanciait seulement les types de configuration avec des constructeurs publics sans paramètre. Dans EF9, nous avons amélioré les messages d’erreur générés en cas d’échec, et avons rendu possible l’instanciation par les constructeurs non publics. Cela est utile en cas de colocalisation de la configuration dans une classe imbriquée privée qui ne doit jamais être instanciée par le code d’application. Par exemple :

public class Country
{
    public int Code { get; set; }
    public required string Name { get; set; }

    private class FooConfiguration : IEntityTypeConfiguration<Country>
    {
        private FooConfiguration()
        {
        }

        public void Configure(EntityTypeBuilder<Country> builder)
        {
            builder.HasKey(e => e.Code);
        }
    }
}

Entre parenthèses, certaines personnes pensent que ce modèle est une abomination, car il couple le type d’entité à la configuration. D’autres personnes pensent qu’il est très utile, car il colocalise la configuration avec le type d’entité. Nous n’entrerons pas dans le débat. :-)

SQL Server HierarchyId

Conseil

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

Sugar pour la génération de chemin HierarchyId

La prise en charge de première classe pour le type SQL Server HierarchyId a été ajoutée dans EF8. Dans EF9, une méthode de sugar a été ajoutée pour faciliter la création de nœuds enfants dans la structure d’arborescence. Par exemple, les requêtes de code suivantes pour une entité existante avec une propriété HierarchyId :

var daisy = await context.Halflings.SingleAsync(e => e.Name == "Daisy");

Cette propriété HierarchyId peut ensuite être utilisée pour créer des nœuds enfants sans manipulation de chaîne explicite. Par exemple :

var child1 = new Halfling(HierarchyId.Parse(daisy.PathFromPatriarch, 1), "Toast");
var child2 = new Halfling(HierarchyId.Parse(daisy.PathFromPatriarch, 2), "Wills");

Si daisy a un HierarchyId de /4/1/3/1/, alors child1 obtiendra le HierarchyId « /4/1/3/1/1/ » et child2 obtiendra le HierarchyId « /4/1/1/3/1/2/ ».

Pour créer un nœud entre ces deux enfants, un sous-niveau supplémentaire peut être utilisé. Par exemple :

var child1b = new Halfling(HierarchyId.Parse(daisy.PathFromPatriarch, 1, 5), "Toast");

Cela crée un nœud avec un HierarchyId de /4/1/3/1/1.5/, en le plaçant entre child1 et child2.

Cette amélioration a été apportée par @Rezakazemi890. Merci !

Outillage

Moins de régénérations

L’dotnet efoutil de ligne de commande génère par défaut votre projet avant d’exécuter l’outil. Cela est dû au fait que la régénération avant l’exécution de l’outil est une source courante de confusion lorsque les choses ne fonctionnent pas normalement. Les développeurs expérimentés peuvent utiliser l’option --no-build pour éviter cette build, ce qui peut être lent. Toutefois, même l’option --no-build peut entraîner la régénération du projet la prochaine fois qu’il est généré en dehors de l’outil EF.

Nous croyons qu’une contribution communautaire de @Suchiman a résolu cela. Toutefois, nous sommes également conscients que les ajustements autour des comportements MSBuild ont tendance à avoir des conséquences inattendues, donc nous demandons aux personnes comme vous d’essayer ceci et de signaler toute expérience négative rencontrée.