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 :

C#
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 :

C#
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 :

C#
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 :

SQL
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.

Notes

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 :

C#
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 :

SQL
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 :

text
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 :

C#
[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 :

C#
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 :

C#
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 :

SQL
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 :

C#
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 :

C#
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 :

C#
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 :

SQL
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 :

C#
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 :

C#
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 :

C#
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# :

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é :

C#
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 :

C#
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 :

C#
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 :

C#
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 :

C#
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.

Notes

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 :

C#
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 :

C#
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 :

C#
[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 :

C#
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 :

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

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

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

Notez deux éléments dans ce code SQL :

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

Projections

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

C#
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 :

SQL
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 :

C#
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 :

SQL
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é :

C#
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 :

SQL
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 :

C#
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 :

C#
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 :

C#
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 :

C#
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 :

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

Notes

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 :

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

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

C#
[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 :

C#
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 »:

C#
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 :

C#
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 :

C#
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 :

SQL
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 :

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

La requête utilise ensuite OpenJson sur SQL Server :

SQL
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 :

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

Notes

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 :

C#
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 :

C#
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.

SQL
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 :

C#
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 :

SQL
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 :

C#
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 :

SQL
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é :

C#
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 :

SQL
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 :

C#
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 :

SQL
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 :

none
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 :

C#
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 :

C#
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 :

SQL
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 :

SQL
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:

C#
[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 :

C#
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 :

SQL
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.

C#
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 :

SQL
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

Notes

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.

C#
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 :

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

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

C#
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 :

SQL
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 :

C#
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 :

SQL
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 :

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

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

SQL
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 :

C#
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 :

text
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 :

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

HierarchyId dans .NET et EF Core

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

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

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

Prise en charge dans .NET et EF Core

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

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

Conseil

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

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

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

Notes

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.

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

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

Conseil

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

Conseil

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

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

Arbre généalogique des Halfelins

Dans cette arborescence :

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

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

C#
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 :

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

Cela se traduit par le code SQL suivant :

SQL
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 :

text
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 :

C#
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 :

SQL
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 :

C#
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 :

SQL
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 :

C#
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 :

SQL
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 :

C#
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 :

SQL
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 :

C#
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 :

SQL
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 ».

j.ramos
j.ramos

President Codevia & Senior Software Engineer, de plus de 10 ans d'expérience dans le domaine du développement logiciel, avec une spécialisation particulière dans la transition des logiciels obsolètes à caractère industriel. Fort de mon expertise technique et de ma compréhension approfondie des besoins spécifiques de l'industrie, j'ai consacré ma carrière à résoudre les défis complexes liés à la modernisation des systèmes logiciels obsolètes.

Articles: 62

Laisser un commentaire

Votre adresse e-mail ne sera pas publiée. Les champs obligatoires sont indiqués avec *