Quoi de neuf dans EF Core 7.0

EF Core 7.0 (EF7) est sorti en novembre 2022.

Conseil

Vous pouvez exécuter et déboguer les exemples en téléchargeant l'exemple de code depuis GitHub. Chaque section renvoie au code source spécifique à cette section.

EF7 cible .NET 6 et peut donc être utilisé avec .NET 6 (LTS) ou .NET 7.

Exemple de modèle

La plupart des exemples ci-dessous utilisent un modèle simple avec des blogs, des articles, des balises et des auteurs :

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

    public int Id { get; private set; }
    public string Name { get; set; }
    public List<Post> Posts { get; } = new();
}

public class Post
{
    public Post(string title, string content, DateTime publishedOn)
    {
        Title = title;
        Content = content;
        PublishedOn = publishedOn;
    }

    public int Id { get; private set; }
    public string Title { get; set; }
    public string Content { get; set; }
    public DateTime PublishedOn { get; set; }
    public Blog Blog { get; set; } = null!;
    public List<Tag> Tags { get; } = new();
    public Author? Author { get; set; }
    public PostMetadata? Metadata { get; set; }
}

public class FeaturedPost : Post
{
    public FeaturedPost(string title, string content, DateTime publishedOn, string promoText)
        : base(title, content, publishedOn)
    {
        PromoText = promoText;
    }

    public string PromoText { get; set; }
}

public class Tag
{
    public Tag(string id, string text)
    {
        Id = id;
        Text = text;
    }

    public string Id { get; private set; }
    public string Text { get; set; }
    public List<Post> Posts { get; } = new();
}

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

    public int Id { get; private set; }
    public string Name { get; set; }
    public ContactDetails Contact { get; set; } = null!;
    public List<Post> Posts { get; } = new();
}

Certains exemples utilisent également des types d’agrégats, qui sont mappés de différentes manières dans différents échantillons. Il existe un type d'agrégation pour les contacts :

public class ContactDetails
{
    public Address Address { get; set; } = null!;
    public string? Phone { get; set; }
}

public class Address
{
    public Address(string street, string city, string postcode, string country)
    {
        Street = street;
        City = city;
        Postcode = postcode;
        Country = country;
    }

    public string Street { get; set; }
    public string City { get; set; }
    public string Postcode { get; set; }
    public string Country { get; set; }
}

Et un deuxième type d'agrégat pour les métadonnées de publication :

public class PostMetadata
{
    public PostMetadata(int views)
    {
        Views = views;
    }

    public int Views { get; set; }
    public List<SearchTerm> TopSearches { get; } = new();
    public List<Visits> TopGeographies { get; } = new();
    public List<PostUpdate> Updates { get; } = new();
}

public class SearchTerm
{
    public SearchTerm(string term, int count)
    {
        Term = term;
        Count = count;
    }

    public string Term { get; private set; }
    public int Count { get; private set; }
}

public class Visits
{
    public Visits(double latitude, double longitude, int count)
    {
        Latitude = latitude;
        Longitude = longitude;
        Count = count;
    }

    public double Latitude { get; private set; }
    public double Longitude { get; private set; }
    public int Count { get; private set; }
    public List<string>? Browsers { get; set; }
}

public class PostUpdate
{
    public PostUpdate(IPAddress postedFrom, DateTime updatedOn)
    {
        PostedFrom = postedFrom;
        UpdatedOn = updatedOn;
    }

    public IPAddress PostedFrom { get; private set; }
    public string? UpdatedBy { get; init; }
    public DateTime UpdatedOn { get; private set; }
    public List<Commit> Commits { get; } = new();
}

public class Commit
{
    public Commit(DateTime committedOn, string comment)
    {
        CommittedOn = committedOn;
        Comment = comment;
    }

    public DateTime CommittedOn { get; private set; }
    public string Comment { get; set; }
}

Conseil

L’exemple de modèle peut être trouvé dans BlogsContext.cs.

Colonnes JSON

La plupart des bases de données relationnelles prennent en charge les colonnes contenant des documents JSON. Le JSON dans ces colonnes peut être exploré avec des requêtes. Cela permet, par exemple, de filtrer et de trier par éléments des documents, ainsi que de projeter des éléments hors des documents en résultats. Les colonnes JSON permettent aux bases de données relationnelles de reprendre certaines des caractéristiques des bases de données de documents, créant ainsi un hybride utile entre les deux.

EF7 contient une prise en charge indépendante du fournisseur pour les colonnes JSON, avec une implémentation pour SQL Server. Cette prise en charge permet le mappage d'agrégats construits à partir de types .NET vers des documents JSON. Les requêtes LINQ normales peuvent être utilisées sur les agrégats, et celles-ci seront traduites en constructions de requêtes appropriées nécessaires pour explorer le JSON. EF7 prend également en charge la mise à jour et l'enregistrement des modifications apportées aux documents JSON.

Remarque

La prise en charge de SQLite pour JSON est prévue après EF7. Les fournisseurs PostgreSQL et Pomelo MySQL contiennent déjà une certaine prise en charge des colonnes JSON. Nous travaillerons avec les auteurs de ces fournisseurs pour aligner la prise en charge de JSON sur tous les fournisseurs.

Mappage aux colonnes JSON

Dans EF Core, les types d’agrégats sont définis à l’aide de OwnsOne et OwnsMany. Par exemple, considérons le type d'agrégat de notre exemple de modèle utilisé pour stocker les informations de contact :

public class ContactDetails
{
    public Address Address { get; set; } = null!;
    public string? Phone { get; set; }
}

public class Address
{
    public Address(string street, string city, string postcode, string country)
    {
        Street = street;
        City = city;
        Postcode = postcode;
        Country = country;
    }

    public string Street { get; set; }
    public string City { get; set; }
    public string Postcode { get; set; }
    public string Country { get; set; }
}

Ceci peut ensuite être utilisé dans une entité de type « propriétaire », par exemple pour stocker les coordonnées d'un auteur :

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

Le type d'agrégat se configure OnModelCreating en utilisant OwnsOne :

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

Conseil

Le code affiché ici provient de JsonColumnsSample.cs.

Par défaut, les fournisseurs de bases de données relationnelles mappent les types d'agrégats comme celui-ci à la même table que le type d'entité propriétaire. Autrement dit, chaque propriété des classes ContactDetails et Address est mappée à une colonne du tableau Authors.

Certains auteurs enregistrés avec leurs coordonnées ressembleront à ceci :

Auteurs

Id Nom Contact_Address_Street Contact_Address_City Contact_Address_Postcode Contact_Address_Country Contact_Phone
1 Maddy Montaquila 1 Main St Camberwick Green CW1 5ZH Royaume-Uni 01632 12345
2 Jeremy Likness 2 Main St Chigley CW1 5ZH Royaume-Uni 01632 12346
3 Daniel Roth 3 Main St Camberwick Green CW1 5ZH Royaume-Uni 01632 12347
4 Arthur Vickers 15a Main St Chigley CW1 5ZH Royaume-Uni 01632 22345
5 Brice Lambson 4 Main St Chigley CW1 5ZH Royaume-Uni 01632 12349

Si vous le souhaitez, chaque type d'entité composant l'agrégat peut être mappé à sa propre table :

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

Les mêmes données sont ensuite stockées dans trois tables :

Auteurs

Id Nom
1 Maddy Montaquila
2 Jeremy Likness
3 Daniel Roth
4 Arthur Vickers
5 Brice Lambson

Contacts

AuthorId Téléphone
1 01632 12345
2 01632 12346
3 01632 12347
4 01632 22345
5 01632 12349

Adresses

ContactDetailsAuthorId Rue Ville Code postal Pays ou région
1 1 Main St Camberwick Green CW1 5ZH Royaume-Uni
2 2 Main St Chigley CW1 5ZH Royaume-Uni
3 3 Main St Camberwick Green CW1 5ZH Royaume-Uni
4 15a Main St Chigley CW1 5ZH Royaume-Uni
5 4 Main St Chigley CW1 5ZH Royaume-Uni

Passons maintenant à la partie intéressante. Dans EF7, le type d'agrégat ContactDetails peut être mappé à une colonne JSON. Cela nécessite un seul appel ToJson() à lors de la configuration du type d'agrégat :

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

Le tableau Authors contiendra désormais une colonne JSON renseignée ContactDetails avec un document JSON pour chaque auteur :

Auteurs

Id Nom Contact
1 Maddy Montaquila {
  "Téléphone": "01632 12345",
  "Adresse": {
    "Ville": "Camberwick Green",
    "Pays": "Royaume-Uni",
    "Code postal": "CW1 5ZH",
    "Rue": "1, rue Main"
  }
}
2 Jeremy Likness {
  "Téléphone": "01632 12346",
  "Adresse": {
    "Ville": "Chigley",
    "Pays": "Royaume-Uni",
    "Code postal": "CH1 5ZH",
    "Rue": "2, rue Main"
  }
}
3 Daniel Roth {
  "Téléphone": "01632 12347",
  "Adresse": {
    "Ville": "Camberwick Green",
    "Pays": "Royaume-Uni",
    "Code postal": "CW1 5ZH",
    "Rue": "3, rue Main"
  }
}
4 Arthur Vickers {
  "Téléphone": "01632 12348",
  "Adresse": {
    "Ville": "Chigley",
    "Pays": "Royaume-Uni",
    "Code postal": "CH1 5ZH",
    "Rue": "15a, rue Main"
  }
}
5 Brice Lambson {
  "Téléphone": "01632 12349",
  "Adresse": {
    "Ville": "Chigley",
    "Pays": "Royaume-Uni",
    "Code postal": "CH1 5ZH",
    "Rue": "4, rue Main"
  }
}

Conseil

Cette utilisation des agrégats est très similaire à la façon dont les documents JSON sont mappés lors de l’utilisation du fournisseur EF Core pour Azure Cosmos DB. Les colonnes JSON apportent les fonctionnalités d’utilisation d’EF Core avec des bases de données de documents aux documents incorporés dans une base de données relationnelle.

Les documents JSON présentés ci-dessus sont très simples, mais cette fonctionnalité de mappage peut également être utilisée avec des structures de documents plus complexes. Par exemple, considérons un autre type d'agrégat de notre exemple de modèle, utilisé pour représenter les métadonnées d'une publication :

public class PostMetadata
{
    public PostMetadata(int views)
    {
        Views = views;
    }

    public int Views { get; set; }
    public List<SearchTerm> TopSearches { get; } = new();
    public List<Visits> TopGeographies { get; } = new();
    public List<PostUpdate> Updates { get; } = new();
}

public class SearchTerm
{
    public SearchTerm(string term, int count)
    {
        Term = term;
        Count = count;
    }

    public string Term { get; private set; }
    public int Count { get; private set; }
}

public class Visits
{
    public Visits(double latitude, double longitude, int count)
    {
        Latitude = latitude;
        Longitude = longitude;
        Count = count;
    }

    public double Latitude { get; private set; }
    public double Longitude { get; private set; }
    public int Count { get; private set; }
    public List<string>? Browsers { get; set; }
}

public class PostUpdate
{
    public PostUpdate(IPAddress postedFrom, DateTime updatedOn)
    {
        PostedFrom = postedFrom;
        UpdatedOn = updatedOn;
    }

    public IPAddress PostedFrom { get; private set; }
    public string? UpdatedBy { get; init; }
    public DateTime UpdatedOn { get; private set; }
    public List<Commit> Commits { get; } = new();
}

public class Commit
{
    public Commit(DateTime committedOn, string comment)
    {
        CommittedOn = committedOn;
        Comment = comment;
    }

    public DateTime CommittedOn { get; private set; }
    public string Comment { get; set; }
}

Ce type d'agrégat contient plusieurs types et collections imbriqués. Les appels à OwnsOne et OwnsMany sont utilisés pour mapper ce type d'agrégat :

modelBuilder.Entity<Post>().OwnsOne(
    post => post.Metadata, ownedNavigationBuilder =>
    {
        ownedNavigationBuilder.ToJson();
        ownedNavigationBuilder.OwnsMany(metadata => metadata.TopSearches);
        ownedNavigationBuilder.OwnsMany(metadata => metadata.TopGeographies);
        ownedNavigationBuilder.OwnsMany(
            metadata => metadata.Updates,
            ownedOwnedNavigationBuilder => ownedOwnedNavigationBuilder.OwnsMany(update => update.Commits));
    });

Conseil

ToJson n'est nécessaire qu'à la racine de l'agrégat pour mapper l'intégralité de l'agrégat à un document JSON.

Avec ce mappage, EF7 peut créer et interroger un document JSON complexe comme celui-ci :

{
  "Views": 5085,
  "TopGeographies": [
    {
      "Browsers": "Firefox, Netscape",
      "Count": 924,
      "Latitude": 110.793,
      "Longitude": 39.2431
    },
    {
      "Browsers": "Firefox, Netscape",
      "Count": 885,
      "Latitude": 133.793,
      "Longitude": 45.2431
    }
  ],
  "TopSearches": [
    {
      "Count": 9359,
      "Term": "Search #1"
    }
  ],
  "Updates": [
    {
      "PostedFrom": "127.0.0.1",
      "UpdatedBy": "Admin",
      "UpdatedOn": "1996-02-17T19:24:29.5429092Z",
      "Commits": []
    },
    {
      "PostedFrom": "127.0.0.1",
      "UpdatedBy": "Admin",
      "UpdatedOn": "2019-11-24T19:24:29.5429093Z",
      "Commits": [
        {
          "Comment": "Commit #1",
          "CommittedOn": "2022-08-21T00:00:00+01:00"
        }
      ]
    },
    {
      "PostedFrom": "127.0.0.1",
      "UpdatedBy": "Admin",
      "UpdatedOn": "1997-05-28T19:24:29.5429097Z",
      "Commits": [
        {
          "Comment": "Commit #1",
          "CommittedOn": "2022-08-21T00:00:00+01:00"
        },
        {
          "Comment": "Commit #2",
          "CommittedOn": "2022-08-21T00:00:00+01:00"
        }
      ]
    }
  ]
}

Remarque

Le mappage des types spatiaux directement vers JSON n’est pas encore pris en charge. Le document ci-dessus utilise des valeurs double comme solution de contournement. Votez pour la prise en charge des types spatiaux dans les colonnes JSON si cela vous intéresse.

Remarque

Le mappage de collections de types primitifs vers JSON n'est pas encore pris en charge. Le document ci-dessus utilise un convertisseur de valeurs pour transformer la collection en une chaîne séparée par des virgules. Votez pour Json : ajoutez la prise en charge de la collection de types primitifs si cela vous intéresse.

Remarque

Le mappage des types détenus vers JSON n'est pas encore pris en charge avec l'héritage TPT ou TPC. Votez pour la prise en charge des propriétés JSON avec le mappage d'héritage TPT/TPC si cela vous intéresse.

Requêtes dans les colonnes JSON

Les requêtes dans les colonnes JSON fonctionnent de la même manière que les requêtes dans tout autre type d’agrégat dans EF Core. Autrement dit, utilisez simplement LINQ ! Voici quelques exemples.

Une requête pour tous les auteurs qui vivent à Chigley :

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

Cette requête génère le SQL suivant lors de l'utilisation de SQL Server :

SELECT [a].[Id], [a].[Name], JSON_QUERY([a].[Contact],'$')
FROM [Authors] AS [a]
WHERE CAST(JSON_VALUE([a].[Contact],'$.Address.City') AS nvarchar(max)) = N'Chigley'

Notez l'utilisation de JSON_VALUE pour obtenir le City depuis l'intérieur Address du document JSON.

Select peut être utilisé pour extraire et projeter des éléments du document JSON :

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

Cette requête génère le SQL suivant :

SELECT CAST(JSON_VALUE([a].[Contact],'$.Address.Postcode') AS nvarchar(max))
FROM [Authors] AS [a]
WHERE CAST(JSON_VALUE([a].[Contact],'$.Address.City') AS nvarchar(max)) = N'Chigley'

Voici un exemple qui fait un peu plus dans le filtre et la projection, et qui classe également par numéro de téléphone dans le document JSON :

var orderedAddresses = await context.Authors
    .Where(
        author => (author.Contact.Address.City == "Chigley"
                   && author.Contact.Phone != null)
                  || author.Name.StartsWith("D"))
    .OrderBy(author => author.Contact.Phone)
    .Select(
        author => author.Name + " (" + author.Contact.Address.Street
                  + ", " + author.Contact.Address.City
                  + " " + author.Contact.Address.Postcode + ")")
    .ToListAsync();

Cette requête génère le SQL suivant :

SELECT (((((([a].[Name] + N' (') + CAST(JSON_VALUE([a].[Contact],'$.Address.Street') AS nvarchar(max))) + N', ') + CAST(JSON_VALUE([a].[Contact],'$.Address.City') AS nvarchar(max))) + N' ') + CAST(JSON_VALUE([a].[Contact],'$.Address.Postcode') AS nvarchar(max))) + N')'
FROM [Authors] AS [a]
WHERE (CAST(JSON_VALUE([a].[Contact],'$.Address.City') AS nvarchar(max)) = N'Chigley' AND CAST(JSON_VALUE([a].[Contact],'$.Phone') AS nvarchar(max)) IS NOT NULL) OR ([a].[Name] LIKE N'D%')
ORDER BY CAST(JSON_VALUE([a].[Contact],'$.Phone') AS nvarchar(max))

Et lorsque le document JSON contient des collections, celles-ci peuvent être projetées dans les résultats :

var postsWithViews = await context.Posts.Where(post => post.Metadata!.Views > 3000)
    .AsNoTracking()
    .Select(
        post => new
        {
            post.Author!.Name, post.Metadata!.Views, Searches = post.Metadata.TopSearches, Commits = post.Metadata.Updates
        })
    .ToListAsync();

Cette requête génère le SQL suivant :

SELECT [a].[Name], CAST(JSON_VALUE([p].[Metadata],'$.Views') AS int), JSON_QUERY([p].[Metadata],'$.TopSearches'), [p].[Id], JSON_QUERY([p].[Metadata],'$.Updates')
FROM [Posts] AS [p]
LEFT JOIN [Authors] AS [a] ON [p].[AuthorId] = [a].[Id]
WHERE CAST(JSON_VALUE([p].[Metadata],'$.Views') AS int) > 3000

Remarque

Les requêtes plus complexes impliquant des collections JSON nécessitent une prise en charge jsonpath. Votez pour le support jsonpath en demandant si c'est quelque chose qui vous intéresse.

Conseil

Pensez à créer des index pour améliorer les performances des requêtes dans les documents JSON. Par exemple, consultez Indexer les données Json lors de l’utilisation de SQL Server.

Mise à jour des colonnes JSON

SaveChanges et SaveChangesAsync travaillez normalement pour mettre à jour une colonne JSON. En cas de modifications importantes, l’intégralité du document sera mise à jour. Par exemple, remplacer la majeure partie du document Contact pour un auteur :

var jeremy = await context.Authors.SingleAsync(author => author.Name.StartsWith("Jeremy"));

jeremy.Contact = new() { Address = new("2 Riverside", "Trimbridge", "TB1 5ZS", "UK"), Phone = "01632 88346" };

await context.SaveChangesAsync();

Dans ce cas, l'intégralité du nouveau document est passée en paramètre :

info: 8/30/2022 20:21:24.392 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (2ms) [Parameters=[@p0='{"Phone":"01632 88346","Address":{"City":"Trimbridge","Country":"UK","Postcode":"TB1 5ZS","Street":"2 Riverside"}}' (Nullable = false) (Size = 114), @p1='2'], CommandType='Text', CommandTimeout='30']

Qui est ensuite utilisé dans le langage SQL UPDATE :

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

Toutefois, si seul un sous-document est modifié, EF Core utilisera une commande JSON_MODIFY pour mettre à jour uniquement le sous-document. Par exemple, modifier Address l'intérieur d'un document Contact :

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

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

await context.SaveChangesAsync();

Génère les paramètres suivants :

info: 10/2/2022 15:51:15.895 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (2ms) [Parameters=[@p0='{"City":"Trimbridge","Country":"UK","Postcode":"TB1 5ZS","Street":"4 Riverside"}' (Nullable = false) (Size = 80), @p1='5'], CommandType='Text', CommandTimeout='30']

Qui est utilisé dans le UPDATE via un appel JSON_MODIFY :

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

Enfin, si une seule propriété est modifiée, EF Core utilisera à nouveau une commande « JSON_MODIFY », cette fois pour corriger uniquement la valeur de propriété modifiée. Par exemple :

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

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

await context.SaveChangesAsync();

Génère les paramètres suivants :

info: 10/2/2022 15:54:05.112 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (2ms) [Parameters=[@p0='["United Kingdom"]' (Nullable = false) (Size = 18), @p1='4'], CommandType='Text', CommandTimeout='30']

Qui sont à nouveau utilisés avec un JSON_MODIFY :

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

ExecuteUpdate et ExecuteDelete (mises à jour groupées)

Par défaut, EF Core suit les modifications apportées aux entités, puis envoie des mises à jour à la base de données lorsque l'une des méthodes est appelée. Les modifications sont envoyées uniquement pour les propriétés et les relations qui ont réellement changé. De plus, les entités suivies restent synchronisées avec les modifications envoyées à la base de données. Ce mécanisme constitue un moyen efficace et pratique d'envoyer des insertions, des mises à jour et des suppressions à usage général à la base de données. Ces modifications sont également regroupées pour réduire le nombre d’allers-retours dans la base de données.

Cependant, il est parfois utile d'exécuter des commandes de mise à jour ou de suppression sur la base de données sans impliquer le suivi des modifications. EF7 permet cela avec les nouvelles méthodes ExecuteUpdate et ExecuteDelete. Ces méthodes sont appliquées à une requête LINQ et mettront à jour ou supprimeront les entités de la base de données en fonction des résultats de cette requête. De nombreuses entités peuvent être mises à jour avec une seule commande et les entités ne sont pas chargées en mémoire, ce qui signifie que cela peut entraîner des mises à jour et des suppressions plus efficaces.

Gardez toutefois à l’esprit que :

  • Les modifications spécifiques à apporter doivent être spécifiées explicitement ; ils ne sont pas automatiquement détectés par EF Core.
  • Les entités suivies ne seront pas synchronisées.
  • Des commandes supplémentaires devront peut-être être envoyées dans le bon ordre afin de ne pas violer les contraintes de la base de données. Par exemple, supprimer des personnes à charge avant de pouvoir supprimer un principal.

Tout cela signifie que les méthodes ExecuteUpdate et ExecuteDelete complètent, plutôt que de remplacer, le mécanisme existant SaveChanges.

Exemples De base ExecuteDelete

Conseil

Le code affiché ici provient de ExecuteDeleteSample.cs.

L'appel de ExecuteDelete ou ExecuteDeleteAsync sur un DbSet supprime immédiatement toutes les entités DbSet de la base de données. Par exemple, pour supprimer toutes les entités Tag :

await context.Tags.ExecuteDeleteAsync();

Cela exécute le SQL suivant lors de l'utilisation de SQL Server :

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

Plus intéressant encore, la requête peut contenir un filtre. Par exemple :

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

Cela exécute le SQL suivant :

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

La requête peut également utiliser des filtres plus complexes, notamment des navigations vers d'autres types. Par exemple, pour supprimer les balises uniquement des anciens articles de blog :

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

Qui exécute :

DELETE FROM [t]
FROM [Tags] AS [t]
WHERE NOT EXISTS (
    SELECT 1
    FROM [PostTag] AS [p]
    INNER JOIN [Posts] AS [p0] ON [p].[PostsId] = [p0].[Id]
    WHERE [t].[Id] = [p].[TagsId] AND NOT (DATEPART(year, [p0].[PublishedOn]) < 2022))

Exemples De base ExecuteUpdate

Conseil

Le code affiché ici provient de ExecuteUpdateSample.cs.

ExecuteUpdate et ExecuteUpdateAsync se comportent d'une manière très similaire aux méthodes ExecuteDelete. La principale différence est qu’une mise à jour nécessite de savoir quelles propriétés mettre à jour et comment les mettre à jour. Ceci est réalisé en utilisant un ou plusieurs appels à SetProperty. Par exemple, pour mettre à jour le contenu Name de chaque blog :

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

Le premier paramètre de SetProperty spécifie la propriété à mettre à jour ; dans ce cas, Blog.Name. Le deuxième paramètre spécifie comment la nouvelle valeur doit être calculée ; dans ce cas, en prenant la valeur existante et en ajoutant "*Featured!*". Le langage SQL résultant est :

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

Comme pour ExecuteDelete, la requête peut être utilisée pour filtrer les entités mises à jour. De plus, plusieurs appels à SetProperty peuvent être utilisés pour mettre à jour plusieurs propriétés sur l’entité cible. Par exemple, pour mettre à jour le Title et Content de tous les articles publiés avant 2022 :

await context.Posts
    .Where(p => p.PublishedOn.Year < 2022)
    .ExecuteUpdateAsync(s => s
        .SetProperty(b => b.Title, b => b.Title + " (" + b.PublishedOn.Year + ")")
        .SetProperty(b => b.Content, b => b.Content + " ( This content was published in " + b.PublishedOn.Year + ")"));

Dans ce cas, le SQL généré est un peu plus compliqué :

UPDATE [p]
SET [p].[Content] = (([p].[Content] + N' ( This content was published in ') + COALESCE(CAST(DATEPART(year, [p].[PublishedOn]) AS nvarchar(max)), N'')) + N')',
    [p].[Title] = (([p].[Title] + N' (') + COALESCE(CAST(DATEPART(year, [p].[PublishedOn]) AS nvarchar(max)), N'')) + N')'
FROM [Posts] AS [p]
WHERE DATEPART(year, [p].[PublishedOn]) < 2022

Enfin, toujours comme avec ExecuteDelete, le filtre peut référencer d'autres tables. Par exemple, pour mettre à jour toutes les balises des anciens messages :

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

Ce qui génère :

UPDATE [t]
SET [t].[Text] = [t].[Text] + N' (old)'
FROM [Tags] AS [t]
WHERE NOT EXISTS (
    SELECT 1
    FROM [PostTag] AS [p]
    INNER JOIN [Posts] AS [p0] ON [p].[PostsId] = [p0].[Id]
    WHERE [t].[Id] = [p].[TagsId] AND NOT (DATEPART(year, [p0].[PublishedOn]) < 2022))

Pour plus d’informations et des exemples de code sur ExecuteUpdate et ExecuteDelete, consultez ExecuteUpdate et ExecuteDelete.

Héritage et tables multiples

ExecuteUpdate et ExecuteDelete ne peut agir que sur une seule table. Cela a des implications lorsque vous travaillez avec différentes stratégies de mappage d'héritage. Généralement, il n'y a aucun problème lors de l'utilisation de la stratégie de cartographie TPH, puisqu'il n'y a qu'une seule table à modifier. Par exemple, supprimer toutes les entités FeaturedPost :

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

Génère le code SQL suivant lors de l'utilisation du mappage TPH :

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

Il n'y a également aucun problème dans ce cas lors de l'utilisation de la stratégie de mappage TPC, puisque là encore, seules les modifications d'une seule table sont nécessaires :

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

Cependant, tenter cela en utilisant la stratégie de mappage TPT échouera car cela nécessiterait la suppression de lignes de deux tables différentes.

L'ajout d'un filtre à la requête signifie souvent que l'opération échouera avec les stratégies TPC et TPT. C'est encore une fois parce que les lignes doivent peut-être être supprimées de plusieurs tables. Par exemple, cette requête :

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

Génère le code SQL suivant lors de l'utilisation de TPH :

DELETE FROM [p]
FROM [Posts] AS [p]
LEFT JOIN [Authors] AS [a] ON [p].[AuthorId] = [a].[Id]
WHERE [a].[Name] IS NOT NULL AND ([a].[Name] LIKE N'Arthur%')

Mais échoue lors de l'utilisation de TPC ou TPT.

Conseil

Le numéro #10879 suit l'ajout de la prise en charge de l'envoi automatique de plusieurs commandes dans ces scénarios. Votez pour ce problème si c'est quelque chose que vous aimeriez voir mis en œuvre.

ExecuteDelete et les relations

Comme mentionné ci-dessus, il peut être nécessaire de supprimer ou de mettre à jour les entités dépendantes avant de pouvoir supprimer le principal d'une relation. Par exemple, chacun Post dépend de son associé Author. Cela signifie qu'un auteur ne peut pas être supprimé si une publication y fait toujours référence ; cela violerait la contrainte de clé étrangère dans la base de données. Par exemple, en essayant ceci :

await context.Authors.ExecuteDeleteAsync();

Cela entraînera l'exception suivante sur SQL Server :

Microsoft.Data.SqlClient.SqlException (0x80131904) : l'instruction DELETE est en conflit avec la contrainte REFERENCE « FK_Posts_Authors_AuthorId ». Le conflit s'est produit dans la base de données "TphBlogsContext", table "dbo.Posts", colonne "AuthorId". L'instruction a été arrêtée.

Pour résoudre ce problème, nous devons d'abord soit supprimer les publications, soit rompre la relation entre chaque publication et son auteur en définissant la propriété de clé étrangère AuthorId sur null. Par exemple, en utilisant l'option de suppression :

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

Conseil

TagWith peut être utilisé pour baliser ExecuteDelete ou ExecuteUpdate de la même manière qu'il balise les requêtes normales.

Cela se traduit par deux commandes distinctes ; le premier à supprimer les personnes à charge :

-- Deleting posts...

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

Et le second pour supprimer les principaux :

-- Deleting authors...

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

Important

Les commandes multiples ExecuteDelete et ExecuteUpdate ne seront pas contenues dans une seule transaction par défaut. Cependant, les API de transaction DbContext peuvent être utilisées de manière normale pour encapsuler ces commandes dans une transaction.

Conseil

L'envoi de ces commandes en un seul aller-retour dépend du problème #10879. Votez pour ce problème si c'est quelque chose que vous aimeriez voir mis en œuvre.

La configuration des suppressions en cascade dans la base de données peut être très utile ici. Dans notre modèle, la relation entre Blog et Post est requise, ce qui amène EF Core à configurer une suppression en cascade par convention. Cela signifie que lorsqu'un blog est supprimé de la base de données, tous ses articles dépendants seront également supprimés. Il s’ensuit que pour supprimer tous les blogs et articles, il suffit de supprimer les blogs :

await context.Blogs.ExecuteDeleteAsync();

Cela donne le SQL suivant :

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

Ce qui, comme il s'agit de supprimer un blog, entraînera également la suppression de tous les articles associés par la suppression en cascade configurée.

Sauvegarde plus rapide des modifications

Dans EF7, les performances de SaveChanges et SaveChangesAsync ont été considérablement améliorées. Dans certains scénarios, l’enregistrement des modifications est désormais jusqu’à quatre fois plus rapide qu’avec EF Core 6.0 !

La plupart de ces améliorations proviennent :

  • Effectuer moins d'allers-retours vers la base de données
  • Générer du SQL plus rapide

Quelques exemples de ces améliorations sont présentés ci-dessous.

Remarque

Voir Annonce d'Entity Framework Core 7 Preview 6 : Performance Edition sur le blog .NET pour une discussion approfondie de ces modifications.

Conseil

Le code affiché ici provient de SaveChangesPerformanceSample.cs.

Les transactions inutiles sont éliminées

Toutes les bases de données relationnelles modernes garantissent la transactionnalité pour (la plupart) des instructions SQL uniques. Autrement dit, l'instruction ne sera jamais complétée que partiellement, même si une erreur se produit. EF7 évite de démarrer une transaction explicite dans ces cas.

Par exemple, en regardant la journalisation de l'appel suivant à SaveChanges :

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

Montre que dans EF Core 6.0, la commande INSERT est encapsulée par des commandes pour commencer puis valider une transaction :

dbug: 9/29/2022 11:43:09.196 RelationalEventId.TransactionStarted[20200] (Microsoft.EntityFrameworkCore.Database.Transaction)
      Began transaction with isolation level 'ReadCommitted'.
info: 9/29/2022 11:43:09.265 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (27ms) [Parameters=[@p0='MyBlog' (Nullable = false) (Size = 4000)], CommandType='Text', CommandTimeout='30']
      SET NOCOUNT ON;
      INSERT INTO [Blogs] ([Name])
      VALUES (@p0);
      SELECT [Id]
      FROM [Blogs]
      WHERE @@ROWCOUNT = 1 AND [Id] = scope_identity();
dbug: 9/29/2022 11:43:09.297 RelationalEventId.TransactionCommitted[20202] (Microsoft.EntityFrameworkCore.Database.Transaction)
      Committed transaction.

EF7 détecte que la transaction n'est pas nécessaire ici et supprime donc ces appels :

info: 9/29/2022 11:42:34.776 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (25ms) [Parameters=[@p0='MyBlog' (Nullable = false) (Size = 4000)], CommandType='Text', CommandTimeout='30']
      SET IMPLICIT_TRANSACTIONS OFF;
      SET NOCOUNT ON;
      INSERT INTO [Blogs] ([Name])
      OUTPUT INSERTED.[Id]
      VALUES (@p0);

Cela supprime deux allers-retours vers la base de données, ce qui peut faire une énorme différence sur les performances globales, en particulier lorsque la latence des appels vers la base de données est élevée. Dans les systèmes de production typiques, la base de données n'est pas colocalisée sur la même machine que l'application. Cela signifie que la latence est souvent relativement élevée, ce qui rend cette optimisation particulièrement efficace dans les systèmes de production réels.

SQL amélioré pour une insertion d'identité simple

Le cas ci-dessus insère une seule ligne avec une colonne clé IDENTITY et aucune autre valeur générée par la base de données. EF7 simplifie le SQL dans ce cas en utilisant OUTPUT INSERTED. Même si cette simplification n'est pas valable pour de nombreux autres cas, il est tout de même important de l'améliorer puisque ce type de plaquette à une seule rangée est très courant dans de nombreuses applications.

Insérer plusieurs lignes

Dans EF Core 6.0, l’approche par défaut pour l’insertion de plusieurs lignes était motivée par les limitations de la prise en charge de SQL Server pour les tables avec déclencheurs. Nous voulions nous assurer que l'expérience par défaut fonctionnait même pour la minorité d'utilisateurs disposant de déclencheurs dans leurs tables. Cela signifiait que nous ne pouvions pas utiliser une clause OUTPUT simple, car sur SQL Server, cela ne fonctionne pas avec les déclencheurs. Au lieu de cela, lors de l’insertion de plusieurs entités, EF Core 6.0 générait du SQL assez alambiqué. Par exemple, cet appel à SaveChanges :

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

await context.SaveChangesAsync();

Entraîne les actions suivantes lors de l’exécution sur SQL Server avec EF Core 6.0 :

dbug: 9/30/2022 17:19:51.919 RelationalEventId.TransactionStarted[20200] (Microsoft.EntityFrameworkCore.Database.Transaction)
      Began transaction with isolation level 'ReadCommitted'.
info: 9/30/2022 17:19:51.993 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (27ms) [Parameters=[@p0='Foo0' (Nullable = false) (Size = 4000), @p1='Foo1' (Nullable = false) (Size = 4000), @p2='Foo2' (Nullable = false) (Size = 4000), @p3='Foo3' (Nullable = false) (Size = 4000)], CommandType='Text', CommandTimeout='30']
      SET NOCOUNT ON;
      DECLARE @inserted0 TABLE ([Id] int, [_Position] [int]);
      MERGE [Blogs] USING (
      VALUES (@p0, 0),
      (@p1, 1),
      (@p2, 2),
      (@p3, 3)) AS i ([Name], _Position) ON 1=0
      WHEN NOT MATCHED THEN
      INSERT ([Name])
      VALUES (i.[Name])
      OUTPUT INSERTED.[Id], i._Position
      INTO @inserted0;

      SELECT [i].[Id] FROM @inserted0 i
      ORDER BY [i].[_Position];
dbug: 9/30/2022 17:19:52.023 RelationalEventId.TransactionCommitted[20202] (Microsoft.EntityFrameworkCore.Database.Transaction)
      Committed transaction.

Important

Même si cela est compliqué, le regroupement de plusieurs insertions comme celle-ci reste nettement plus rapide que l'envoi d'une seule commande pour chaque insertion.

Dans EF7, vous pouvez toujours obtenir ce SQL si vos tables contiennent des déclencheurs, mais pour le cas courant, nous générons désormais des commandes beaucoup plus efficaces, bien qu'encore quelque peu complexes :

info: 9/30/2022 17:40:37.612 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (4ms) [Parameters=[@p0='Foo0' (Nullable = false) (Size = 4000), @p1='Foo1' (Nullable = false) (Size = 4000), @p2='Foo2' (Nullable = false) (Size = 4000), @p3='Foo3' (Nullable = false) (Size = 4000)], CommandType='Text', CommandTimeout='30']
      SET IMPLICIT_TRANSACTIONS OFF;
      SET NOCOUNT ON;
      MERGE [Blogs] USING (
      VALUES (@p0, 0),
      (@p1, 1),
      (@p2, 2),
      (@p3, 3)) AS i ([Name], _Position) ON 1=0
      WHEN NOT MATCHED THEN
      INSERT ([Name])
      VALUES (i.[Name])
      OUTPUT INSERTED.[Id], i._Position;

La transaction a disparu, comme dans le cas d'une insertion unique, car MERGE s'agit d'une seule instruction protégée par une transaction implicite. De plus, la table temporaire a disparu et la clause OUTPUT renvoie désormais les ID générés directement au client. Cela peut être quatre fois plus rapide que sur EF Core 6.0, en fonction de facteurs environnementaux tels que la latence entre l'application et la base de données.

Déclencheurs

Si la table a des déclencheurs, alors l'appel à SaveChanges dans le code ci-dessus lèvera une exception :

Exception non gérée. Microsoft.EntityFrameworkCore.DbUpdateException :
impossible d'enregistrer les modifications car la table cible comporte des déclencheurs de base de données. Veuillez configurer votre type d'entité en conséquence, voir https://aka.ms/efcore-docs-sqlserver-save-changes-and-triggers pour plus d'informations.
---> Microsoft.Data.SqlClient.SqlException (0x80131904) :
la table cible « BlogsWithTriggers » de l'instruction DML ne peut pas avoir de déclencheurs activés si l'instruction contient une clause OUTPUT sans clause INTO.

Le code suivant peut être utilisé pour informer EF Core que la table possède un déclencheur :

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

EF7 reviendra ensuite au SQL EF Core 6.0 lors de l'envoi de commandes d'insertion et de mise à jour pour cette table.

Pour plus d’informations, y compris une convention pour configurer automatiquement toutes les tables mappées avec des déclencheurs, consultez Les tables langage SQL Server avec des déclencheurs nécessitent désormais une configuration spéciale EF Core dans la documentation sur les modifications avec rupture d’EF7.

Moins d'allers-retours pour l'insertion de graphiques

Pensez à insérer un graphique d'entités contenant une nouvelle entité principale ainsi que de nouvelles entités dépendantes avec des clés étrangères faisant référence au nouveau principal. Par exemple :

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

Si la clé primaire du principal est générée par la base de données, alors la valeur à définir pour la clé étrangère dans la dépendance n'est pas connue tant que le principal n'a pas été inséré. EF Core génère deux allers-retours pour cela : un pour insérer le principal et récupérer la nouvelle clé primaire, et un second pour insérer les dépendances avec la valeur de clé étrangère définie. Et comme il y a deux instructions pour cela, une transaction est nécessaire, ce qui signifie qu'il y a au total quatre allers-retours :

dbug: 10/1/2022 13:12:02.517 RelationalEventId.TransactionStarted[20200] (Microsoft.EntityFrameworkCore.Database.Transaction)
      Began transaction with isolation level 'ReadCommitted'.
info: 10/1/2022 13:12:02.517 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (0ms) [Parameters=[@p0='MyBlog' (Nullable = false) (Size = 4000)], CommandType='Text', CommandTimeout='30']
      SET IMPLICIT_TRANSACTIONS OFF;
      SET NOCOUNT ON;
      INSERT INTO [Blogs] ([Name])
      OUTPUT INSERTED.[Id]
      VALUES (@p0);
info: 10/1/2022 13:12:02.529 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (5ms) [Parameters=[@p1='6', @p2='My first post' (Nullable = false) (Size = 4000), @p3='6', @p4='My second post' (Nullable = false) (Size = 4000)], CommandType='Text', CommandTimeout='30']
      SET IMPLICIT_TRANSACTIONS OFF;
      SET NOCOUNT ON;
      MERGE [Post] USING (
      VALUES (@p1, @p2, 0),
      (@p3, @p4, 1)) AS i ([BlogId], [Title], _Position) ON 1=0
      WHEN NOT MATCHED THEN
      INSERT ([BlogId], [Title])
      VALUES (i.[BlogId], i.[Title])
      OUTPUT INSERTED.[Id], i._Position;
dbug: 10/1/2022 13:12:02.531 RelationalEventId.TransactionCommitted[20202] (Microsoft.EntityFrameworkCore.Database.Transaction)
      Committed transaction.

Cependant, dans certains cas, la valeur de la clé primaire est connue avant que le principal ne soit inséré. Cela inclut :

  • Valeurs clés qui ne sont pas générées automatiquement
  • Valeurs de clé générées sur le client, telles que les clés Guid
  • Valeurs clés générées sur le serveur par lots, par exemple lors de l'utilisation d'un générateur de valeurs hi-lo

Dans EF7, ces cas sont désormais optimisés en un seul aller-retour. Par exemple, dans le cas ci-dessus sur SQL Server, la clé primaire Blog.Id peut être configurée pour utiliser la stratégie de génération hi-lo :

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

L'appel SaveChanges d'en haut est désormais optimisé pour un seul aller-retour pour les inserts.

dbug: 10/1/2022 21:51:55.805 RelationalEventId.TransactionStarted[20200] (Microsoft.EntityFrameworkCore.Database.Transaction)
      Began transaction with isolation level 'ReadCommitted'.
info: 10/1/2022 21:51:55.806 RelationalEventId.CommandExecuted[20101] (Microsoft.EntityFrameworkCore.Database.Command)
      Executed DbCommand (0ms) [Parameters=[@p0='9', @p1='MyBlog' (Nullable = false) (Size = 4000), @p2='10', @p3='9', @p4='My first post' (Nullable = false) (Size = 4000), @p5='11', @p6='9', @p7='My second post' (Nullable = false) (Size = 4000)], CommandType='Text', CommandTimeout='30']
      SET NOCOUNT ON;
      INSERT INTO [Blogs] ([Id], [Name])
      VALUES (@p0, @p1);
      INSERT INTO [Posts] ([Id], [BlogId], [Title])
      VALUES (@p2, @p3, @p4),
      (@p5, @p6, @p7);
dbug: 10/1/2022 21:51:55.807 RelationalEventId.TransactionCommitted[20202] (Microsoft.EntityFrameworkCore.Database.Transaction)
      Committed transaction.

Notez qu'une transaction est toujours nécessaire ici. En effet, les insertions sont effectuées dans deux tables distinctes.

EF7 utilise également un seul lot dans d’autres cas où EF Core 6.0 en créerait plusieurs. Par exemple, lors de la suppression et de l'insertion de lignes dans le même tableau.

La valeur de SaveChanges

Comme le montrent certains exemples présentés ici, la sauvegarde des résultats dans la base de données peut être une tâche complexe. C’est là que l’utilisation de quelque chose comme EF Core montre vraiment sa valeur. EF Core :

  • Regroupe plusieurs commandes d'insertion, de mise à jour et de suppression ensemble pour réduire les allers-retours
  • Détermine si une transaction explicite est nécessaire ou non
  • Détermine l'ordre dans lequel insérer, mettre à jour et supprimer les entités afin que les contraintes de la base de données ne soient pas violées
  • Garantit que les valeurs générées par la base de données sont renvoyées efficacement et propagées dans les entités
  • Définit automatiquement les valeurs des clés étrangères à l'aide des valeurs générées pour les clés primaires
  • Détecter les conflits de concurrence

De plus, différents systèmes de bases de données nécessitent un code SQL différent dans bon nombre de ces cas. Le fournisseur de base de données EF Core travaille avec EF Core pour garantir que des commandes correctes et efficaces sont envoyées pour chaque cas.

Mappage d'héritage table par type de béton (TPC)

Par défaut, EF Core mappe une hiérarchie d’héritage de types .NET à une seule table de base de données. C’est ce qu’on appelle la stratégie de mappage table par hiérarchie (TPH). EF Core 5.0 a introduit la stratégie table par type (TPT), qui prend en charge le mappage de chaque type .NET à une table de base de données différente. EF7 introduit la stratégie table par type de béton (TPC). TPC mappe également les types .NET à différentes tables, mais d'une manière qui résout certains problèmes de performances courants avec la stratégie TPT.

Conseil

Le code affiché ici provient de TpcInheritanceSample.cs.

Conseil

L'équipe EF a démontré et parlé en profondeur du mappage TPC dans un épisode du .NET Data Community Standup. Comme pour tous les épisodes de Community Standup, vous pouvez regarder l'épisode TPC dès maintenant sur YouTube.

Schéma de la base de données TPC

La stratégie TPC est similaire à la stratégie TPT sauf qu'une table différente est créée pour chaque type concret dans la hiérarchie, mais les tables ne sont pas créées pour les types abstraits – d'où le nom « table par type concret ». Comme pour TPT, le tableau lui-même indique le type de l'objet enregistré. Cependant, contrairement au mappage TPT, chaque table contient des colonnes pour chaque propriété du type concret et de ses types de base. Les schémas de base de données TPC sont dénormalisés.

Par exemple, envisagez de mapper cette hiérarchie :

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

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

    public Food? Food { get; set; }
}

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

    public string? Vet { get; set; }

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

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

    public override string Species { get; }

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

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

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

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

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

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

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

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

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

    public override string Species => "Homo sapiens";

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

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

Lors de l'utilisation de SQL Server, les tables créées pour cette hiérarchie sont :

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

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

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

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

Notez que :

  • Il n'existe pas de tables pour les types Animal ou Pet, car ceux-ci se trouvent abstract dans le modèle objet. N'oubliez pas que C# n'autorise pas les instances de types abstraits et qu'il n'existe donc aucune situation dans laquelle une instance de type abstrait sera enregistrée dans la base de données.

  • Le mappage des propriétés dans les types de base est répété pour chaque type concret. Par exemple, chaque table a une colonne Name, et les chats et les chiens ont une colonne Vet.

  • L'enregistrement de certaines données dans cette base de données entraîne les résultats suivants :

Tableau Chats

Id Nom FoodId Vétérinaire EducationLevel
1 Alice 99ca3e98-b26d-4a0c-d4ae-08da7aca624f Pengelly MBA
2 Mac 99ca3e98-b26d-4a0c-d4ae-08da7aca624f Pengelly Préscolaire
8 Baxter 5dc5019e-6f72-454b-d4b0-08da7aca624f Hôpital pour animaux de compagnie Bothell BSc

Tableau Chiens

Id Nom FoodId Vétérinaire FavoriteToy
3 Toast 011aaf6f-d588-4fad-d4ac-08da7aca624f Pengelly M. Squirrel

Tableau des animaux de la ferme

Id Nom FoodId Valeur Species (Espèce)
4 Clyde 1d495075-f527-4498-d4af-08da7aca624f 100.00 Equus africanus asinus

Tableau Humains

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

Notez que, contrairement au mappage TPT, toutes les informations relatives à un seul objet sont contenues dans une seule table. Et, contrairement au mappage TPH, il n'y a aucune combinaison de colonne et de ligne dans aucune table où cela n'est jamais utilisé par le modèle. Nous verrons ci-dessous en quoi ces caractéristiques peuvent être importantes pour les requêtes et le stockage.

Configuration de l'héritage TPC

Tous les types d’une hiérarchie d’héritage doivent être explicitement inclus dans le modèle lors du mappage de la hiérarchie avec EF Core. Cela peut être fait en créant des propriétés DbSet sur votre DbContext pour chaque type :

public DbSet<Animal> Animals => Set<Animal>();
public DbSet<Pet> Pets => Set<Pet>();
public DbSet<FarmAnimal> FarmAnimals => Set<FarmAnimal>();
public DbSet<Cat> Cats => Set<Cat>();
public DbSet<Dog> Dogs => Set<Dog>();
public DbSet<Human> Humans => Set<Human>();

Ou en utilisant la méthode Entity dans OnModelCreating :

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

Important

Ceci est différent du comportement EF6 existant, dans lequel les types dérivés des types de base mappés seraient automatiquement découverts s’ils étaient contenus dans le même assembly.

Rien d'autre ne doit être fait pour mapper la hiérarchie en TPH, puisqu'il s'agit de la stratégie par défaut. Cependant, à partir de EF7, TPH peut être rendu explicite en appelant UseTphMappingStrategy le type de base de la hiérarchie :

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

Pour utiliser TPT à la place, remplacez ceci par UseTptMappingStrategy:

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

De même, UseTpcMappingStrategy sert à configurer TPC :

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

Dans chaque cas, le nom de table utilisé pour chaque type est extrait du nom de propriété DbSet sur votre DbContext, ou peut être configuré à l'aide de la méthode builder ToTable ou de l'attribut [Table].

Performances des requêtes TPC

Pour les requêtes, la stratégie TPC constitue une amélioration par rapport à TPT car elle garantit que les informations d'une instance d'entité donnée sont toujours stockées dans une seule table. Cela signifie que la stratégie TPC peut être utile lorsque la hiérarchie mappée est grande et comporte de nombreux types concrets (généralement des feuilles), chacun avec un grand nombre de propriétés, et où seul un petit sous-ensemble de types est utilisé dans la plupart des requêtes.

Le SQL généré pour trois requêtes LINQ simples peut être utilisé pour observer où TPC réussit par rapport à TPH et TPT. Ces requêtes sont :

  1. Une requête qui renvoie des entités de tous types dans la hiérarchie :

    context.Animals.ToList();
    
  2. Requête qui renvoie des entités d'un sous-ensemble de types dans la hiérarchie :

    context.Pets.ToList();
    
  3. Une requête qui renvoie uniquement les entités d'un seul type feuille dans la hiérarchie :

    context.Cats.ToList();
    

Requêtes TPH

Lors de l'utilisation de TPH, les trois requêtes n'interrogent qu'une seule table, mais avec des filtres différents sur la colonne du discriminateur :

  1. TPH SQL renvoyant des entités de tous types dans la hiérarchie :

    SELECT [a].[Id], [a].[Discriminator], [a].[FoodId], [a].[Name], [a].[Species], [a].[Value], [a].[FavoriteAnimalId], [a].[Vet], [a].[EducationLevel], [a].[FavoriteToy]
    FROM [Animals] AS [a]
    
  2. TPH SQL renvoyant des entités à partir d'un sous-ensemble de types dans la hiérarchie :

    SELECT [a].[Id], [a].[Discriminator], [a].[FoodId], [a].[Name], [a].[Vet], [a].[EducationLevel], [a].[FavoriteToy]
    FROM [Animals] AS [a]
    WHERE [a].[Discriminator] IN (N'Cat', N'Dog')
    
  3. TPH SQL renvoyant uniquement les entités d'un seul type feuille dans la hiérarchie :

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

Toutes ces requêtes devraient bien fonctionner, notamment avec un index de base de données approprié sur la colonne discriminateur.

Requêtes TPT

Lors de l'utilisation de TPT, toutes ces requêtes nécessitent de joindre plusieurs tables, car les données d'un type concret donné sont réparties sur plusieurs tables :

  1. TPT SQL renvoyant des entités de tous types dans la hiérarchie :

    SELECT [a].[Id], [a].[FoodId], [a].[Name], [f].[Species], [f].[Value], [h].[FavoriteAnimalId], [p].[Vet], [c].[EducationLevel], [d].[FavoriteToy], CASE
        WHEN [d].[Id] IS NOT NULL THEN N'Dog'
        WHEN [c].[Id] IS NOT NULL THEN N'Cat'
        WHEN [h].[Id] IS NOT NULL THEN N'Human'
        WHEN [f].[Id] IS NOT NULL THEN N'FarmAnimal'
    END AS [Discriminator]
    FROM [Animals] AS [a]
    LEFT JOIN [FarmAnimals] AS [f] ON [a].[Id] = [f].[Id]
    LEFT JOIN [Humans] AS [h] ON [a].[Id] = [h].[Id]
    LEFT JOIN [Pets] AS [p] ON [a].[Id] = [p].[Id]
    LEFT JOIN [Cats] AS [c] ON [a].[Id] = [c].[Id]
    LEFT JOIN [Dogs] AS [d] ON [a].[Id] = [d].[Id]
    
  2. TPT SQL renvoyant des entités à partir d'un sous-ensemble de types dans la hiérarchie :

    SELECT [a].[Id], [a].[FoodId], [a].[Name], [p].[Vet], [c].[EducationLevel], [d].[FavoriteToy], CASE
        WHEN [d].[Id] IS NOT NULL THEN N'Dog'
        WHEN [c].[Id] IS NOT NULL THEN N'Cat'
    END AS [Discriminator]
    FROM [Animals] AS [a]
    INNER JOIN [Pets] AS [p] ON [a].[Id] = [p].[Id]
    LEFT JOIN [Cats] AS [c] ON [a].[Id] = [c].[Id]
    LEFT JOIN [Dogs] AS [d] ON [a].[Id] = [d].[Id]
    
  3. TPT SQL renvoyant uniquement les entités d'un seul type feuille dans la hiérarchie :

    SELECT [a].[Id], [a].[FoodId], [a].[Name], [p].[Vet], [c].[EducationLevel]
    FROM [Animals] AS [a]
    INNER JOIN [Pets] AS [p] ON [a].[Id] = [p].[Id]
    INNER JOIN [Cats] AS [c] ON [a].[Id] = [c].[Id]
    

Remarque

EF Core utilise la « synthèse discriminante » pour déterminer de quelle table proviennent les données, et donc le type correct à utiliser. Cela fonctionne car LEFT JOIN renvoie null pour la colonne ID dépendante (les « sous-tables ») qui ne sont pas du type correct. Donc pour un chien, [d].[Id] sera non nul, et tous les autres identifiants (concrets) seront nuls.

Toutes ces requêtes peuvent souffrir de problèmes de performances dus aux jointures de tables. C'est pourquoi TPT n'est jamais un bon choix pour les performances des requêtes.

Requêtes TPC

TPC s'améliore par rapport à TPT pour toutes ces requêtes car le nombre de tables à interroger est réduit. De plus, les résultats de chaque table sont combinés à l'aide de UNION ALL, ce qui peut être considérablement plus rapide qu'une jointure de table, car il n'est pas nécessaire d'effectuer de correspondance entre les lignes ni de déduplication des lignes.

  1. TPC SQL renvoyant des entités de tous types dans la hiérarchie :

    SELECT [f].[Id], [f].[FoodId], [f].[Name], [f].[Species], [f].[Value], NULL AS [FavoriteAnimalId], NULL AS [Vet], NULL AS [EducationLevel], NULL AS [FavoriteToy], N'FarmAnimal' AS [Discriminator]
    FROM [FarmAnimals] AS [f]
    UNION ALL
    SELECT [h].[Id], [h].[FoodId], [h].[Name], NULL AS [Species], NULL AS [Value], [h].[FavoriteAnimalId], NULL AS [Vet], NULL AS [EducationLevel], NULL AS [FavoriteToy], N'Human' AS [Discriminator]
    FROM [Humans] AS [h]
    UNION ALL
    SELECT [c].[Id], [c].[FoodId], [c].[Name], NULL AS [Species], NULL AS [Value], NULL AS [FavoriteAnimalId], [c].[Vet], [c].[EducationLevel], NULL AS [FavoriteToy], N'Cat' AS [Discriminator]
    FROM [Cats] AS [c]
    UNION ALL
    SELECT [d].[Id], [d].[FoodId], [d].[Name], NULL AS [Species], NULL AS [Value], NULL AS [FavoriteAnimalId], [d].[Vet], NULL AS [EducationLevel], [d].[FavoriteToy], N'Dog' AS [Discriminator]
    FROM [Dogs] AS [d]
    
  2. TPC SQL renvoyant des entités à partir d'un sous-ensemble de types dans la hiérarchie :

    SELECT [c].[Id], [c].[FoodId], [c].[Name], [c].[Vet], [c].[EducationLevel], NULL AS [FavoriteToy], N'Cat' AS [Discriminator]
    FROM [Cats] AS [c]
    UNION ALL
    SELECT [d].[Id], [d].[FoodId], [d].[Name], [d].[Vet], NULL AS [EducationLevel], [d].[FavoriteToy], N'Dog' AS [Discriminator]
    FROM [Dogs] AS [d]
    
  3. TPC SQL renvoyant uniquement les entités d'un seul type feuille dans la hiérarchie :

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

Même si TPC est meilleur que TPT pour toutes ces requêtes, les requêtes TPH sont toujours meilleures lorsqu'elles renvoient des instances de plusieurs types. C'est l'une des raisons pour lesquelles TPH est la stratégie par défaut utilisée par EF Core.

Comme le montre le SQL pour la requête n°3, TPC excelle vraiment lors de l'interrogation d'entités d'un type à feuille unique. La requête n'utilise qu'une seule table et ne nécessite aucun filtrage.

Insertions et mises à jour TPC

TPC fonctionne également bien lors de l'enregistrement d'une nouvelle entité, car cela nécessite l'insertion d'une seule ligne dans une seule table. Cela est également vrai pour TPH. Avec TPT, les lignes doivent être insérées dans de nombreux tableaux, ce qui sera moins performant.

La même chose est souvent vraie pour les mises à jour, même si dans ce cas, si toutes les colonnes mises à jour se trouvent dans la même table, même pour TPT, la différence peut ne pas être significative.

Considérations spatiales

TPT et TPC peuvent utiliser moins de stockage que TPH lorsqu'il existe de nombreux sous-types avec de nombreuses propriétés qui ne sont souvent pas utilisées. En effet, chaque ligne de la table TPH doit stocker un NULL pour chacune de ces propriétés inutilisées. En pratique, cela pose rarement un problème, mais cela peut être intéressant à considérer lors du stockage de grandes quantités de données présentant ces caractéristiques.

Conseil

Si votre système de base de données le prend en charge (par exemple SQL Server), envisagez d'utiliser des « colonnes clairsemées » pour les colonnes TPH qui seront rarement remplies.

Génération de la clé

La stratégie de mappage d'héritage choisie a des conséquences sur la façon dont les valeurs de clé primaire sont générées et gérées. Les clés dans TPH sont simples puisque chaque instance d’entité est représentée par une seule ligne dans un seul tableau. Tout type de génération de valeur clé peut être utilisé et aucune contrainte supplémentaire n'est nécessaire.

Pour la stratégie TPT, il y a toujours une ligne dans le tableau mappée au type de base de la hiérarchie. Tout type de génération de clé peut être utilisé sur cette ligne, et les clés des autres tables sont liées à cette table à l'aide de contraintes de clé étrangère.

Les choses se compliquent un peu pour TPC. Tout d’abord, il est important de comprendre qu’EF Core exige que toutes les entités d’une hiérarchie aient une valeur de clé unique, même si les entités ont des types différents. Ainsi, en utilisant notre exemple de modèle, un chien ne peut pas avoir la même valeur de clé Id qu'un chat. Deuxièmement, contrairement à TPT, il n'existe pas de table commune pouvant servir de lieu unique où les valeurs clés vivent et peuvent être générées. Cela signifie qu’une simple colonne Identity ne peut pas être utilisée.

Pour les bases de données prenant en charge les séquences, les valeurs clés peuvent être générées en utilisant une seule séquence référencée dans la contrainte par défaut de chaque table. Il s'agit de la stratégie utilisée dans les tableaux TPC présentés ci-dessus, où chaque tableau contient les éléments suivants :

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

AnimalSequence est une séquence de base de données créée par EF Core. Cette stratégie est utilisée par défaut pour les hiérarchies TPC lors de l'utilisation du fournisseur de base de données EF Core pour SQL Server. Les fournisseurs de bases de données pour d’autres bases de données prenant en charge les séquences doivent avoir une valeur par défaut similaire. D'autres stratégies de génération de clés utilisant des séquences, telles que les modèles Hi-Lo, peuvent également être utilisées avec TPC.

Bien que les colonnes d'identité standard ne fonctionnent pas avec TPC, il est possible d'utiliser des colonnes d'identité si chaque table est configurée avec une graine et un incrément appropriés de telle sorte que les valeurs générées pour chaque table ne soient jamais en conflit. Par exemple :

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

SQLite ne prend pas en charge les séquences ou les graines/incréments d'identité, et par conséquent la génération de valeurs de clé entières n'est pas prise en charge lors de l'utilisation de SQLite avec la stratégie TPC. Toutefois, la génération côté client ou les clés uniques au monde--par exemple, les clés GUID--sont prises en charge sur n'importe quelle base de données, y compris SQLite.

Contraintes de clés étrangères

La stratégie de mappage TPC crée un schéma SQL dénormalisé. C'est l'une des raisons pour lesquelles certains puristes des bases de données s'y opposent. Par exemple, considérons la colonne de clé étrangère FavoriteAnimalId. La valeur de cette colonne doit correspondre à la valeur de la clé primaire d'un animal. Cela peut être appliqué dans la base de données avec une simple contrainte FK lors de l'utilisation de TPH ou TPT. Par exemple :

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

Mais lors de l'utilisation de TPC, la clé primaire d'un animal est stockée dans la table pour le type concret de cet animal. Par exemple, la clé primaire d'un chat est stockée dans la colonne Cats.Id, tandis que la clé primaire d'un chien est stockée dans la colonne Dogs.Id, et ainsi de suite. Cela signifie qu'une contrainte FK ne peut pas être créée pour cette relation.

En pratique, cela ne pose pas de problème tant que l'application ne tente pas d'insérer des données invalides. Par exemple, si toutes les données sont insérées par EF Core et utilisent des navigations pour relier les entités, il est alors garanti que la colonne FK contiendra une valeur PK valide à tout moment.

Résumé et conseils

En résumé, TPC est une bonne stratégie de mappage à utiliser lorsque votre code recherchera principalement des entités d'un type feuille unique. En effet, les besoins de stockage sont moindres et il n'existe aucune colonne de discriminateur pouvant nécessiter un index. Les insertions et mises à jour sont également efficaces.

Cela étant dit, TPH convient généralement à la plupart des applications et constitue une bonne valeur par défaut pour un large éventail de scénarios. N'ajoutez donc pas la complexité de TPC si vous n'en avez pas besoin. Plus précisément, si votre code interrogera principalement des entités de nombreux types, comme l'écriture de requêtes sur le type de base, penchez-vous alors vers TPH plutôt que TPC.

N’utilisez le TPT que si des facteurs externes vous y obligent.

Modèles d'ingénierie inverse personnalisés

Vous pouvez désormais personnaliser le code échafaudé lors de l'ingénierie inverse d'un modèle EF à partir d'une base de données. Commencez par ajouter les modèles par défaut à votre projet :

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

Les modèles pourront ensuite être personnalisés et seront automatiquement utilisés par dotnet ef dbcontext scaffold et Scaffold-DbContext.

Pour plus de détails, consultez Modèles de rétro-ingénierie personnalisés.

Conseil

L'équipe EF a démontré et discuté en profondeur des modèles d'ingénierie inverse dans un épisode du .NET Data Community Standup. Comme pour tous les épisodes de Community Standup, vous pouvez maintenant regarder l'épisode sur les modèles T4 sur YouTube.

Conventions de création de modèles

EF Core utilise un « modèle » de métadonnées pour décrire comment les types d’entités de l’application sont mappés à la base de données sous-jacente. Ce modèle est construit à partir d'un ensemble d'une soixantaine de « conventions ». Le modèle construit par conventions peut ensuite être personnalisé à l'aide d'attributs de mappage (alias « annotations de données ») et/ou d'appels à l'DbModelBuilderAPI dans OnModelCreating.

À partir d’EF7, les applications peuvent désormais supprimer ou remplacer n’importe laquelle de ces conventions, ainsi qu’en ajouter de nouvelles. Les conventions de création de modèles constituent un moyen puissant de contrôler la configuration du modèle, mais elles peuvent être complexes et difficiles à mettre en œuvre. Dans de nombreux cas, la configuration du modèle pré-convention existante peut être utilisée à la place pour spécifier facilement une configuration commune pour les propriétés et les types.

Les modifications apportées aux conventions utilisées par un DbContext sont apportées en remplaçant la méthode DbContext.ConfigureConventions. Par exemple :

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

Conseil

Pour trouver toutes les conventions de création de modèles intégrées, recherchez chaque classe qui implémente l'interface IConvention.

Conseil

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

Supprimer une convention existante

Parfois, l’une des conventions intégrées peut ne pas convenir à votre application, auquel cas elle peut être supprimée.

Exemple : Ne créez pas d'index pour les colonnes de clé étrangère

Il est généralement judicieux de créer des index pour les colonnes de clé étrangère (FK), et il existe donc une convention intégrée pour cela : ForeignKeyIndexConvention. En regardant la vue de débogage du modèle pour un type d'entité Post avec des relations Blog et Author, nous pouvons voir que deux index sont créés : un pour le FK BlogId et l'autre pour le FK AuthorId.

  EntityType: Post
    Properties:
      Id (int) Required PK AfterSave:Throw ValueGenerated.OnAdd
      AuthorId (no field, int?) Shadow FK Index
      BlogId (no field, int) Shadow Required FK Index
    Navigations:
      Author (Author) ToPrincipal Author Inverse: Posts
      Blog (Blog) ToPrincipal Blog Inverse: Posts
    Keys:
      Id PK
    Foreign keys:
      Post {'AuthorId'} -> Author {'Id'} ToDependent: Posts ToPrincipal: Author ClientSetNull
      Post {'BlogId'} -> Blog {'Id'} ToDependent: Posts ToPrincipal: Blog Cascade
    Indexes:
      AuthorId
      BlogId

Cependant, les index ont une surcharge et, comme demandé ici, il n'est pas toujours approprié de les créer pour toutes les colonnes FK. Pour y parvenir, le ForeignKeyIndexConvention peuvent être supprimés lors de la construction du modèle :

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

En regardant la vue de débogage du modèle pour Post pour l'instant, nous voyons que les index sur les FK n'ont pas été créés :

  EntityType: Post
    Properties:
      Id (int) Required PK AfterSave:Throw ValueGenerated.OnAdd
      AuthorId (no field, int?) Shadow FK
      BlogId (no field, int) Shadow Required FK
    Navigations:
      Author (Author) ToPrincipal Author Inverse: Posts
      Blog (Blog) ToPrincipal Blog Inverse: Posts
    Keys:
      Id PK
    Foreign keys:
      Post {'AuthorId'} -> Author {'Id'} ToDependent: Posts ToPrincipal: Author ClientSetNull
      Post {'BlogId'} -> Blog {'Id'} ToDependent: Posts ToPrincipal: Blog Cascade

Lorsque vous le souhaitez, les index peuvent toujours être explicitement créés pour les colonnes de clé étrangère, soit en utilisant IndexAttribute :

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

Ou avec configuration dans OnModelCreating :

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

En regardant à nouveau le type d'entité Post, il contient désormais l'index BlogId, mais pas l'index AuthorId :

  EntityType: Post
    Properties:
      Id (int) Required PK AfterSave:Throw ValueGenerated.OnAdd
      AuthorId (no field, int?) Shadow FK
      BlogId (no field, int) Shadow Required FK Index
    Navigations:
      Author (Author) ToPrincipal Author Inverse: Posts
      Blog (Blog) ToPrincipal Blog Inverse: Posts
    Keys:
      Id PK
    Foreign keys:
      Post {'AuthorId'} -> Author {'Id'} ToDependent: Posts ToPrincipal: Author ClientSetNull
      Post {'BlogId'} -> Blog {'Id'} ToDependent: Posts ToPrincipal: Blog Cascade
    Indexes:
      BlogId

Conseil

Si votre modèle n'utilise pas d'attributs de mappage (c'est-à-dire d'annotations de données) pour la configuration, toutes les conventions se terminant par AttributeConvention peuvent être supprimées en toute sécurité pour accélérer la création du modèle.

Ajouter une nouvelle convention

Supprimer les conventions existantes est un début, mais qu'en est-il de l'ajout de conventions de création de modèles complètement nouvelles ? EF7 prend également en charge cela !

Exemple : Contraindre la longueur des propriétés du discriminateur

La stratégie de mappage d'héritage table par hiérarchie nécessite une colonne de discriminateur pour spécifier quel type est représenté dans une ligne donnée. Par défaut, EF utilise une colonne de chaîne illimitée pour le discriminateur, ce qui garantit qu'il fonctionnera pour n'importe quelle longueur de discriminateur. Cependant, limiter la longueur maximale des chaînes de discrimination peut permettre un stockage et des requêtes plus efficaces. Créons une nouvelle convention qui fera cela.

Les conventions de création de modèles EF Core sont déclenchées en fonction des modifications apportées au modèle au cours de sa construction. Cela maintient le modèle à jour à mesure que la configuration explicite est effectuée, que les attributs de mappage sont appliqués et que d'autres conventions sont exécutées. Pour y participer, chaque convention implémente une ou plusieurs interfaces qui déterminent quand la convention sera déclenchée. Par exemple, une convention implémentée IEntityTypeAddedConvention sera déclenchée chaque fois qu'un nouveau type d'entité est ajouté au modèle. De même, une convention qui implémente les deux IForeignKeyAddedConvention et IKeyAddedConvention sera déclenchée chaque fois qu'une clé ou une clé étrangère est ajoutée au modèle.

Savoir quelles interfaces implémenter peut être délicat, car la configuration effectuée sur le modèle à un moment donné peut être modifiée ou supprimée ultérieurement. Par exemple, une clé peut être créée par convention, puis remplacée ultérieurement lorsqu'une clé différente est configurée explicitement.

Rendons cela un peu plus concret en faisant une première tentative d'implémentation de la convention de longueur du discriminateur :

public class DiscriminatorLengthConvention1 : IEntityTypeBaseTypeChangedConvention
{
    public void ProcessEntityTypeBaseTypeChanged(
        IConventionEntityTypeBuilder entityTypeBuilder,
        IConventionEntityType? newBaseType,
        IConventionEntityType? oldBaseType,
        IConventionContext<IConventionEntityType> context)
    {
        var discriminatorProperty = entityTypeBuilder.Metadata.FindDiscriminatorProperty();
        if (discriminatorProperty != null
            && discriminatorProperty.ClrType == typeof(string))
        {
            discriminatorProperty.Builder.HasMaxLength(24);
        }
    }
}

Cette convention implémente IEntityTypeBaseTypeChangedConvention, ce qui signifie qu'elle sera déclenchée chaque fois que la hiérarchie d'héritage mappée pour un type d'entité est modifiée. La convention recherche et configure ensuite la propriété de discriminateur de chaîne pour la hiérarchie.

Cette convention est ensuite utilisée Add en appelant ConfigureConventions :

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

Conseil

Plutôt que d’ajouter directement une instance de la convention, la méthode Add accepte une fabrique pour créer des instances de la convention. Cela permet à la convention d’utiliser les dépendances du fournisseur de services interne EF Core. Cette convention n'ayant aucune dépendance, le paramètre du fournisseur de services est nommé _, indiquant qu'il n'est jamais utilisé.

La construction du modèle et l'examen du type d'entité Post montrent que cela a fonctionné : la propriété du discriminateur est désormais configurée avec une longueur maximale de 24 :

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

Mais que se passe-t-il si nous configurons maintenant explicitement une propriété de discriminateur différente ? Par exemple :

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

En regardant la vue débogage du modèle, nous constatons que la longueur du discriminateur n'est plus configurée !

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

En effet, la propriété discriminateur que nous avons configurée dans notre convention a ensuite été supprimée lors de l'ajout du discriminateur personnalisé. Nous pourrions tenter de résoudre ce problème en implémentant une autre interface sur notre convention pour réagir aux changements du discriminateur, mais déterminer quelle interface implémenter n'est pas facile.

Heureusement, il existe une autre façon d’aborder les choses qui rend les choses beaucoup plus faciles. La plupart du temps, l'apparence du modèle pendant sa construction n'a pas d'importance, du moment que le modèle final est correct. De plus, la configuration que l’on souhaite appliquer n’a souvent pas besoin de déclencher d’autres conventions pour réagir. Par conséquent, notre convention peut mettre en œuvre IModelFinalizingConvention. Les conventions de finalisation du modèle s'exécutent une fois que toutes les autres créations de modèles sont terminées et vous avez ainsi accès à l'état final du modèle. Une convention de finalisation de modèle parcourra généralement l'ensemble du modèle en configurant les éléments du modèle au fur et à mesure. Donc, dans ce cas, nous trouverons chaque discriminateur dans le modèle et le configurerons :

public class DiscriminatorLengthConvention2 : IModelFinalizingConvention
{
    public void ProcessModelFinalizing(IConventionModelBuilder modelBuilder, IConventionContext<IConventionModelBuilder> context)
    {
        foreach (var entityType in modelBuilder.Metadata.GetEntityTypes()
                     .Where(entityType => entityType.BaseType == null))
        {
            var discriminatorProperty = entityType.FindDiscriminatorProperty();
            if (discriminatorProperty != null
                && discriminatorProperty.ClrType == typeof(string))
            {
                discriminatorProperty.Builder.HasMaxLength(24);
            }
        }
    }
}

Après avoir construit le modèle avec cette nouvelle convention, nous constatons que la longueur du discriminateur est désormais configurée correctement même si elle a été personnalisée :

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

Juste pour le plaisir, allons plus loin et configurons la longueur maximale pour qu'elle soit la longueur de la valeur du discriminateur la plus longue.

public class DiscriminatorLengthConvention3 : IModelFinalizingConvention
{
    public void ProcessModelFinalizing(IConventionModelBuilder modelBuilder, IConventionContext<IConventionModelBuilder> context)
    {
        foreach (var entityType in modelBuilder.Metadata.GetEntityTypes()
                     .Where(entityType => entityType.BaseType == null))
        {
            var discriminatorProperty = entityType.FindDiscriminatorProperty();
            if (discriminatorProperty != null
                && discriminatorProperty.ClrType == typeof(string))
            {
                var maxDiscriminatorValueLength =
                    entityType.GetDerivedTypesInclusive().Select(e => ((string)e.GetDiscriminatorValue()!).Length).Max();

                discriminatorProperty.Builder.HasMaxLength(maxDiscriminatorValueLength);
            }
        }
    }
}

Désormais, la longueur maximale de la colonne du discriminateur est de 8, ce qui correspond à la longueur de « En vedette », la valeur de discriminateur la plus longue utilisée.

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

Conseil

Vous vous demandez peut-être si la convention devrait également créer un index pour la colonne discriminateur. Il y a une discussion à ce sujet sur GitHub. La réponse courte est que parfois un index peut être utile, mais la plupart du temps, il ne le sera probablement pas. Par conséquent, il est préférable de créer ici les index appropriés selon les besoins, plutôt que d'avoir une convention pour le faire toujours. Mais si vous n’êtes pas d’accord avec cela, la convention ci-dessus peut facilement être modifiée pour créer également un index.

Exemple : longueur par défaut pour toutes les propriétés de chaîne

Examinons un autre exemple dans lequel une convention de finalisation peut être utilisée : cette fois, en définissant une longueur maximale par défaut pourtoutepropriété de chaîne, comme demandé sur GitHub. La convention ressemble beaucoup à l’exemple précédent :

public class MaxStringLengthConvention : IModelFinalizingConvention
{
    public void ProcessModelFinalizing(IConventionModelBuilder modelBuilder, IConventionContext<IConventionModelBuilder> context)
    {
        foreach (var property in modelBuilder.Metadata.GetEntityTypes()
                     .SelectMany(
                         entityType => entityType.GetDeclaredProperties()
                             .Where(
                                 property => property.ClrType == typeof(string))))
        {
            property.Builder.HasMaxLength(512);
        }
    }
}

Cette convention est assez simple. Il trouve chaque propriété de chaîne dans le modèle et définit sa longueur maximale sur 512. En regardant dans la vue de débogage les propriétés de Post, nous voyons que toutes les propriétés de chaîne ont désormais une longueur maximale de 512.

EntityType: Post
  Properties:
    Id (int) Required PK AfterSave:Throw ValueGenerated.OnAdd
    AuthorId (no field, int?) Shadow FK Index
    BlogId (no field, int) Shadow Required FK Index
    Content (string) Required MaxLength(512)
    Discriminator (no field, string) Shadow Required AfterSave:Throw MaxLength(512)
    PublishedOn (DateTime) Required
    Title (string) Required MaxLength(512)

Mais la propriété Content devrait probablement autoriser plus de 512 caractères, sinon tous nos messages seront assez courts ! Cela peut être fait sans changer notre convention en configurant explicitement la longueur maximale uniquement pour cette propriété, soit en utilisant un attribut de mappage :

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

Ou avec le code dans OnModelCreating :

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

Désormais, toutes les propriétés ont une longueur maximale de 512, sauf Content celle qui a été explicitement configurée avec 4 000 :

EntityType: Post
  Properties:
    Id (int) Required PK AfterSave:Throw ValueGenerated.OnAdd
    AuthorId (no field, int?) Shadow FK Index
    BlogId (no field, int) Shadow Required FK Index
    Content (string) Required MaxLength(4000)
    Discriminator (no field, string) Shadow Required AfterSave:Throw MaxLength(512)
    PublishedOn (DateTime) Required
    Title (string) Required MaxLength(512)

Alors pourquoi notre convention n'a-t-elle pas remplacé la longueur maximale explicitement configurée ? La réponse est qu’EF Core garde une trace de la façon dont chaque élément de configuration a été créé. Ceci est représenté par l'énumération ConfigurationSource. Les différents types de configuration sont :

  • Explicit : L'élément de modèle a été explicitement configuré dans OnModelCreating
  • DataAnnotation : L'élément de modèle a été configuré à l'aide d'un attribut de mappage (alias annotation de données) sur le type CLR
  • Convention : L'élément de modèle a été configuré par une convention de création de modèle

Les conventions ne remplacent jamais la configuration marquée par DataAnnotation ou Explicit. Ceci est réalisé en utilisant un "constructeur de convention", par exemple le IConventionPropertyBuilder, qui est obtenu à partir de la propriété Builder. Par exemple :

property.Builder.HasMaxLength(512);

L'appel HasMaxLength au générateur de conventions ne définira la longueur maximale que si elle n'a pas déjà été configurée par un attribut de mappage ou dans OnModelCreating.

Les méthodes de construction comme celle-ci ont également un deuxième paramètre : fromDataAnnotation. Définissez cette valeur true si la convention effectue la configuration au nom d'un attribut de mappage. Par exemple :

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

Cela définit le ConfigurationSource sur DataAnnotation, ce qui signifie que la valeur peut désormais être remplacée par un mappage explicite sur OnModelCreating, mais pas par des conventions d'attributs non mappants.

Enfin, avant de quitter cet exemple, que se passe-t-il si nous utilisons à la fois le MaxStringLengthConvention et DiscriminatorLengthConvention3 en même temps ? La réponse est que cela dépend de l'ordre dans lequel ils sont ajoutés, puisque les conventions de finalisation du modèle s'exécutent dans l'ordre dans lequel elles sont ajoutées. Donc, si MaxStringLengthConvention est ajouté en dernier, il s'exécutera en dernier et définira la longueur maximale de la propriété du discriminateur sur 512. Par conséquent, dans ce cas, il est préférable d'ajouter le dernier DiscriminatorLengthConvention3 afin qu'il puisse remplacer la longueur maximale par défaut pour uniquement les propriétés du discriminateur, tout en laissant toutes les autres propriétés de chaîne à 512.

Remplacement d'une convention existante

Parfois, plutôt que de supprimer complètement une convention existante, nous souhaitons la remplacer par une convention qui fait fondamentalement la même chose, mais avec un comportement modifié. Ceci est utile car la convention existante implémentera déjà les interfaces dont elle a besoin pour être déclenchée de manière appropriée.

Exemple : Mappage des propriétés d'inscription

EF Core mappe toutes les propriétés publiques en lecture-écriture par convention. Cela n'est peut-être pas approprié à la manière dont vos types d'entités sont définis. Pour changer cela, nous pouvons remplacer le PropertyDiscoveryConvention par notre propre implémentation qui ne mappe aucune propriété à moins qu'elle ne soit explicitement mappée OnModelCreating ou marquée avec un nouvel attribut appelé Persist :

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

Voici la nouvelle convention :

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

Conseil

Lors du remplacement d'une convention intégrée, la nouvelle implémentation de convention doit hériter de la classe de convention existante. Notez que certaines conventions ont des implémentations relationnelles ou spécifiques au fournisseur, auquel cas la nouvelle implémentation de convention doit hériter de la classe de convention existante la plus spécifique pour le fournisseur de base de données utilisé.

La convention est ensuite enregistrée en utilisant la méthode Replace dans ConfigureConventions :

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

Conseil

Il s'agit d'un cas où la convention existante a des dépendances, représentées par l'objet dépendance ProviderConventionSetBuilderDependencies. Ceux-ci sont obtenus auprès du fournisseur de services interne utilisant GetRequiredService et transmis au constructeur de convention.

Cette convention fonctionne en obtenant toutes les propriétés et tous les champs lisibles du type d'entité donné. Si le membre est attribué avec [Persist], alors il est mappé en appelant :

entityTypeBuilder.Property(memberInfo);

En revanche, si le membre est une propriété qui aurait autrement été mappée, il est alors exclu du modèle à l'aide de :

entityTypeBuilder.Ignore(propertyInfo.Name);

Notez que la convention permet de mapper les champs (en plus des propriétés) tant qu'ils sont marqués avec [Persist]. Cela signifie que nous pouvons utiliser des champs privés comme clés cachées dans le modèle.

Par exemple, considérons les types d'entités suivants :

public class LaundryBasket
{
    [Persist]
    [Key]
    private readonly int _id;

    [Persist]
    public int TenantId { get; init; }

    public bool IsClean { get; set; }

    public List<Garment> Garments { get; } = new();
}

public class Garment
{
    public Garment(string name, string color)
    {
        Name = name;
        Color = color;
    }

    [Persist]
    [Key]
    private readonly int _id;

    [Persist]
    public int TenantId { get; init; }

    [Persist]
    public string Name { get; }

    [Persist]
    public string Color { get; }

    public bool IsClean { get; set; }

    public LaundryBasket? Basket { get; set; }
}

Le modèle construit à partir de ces types d'entités est :

Model:
  EntityType: Garment
    Properties:
      _id (_id, int) Required PK AfterSave:Throw ValueGenerated.OnAdd
      Basket_id (no field, int?) Shadow FK Index
      Color (string) Required
      Name (string) Required
      TenantId (int) Required
    Navigations:
      Basket (LaundryBasket) ToPrincipal LaundryBasket Inverse: Garments
    Keys:
      _id PK
    Foreign keys:
      Garment {'Basket_id'} -> LaundryBasket {'_id'} ToDependent: Garments ToPrincipal: Basket ClientSetNull
    Indexes:
      Basket_id
  EntityType: LaundryBasket
    Properties:
      _id (_id, int) Required PK AfterSave:Throw ValueGenerated.OnAdd
      TenantId (int) Required
    Navigations:
      Garments (List<Garment>) Collection ToDependent Garment Inverse: Basket
    Keys:
      _id PK

Notez que normalement, IsClean aurait été mappée, mais comme elle n'est pas marquée avec [Perist] (sans doute parce que la propreté n'est pas une propriété persistante du linge), elle est désormais traitée comme une propriété non mappée.

Conseil

Cette convention n'a pas pu être implémentée en tant que convention de finalisation de modèle car le mappage d'une propriété déclenche l'exécution de nombreuses autres conventions pour configurer davantage la propriété mappée.

Mappage de procédure stockée

Par défaut, EF Core génère des commandes d'insertion, de mise à jour et de suppression qui fonctionnent directement avec des tables ou des vues pouvant être mises à jour. EF7 introduit la prise en charge du mappage de ces commandes aux procédures stockées.

Conseil

EF Core a toujours pris en charge les requêtes via des procédures stockées. La nouvelle prise en charge dans EF7 concerne explicitement l'utilisation de procédures stockées pour les insertions, les mises à jour et les suppressions.

Important

La prise en charge du mappage de procédures stockées n’implique pas que les procédures stockées soient recommandées.

Les procédures stockées sont mappées OnModelCreating à l'aide de InsertUsingStoredProcedure, UpdateUsingStoredProcedure et DeleteUsingStoredProcedure. Par exemple, pour mapper des procédures stockées pour un type d'entité Person :

modelBuilder.Entity<Person>()
    .InsertUsingStoredProcedure(
        "People_Insert",
        storedProcedureBuilder =>
        {
            storedProcedureBuilder.HasParameter(a => a.Name);
            storedProcedureBuilder.HasResultColumn(a => a.Id);
        })
    .UpdateUsingStoredProcedure(
        "People_Update",
        storedProcedureBuilder =>
        {
            storedProcedureBuilder.HasOriginalValueParameter(person => person.Id);
            storedProcedureBuilder.HasOriginalValueParameter(person => person.Name);
            storedProcedureBuilder.HasParameter(person => person.Name);
            storedProcedureBuilder.HasRowsAffectedResultColumn();
        })
    .DeleteUsingStoredProcedure(
        "People_Delete",
        storedProcedureBuilder =>
        {
            storedProcedureBuilder.HasOriginalValueParameter(person => person.Id);
            storedProcedureBuilder.HasOriginalValueParameter(person => person.Name);
            storedProcedureBuilder.HasRowsAffectedResultColumn();
        });

Cette configuration correspond aux procédures stockées suivantes lors de l'utilisation de SQL Server :

Pour les inserts

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

Pour les mises à jour

CREATE PROCEDURE [dbo].[People_Update]
    @Id [int],
    @Name_Original [nvarchar](max),
    @Name [nvarchar](max)
AS
BEGIN
    UPDATE [People] SET [Name] = @Name
    WHERE [Id] = @Id AND [Name] = @Name_Original
    SELECT @@ROWCOUNT
END

Pour les suppressions

CREATE PROCEDURE [dbo].[People_Delete]
    @Id [int],
    @Name_Original [nvarchar](max)
AS
BEGIN
    DELETE FROM [People]
    OUTPUT 1
    WHERE [Id] = @Id AND [Name] = @Name_Original;
END

Conseil

Il n'est pas nécessaire d'utiliser les procédures stockées pour chaque type du modèle, ni pour toutes les opérations sur un type donné. Par exemple, si DeleteUsingStoredProcedure uniquement est spécifié pour un type donné, EF Core générera du SQL normalement pour les opérations d’insertion et de mise à jour et utilisera uniquement la procédure stockée pour les suppressions.

Le premier argument transmis à chaque méthode est le nom de la procédure stockée. Cela peut être omis, auquel cas EF Core utilisera le nom de la table suivi de « _Insert », « _Update » ou « _Delete ». Ainsi, dans l'exemple ci-dessus, puisque la table s'appelle « Personnes », les noms de procédures stockées peuvent être supprimés sans modification des fonctionnalités.

Le deuxième argument est un générateur utilisé pour configurer l'entrée et la sortie de la procédure stockée, y compris les paramètres, les valeurs de retour et les colonnes de résultats.

Paramètres

Les paramètres doivent être ajoutés au générateur dans le même ordre dans lequel ils apparaissent dans la définition de la procédure stockée.

Remarque

Les paramètres peuvent être nommés, mais EF Core appelle toujours les procédures stockées à l’aide d’arguments positionnels plutôt que d’arguments nommés. Votez pour Autoriser la configuration du mappage de sproc afin d'utiliser les noms de paramètres pour l'invocation si l'appel par nom vous intéresse.

Le premier argument de chaque méthode de création de paramètres spécifie la propriété du modèle à laquelle le paramètre est lié. Cela peut être une expression lambda :

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

Ou une chaîne, particulièrement utile lors du mappage des propriétés de l'ombre :

storedProcedureBuilder.HasParameter("Name");

Les paramètres sont, par défaut, configurés pour « entrée ». Les paramètres « Sortie » ou « Entrée/sortie » peuvent être configurés à l'aide d'un constructeur imbriqué. Par exemple :

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

Il existe trois méthodes de création différentes pour différents types de paramètres :

  • HasParameter spécifie un paramètre normal lié à la valeur actuelle de la propriété donnée.
  • HasOriginalValueParameter spécifie un paramètre lié à la valeur d'origine de la propriété donnée. La valeur d'origine est la valeur qu'avait la propriété lorsqu'elle a été interrogée dans la base de données, si elle est connue. Si cette valeur n'est pas connue, la valeur actuelle est utilisée à la place. Les paramètres de valeur d'origine sont utiles pour les jetons de concurrence.
  • HasRowsAffectedParameter spécifie un paramètre utilisé pour renvoyer le nombre de lignes affectées par la procédure stockée.

Conseil

Les paramètres de valeur d'origine doivent être utilisés pour les valeurs clés dans les procédures stockées « mettre à jour » et « supprimer ». Cela garantit que la ligne correcte sera mise à jour dans les futures versions d’EF Core qui prennent en charge les valeurs de clé mutables.

Retourner des valeurs

EF Core prend en charge trois mécanismes pour renvoyer les valeurs des procédures stockées :

  • Paramètres de sortie, comme indiqué ci-dessus.
  • Colonnes de résultats, spécifiées à l'aide de la méthode builder HasResultColumn.
  • La valeur de retour, qui se limite au nombre de lignes affectées, et est spécifiée à l'aide de la méthode builder HasRowsAffectedReturnValue.

Les valeurs renvoyées par les procédures stockées sont souvent utilisées pour les valeurs générées, par défaut ou calculées, par exemple à partir d'une clé Identity ou d'une colonne calculée. Par exemple, la configuration suivante spécifie quatre colonnes de résultats :

entityTypeBuilder.InsertUsingStoredProcedure(
        storedProcedureBuilder =>
        {
            storedProcedureBuilder.HasParameter(document => document.Title);
            storedProcedureBuilder.HasResultColumn(document => document.Id);
            storedProcedureBuilder.HasResultColumn(document => document.FirstRecordedOn);
            storedProcedureBuilder.HasResultColumn(document => document.RetrievedOn);
            storedProcedureBuilder.HasResultColumn(document => document.RowVersion);
        });

Ceux-ci sont utilisés pour renvoyer :

  • Valeur de clé générée pour la propriété Id.
  • La valeur par défaut générée par la base de données pour la propriété FirstRecordedOn.
  • La valeur calculée générée par la base de données pour la propriété RetrievedOn.
  • Jeton de concurrence rowversion généré automatiquement pour la propriété RowVersion.

Cette configuration correspond à la procédure stockée suivante lors de l'utilisation de SQL Server :

CREATE PROCEDURE [dbo].[Documents_Insert]
    @Title [nvarchar](max)
AS
BEGIN
    INSERT INTO [Documents] ([Title])
    OUTPUT INSERTED.[Id], INSERTED.[FirstRecordedOn], INSERTED.[RetrievedOn], INSERTED.[RowVersion]
    VALUES (@Title);
END

Accès concurrentiel optimiste

La concurrence optimiste fonctionne de la même manière avec les procédures stockées et sans. La procédure stockée doit :

  • Utilisez un jeton de concurrence dans une clause WHERE pour garantir que la ligne n'est mise à jour que si elle possède un jeton valide. La valeur utilisée pour le jeton de concurrence est généralement, mais ne doit pas nécessairement être, la valeur d'origine de la propriété du jeton de concurrence.
  • Renvoie le nombre de lignes affectées afin que EF Core puisse le comparer au nombre attendu de lignes affectées et lancer un DbUpdateConcurrencyException si les valeurs ne correspondent pas.

Par exemple, la procédure stockée SQL Server suivante utilise un jeton de concurrence automatique rowversion :

CREATE PROCEDURE [dbo].[Documents_Update]
    @Id [int],
    @RowVersion_Original [rowversion],
    @Title [nvarchar](max),
    @RowVersion [rowversion] OUT
AS
BEGIN
    DECLARE @TempTable table ([RowVersion] varbinary(8));
    UPDATE [Documents] SET
        [Title] = @Title
    OUTPUT INSERTED.[RowVersion] INTO @TempTable
    WHERE [Id] = @Id AND [RowVersion] = @RowVersion_Original
    SELECT @@ROWCOUNT;
    SELECT @RowVersion = [RowVersion] FROM @TempTable;
END

Ceci est configuré dans EF Core à l’aide de :

.UpdateUsingStoredProcedure(
    storedProcedureBuilder =>
    {
        storedProcedureBuilder.HasOriginalValueParameter(document => document.Id);
        storedProcedureBuilder.HasOriginalValueParameter(document => document.RowVersion);
        storedProcedureBuilder.HasParameter(document => document.Title);
        storedProcedureBuilder.HasParameter(document => document.RowVersion, parameterBuilder => parameterBuilder.IsOutput());
        storedProcedureBuilder.HasRowsAffectedResultColumn();
    });

Notez que :

  • La valeur originale du jeton de concurrence RowVersion est utilisée.
  • La procédure stockée utilise une clause WHERE pour garantir que la ligne n'est mise à jour que si la valeur d'origine RowVersion correspond.
  • La nouvelle valeur générée pour le RowVersion est insérée dans une table temporaire.
  • Le nombre de lignes affectées (@@ROWCOUNT) et la valeur RowVersion générée sont renvoyés.

Mappage des hiérarchies d'héritage aux procédures stockées

EF Core exige que les procédures stockées suivent la disposition des tables pour les types dans une hiérarchie. Ce qui signifie que :

  • Une hiérarchie mappée à l’aide de TPH doit avoir une seule procédure stockée d’insertion, de mise à jour et/ou de suppression ciblant la table mappée unique. Les procédures stockées d'insertion et de mise à jour doivent avoir un paramètre pour la valeur du discriminateur.
  • Une hiérarchie mappée à l’aide de TPT doit avoir une procédure stockée d’insertion, de mise à jour et/ou de suppression pour chaque type, y compris les types abstraits. EF Core effectuera plusieurs appels si nécessaire pour mettre à jour, insérer et supprimer des lignes dans toutes les tables.
  • Une hiérarchie mappée à l'aide de TPC doit avoir une procédure stockée d'insertion, de mise à jour et/ou de suppression pour chaque type concret, mais pas pour les types abstraits.

Remarque

Si l'utilisation d'une seule procédure stockée par type concret, quelle que soit la stratégie de mappage, vous intéresse, alors votez pour la prise en charge de l'utilisation d'une seule procédure par type concret, quelle que soit la stratégie de mappage d'héritage.

Mappage des types détenus avec des procédures stockées

La configuration des procédures stockées pour les types détenus est effectuée dans le générateur de types détenus imbriqués. Par exemple :

modelBuilder.Entity<Person>(
    entityTypeBuilder =>
    {
        entityTypeBuilder.OwnsOne(
            author => author.Contact,
            ownedNavigationBuilder =>
            {
                ownedNavigationBuilder.ToTable("Contacts");
                ownedNavigationBuilder
                    .InsertUsingStoredProcedure(
                        storedProcedureBuilder =>
                        {
                            storedProcedureBuilder.HasParameter("PersonId");
                            storedProcedureBuilder.HasParameter(contactDetails => contactDetails.Phone);
                        })
                    .UpdateUsingStoredProcedure(
                        storedProcedureBuilder =>
                        {
                            storedProcedureBuilder.HasOriginalValueParameter("PersonId");
                            storedProcedureBuilder.HasParameter(contactDetails => contactDetails.Phone);
                            storedProcedureBuilder.HasRowsAffectedResultColumn();
                        })
                    .DeleteUsingStoredProcedure(
                        storedProcedureBuilder =>
                        {
                            storedProcedureBuilder.HasOriginalValueParameter("PersonId");
                            storedProcedureBuilder.HasRowsAffectedResultColumn();
            });
    });

Remarque

Les procédures actuellement stockées pour l'insertion, la mise à jour et la suppression uniquement prennent en charge les types détenus doivent être mappées sur des tables distinctes. Autrement dit, le type détenu ne peut pas être représenté par des colonnes dans la table propriétaire. Votez pour l'ajout de la prise en charge du fractionnement de « table » au mappage de sproc CUD si s'agit d'une limitation que vous souhaiteriez voir supprimée.

Mappage d'entités de jointure plusieurs-à-plusieurs à des procédures stockées

La configuration des procédures stockées plusieurs-à-plusieurs entités de jointure peut être effectuée dans le cadre de la configuration plusieurs-à-plusieurs. Par exemple :

modelBuilder.Entity<Book>(
    entityTypeBuilder =>
    {
        entityTypeBuilder
            .HasMany(document => document.Authors)
            .WithMany(author => author.PublishedWorks)
            .UsingEntity<Dictionary<string, object>>(
                "BookPerson",
                builder => builder.HasOne<Person>().WithMany().OnDelete(DeleteBehavior.Cascade),
                builder => builder.HasOne<Book>().WithMany().OnDelete(DeleteBehavior.ClientCascade),
                joinTypeBuilder =>
                {
                    joinTypeBuilder
                        .InsertUsingStoredProcedure(
                            storedProcedureBuilder =>
                            {
                                storedProcedureBuilder.HasParameter("AuthorsId");
                                storedProcedureBuilder.HasParameter("PublishedWorksId");
                            })
                        .DeleteUsingStoredProcedure(
                            storedProcedureBuilder =>
                            {
                                storedProcedureBuilder.HasOriginalValueParameter("AuthorsId");
                                storedProcedureBuilder.HasOriginalValueParameter("PublishedWorksId");
                                storedProcedureBuilder.HasRowsAffectedResultColumn();
                            });
                });
    });

Intercepteurs et événements nouveaux et améliorés

Les intercepteurs EF Core permettent l’interception, la modification et/ou la suppression des opérations EF Core. EF Core inclut également les événements et la journalisation .NET traditionnels.

EF7 inclut les améliorations suivantes pour les intercepteurs :

De plus, EF7 inclut de nouveaux événements .NET traditionnels pour :

Les sections suivantes montrent quelques exemples d'utilisation de ces nouvelles capacités d'interception.

Actions simples sur la création d'entité

Conseil

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

Le nouveau IMaterializationInterceptor prend en charge l'interception avant et après la création d'une instance d'entité, et avant et après l'initialisation des propriétés de cette instance. L'intercepteur peut modifier ou remplacer l'instance d'entité à chaque point. Cela permet :

  • Définition de propriétés non mappées ou d'appel de méthodes nécessaires à la validation, aux valeurs calculées ou aux indicateurs.
  • Utiliser une usine pour créer des instances.
  • Création d'une instance d'entité différente de celle qu'EF créerait normalement, comme une instance à partir d'un cache ou d'un type proxy.
  • Injection de services dans une instance d'entité.

Par exemple, imaginez que nous souhaitions garder une trace de l'heure à laquelle une entité a été récupérée de la base de données, peut-être afin qu'elle puisse être affichée à un utilisateur modifiant les données. Pour ce faire, nous définissons d'abord une interface :

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

L'utilisation d'une interface est courante avec les intercepteurs car elle permet au même intercepteur de fonctionner avec de nombreux types d'entités différents. Par exemple :

public class Customer : IHasRetrieved
{
    public int Id { get; set; }
    public string Name { get; set; } = null!;
    public string? PhoneNumber { get; set; }

    [NotMapped]
    public DateTime Retrieved { get; set; }
}

Notez que l'attribut [NotMapped] est utilisé pour indiquer que cette propriété est utilisée uniquement lors de l'utilisation de l'entité et ne doit pas être conservée dans la base de données.

L'intercepteur doit alors implémenter la méthode appropriée à partir de IMaterializationInterceptor et définir l'heure récupérée :

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

        return instance;
    }
}

Une instance de cet intercepteur est enregistrée lors de la configuration du DbContext :

public class CustomerContext : DbContext
{
    private static readonly SetRetrievedInterceptor _setRetrievedInterceptor = new();

    public DbSet<Customer> Customers
        => Set<Customer>();

    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
        => optionsBuilder
            .AddInterceptors(_setRetrievedInterceptor)
            .UseSqlite("Data Source = customers.db");
}

Conseil

Cet intercepteur est sans état, ce qui est courant, donc une seule instance est créée et partagée entre toutes les instances DbContext.

Désormais, chaque fois que un Customer est interrogé à partir de la base de données, la propriété Retrieved sera définie automatiquement. Par exemple :

await using (var context = new CustomerContext())
{
    var customer = await context.Customers.SingleAsync(e => e.Name == "Alice");
    Console.WriteLine($"Customer '{customer.Name}' was retrieved at '{customer.Retrieved.ToLocalTime()}'");
}

Vous produisez un résultat :

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

Injection de services dans les entités

Conseil

Le code présenté ici provient d'InjectLoggerSample.cs.

EF Core dispose déjà d'une prise en charge intégrée pour l'injection de certains services spéciaux dans les instances de contexte ; par exemple, voir Chargement paresseux sans proxys, qui fonctionne en injectant le service ILazyLoader.

Une IMaterializationInterceptor peut être utilisé pour généraliser cela à n’importe quel service. L'exemple suivant montre comment injecter un ILogger dans des entités afin qu'elles puissent effectuer leur propre journalisation.

Remarque

L'injection de services dans des entités couple ces types d'entités aux services injectés, ce que certaines personnes considèrent comme un anti-modèle.

Comme auparavant, une interface permet de définir ce qui peut être fait.

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

Et les types d'entités qui seront enregistrées doivent implémenter cette interface. Par exemple :

public class Customer : IHasLogger
{
    private string? _phoneNumber;

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

    public string? PhoneNumber
    {
        get => _phoneNumber;
        set
        {
            Logger?.LogInformation(1, $"Updating phone number for '{Name}' from '{_phoneNumber}' to '{value}'.");

            _phoneNumber = value;
        }
    }

    [NotMapped]
    public ILogger? Logger { get; set; }
}

Cette fois, l'intercepteur doit implémenter IMaterializationInterceptor.InitializedInstance, qui est appelé après la création de chaque instance d'entité et l'initialisation de ses valeurs de propriété. L'intercepteur obtient un ILogger du contexte et s'initialise IHasLogger.Logger avec :

public class LoggerInjectionInterceptor : IMaterializationInterceptor
{
    private ILogger? _logger;

    public object InitializedInstance(MaterializationInterceptionData materializationData, object instance)
    {
        if (instance is IHasLogger hasLogger)
        {
            _logger ??= materializationData.Context.GetService<ILoggerFactory>().CreateLogger("CustomersLogger");
            hasLogger.Logger = _logger;
        }

        return instance;
    }
}

Cette fois, une nouvelle instance de l'intercepteur est utilisée pour chaque instance DbContext, puisque celle ILogger obtenue peut changer par instance DbContext, et ILogger est mise en cache sur l'intercepteur :

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

Désormais, chaque fois que le Customer.PhoneNumber est modifié, cette modification sera enregistrée dans le journal de l'application. Par exemple :

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

Interception de l'arbre d'expression LINQ

Conseil

Le code affiché ici provient de QueryInterceptionSample.cs .

EF Core utilise les requêtes .NET LINQ. Cela implique généralement l’utilisation du compilateur C#, VB ou F# pour créer une arborescence d’expression qui est ensuite traduite par EF Core dans le SQL approprié. Par exemple, considérons une méthode qui renvoie une page de clients :

Task<List<Customer>> GetPageOfCustomers(string sortProperty, int page)
{
    using var context = new CustomerContext();

    return context.Customers
        .OrderBy(e => EF.Property<object>(e, sortProperty))
        .Skip(page * 20).Take(20).ToListAsync();
}

Conseil

Cette requête utilise la méthode EF.Property pour spécifier la propriété sur laquelle trier. Cela permet à l'application de transmettre dynamiquement le nom de la propriété, permettant ainsi le tri selon n'importe quelle propriété du type d'entité. Sachez que le tri par colonnes non indexées peut être lent.

Cela fonctionnera bien tant que la propriété utilisée pour le tri renvoie toujours un ordre stable. Mais ce n’est peut-être pas toujours le cas. Par exemple, la requête LINQ ci-dessus génère ce qui suit sur SQLite lors du tri par Customer.City :

SELECT "c"."Id", "c"."City", "c"."Name", "c"."PhoneNumber"
FROM "Customers" AS "c"
ORDER BY "c"."City"
LIMIT @__p_1 OFFSET @__p_0

S'il existe plusieurs clients avec le même City, l'ordre de cette requête n'est pas stable. Cela pourrait entraîner des résultats manquants ou en double lorsque l'utilisateur parcourt les données.

Un moyen courant de résoudre ce problème consiste à effectuer un tri secondaire par clé primaire. Cependant, plutôt que de l'ajouter manuellement à chaque requête, EF7 permet l'interception de l'arborescence des expressions de requête où l'ordre secondaire peut être ajouté dynamiquement. Pour faciliter cela, nous utiliserons à nouveau une interface, cette fois pour toute entité possédant une clé primaire entière :

public interface IHasIntKey
{
    int Id { get; }
}

Cette interface est implémentée par les types d'entités qui vous intéressent :

public class Customer : IHasIntKey
{
    public int Id { get; set; }
    public string Name { get; set; } = null!;
    public string? City { get; set; }
    public string? PhoneNumber { get; set; }
}

Nous avons alors besoin d'un intercepteur qui implémente IQueryExpressionInterceptor

public class KeyOrderingExpressionInterceptor : IQueryExpressionInterceptor
{
    public Expression QueryCompilationStarting(Expression queryExpression, QueryExpressionEventData eventData)
        => new KeyOrderingExpressionVisitor().Visit(queryExpression);

    private class KeyOrderingExpressionVisitor : ExpressionVisitor
    {
        private static readonly MethodInfo ThenByMethod
            = typeof(Queryable).GetMethods()
                .Single(m => m.Name == nameof(Queryable.ThenBy) && m.GetParameters().Length == 2);

        protected override Expression VisitMethodCall(MethodCallExpression? methodCallExpression)
        {
            var methodInfo = methodCallExpression!.Method;
            if (methodInfo.DeclaringType == typeof(Queryable)
                && methodInfo.Name == nameof(Queryable.OrderBy)
                && methodInfo.GetParameters().Length == 2)
            {
                var sourceType = methodCallExpression.Type.GetGenericArguments()[0];
                if (typeof(IHasIntKey).IsAssignableFrom(sourceType))
                {
                    var lambdaExpression = (LambdaExpression)((UnaryExpression)methodCallExpression.Arguments[1]).Operand;
                    var entityParameterExpression = lambdaExpression.Parameters[0];

                    return Expression.Call(
                        ThenByMethod.MakeGenericMethod(
                            sourceType,
                            typeof(int)),
                        base.VisitMethodCall(methodCallExpression),
                        Expression.Lambda(
                            typeof(Func<,>).MakeGenericType(entityParameterExpression.Type, typeof(int)),
                            Expression.Property(entityParameterExpression, nameof(IHasIntKey.Id)),
                            entityParameterExpression));
                }
            }

            return base.VisitMethodCall(methodCallExpression);
        }
    }
}

Cela semble probablement assez compliqué – et ça l’est ! Travailler avec des arbres d’expression n’est généralement pas facile. Regardons ce qui se passe :

  • Fondamentalement, l'intercepteur encapsule un fichier ExpressionVisitor. Le visiteur remplace VisitMethodCall, qui sera appelé chaque fois qu'il y aura un appel à une méthode dans l'arborescence des expressions de requête.

  • Le visiteur vérifie s'il s'agit ou non d'un appel à la méthode OrderBy qui nous intéresse.

  • Si tel est le cas, le visiteur vérifie en outre si l'appel de méthode générique concerne un type qui implémente notre interface IHasIntKey.

  • À ce stade, nous savons que l’appel de méthode est de la forme OrderBy(e => ...). Nous extrayons l'expression lambda de cet appel et obtenons le paramètre utilisé dans cette expression, c'est-à-dire le e.

  • Nous en construisons maintenant un nouveau MethodCallExpression en utilisant la méthode builder Expression.Call. Dans ce cas, la méthode appelée est ThenBy(e => e.Id). Nous construisons cela en utilisant le paramètre extrait ci-dessus et un accès de propriété à la propriété Id de l'interface IHasIntKey.

  • L'entrée dans cet appel est l'original OrderBy(e => ...) et le résultat final est donc une expression pour OrderBy(e => ...).ThenBy(e => e.Id).

  • Cette expression modifiée est renvoyée par le visiteur, ce qui signifie que la requête LINQ a maintenant été modifiée de manière appropriée pour inclure un appel ThenBy.

  • EF Core continue et compile cette expression de requête dans le SQL approprié pour la base de données utilisée.

Cet intercepteur est enregistré de la même manière que nous l'avons fait pour le premier exemple. L'exécution GetPageOfCustomers génère maintenant le SQL suivant :

SELECT "c"."Id", "c"."City", "c"."Name", "c"."PhoneNumber"
FROM "Customers" AS "c"
ORDER BY "c"."City", "c"."Id"
LIMIT @__p_1 OFFSET @__p_0

Cela produira désormais toujours une commande stable, même s'il y a plusieurs clients avec le même City.

Ouf ! Cela représente beaucoup de code pour apporter une simple modification à une requête. Et pire encore, cela pourrait même ne pas fonctionner pour toutes les requêtes. Il est notoirement difficile d'écrire une expression visiteur qui reconnaît toutes les formes de requête qu'elle devrait, et aucune de celles qu'elle ne devrait pas. Par exemple, cela ne fonctionnera probablement pas si le classement est effectué dans une sous-requête.

Cela nous amène à un point critique concernant les intercepteurs--demandez-vous toujours s'il existe un moyen plus simple de faire ce que vous voulez. Les intercepteurs sont puissants, mais il est facile de se tromper. C’est, comme le dit le proverbe, un moyen facile de se tirer une balle dans le pied.

Par exemple, imaginez si nous modifiions notre méthode GetPageOfCustomers comme ceci :

Task<List<Customer>> GetPageOfCustomers2(string sortProperty, int page)
{
    using var context = new CustomerContext();

    return context.Customers
        .OrderBy(e => EF.Property<object>(e, sortProperty))
        .ThenBy(e => e.Id)
        .Skip(page * 20).Take(20).ToListAsync();
}

Dans ce cas, le ThenBy est simplement ajouté à la requête. Oui, cela devra peut-être être fait séparément pour chaque requête, mais c'est simple, facile à comprendre et cela fonctionnera toujours.

Interception de concurrence optimiste

Conseil

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

EF Core prend en charge le modèle de concurrence optimiste en vérifiant que le nombre de lignes réellement affectées par une mise à jour ou une suppression est le même que le nombre de lignes censées être affectées. Ceci est souvent associé à un jeton de concurrence ; c'est-à-dire une valeur de colonne qui ne correspondra à sa valeur attendue que si la ligne n'a pas été mise à jour depuis la lecture de la valeur attendue.

EF signale une violation de la concurrence optimiste en lançant un DbUpdateConcurrencyException. Dans EF7, ISaveChangesInterceptor vous disposez de nouvelles méthodes ThrowingConcurrencyException et ThrowingConcurrencyExceptionAsync qui sont appelées avant que celle-ci DbUpdateConcurrencyException ne soit lancée. Ces points d'interception permettent de supprimer l'exception, éventuellement couplée à des modifications asynchrones de la base de données pour résoudre la violation.

Par exemple, si deux requêtes tentent de supprimer la même entité presque en même temps, la deuxième suppression peut échouer car la ligne de la base de données n'existe plus. Cela peut être bien : le résultat final est que l'entité a de toute façon été supprimée. L'intercepteur suivant montre comment cela peut être réalisé :

public class SuppressDeleteConcurrencyInterceptor : ISaveChangesInterceptor
{
    public InterceptionResult ThrowingConcurrencyException(
        ConcurrencyExceptionEventData eventData,
        InterceptionResult result)
    {
        if (eventData.Entries.All(e => e.State == EntityState.Deleted))
        {
            Console.WriteLine("Suppressing Concurrency violation for command:");
            Console.WriteLine(((RelationalConcurrencyExceptionEventData)eventData).Command.CommandText);

            return InterceptionResult.Suppress();
        }

        return result;
    }

    public ValueTask<InterceptionResult> ThrowingConcurrencyExceptionAsync(
        ConcurrencyExceptionEventData eventData,
        InterceptionResult result,
        CancellationToken cancellationToken = default)
        => new(ThrowingConcurrencyException(eventData, result));
}

Il y a plusieurs choses à noter à propos de cet intercepteur :

  • Les méthodes d'interception synchrone et asynchrone sont implémentées. Ceci est important si l'application peut appeler soit SaveChanges ou SaveChangesAsync. Cependant, si tout le code de l’application est asynchrone, ThrowingConcurrencyExceptionAsync suffit alors de l’implémenter. De même, si l'application n'utilise jamais de méthodes de base de données asynchrones, ThrowingConcurrencyException suffit alors de l'implémenter. Cela est généralement vrai pour tous les intercepteurs dotés de méthodes sync et async. (Cela vaut peut-être la peine d'implémenter la méthode que votre application n'utilise pas pour lancer, juste au cas où du code de synchronisation/async s'infiltrerait.)
  • L'intercepteur a accès aux objets EntityEntry des entités en cours de sauvegarde. Dans ce cas, ceci est utilisé pour vérifier si la violation de concurrence se produit ou non pour une opération de suppression.
  • Si l'application utilise un fournisseur de base de données relationnelle, l'objet ConcurrencyExceptionEventData peut être converti en objet RelationalConcurrencyExceptionEventData. Cela fournit des informations relationnelles supplémentaires sur l'opération de base de données en cours d'exécution. Dans ce cas, le texte de la commande relationnelle est imprimé sur la console.
  • Le retour InterceptionResult.Suppress() indique à EF Core de supprimer l'action qu'il était sur le point d'entreprendre – dans ce cas, en lançant le DbUpdateConcurrencyException. Cette capacité à modifier le comportement d’EF Core, plutôt que de simplement observer ce que fait EF Core, est l’une des fonctionnalités les plus puissantes des intercepteurs.

Initialisation paresseuse d'une chaîne de connexion

Conseil

Le code affiché ici provient de LazyConnectionStringSample.cs.

Les chaînes de connexion sont souvent des actifs statiques lus à partir d’un fichier de configuration. Ceux-ci peuvent facilement être transmis à UseSqlServer ou similaire lors de la configuration d'un fichier DbContext. Cependant, la chaîne de connexion peut parfois changer pour chaque instance de contexte. Par exemple, chaque locataire d'un système multi-tenant peut avoir une chaîne de connexion différente.

EF7 facilite la gestion des connexions dynamiques et des chaînes de connexion grâce à des améliorations apportées au IDbConnectionInterceptor. Cela commence par la possibilité de configurer le DbContext sans aucune chaîne de connexion. Par exemple :

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

L'une des méthodes IDbConnectionInterceptor peut alors être mise en œuvre pour configurer la connexion avant son utilisation. ConnectionOpeningAsync est un bon choix, car il peut effectuer une opération asynchrone pour obtenir la chaîne de connexion, trouver un jeton d'accès, etc. Par exemple, imaginez un service étendu à la requête actuelle qui comprend le locataire actuel :

services.AddScoped<ITenantConnectionStringFactory, TestTenantConnectionStringFactory>();

Avertissement

Effectuer une recherche asynchrone d'une chaîne de connexion, d'un jeton d'accès ou similaire à chaque fois que cela est nécessaire peut être très lent. Pensez à mettre ces éléments en cache et à actualiser uniquement périodiquement la chaîne ou le jeton mis en cache. Par exemple, les jetons d’accès peuvent souvent être utilisés pendant une période de temps significative avant de devoir être actualisés.

Cela peut être injecté dans chaque instance DbContext à l'aide de l'injection de constructeur :

public class CustomerContext : DbContext
{
    private readonly ITenantConnectionStringFactory _connectionStringFactory;

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

    // ...
}

Ce service est ensuite utilisé lors de la construction de l'implémentation de l'intercepteur pour le contexte :

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

Enfin, l'intercepteur utilise ce service pour obtenir la chaîne de connexion de manière asynchrone et la définir lors de la première utilisation de la connexion :

public class ConnectionStringInitializationInterceptor : DbConnectionInterceptor
{
    private readonly IClientConnectionStringFactory _connectionStringFactory;

    public ConnectionStringInitializationInterceptor(IClientConnectionStringFactory connectionStringFactory)
    {
        _connectionStringFactory = connectionStringFactory;
    }

    public override InterceptionResult ConnectionOpening(
        DbConnection connection,
        ConnectionEventData eventData,
        InterceptionResult result)
        => throw new NotSupportedException("Synchronous connections not supported.");

    public override async ValueTask<InterceptionResult> ConnectionOpeningAsync(
        DbConnection connection, ConnectionEventData eventData, InterceptionResult result,
        CancellationToken cancellationToken = new())
    {
        if (string.IsNullOrEmpty(connection.ConnectionString))
        {
            connection.ConnectionString = (await _connectionStringFactory.GetConnectionStringAsync(cancellationToken));
        }

        return result;
    }
}

Remarque

La chaîne de connexion n'est obtenue que la première fois qu'une connexion est utilisée. Après cela, la chaîne de connexion stockée sur DbConnection sera utilisée sans rechercher une nouvelle chaîne de connexion.

Conseil

Cet intercepteur remplace la méthode ConnectionOpening non asynchrone à lancer puisque le service permettant d'obtenir la chaîne de connexion doit être appelé à partir d'un chemin de code asynchrone.

Journalisation des statistiques de requêtes SQL Server

Conseil

Le code affiché ici provient de QueryStatisticsLoggerSample.cs.

Enfin, créons deux intercepteurs qui fonctionnent ensemble pour envoyer les statistiques des requêtes SQL Server au journal des applications. Pour générer les statistiques, nous avons besoin d’un IDbCommandInterceptor pour faire deux choses.

Tout d’abord, l’intercepteur préfixera les commandes avec SET STATISTICS IO ON, ce qui indique à SQL Server d’envoyer des statistiques au client une fois qu’un jeu de résultats a été consommé :

public override ValueTask<InterceptionResult<DbDataReader>> ReaderExecutingAsync(
    DbCommand command,
    CommandEventData eventData,
    InterceptionResult<DbDataReader> result,
    CancellationToken cancellationToken = default)
{
    command.CommandText = "SET STATISTICS IO ON;" + Environment.NewLine + command.CommandText;

    return new(result);
}

Deuxièmement, l'intercepteur implémentera la nouvelle méthode DataReaderClosingAsync, qui est appelée après le DbDataReader a fini de consommer les résultats, mais avant sa fermeture. Lorsque SQL Server envoie des statistiques, il les place dans un deuxième résultat sur le lecteur. Ainsi, à ce stade, l'intercepteur lit ce résultat en appelant NextResultAsync, ce qui remplit les statistiques sur la connexion.

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

    return result;
}

Le deuxième intercepteur est nécessaire pour obtenir les statistiques de la connexion et les écrire dans l'enregistreur de l'application. Pour cela, nous utiliserons un IDbConnectionInterceptor, implémentant la nouvelle méthode ConnectionCreated. ConnectionCreated est appelé immédiatement après qu’EF Core a créé une connexion et peut donc être utilisé pour effectuer une configuration supplémentaire de cette connexion. Dans ce cas, l'intercepteur obtient un ILogger puis se connecte à l'événement SqlConnection.InfoMessage pour enregistrer les messages.

public override DbConnection ConnectionCreated(ConnectionCreatedEventData eventData, DbConnection result)
{
    var logger = eventData.Context!.GetService<ILoggerFactory>().CreateLogger("InfoMessageLogger");
    ((SqlConnection)eventData.Connection).InfoMessage += (_, args) =>
    {
        logger.LogInformation(1, args.Message);
    };
    return result;
}

Important

Les méthodes ConnectionCreating et ConnectionCreated ne sont appelées que lorsque EF Core crée un fichier DbConnection. Ils ne seront pas appelés si l’application les crée DbConnection et les transmet à EF Core.

L'exécution de code utilisant ces intercepteurs affiche les statistiques de requête SQL Server dans le journal :

info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (4ms) [Parameters=[@p0='?' (Size = 4000), @p1='?' (Size = 4000), @p2='?' (Size = 4000), @p3='?' (Size = 4000)], CommandType='Text', CommandTimeout='30']
      SET STATISTICS IO ON;
      SET IMPLICIT_TRANSACTIONS OFF;
      SET NOCOUNT ON;
      MERGE [Customers] USING (
      VALUES (@p0, @p1, 0),
      (@p2, @p3, 1)) AS i ([Name], [PhoneNumber], _Position) ON 1=0
      WHEN NOT MATCHED THEN
      INSERT ([Name], [PhoneNumber])
      VALUES (i.[Name], i.[PhoneNumber])
      OUTPUT INSERTED.[Id], i._Position;
info: InfoMessageLogger[1]
      Table 'Customers'. Scan count 0, logical reads 5, physical reads 0, page server reads 0, read-ahead reads 0, page server read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob page server reads 0, lob read-ahead reads 0, lob page server read-ahead reads 0.
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (2ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
      SET STATISTICS IO ON;
      SELECT TOP(2) [c].[Id], [c].[Name], [c].[PhoneNumber]
      FROM [Customers] AS [c]
      WHERE [c].[Name] = N'Alice'
info: InfoMessageLogger[1]
      Table 'Customers'. Scan count 1, logical reads 2, physical reads 0, page server reads 0, read-ahead reads 0, page server read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob page server reads 0, lob read-ahead reads 0, lob page server read-ahead reads 0.

Améliorations des requêtes

EF7 contient de nombreuses améliorations dans la traduction des requêtes LINQ.

GroupBy comme opérateur final

Conseil

Le code affiché ici provient de GroupByFinalOperatorSample.cs.

EF7 prend en charge l'utilisation GroupBy comme opérateur final dans une requête. Par exemple, cette requête LINQ :

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

Se traduit par le code SQL suivant lors de l'utilisation de SQL Server :

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

Remarque

Ce type de GroupBy ne se traduit pas directement en SQL, c'est pourquoi EF Core effectue le regroupement sur les résultats renvoyés. Toutefois, cela n’entraîne pas de transfert de données supplémentaires depuis le serveur.

GroupJoin en tant qu'opérateur final

Conseil

Le code affiché ici provient de GroupJoinFinalOperatorSample.cs.

EF7 prend en charge l'utilisation GroupJoin comme opérateur final dans une requête. Par exemple, cette requête LINQ :

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

Se traduit par le code SQL suivant lors de l'utilisation de SQL Server :

SELECT [c].[Id], [c].[Name], [t].[Id], [t].[Amount], [t].[CustomerId]
FROM [Customers] AS [c]
OUTER APPLY (
    SELECT [o].[Id], [o].[Amount], [o].[CustomerId]
    FROM [Orders] AS [o]
    WHERE [c].[Id] = [o].[CustomerId]
) AS [t]
ORDER BY [c].[Id]

Type d'entité GroupBy

Conseil

Le code affiché ici provient de GroupByEntityTypeSample.cs.

EF7 prend en charge le regroupement par type d'entité. Par exemple, cette requête LINQ :

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

Se traduit par le code SQL suivant lors de l'utilisation de SQLite :

SELECT [a].[Id], [a].[Name], MAX([b].[Price]) AS [MaxPrice]
FROM [Books] AS [b]
INNER JOIN [Author] AS [a] ON [b].[AuthorId] = [a].[Id]
GROUP BY [a].[Id], [a].[Name]

Gardez à l’esprit que le regroupement par une propriété unique, telle que la clé primaire, sera toujours plus efficace que le regroupement par type d’entité. Cependant, le regroupement par types d'entités peut être utilisé pour les types d'entités avec et sans clé.

De plus, le regroupement par type d'entité avec une clé primaire donnera toujours un groupe par instance d'entité, puisque chaque entité doit avoir une valeur de clé unique. Il est parfois utile de changer la source de la requête afin que le regroupement ne soit pas nécessaire. Par exemple, la requête suivante renvoie les mêmes résultats que la requête précédente :

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

Cette requête se traduit par le code SQL suivant lors de l'utilisation de SQLite :

SELECT [a].[Id], [a].[Name], (
    SELECT MAX([b].[Price])
    FROM [Books] AS [b]
    WHERE [a].[Id] = [b].[AuthorId]) AS [MaxPrice]
FROM [Authors] AS [a]

Les sous-requêtes ne font pas référence aux colonnes non regroupées de la requête externe

Conseil

Le code présenté ici concerne la requête de UngroupedColumnsQuerySample.cs.

Dans EF Core 6.0, une clause GROUP BY ferait référence aux colonnes de la requête externe, ce qui échoue avec certaines bases de données et est inefficace dans d’autres. Par exemple, considérez la requête suivante :

var query = from s in (from i in context.Invoices
                       group i by i.History.Month
                       into g
                       select new { Month = g.Key, Total = g.Sum(p => p.Amount), })
            select new
            {
                s.Month, s.Total, Payment = context.Payments.Where(p => p.History.Month == s.Month).Sum(p => p.Amount)
            };

Dans EF Core 6.0 sur SQL Server, cela a été traduit par :

SELECT DATEPART(month, [i].[History]) AS [Month], COALESCE(SUM([i].[Amount]), 0.0) AS [Total], (
    SELECT COALESCE(SUM([p].[Amount]), 0.0)
    FROM [Payments] AS [p]
    WHERE DATEPART(month, [p].[History]) = DATEPART(month, [i].[History])) AS [Payment]
FROM [Invoices] AS [i]
GROUP BY DATEPART(month, [i].[History])

Sur EF7, la traduction est :

SELECT [t].[Key] AS [Month], COALESCE(SUM([t].[Amount]), 0.0) AS [Total], (
    SELECT COALESCE(SUM([p].[Amount]), 0.0)
    FROM [Payments] AS [p]
    WHERE DATEPART(month, [p].[History]) = [t].[Key]) AS [Payment]
FROM (
    SELECT [i].[Amount], DATEPART(month, [i].[History]) AS [Key]
    FROM [Invoices] AS [i]
) AS [t]
GROUP BY [t].[Key]

Les collections en lecture seule peuvent être utilisées pour Contains

Conseil

Le code affiché ici provient de ReadOnlySetQuerySample.cs.

EF7 prend en charge l'utilisation Contains lorsque les éléments à rechercher sont contenus dans un IReadOnlySet ou IReadOnlyCollection, ou IReadOnlyList. Par exemple, cette requête LINQ :

IReadOnlySet<int> searchIds = new HashSet<int> { 1, 3, 5 };
var query = context.Customers.Where(p => p.Orders.Any(l => searchIds.Contains(l.Id)));

Se traduit par le code SQL suivant lors de l'utilisation de SQL Server :

SELECT [c].[Id], [c].[Name]
FROM [Customers] AS [c]
WHERE EXISTS (
    SELECT 1
    FROM [Orders] AS [o]
    WHERE [c].[Id] = [o].[Customer1Id] AND [o].[Id] IN (1, 3, 5))

Traductions des fonctions d'agrégation

EF7 introduit une meilleure extensibilité permettant aux fournisseurs de traduire les fonctions d'agrégation. Ce travail et d'autres dans ce domaine ont abouti à plusieurs nouvelles traductions entre fournisseurs, notamment :

Remarque

Les fonctions d'agrégation qui agissent sur les arguments IEnumerable ne sont généralement traduites que dans des requêtes GroupBy. Votez pour la prise en charge des types spatiaux dans les colonnes JSON si vous souhaitez supprimer cette limitation.

Fonctions d'agrégation de chaînes

Conseil

Le code affiché ici provient de StringAggregateFunctionsSample.cs.

Les requêtes utilisant Join et Concat sont désormais traduites le cas échéant. Par exemple :

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

Cette requête se traduit par ce qui suit lors de l'utilisation de SQL Server :

SELECT [a].[Id], [a].[Name], COALESCE(STRING_AGG([p].[Title], N'|'), N'') AS [Books]
FROM [Posts] AS [p]
LEFT JOIN [Authors] AS [a] ON [p].[AuthorId] = [a].[Id]
GROUP BY [a].[Id], [a].[Name]

Lorsqu'elles sont combinées avec d'autres fonctions de chaîne, ces traductions permettent des manipulations complexes de chaînes sur le serveur. Par exemple :

var query = context.Posts
    .GroupBy(post => post.Author!.Name)
    .Select(
        grouping =>
            new
            {
                PostAuthor = grouping.Key,
                Blogs = string.Concat(
                    grouping
                        .Select(post => post.Blog.Name)
                        .Distinct()
                        .Select(postName => "'" + postName + "' ")),
                ContentSummaries = string.Join(
                    " | ",
                    grouping
                        .Where(post => post.Content.Length >= 10)
                        .Select(post => "'" + post.Content.Substring(0, 10) + "' "))
            });

Cette requête se traduit par ce qui suit lors de l'utilisation de SQL Server :

SELECT [t].[Name], (N'''' + [t0].[Name]) + N''' ', [t0].[Name], [t].[c]
FROM (
    SELECT [a].[Name], COALESCE(STRING_AGG(CASE
        WHEN CAST(LEN([p].[Content]) AS int) >= 10 THEN COALESCE((N'''' + COALESCE(SUBSTRING([p].[Content], 0 + 1, 10), N'')) + N''' ', N'')
    END, N' | '), N'') AS [c]
    FROM [Posts] AS [p]
    LEFT JOIN [Authors] AS [a] ON [p].[AuthorId] = [a].[Id]
    GROUP BY [a].[Name]
) AS [t]
OUTER APPLY (
    SELECT DISTINCT [b].[Name]
    FROM [Posts] AS [p0]
    LEFT JOIN [Authors] AS [a0] ON [p0].[AuthorId] = [a0].[Id]
    INNER JOIN [Blogs] AS [b] ON [p0].[BlogId] = [b].[Id]
    WHERE [t].[Name] = [a0].[Name] OR ([t].[Name] IS NULL AND [a0].[Name] IS NULL)
) AS [t0]
ORDER BY [t].[Name]

Fonctions d'agrégation spatiale

Conseil

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

Il est désormais possible pour les fournisseurs de bases de données prenant en charge NetTopologySuite de traduire les fonctions d'agrégation spatiale suivantes :

Conseil

Ces traductions ont été implémentées par l'équipe pour SQL Server et SQLite. Pour les autres fournisseurs, contactez le responsable du fournisseur pour ajouter une prise en charge si elle a été implémentée pour ce fournisseur.

Par exemple :

var query = context.Caches
    .Where(cache => cache.Location.X < -90)
    .GroupBy(cache => cache.Owner)
    .Select(
        grouping => new { Id = grouping.Key, Combined = GeometryCombiner.Combine(grouping.Select(cache => cache.Location)) });

Cette requête est traduite dans le SQL suivant lors de l'utilisation de SQL Server :

SELECT [c].[Owner] AS [Id], geography::CollectionAggregate([c].[Location]) AS [Combined]
FROM [Caches] AS [c]
WHERE [c].[Location].Long < -90.0E0
GROUP BY [c].[Owner]

Fonctions d'agrégation statistique

Conseil

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

Des traductions SQL Server ont été implémentées pour les fonctions statistiques suivantes :

Conseil

Ces traductions ont été implémentées par l'équipe pour SQL Server. Pour les autres fournisseurs, contactez le responsable du fournisseur pour ajouter une prise en charge si elle a été implémentée pour ce fournisseur.

Par exemple :

var query = context.Downloads
    .GroupBy(download => download.Uploader.Id)
    .Select(
        grouping => new
        {
            Author = grouping.Key,
            TotalCost = grouping.Sum(d => d.DownloadCount),
            AverageViews = grouping.Average(d => d.DownloadCount),
            VariancePopulation = EF.Functions.VariancePopulation(grouping.Select(d => d.DownloadCount)),
            VarianceSample = EF.Functions.VarianceSample(grouping.Select(d => d.DownloadCount)),
            StandardDeviationPopulation = EF.Functions.StandardDeviationPopulation(grouping.Select(d => d.DownloadCount)),
            StandardDeviationSample = EF.Functions.StandardDeviationSample(grouping.Select(d => d.DownloadCount))
        });

Cette requête est traduite dans le SQL suivant lors de l'utilisation de SQL Server :

SELECT [u].[Id] AS [Author], COALESCE(SUM([d].[DownloadCount]), 0) AS [TotalCost], AVG(CAST([d].[DownloadCount] AS float)) AS [AverageViews], VARP([d].[DownloadCount]) AS [VariancePopulation], VAR([d].[DownloadCount]) AS [VarianceSample], STDEVP([d].[DownloadCount]) AS [StandardDeviationPopulation], STDEV([d].[DownloadCount]) AS [StandardDeviationSample]
FROM [Downloads] AS [d]
INNER JOIN [Uploader] AS [u] ON [d].[UploaderId] = [u].[Id]
GROUP BY [u].[Id]

Traduction de string.IndexOf

Conseil

Le code affiché ici provient de MiscellaneousTranslationsSample.cs.

EF7 se traduit désormais String.IndexOf en requêtes LINQ. Par exemple :

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

Cette requête se traduit par le SQL suivant lors de l'utilisation de SQL Server :

SELECT [p].[Title], CAST(CHARINDEX(N'Entity', [p].[Content]) AS int) - 1 AS [IndexOfEntity]
FROM [Posts] AS [p]
WHERE (CAST(CHARINDEX(N'Entity', [p].[Content]) AS int) - 1) > 0

Traduction de GetType pour les types d'entités

Conseil

Le code affiché ici provient de MiscellaneousTranslationsSample.cs.

EF7 se traduit désormais Object.GetType() en requêtes LINQ. Par exemple :

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

Cette requête se traduit par le code SQL suivant lors de l'utilisation de SQL Server avec l'héritage TPH :

SELECT [p].[Id], [p].[AuthorId], [p].[BlogId], [p].[Content], [p].[Discriminator], [p].[PublishedOn], [p].[Title], [p].[PromoText]
FROM [Posts] AS [p]
WHERE [p].[Discriminator] = N'Post'

Notez que cette requête renvoie uniquement les instances Post qui sont réellement de type Post, et non celles d'un type dérivé. Ceci est différent d'une requête qui utilise is ou OfType, qui renverra également des instances de tout type dérivé. Par exemple, considérons la requête :

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

Ce qui se traduit par un SQL différent :

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

Et renverra les deux entités Post et FeaturedPost.

Prise en charge de AT TIME ZONE

Conseil

Le code affiché ici provient de MiscellaneousTranslationsSample.cs.

EF7 introduit de nouvelles fonctions AtTimeZone pour DateTime et DateTimeOffset. Ces fonctions se traduisent en clauses AT TIME ZONE dans le SQL généré. Par exemple :

var query = context.Posts
    .Select(
        post => new
        {
            post.Title,
            PacificTime = EF.Functions.AtTimeZone(post.PublishedOn, "Pacific Standard Time"),
            UkTime = EF.Functions.AtTimeZone(post.PublishedOn, "GMT Standard Time"),
        });

Cette requête se traduit par le SQL suivant lors de l'utilisation de SQL Server :

SELECT [p].[Title], [p].[PublishedOn] AT TIME ZONE 'Pacific Standard Time' AS [PacificTime], [p].[PublishedOn] AT TIME ZONE 'GMT Standard Time' AS [UkTime]
FROM [Posts] AS [p]

Conseil

Ces traductions ont été implémentées par l'équipe pour SQL Server. Pour les autres fournisseurs, contactez le responsable du fournisseur pour ajouter une prise en charge si elle a été implémentée pour ce fournisseur.

Filtré Inclure sur les navigations masquées

Conseil

Le code affiché ici provient de MiscellaneousTranslationsSample.cs.

Les méthodes Include peuvent désormais être utilisées avec EF.Property. Cela permet de filtrer et de trier même les propriétés de navigation privées ou les navigations privées représentées par des champs. Par exemple :

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

Ceci équivaut à :

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

Mais il n’est pas nécessaire Blog.Posts qu’il soit accessible au public.

Lors de l'utilisation de SQL Server, les deux requêtes ci-dessus se traduisent par :

SELECT [b].[Id], [b].[Name], [t].[Id], [t].[AuthorId], [t].[BlogId], [t].[Content], [t].[Discriminator], [t].[PublishedOn], [t].[Title], [t].[PromoText]
FROM [Blogs] AS [b]
LEFT JOIN (
    SELECT [p].[Id], [p].[AuthorId], [p].[BlogId], [p].[Content], [p].[Discriminator], [p].[PublishedOn], [p].[Title], [p].[PromoText]
    FROM [Posts] AS [p]
    WHERE [p].[Content] LIKE N'%.NET%'
) AS [t] ON [b].[Id] = [t].[BlogId]
ORDER BY [b].[Id], [t].[Title]

Traduction de Cosmos pour Regex.IsMatch

Conseil

Le code affiché ici provient de CosmosQueriesSample.cs.

EF7 prend en charge l'utilisation de Regex.IsMatch dans les requêtes LINQ sur Azure Cosmos DB. Par exemple :

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

Se traduit par le SQL suivant :

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

API DbContext et améliorations du comportement

EF7 contient une variété de petites améliorations pour DbContext et des classes associées.

Conseil

Le code des exemples de cette section provient de DbContextApiSample.cs.

Suppresseur pour les propriétés DbSet non initialisées

Les propriétés publiques DbSet et définissables sur un DbContext sont automatiquement initialisées par EF Core lors de la construction de DbContext. Par exemple, considérons la définition suivante DbContext :

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

La propriété Blogs sera définie sur une instance DbSet<Blog> dans le cadre de la construction de l'instance DbContext. Cela permet d'utiliser le contexte pour les requêtes sans aucune étape supplémentaire.

Cependant, suite à l'introduction des types de référence nullables C#, le compilateur avertit désormais que la propriété non nullable Blogs n'est pas initialisée :

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

Il s’agit d’un faux avertissement ; la propriété est définie sur une valeur non nulle par EF Core. De plus, déclarer la propriété comme nullable fera disparaître l'avertissement, mais ce n'est pas une bonne idée car, conceptuellement, la propriété n'est pas nullable et ne le sera jamais.

EF7 contient un DiagnosticSuppressor pour les propriétés DbSet sur un DbContext qui empêche le compilateur de générer cet avertissement.

Conseil

Ce modèle est né à l’époque où les propriétés automatiques C# étaient très limitées. Avec le C# moderne, envisagez de rendre les propriétés automatiques en lecture seule, puis initialisez-les explicitement dans le constructeur DbContext ou obtenez l'instance DbSet mise en cache à partir du contexte si nécessaire. Par exemple, public DbSet<Blog> Blogs => Set<Blog>()

Distinguer l'annulation de l'échec dans les journaux

Parfois, une application annule explicitement une requête ou une autre opération de base de données. Cela se fait généralement en utilisant un CancellationToken transmis à la méthode effectuant l'opération.

Dans EF Core 6, les événements enregistrés lorsqu’une opération est annulée sont les mêmes que ceux enregistrés lorsque l’opération échoue pour une autre raison. EF7 introduit de nouveaux événements de journal spécifiquement pour les opérations de base de données annulées. Ces nouveaux événements sont, par défaut, journalisés au niveau Debug. Le tableau suivant présente les événements pertinents et leurs niveaux de journalisation par défaut :

Événement Description Niveau de journalisation par défaut
CoreEventId.QueryIterationFailed Une erreur s'est produite lors du traitement des résultats d'une requête. LogLevel.Error
CoreEventId.SaveChangesFailed Une erreur s'est produite lors de la tentative d'enregistrement des modifications apportées à la base de données. LogLevel.Error
RelationalEventId.CommandError Une erreur s'est produite lors de l'exécution d'une commande de base de données. LogLevel.Error
CoreEventId.QueryCanceled Une requête a été annulée. LogLevel.Debug
CoreEventId.SaveChangesCanceled La commande de base de données a été annulée lors de la tentative d'enregistrement des modifications. LogLevel.Debug
RelationalEventId.CommandCanceled L'exécution d'un DbCommand a été annulée. LogLevel.Debug

Remarque

L'annulation est détectée en examinant l'exception plutôt qu'en vérifiant le jeton d'annulation. Cela signifie que les annulations non déclenchées via le jeton d'annulation seront toujours détectées et enregistrées de cette manière.

Nouveautés surcharges IProperty et INavigation pour les méthodes EntityEntry

Le code fonctionnant avec le modèle EF aura souvent un IProperty ou INavigation représentant des métadonnées de propriété ou de navigation. Un EntityEntry est ensuite utilisé pour obtenir la valeur de propriété/navigation ou interroger son état. Cependant, avant EF7, cela nécessitait de transmettre le nom de la propriété ou de la navigation aux méthodes du EntityEntry, qui recherchaient ensuite le IProperty ou INavigation. Dans EF7, le IProperty ou INavigation peut être transmis directement, évitant ainsi la recherche supplémentaire.

Par exemple, considérons une méthode pour trouver tous les frères et sœurs d'une entité donnée :

public static IEnumerable<TEntity> FindSiblings<TEntity>(
    this DbContext context, TEntity entity, string navigationToParent)
    where TEntity : class
{
    var parentEntry = context.Entry(entity).Reference(navigationToParent);

    return context.Entry(parentEntry.CurrentValue!)
        .Collection(parentEntry.Metadata.Inverse!)
        .CurrentValue!
        .OfType<TEntity>()
        .Where(e => !ReferenceEquals(e, entity));
}

Cette méthode recherche le parent d'une entité donnée, puis transmet l'inverse INavigation à la méthode Collection de l'entrée parent. Ces métadonnées sont ensuite utilisées pour renvoyer tous les frères et sœurs du parent donné. Voici un exemple de son utilisation :


Console.WriteLine($"Siblings to {post.Id}: '{post.Title}' are...");
foreach (var sibling in context.FindSiblings(post, nameof(post.Blog)))
{
    Console.WriteLine($"    {sibling.Id}: '{sibling.Title}'");
}

Et le résultat :

Siblings to 1: 'Announcing Entity Framework 7 Preview 7: Interceptors!' are...
    5: 'Productivity comes to .NET MAUI in Visual Studio 2022'
    6: 'Announcing .NET 7 Preview 7'
    7: 'ASP.NET Core updates in .NET 7 Preview 7'

EntityEntry pour les types d'entités de type partagé

EF Core peut utiliser le même type CLR pour plusieurs types d’entités différents. Ceux-ci sont connus sous le nom de « types d’entités de type partagé » et sont souvent utilisés pour mapper un type de dictionnaire avec des paires clé/valeur utilisées pour les propriétés du type d’entité. Par exemple, un type d'entité BuildMetadata peut être défini sans définir de type CLR dédié :

modelBuilder.SharedTypeEntity<Dictionary<string, object>>(
    "BuildMetadata", b =>
    {
        b.IndexerProperty<int>("Id");
        b.IndexerProperty<string>("Tag");
        b.IndexerProperty<Version>("Version");
        b.IndexerProperty<string>("Hash");
        b.IndexerProperty<bool>("Prerelease");
    });

Notez que le type d’entité de type partagé doit être nommé – dans ce cas, le nom est BuildMetadata. Ces types d'entités sont ensuite accessibles à l'aide de DbSet pour le type d'entité qui est obtenu à l'aide du nom. Par exemple :

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

Cela DbSet peut être utilisé pour suivre les instances d'entité :

await context.BuildMetadata.AddAsync(
    new Dictionary<string, object>
    {
        { "Tag", "v7.0.0-rc.1.22426.7" },
        { "Version", new Version(7, 0, 0) },
        { "Prerelease", true },
        { "Hash", "dc0f3e8ef10eb1464b27f0fd4704f53c01226036" }
    });

Et exécutez des requêtes :

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

Désormais, dans EF7, il existe également une méthode Entry sur DbSet permettant d'obtenir l'état d'une instance, même si elle n'est pas encore suivie. Par exemple :

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

ContextInitialized est maintenant enregistré en tant que Debug

Dans EF7, l'événement ContextInitialized est enregistré au niveau Debug. Par exemple :

dbug: 10/7/2022 12:27:52.379 CoreEventId.ContextInitialized[10403] (Microsoft.EntityFrameworkCore.Infrastructure)
      Entity Framework Core 7.0.0 initialized 'BlogsContext' using provider 'Microsoft.EntityFrameworkCore.SqlServer:7.0.0' with options: SensitiveDataLoggingEnabled using NetTopologySuite

Dans les versions précédentes, il était enregistré au niveau Information. Par exemple :

info: 10/7/2022 12:30:34.757 CoreEventId.ContextInitialized[10403] (Microsoft.EntityFrameworkCore.Infrastructure)
      Entity Framework Core 7.0.0 initialized 'BlogsContext' using provider 'Microsoft.EntityFrameworkCore.SqlServer:7.0.0' with options: SensitiveDataLoggingEnabled using NetTopologySuite

Si vous le souhaitez, le niveau de journalisation peut être rétabli à Information :

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

IEntityEntryGraphIterator est publiquement utilisable

Dans EF7, le service IEntityEntryGraphIterator peut être utilisé par les applications. C'est le service utilisé en interne lors de la découverte d'un graphe d'entités à suivre, et également par TrackGraph. Voici un exemple qui parcourt toutes les entités accessibles à partir d'une entité de départ :

var blogEntry = context.ChangeTracker.Entries<Blog>().First();
var found = new HashSet<object>();
var iterator = context.GetService<IEntityEntryGraphIterator>();
iterator.TraverseGraph(new EntityEntryGraphNode<HashSet<object>>(blogEntry, found, null, null), node =>
{
    if (node.NodeState.Contains(node.Entry.Entity))
    {
        return false;
    }

    Console.Write($"Found with '{node.Entry.Entity.GetType().Name}'");

    if (node.InboundNavigation != null)
    {
        Console.Write($" by traversing '{node.InboundNavigation.Name}' from '{node.SourceEntry!.Entity.GetType().Name}'");
    }

    Console.WriteLine();

    node.NodeState.Add(node.Entry.Entity);

    return true;
});

Console.WriteLine();
Console.WriteLine($"Finished iterating. Found {found.Count} entities.");
Console.WriteLine();

Avis :

  • L'itérateur arrête de parcourir un nœud donné lorsque le délégué de rappel renvoie false. Cet exemple montre comment effectuer le suivi des entités visitées et retourner false lorsque l’entité a déjà été visitée. Cela évite les boucles infinies résultant de cycles dans le graphique.
  • L'objet EntityEntryGraphNode<TState> permet de transmettre l'état sans le capturer dans le délégué.
  • Pour chaque nœud visité autre que le premier, le nœud à partir duquel il a été découvert et la navigation via laquelle il a été découvert sont transmis au rappel.

Améliorations de la création de modèles

EF7 contient une variété de petites améliorations dans la création de modèles.

Conseil

Le code des exemples de cette section provient de ModelBuildingSample.cs.

Les index peuvent être ascendants ou décroissants

Par défaut, EF Core crée des index ascendants. EF7 prend également en charge la création d'index décroissants. Par exemple :

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

Ou, en utilisant l'attribut mapping Index :

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

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

Ceci est rarement utile pour les index sur une seule colonne, car la base de données peut utiliser le même index pour trier dans les deux sens. Cependant, ce n'est pas le cas pour les index composites sur plusieurs colonnes où l'ordre sur chaque colonne peut être important. EF Core prend en charge cela en permettant à plusieurs colonnes d’avoir un ordre différent défini pour chaque colonne. Par exemple :

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

Ou, en utilisant un attribut de mappage :

[Index(nameof(Name), nameof(Owner), IsDescending = new[] { false, true })]
public class Blog
{
    public int Id { get; set; }

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

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

    public List<Post> Posts { get; } = new();
}

Cela donne le code SQL suivant lors de l'utilisation de SQL Server :

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

Enfin, plusieurs index peuvent être créés sur le même ensemble ordonné de colonnes en donnant des noms aux index. Par exemple :

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

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

Ou, en utilisant les attributs de mappage :

[Index(nameof(Name), nameof(Owner), IsDescending = new[] { false, true }, Name = "IX_Blogs_Name_Owner_1")]
[Index(nameof(Name), nameof(Owner), IsDescending = new[] { true, true }, Name = "IX_Blogs_Name_Owner_2")]
public class Blog
{
    public int Id { get; set; }

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

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

    public List<Post> Posts { get; } = new();
}

Cela génère le SQL suivant sur SQL Server :

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

Attribut de mappage pour les clés composites

EF7 introduit un nouvel attribut de mappage (alias « annotation de données ») pour spécifier la ou les propriétés de clé primaire de tout type d'entité. Contrairement à System.ComponentModel.DataAnnotations.KeyAttribute, PrimaryKeyAttribute est placé sur la classe de type d'entité plutôt que sur la propriété key. Par exemple :

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

Cela en fait un choix naturel pour définir des clés composites :

[PrimaryKey(nameof(PostId), nameof(CommentId))]
public class Comment
{
    public int PostId { get; set; }
    public int CommentId { get; set; }
    public string CommentText { get; set; } = null!;
}

Définir l'index sur la classe signifie également qu'il peut être utilisé pour spécifier des propriétés privées ou des champs comme clés, même si ceux-ci seraient généralement ignorés lors de la construction du modèle EF. Par exemple :

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

DeleteBehavior attribut de mappage

EF7 introduit un attribut de mappage (alias « annotation de données ») DeleteBehavior pour spécifier une relation. Par exemple, les relations requises sont créées avec DeleteBehavior.Cascade par défaut. Cela peut être modifié DeleteBehavior.NoAction par défaut en utilisant DeleteBehaviorAttribute :

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

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

Cela désactivera les suppressions en cascade pour la relation Blog-Posts.

Propriétés mappées à différents noms de colonnes

Certains modèles de mappage entraînent le mappage de la même propriété CLR à une colonne dans chacune de plusieurs tables différentes. EF7 permet à ces colonnes d'avoir des noms différents. Par exemple, considérons une hiérarchie d'héritage simple :

public abstract class Animal
{
    public int Id { get; set; }
    public string Breed { get; set; } = null!;
}

public class Cat : Animal
{
    public string? EducationalLevel { get; set; }
}

public class Dog : Animal
{
    public string? FavoriteToy { get; set; }
}

Avec la stratégie de mappage d'héritage TPT, ces types seront mappés sur trois tables. Toutefois, la colonne de clé primaire de chaque table peut avoir un nom différent. Par exemple :

CREATE TABLE [Animals] (
    [Id] int NOT NULL IDENTITY,
    [Breed] nvarchar(max) NOT NULL,
    CONSTRAINT [PK_Animals] PRIMARY KEY ([Id])
);

CREATE TABLE [Cats] (
    [CatId] int NOT NULL,
    [EducationalLevel] nvarchar(max) NULL,
    CONSTRAINT [PK_Cats] PRIMARY KEY ([CatId]),
    CONSTRAINT [FK_Cats_Animals_CatId] FOREIGN KEY ([CatId]) REFERENCES [Animals] ([Id]) ON DELETE CASCADE
);

CREATE TABLE [Dogs] (
    [DogId] int NOT NULL,
    [FavoriteToy] nvarchar(max) NULL,
    CONSTRAINT [PK_Dogs] PRIMARY KEY ([DogId]),
    CONSTRAINT [FK_Dogs_Animals_DogId] FOREIGN KEY ([DogId]) REFERENCES [Animals] ([Id]) ON DELETE CASCADE
);

EF7 permet de configurer ce mappage à l'aide d'un générateur de tables imbriquées :

modelBuilder.Entity<Animal>().ToTable("Animals");

modelBuilder.Entity<Cat>()
    .ToTable(
        "Cats",
        tableBuilder => tableBuilder.Property(cat => cat.Id).HasColumnName("CatId"));

modelBuilder.Entity<Dog>()
    .ToTable(
        "Dogs",
        tableBuilder => tableBuilder.Property(dog => dog.Id).HasColumnName("DogId"));

Avec le mappage d'héritage TPC, la propriété Breed peut également être mappée à différents noms de colonnes dans différentes tables. Par exemple, considérons les tables TPC suivantes :

CREATE TABLE [Cats] (
    [CatId] int NOT NULL DEFAULT (NEXT VALUE FOR [AnimalSequence]),
    [CatBreed] nvarchar(max) NOT NULL,
    [EducationalLevel] nvarchar(max) NULL,
    CONSTRAINT [PK_Cats] PRIMARY KEY ([CatId])
);

CREATE TABLE [Dogs] (
    [DogId] int NOT NULL DEFAULT (NEXT VALUE FOR [AnimalSequence]),
    [DogBreed] nvarchar(max) NOT NULL,
    [FavoriteToy] nvarchar(max) NULL,
    CONSTRAINT [PK_Dogs] PRIMARY KEY ([DogId])
);

EF7 prend en charge ce mappage de table :

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

modelBuilder.Entity<Cat>()
    .ToTable(
        "Cats",
        builder =>
        {
            builder.Property(cat => cat.Id).HasColumnName("CatId");
            builder.Property(cat => cat.Breed).HasColumnName("CatBreed");
        });

modelBuilder.Entity<Dog>()
    .ToTable(
        "Dogs",
        builder =>
        {
            builder.Property(dog => dog.Id).HasColumnName("DogId");
            builder.Property(dog => dog.Breed).HasColumnName("DogBreed");
        });

Relations plusieurs-à-plusieurs unidirectionnelles

EF7 prend en charge les relations plusieurs-à-plusieurs dans lesquelles un côté ou l'autre n'a pas de propriété de navigation. Par exemple, considérez Post et Tag tapez :

public class Post
{
    public int Id { get; set; }
    public string? Title { get; set; }
    public Blog Blog { get; set; } = null!;
    public List<Tag> Tags { get; } = new();
}
public class Tag
{
    public int Id { get; set; }
    public string TagName { get; set; } = null!;
}

Notez que le type Post a une propriété de navigation pour une liste de balises, mais le type Tag n'a pas de propriété de navigation pour les publications. Dans EF7, cela peut toujours être configuré comme une relation plusieurs-à-plusieurs, permettant au même objet Tag d'être utilisé pour de nombreuses publications différentes. Par exemple :

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

Cela entraîne un mappage vers la table de jointure appropriée :

CREATE TABLE [Tags] (
    [Id] int NOT NULL IDENTITY,
    [TagName] nvarchar(max) NOT NULL,
    CONSTRAINT [PK_Tags] PRIMARY KEY ([Id])
);

CREATE TABLE [Posts] (
    [Id] int NOT NULL IDENTITY,
    [Title] nvarchar(64) NULL,
    [BlogId] int NOT NULL,
    CONSTRAINT [PK_Posts] PRIMARY KEY ([Id]),
    CONSTRAINT [FK_Posts_Blogs_BlogId] FOREIGN KEY ([BlogId]) REFERENCES [Blogs] ([Id])
);

CREATE TABLE [PostTag] (
    [PostId] int NOT NULL,
    [TagsId] int NOT NULL,
    CONSTRAINT [PK_PostTag] PRIMARY KEY ([PostId], [TagsId]),
    CONSTRAINT [FK_PostTag_Posts_PostId] FOREIGN KEY ([PostId]) REFERENCES [Posts] ([Id]) ON DELETE CASCADE,
    CONSTRAINT [FK_PostTag_Tags_TagsId] FOREIGN KEY ([TagsId]) REFERENCES [Tags] ([Id]) ON DELETE CASCADE
);

Et la relation peut être utilisée comme plusieurs à plusieurs de la manière normale. Par exemple, insérer des articles partageant diverses balises d'un ensemble commun :

var tags = new Tag[] { new() { TagName = "Tag1" }, new() { TagName = "Tag2" }, new() { TagName = "Tag2" }, };

await context.AddRangeAsync(new Blog { Posts =
{
    new Post { Tags = { tags[0], tags[1] } },
    new Post { Tags = { tags[1], tags[0], tags[2] } },
    new Post()
} });

await context.SaveChangesAsync();

Fractionnement d'entité

Le fractionnement d'entité mappe un seul type d'entité à plusieurs tables. Par exemple, considérons une base de données avec trois tables contenant des données client :

  • Un tableau Customers pour les informations clients
  • Un tableau PhoneNumbers pour le numéro de téléphone du client
  • Un tableau Addresses pour l'adresse du client

Voici les définitions de ces tables dans SQL Server :

CREATE TABLE [Customers] (
    [Id] int NOT NULL IDENTITY,
    [Name] nvarchar(max) NOT NULL,
    CONSTRAINT [PK_Customers] PRIMARY KEY ([Id])
);
    
CREATE TABLE [PhoneNumbers] (
    [CustomerId] int NOT NULL,
    [PhoneNumber] nvarchar(max) NULL,
    CONSTRAINT [PK_PhoneNumbers] PRIMARY KEY ([CustomerId]),
    CONSTRAINT [FK_PhoneNumbers_Customers_CustomerId] FOREIGN KEY ([CustomerId]) REFERENCES [Customers] ([Id]) ON DELETE CASCADE
);

CREATE TABLE [Addresses] (
    [CustomerId] int NOT NULL,
    [Street] nvarchar(max) NOT NULL,
    [City] nvarchar(max) NOT NULL,
    [PostCode] nvarchar(max) NULL,
    [Country] nvarchar(max) NOT NULL,
    CONSTRAINT [PK_Addresses] PRIMARY KEY ([CustomerId]),
    CONSTRAINT [FK_Addresses_Customers_CustomerId] FOREIGN KEY ([CustomerId]) REFERENCES [Customers] ([Id]) ON DELETE CASCADE
);

Chacune de ces tables serait généralement mappée à son propre type d'entité, avec des relations entre les types. Toutefois, si les trois tables sont toujours utilisées ensemble, il peut alors être plus pratique de les mapper toutes à un seul type d’entité. Par exemple :

public class Customer
{
    public Customer(string name, string street, string city, string? postCode, string country)
    {
        Name = name;
        Street = street;
        City = city;
        PostCode = postCode;
        Country = country;
    }

    public int Id { get; set; }
    public string Name { get; set; }
    public string? PhoneNumber { get; set; }
    public string Street { get; set; }
    public string City { get; set; }
    public string? PostCode { get; set; }
    public string Country { get; set; }
}

Ceci est réalisé dans EF7 en appelant SplitToTable pour chaque division dans le type d’entité. Par exemple, le code suivant divise le type d'entité Customer en tables Customers, PhoneNumbers et Addresses indiquées ci-dessus :

modelBuilder.Entity<Customer>(
    entityBuilder =>
    {
        entityBuilder
            .ToTable("Customers")
            .SplitToTable(
                "PhoneNumbers",
                tableBuilder =>
                {
                    tableBuilder.Property(customer => customer.Id).HasColumnName("CustomerId");
                    tableBuilder.Property(customer => customer.PhoneNumber);
                })
            .SplitToTable(
                "Addresses",
                tableBuilder =>
                {
                    tableBuilder.Property(customer => customer.Id).HasColumnName("CustomerId");
                    tableBuilder.Property(customer => customer.Street);
                    tableBuilder.Property(customer => customer.City);
                    tableBuilder.Property(customer => customer.PostCode);
                    tableBuilder.Property(customer => customer.Country);
                });
    });

Notez également que, si nécessaire, différents noms de colonnes de clé primaire peuvent être spécifiés pour chacune des tables.

Chaînes SQL Server UTF-8

Les chaînes Unicode SQL Server telles que représentées par les types de données nchar et nvarchar sont stockées au format UTF-16. De plus, les types de données char et varchar sont utilisés pour stocker des chaînes non Unicode avec prise en charge de divers jeux de caractères.

À partir de SQL Server 2019, les types de données char et varchar peuvent être utilisés pour stocker des chaînes Unicode avec le codage UTF-8. Ceci est obtenu en définissant l'un des classements UTF-8. Par exemple, le code suivant configure une chaîne SQL Server UTF-8 de longueur variable pour la colonne CommentText :

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

Cette configuration génère la définition de colonne SQL Server suivante :

CREATE TABLE [Comment] (
    [PostId] int NOT NULL,
    [CommentId] int NOT NULL,
    [CommentText] varchar(max) COLLATE LATIN1_GENERAL_100_CI_AS_SC_UTF8 NOT NULL,
    CONSTRAINT [PK_Comment] PRIMARY KEY ([PostId], [CommentId])
);

Les tables temporelles prennent en charge les entités détenues

Le mappage des tables temporelles EF Core SQL Server a été amélioré dans EF7 pour prendre en charge le partage de tables. Plus particulièrement, le mappage par défaut pour les entités uniques détenues utilise le partage de tables.

Par exemple, considérons un type d'entité propriétaire Employee et son type d'entité détenue EmployeeInfo :

public class Employee
{
    public Guid EmployeeId { get; set; }
    public string Name { get; set; } = null!;

    public EmployeeInfo Info { get; set; } = null!;
}

public class EmployeeInfo
{
    public string Position { get; set; } = null!;
    public string Department { get; set; } = null!;
    public string? Address { get; set; }
    public decimal? AnnualSalary { get; set; }
}

Si ces types sont mappés à la même table, alors dans EF7, cette table peut devenir une table temporelle :

modelBuilder
    .Entity<Employee>()
    .ToTable(
        "Employees",
        tableBuilder =>
        {
            tableBuilder.IsTemporal();
            tableBuilder.Property<DateTime>("PeriodStart").HasColumnName("PeriodStart");
            tableBuilder.Property<DateTime>("PeriodEnd").HasColumnName("PeriodEnd");
        })
    .OwnsOne(
        employee => employee.Info,
        ownedBuilder => ownedBuilder.ToTable(
            "Employees",
            tableBuilder =>
            {
                tableBuilder.IsTemporal();
                tableBuilder.Property<DateTime>("PeriodStart").HasColumnName("PeriodStart");
                tableBuilder.Property<DateTime>("PeriodEnd").HasColumnName("PeriodEnd");
            }));

Remarque

Rendre cette configuration plus facile est suivi par le numéro 29303. Votez pour ce problème si c'est quelque chose que vous aimeriez voir mis en œuvre.

Génération de valeur améliorée

EF7 inclut deux améliorations significatives de la génération automatique de valeurs pour les propriétés clés.

Conseil

Le code des exemples de cette section provient de ValueGenerationSample.cs.

Génération de valeur pour les types protégés DDD

Dans la conception pilotée par domaine (DDD), les « clés protégées » peuvent améliorer la sécurité des types des propriétés de clé. Ceci est réalisé en encapsulant le type de clé dans un autre type spécifique à l'utilisation de la clé. Par exemple, le code suivant définit un type ProductId pour les clés de produit et un type CategoryId pour les clés de catégorie.

public readonly struct ProductId
{
    public ProductId(int value) => Value = value;
    public int Value { get; }
}

public readonly struct CategoryId
{
    public CategoryId(int value) => Value = value;
    public int Value { get; }
}

Ceux-ci sont ensuite utilisés dans les types d'entités Product et Category :

public class Product
{
    public Product(string name) => Name = name;
    public ProductId Id { get; set; }
    public string Name { get; set; }
    public CategoryId CategoryId { get; set; }
    public Category Category { get; set; } = null!;
}

public class Category
{
    public Category(string name) => Name = name;
    public CategoryId Id { get; set; }
    public string Name { get; set; }
    public List<Product> Products { get; } = new();
}

Cela rend impossible l’attribution accidentelle de l’ID d’une catégorie à un produit, ou vice versa.

Avertissement

Comme pour de nombreux concepts DDD, cette sécurité de type améliorée se fait au détriment d’une complexité supplémentaire du code. Il convient de se demander si, par exemple, l’attribution d’un identifiant de produit à une catégorie est quelque chose qui est susceptible de se produire. Garder les choses simples peut être globalement plus bénéfique pour la base de code.

Les types de clés protégées présentés ici encapsulent tous deux les valeurs de clé int, ce qui signifie que les valeurs entières seront utilisées dans les tables de base de données mappées. Ceci est réalisé en définissant des convertisseurs de valeurs pour les types :

protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
{
    configurationBuilder.Properties<ProductId>().HaveConversion<ProductIdConverter>();
    configurationBuilder.Properties<CategoryId>().HaveConversion<CategoryIdConverter>();
}

private class ProductIdConverter : ValueConverter<ProductId, int>
{
    public ProductIdConverter()
        : base(v => v.Value, v => new(v))
    {
    }
}

private class CategoryIdConverter : ValueConverter<CategoryId, int>
{
    public CategoryIdConverter()
        : base(v => v.Value, v => new(v))
    {
    }
}

Remarque

Le code ici utilise des types struct. Cela signifie qu’ils ont une sémantique de type valeur appropriée à utiliser comme clés. Si des types class sont utilisés à la place, ils doivent alors soit remplacer la sémantique d'égalité, soit également spécifier un comparateur de valeurs.

Dans EF7, les types de clés basés sur des convertisseurs de valeurs peuvent utiliser des valeurs de clé générées automatiquement tant que le type sous-jacent le prend en charge. Ceci se configure normalement en utilisant ValueGeneratedOnAdd :

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

Par défaut, cela donne des colonnes IDENTITY lorsqu'il est utilisé avec SQL Server :

CREATE TABLE [Categories] (
    [Id] int NOT NULL IDENTITY,
    [Name] nvarchar(max) NOT NULL,
    CONSTRAINT [PK_Categories] PRIMARY KEY ([Id]));

CREATE TABLE [Products] (
    [Id] int NOT NULL IDENTITY,
    [Name] nvarchar(max) NOT NULL,
    [CategoryId] int NOT NULL,
    CONSTRAINT [PK_Products] PRIMARY KEY ([Id]),
    CONSTRAINT [FK_Products_Categories_CategoryId] FOREIGN KEY ([CategoryId]) REFERENCES [Categories] ([Id]) ON DELETE CASCADE);

Qui sont utilisés de manière normale pour générer des valeurs clés lors de l'insertion d'entités :

MERGE [Categories] USING (
VALUES (@p0, 0),
(@p1, 1)) AS i ([Name], _Position) ON 1=0
WHEN NOT MATCHED THEN
INSERT ([Name])
VALUES (i.[Name])
OUTPUT INSERTED.[Id], i._Position;

Génération de clés basée sur une séquence pour SQL Server

EF Core prend en charge la génération de valeurs de clés à l'aide de colonnes IDENTITY langage SQL Server ou d'un modèle Hi-Lo basé sur des blocs de clés générés par une séquence de base de données. EF7 introduit la prise en charge d'une séquence de base de données attachée à la contrainte par défaut de colonne de la clé. Dans sa forme la plus simple, cela nécessite simplement de dire à EF Core d'utiliser une séquence pour la propriété key :

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

Cela se traduit par la définition d'une séquence dans la base de données :

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

Qui est ensuite utilisé dans la contrainte par défaut de la colonne clé :

CREATE TABLE [Products] (
    [Id] int NOT NULL DEFAULT (NEXT VALUE FOR [ProductSequence]),
    [Name] nvarchar(max) NOT NULL,
    [CategoryId] int NOT NULL,
    CONSTRAINT [PK_Products] PRIMARY KEY ([Id]),
    CONSTRAINT [FK_Products_Categories_CategoryId] FOREIGN KEY ([CategoryId]) REFERENCES [Categories] ([Id]) ON DELETE CASCADE);

Remarque

Cette forme de génération de clé est utilisée par défaut pour les clés générées dans les hiérarchies de types d'entités à l'aide de la stratégie de mappage TPC.

Si vous le souhaitez, la séquence peut recevoir un nom et un schéma différents. Par exemple :

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

Une configuration supplémentaire de la séquence est formée en la configurant explicitement dans le modèle. Par exemple :

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

Améliorations des outils de migration

EF7 inclut deux améliorations significatives lors de l'utilisation des outils de ligne de commande EF Core Migrations.

UseSqlServer etc. accepte null

Il est très courant de lire une chaîne de connexion à partir d'un fichier de configuration, puis de transmettre cette chaîne de connexion à UseSqlServer, UseSqlite ou à la méthode équivalente d'un autre fournisseur. Par exemple :

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

Il est également courant de transmettre une chaîne de connexion lors de l'application de migrations. Par exemple :

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

Ou lorsque vous utilisez un bundle Migrations.

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

Dans ce cas, même si la chaîne de connexion lue depuis la configuration n'est pas utilisée, le code de démarrage de l'application tente toujours de la lire depuis la configuration et de la transmettre à UseSqlServer. Si la configuration n'est pas disponible, cela entraîne la transmission de null à UseSqlServer. Dans EF7, cela est autorisé, à condition que la chaîne de connexion soit définie ultérieurement, par exemple en passant --connection à l'outil de ligne de commande.

Remarque

Ce changement a été effectué pour UseSqlServer et UseSqlite. Pour les autres fournisseurs, contactez le responsable du fournisseur pour effectuer une modification équivalente si cela n'a pas encore été fait pour ce fournisseur.

Détecter quand les outils sont en cours d'exécution

EF Core exécute le code d’application lorsque les commandes dotnet-ef ou PowerShell sont utilisées. Parfois, il peut être nécessaire de détecter cette situation pour empêcher l’exécution de code inapproprié au moment de la conception. Par exemple, le code qui applique automatiquement les migrations au démarrage ne devrait probablement pas le faire au moment de la conception. Dans EF7, cela peut être détecté à l'aide du drapeau EF.IsDesignTime :

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

EF Core définit le IsDesignTime pour true lorsque le code de l’application s’exécute au nom des outils.

Améliorations des performances pour les proxys

EF Core prend en charge les proxys générés dynamiquement pour le chargement différé et le suivi des modifications. EF7 contient deux améliorations de performances lors de l'utilisation de ces proxys :

  • Les types de proxy sont désormais créés paresseusement. Cela signifie que le temps initial de création du modèle lors de l’utilisation de proxys peut être considérablement plus rapide avec EF7 qu’il ne l’était avec EF Core 6.0.
  • Les Azure Functions Proxies peuvent désormais être utilisés avec des modèles compilés.

Voici quelques résultats de performances pour un modèle comportant 449 types d’entités, 6 390 propriétés et 720 relations.

Scénario Méthode Moyenne Erreur StdDev
EF Core 6.0 sans proxy TimeToFirstQuery 1,085 s 0,0083 s 0,0167 s
EF Core 6.0 avec proxys de suivi des modifications TimeToFirstQuery 13,01 s 0,2040 s 0,4110 s
EF Core 7.0 sans proxy TimeToFirstQuery 1,442 s 0,0134 s 0,0272 s
EF Core 7.0 avec proxys de suivi des modifications TimeToFirstQuery 1,446 s 0,0160 s 0,0323 s
EF Core 7.0 avec proxys de suivi des modifications et modèle compilé TimeToFirstQuery 0,162 s 0,0062 s 0,0125 s

Ainsi, dans ce cas, un modèle doté de proxys de suivi des modifications peut être prêt à exécuter la première requête 80 fois plus rapidement dans EF7 qu’il n’était possible avec EF Core 6.0.

Liaison de données Windows Forms de première classe

L'équipe Windows Forms a apporté de grandes améliorations à l'expérience Visual Studio Designer. Cela inclut de nouvelles expériences de liaison de données qui s’intègrent bien à EF Core.

En bref, la nouvelle expérience fournit à Visual Studio U.I. pour créer un ObjectDataSource :

Choose Category data source type

Cela peut ensuite être lié à un EF Core DbSet avec un code simple :

public partial class MainForm : Form
{
    private ProductsContext? dbContext;

    public MainForm()
    {
        InitializeComponent();
    }

    protected override void OnLoad(EventArgs e)
    {
        base.OnLoad(e);

        this.dbContext = new ProductsContext();

        this.dbContext.Categories.Load();
        this.categoryBindingSource.DataSource = dbContext.Categories.Local.ToBindingList();
    }

    protected override void OnClosing(CancelEventArgs e)
    {
        base.OnClosing(e);

        this.dbContext?.Dispose();
        this.dbContext = null;
    }
}

Consultez Prise en main avec Windows Forms pour une présentation complète et un exemple d'application WinForms téléchargeable.