EF Core
Mapping, suivi des entités, migrations, N+1, requêtes compilées.
Vérifié en septembre 2026 · .NET 10 (SDK 10.0.300), C# 14 · environ 18 min
Entity Framework Core est l'ORM de .NET : il fait correspondre des classes à des tables, traduit en SQL les requêtes LINQ écrites sur IQueryable<T>, et enregistre en base les changements faits sur les objets. Il sert dès qu'une application lit et écrit une base relationnelle sans vouloir écrire chaque requête à la main, et il se paie d'une exigence : savoir quel SQL part, et quand. Le cours Program.cs appelle, et tous partagent les deux fichiers de la première section.
Le mapping : conventions, puis configuration fluente
EF Core construit un modèle, une description des entités, de leurs clés et de leurs relations, d'où il tire le schéma et le SQL. La plus grande part vient des conventions : une propriété Id est la clé primaire, un AuteurId accompagné d'une navigation Auteur est une clé étrangère, un string non nullable devient NOT NULL quand les types référence nullables sont activés. Le reste se configure par des attributs sur les classes ou par l'API fluente de OnModelCreating, qui l'emporte sur les attributs, lesquels l'emportent sur les conventions.
// ---- Modele.cs : paquet Microsoft.EntityFrameworkCore.Sqlite 10.0.12
using System.Collections.Generic;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
public sealed class Auteur
{
public int Id { get; set; } // Id : cle primaire, par convention
public required string Nom { get; set; } // string : NOT NULL
public int? AnneeDeces { get; set; } // int? : colonne nullable
public List<Livre> Livres { get; } = []; // navigation de collection
public List<Prix> Prix { get; } = [];
}
public sealed class Livre
{
public int Id { get; set; }
public required string Titre { get; set; }
public int Annee { get; set; }
public int AuteurId { get; set; } // AuteurId : cle etrangere, par convention
public Auteur Auteur { get; set; } = null!; // navigation de reference
}
// Pas de DbSet : le type entre dans le modele par la navigation Auteur.Prix.
public sealed class Prix
{
public int Id { get; set; }
public required string Nom { get; set; }
public int Annee { get; set; }
public int AuteurId { get; set; }
}
public sealed class Bibliotheque(DbContextOptions<Bibliotheque> options) : DbContext(options)
{
public DbSet<Auteur> Auteurs => Set<Auteur>();
public DbSet<Livre> Livres => Set<Livre>();
protected override void OnModelCreating(ModelBuilder modele)
{
modele.Entity<Auteur>().Property(a => a.Nom).HasMaxLength(100);
modele.ApplyConfiguration(new ConfigurationLivre());
}
}
// La configuration d'une entite, a part : OnModelCreating reste court.
sealed class ConfigurationLivre : IEntityTypeConfiguration<Livre>
{
public void Configure(EntityTypeBuilder<Livre> livre)
{
livre.ToTable("Ouvrages");
livre.Property(l => l.Titre).HasMaxLength(200);
livre.HasIndex(l => new { l.AuteurId, l.Titre }).IsUnique();
livre.HasOne(l => l.Auteur).WithMany(a => a.Livres).OnDelete(DeleteBehavior.Restrict);
}
} L'outillage suivant n'est pas du mapping : il ouvre une base en mémoire et branche sur le contexte un journal, par LogTo, qui affiche chaque commande SQL et les compte. C'est le moyen de voir ce qu'EF Core envoie, et tout le cours s'en sert.
// ---- Outillage.cs : une base SQLite en memoire, remplie, et un journal qui
// affiche et compte chaque commande SQL executee. Tous les exemples s'en servent.
using System;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
public sealed class JournalSql
{
public int Commandes { get; private set; }
public int Compilations { get; private set; }
public bool Afficher { get; set; } = true;
public void Noter(EventData donnees)
{
if (donnees.EventId == CoreEventId.QueryCompilationStarting) Compilations++;
else if (donnees is CommandExecutedEventData commande)
{
Commandes++;
if (Afficher) Console.WriteLine(commande.Command.CommandText.TrimEnd() + Environment.NewLine);
}
}
}
public static class Base
{
// Une base :memory: vit autant que sa connexion ouverte ; l'appelant la garde.
public static SqliteConnection Remplie()
{
var connexion = new SqliteConnection("DataSource=:memory:");
connexion.Open();
using var db = Ouvrir(connexion);
db.Database.EnsureCreated();
db.Auteurs.AddRange(
new Auteur
{
Nom = "Ursula K. Le Guin", AnneeDeces = 2018,
Livres = { new() { Titre = "A Wizard of Earthsea", Annee = 1968 },
new() { Titre = "The Left Hand of Darkness", Annee = 1969 },
new() { Titre = "The Dispossessed", Annee = 1974 } },
Prix = { new() { Nom = "Hugo", Annee = 1970 }, new() { Nom = "Nebula", Annee = 1975 } },
},
new Auteur
{
Nom = "Frank Herbert", AnneeDeces = 1986,
Livres = { new() { Titre = "Dune", Annee = 1965 },
new() { Titre = "Dune Messiah", Annee = 1969 } },
Prix = { new() { Nom = "Hugo", Annee = 1966 }, new() { Nom = "Nebula", Annee = 1966 } },
},
new Auteur
{
Nom = "Isaac Asimov", AnneeDeces = 1992,
Livres = { new() { Titre = "Foundation", Annee = 1951 },
new() { Titre = "The Caves of Steel", Annee = 1954 } },
Prix = { new() { Nom = "Hugo", Annee = 1973 }, new() { Nom = "Nebula", Annee = 1973 } },
});
db.SaveChanges();
return connexion;
}
public static Bibliotheque Ouvrir(SqliteConnection connexion, JournalSql? journal = null)
{
var options = new DbContextOptionsBuilder<Bibliotheque>().UseSqlite(connexion);
if (journal is not null)
{
// LogTo recoit chaque evenement que le filtre retient : ici, les
// commandes executees et les compilations de requete.
options.LogTo(
(evenement, _) => evenement == RelationalEventId.CommandExecuted
|| evenement == CoreEventId.QueryCompilationStarting,
journal.Noter);
}
return new Bibliotheque(options.Options);
}
}GenerateCreateScript montre le schéma que le modèle décrit, sans rien exécuter :
using System;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
static class Script
{
public static void Executer()
{
using var connexion = new SqliteConnection("DataSource=:memory:");
using var db = Base.Ouvrir(connexion);
foreach (var type in db.Model.GetEntityTypes())
{
Console.WriteLine($"{type.ClrType.Name} -> table {type.GetTableName()}");
}
// Auteur -> table Auteurs
// Livre -> table Ouvrages
// Prix -> table Prix
// Le schema que le modele decrit, sans rien executer.
Console.WriteLine(db.Database.GenerateCreateScript());
}
}CREATE TABLE "Auteurs" (
"Id" INTEGER NOT NULL CONSTRAINT "PK_Auteurs" PRIMARY KEY AUTOINCREMENT,
"Nom" TEXT NOT NULL,
"AnneeDeces" INTEGER NULL
);
CREATE TABLE "Ouvrages" (
"Id" INTEGER NOT NULL CONSTRAINT "PK_Ouvrages" PRIMARY KEY AUTOINCREMENT,
"Titre" TEXT NOT NULL,
"Annee" INTEGER NOT NULL,
"AuteurId" INTEGER NOT NULL,
CONSTRAINT "FK_Ouvrages_Auteurs_AuteurId" FOREIGN KEY ("AuteurId") REFERENCES "Auteurs" ("Id") ON DELETE RESTRICT
);
CREATE TABLE "Prix" (
"Id" INTEGER NOT NULL CONSTRAINT "PK_Prix" PRIMARY KEY AUTOINCREMENT,
"Nom" TEXT NOT NULL,
"Annee" INTEGER NOT NULL,
"AuteurId" INTEGER NOT NULL,
CONSTRAINT "FK_Prix_Auteurs_AuteurId" FOREIGN KEY ("AuteurId") REFERENCES "Auteurs" ("Id") ON DELETE CASCADE
);
CREATE UNIQUE INDEX "IX_Ouvrages_AuteurId_Titre" ON "Ouvrages" ("AuteurId", "Titre");
CREATE INDEX "IX_Prix_AuteurId" ON "Prix" ("AuteurId"); Chaque ligne se relit contre le modèle. La table d'un type prend le nom de son DbSet, sinon celui du type : Prix. Une relation obligatoire supprime en cascade par convention, sauf là où OnDelete l'a remplacé. L'index composite commence par AuteurId : EF Core n'ajoute pas l'index de clé étrangère qu'il aurait créé seul. Et HasMaxLength a disparu, parce que SQLite n'a qu'un type TEXT ; la longueur reste dans le modèle, et le même modèle donne character varying(100) sous PostgreSQL. Grouper la configuration d'une entité dans une classe IEntityTypeConfiguration<T> garde OnModelCreating lisible ; ApplyConfigurationsFromAssembly les applique toutes, dans un ordre que la documentation dit indéfini.
Le DbContext et le suivi des entités
Un DbContext est une unité de travail : on l'ouvre, on lit, on modifie, on appelle SaveChanges, on le libère. AddDbContext l'enregistre scoped, un par requête HTTP, et il n'est pas conçu pour servir deux threads à la fois : le cours ChangeTracker, qui garde une copie des valeurs lues et lui donne un état.
using System;
using System.Linq;
using Microsoft.EntityFrameworkCore;
static class Suivi
{
public static void Executer()
{
using var connexion = Base.Remplie();
var journal = new JournalSql { Afficher = false };
using var db = Base.Ouvrir(connexion, journal);
var dune = db.Livres.Single(l => l.Titre == "Dune");
Console.WriteLine(db.Entry(dune).State); // Unchanged
// Le contexte garde une copie des valeurs lues. Modifier la propriete ne
// previent personne : DetectChanges compare, quand on le lui demande.
dune.Annee = 1966;
Console.WriteLine(db.Entry(dune).State); // Modified
var suite = new Livre { Titre = "Children of Dune", Annee = 1976, AuteurId = dune.AuteurId };
db.Livres.Add(suite);
Console.WriteLine(db.Entry(suite).State); // Added
// Resolution d'identite : une autre requete sur la meme ligne rend la
// meme instance, avec la valeur en memoire, pas celle de la base.
var relue = db.Livres.Single(l => l.Id == dune.Id);
Console.WriteLine($"{ReferenceEquals(relue, dune)} {relue.Annee}"); // True 1966
journal.Afficher = true;
Console.WriteLine(db.SaveChanges());
Console.WriteLine(db.Entry(dune).State);
// UPDATE "Ouvrages" SET "Annee" = @p0
// WHERE "Id" = @p1
// RETURNING 1;
//
// INSERT INTO "Ouvrages" ("Annee", "AuteurId", "Titre")
// VALUES (@p0, @p1, @p2)
// RETURNING "Id";
//
// 2
// Unchanged
}
} Trois mécanismes apparaissent. Modifier une propriété ne prévient pas le contexte : DetectChanges compare les valeurs à la copie, et SaveChanges comme Entry le déclenchent. L'UPDATE ne porte que la colonne modifiée, et les deux commandes partent dans une transaction, qu'EF Core n'ouvre pas pour une instruction seule. Enfin, la résolution d'identité : une ligne n'a qu'une instance par contexte, et relire la ligne rend l'objet déjà suivi, avec ses valeurs en mémoire, même si la base dit autre chose.
AsNoTracking saute tout cela : ni copie, ni état, ni résolution d'identité, donc une lecture moins chère, qui convient à ce qu'on affiche sans le modifier. Le piège est de l'employer pour une entité qu'on va modifier : le contexte ne la connaît pas, et SaveChanges n'écrit rien, sans erreur.
using System;
using System.Linq;
using Microsoft.EntityFrameworkCore;
static class SansSuiviFaux
{
public static void Executer()
{
using var connexion = Base.Remplie();
using var db = Base.Ouvrir(connexion);
// Une lecture sans suivi, pour une entite qu'on va modifier.
var dune = db.Livres.AsNoTracking().Single(l => l.Titre == "Dune");
dune.Annee = 1966;
Console.WriteLine(db.Entry(dune).State); // Detached
Console.WriteLine(db.SaveChanges()); // 0 : rien n'est ecrit, sans erreur
}
} La règle suit l'intention : une requête suivie pour ce qu'on écrit, une requête sans suivi pour ce qu'on lit seulement. AsNoTrackingWithIdentityResolution rend une seule instance par ligne sans rien suivre ; UseQueryTrackingBehavior change le défaut pour tout un contexte.
using System;
using System.Linq;
using Microsoft.EntityFrameworkCore;
static class SansSuiviJuste
{
public static void Executer()
{
using var connexion = Base.Remplie();
using var db = Base.Ouvrir(connexion);
// Pour ecrire : une requete suivie.
var dune = db.Livres.Single(l => l.Titre == "Dune");
dune.Annee = 1966;
Console.WriteLine(db.SaveChanges()); // 1
// Pour lire seulement : sans suivi, rien n'est garde dans le contexte.
var herbert = db.Livres.AsNoTracking().Include(l => l.Auteur)
.Where(l => l.AuteurId == dune.AuteurId).OrderBy(l => l.Annee).ToList();
Console.WriteLine(db.ChangeTracker.Entries().Count()); // 1 : dune seul
Console.WriteLine(ReferenceEquals(herbert[0], dune)); // False
// Pas de resolution d'identite : un auteur par livre, meme s'il est le meme.
Console.WriteLine(ReferenceEquals(herbert[0].Auteur, herbert[1].Auteur)); // False
}
}Les migrations
Une migration est une classe générée qui fait passer le schéma d'un état du modèle au suivant. dotnet ef migrations add compare le modèle courant à un instantané, BibliothequeModelSnapshot.cs, écrit à côté des migrations, et génère la différence ; la base, elle, garde dans __EFMigrationsHistory la liste de celles qu'elle a reçues. Les outils ont besoin de construire le contexte hors de l'application :
// ---- Program.cs du projet Catalogue, a cote de Modele.cs. Paquets :
// Microsoft.EntityFrameworkCore.Sqlite et Microsoft.EntityFrameworkCore.Design
// 10.0.12 ; outil : dotnet tool install --global dotnet-ef --version 10.0.12.
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
// Les outils dotnet ef cherchent cette fabrique pour construire le contexte a
// la conception. Dans une application ASP.NET Core, ils peuvent a la place
// construire l'hote et prendre le contexte enregistre par AddDbContext.
public sealed class FabriqueBibliotheque : IDesignTimeDbContextFactory<Bibliotheque>
{
public Bibliotheque CreateDbContext(string[] args) =>
new(new DbContextOptionsBuilder<Bibliotheque>().UseSqlite("Data Source=bibliotheque.db").Options);
}
public static class Programme
{
public static void Main(string[] args)
{
using var db = new FabriqueBibliotheque().CreateDbContext(args);
db.Database.Migrate();
Console.WriteLine("migre");
}
}# Le projet Catalogue, modele de la premiere section, sans migration.
$ dotnet ef migrations add Initiale
Build started...
Build succeeded.
Done. To undo this action, use 'ef migrations remove'
# On ajoute a Livre la propriete : public string? Isbn { get; set; }
$ dotnet ef migrations has-pending-model-changes
Build started...
Build succeeded.
Changes have been made to the model since the last migration. Add a new migration.
$ echo $?
1
$ dotnet ef migrations add AjoutIsbn
Build started...
Build succeeded.
Done. To undo this action, use 'ef migrations remove'
$ dotnet ef migrations list
Build started...
Build succeeded.
20260926063336_Initiale (Pending)
20260926063408_AjoutIsbn (Pending)
$ dotnet ef migrations script Initiale
Build started...
Build succeeded.
BEGIN TRANSACTION;
ALTER TABLE "Ouvrages" ADD "Isbn" TEXT NULL;
INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion")
VALUES ('20260926063408_AjoutIsbn', '10.0.12');
COMMIT;
$ dotnet ef database update
Build started...
Build succeeded.
Acquiring an exclusive lock for migration application. See https://aka.ms/efcore-docs-migrations-lock for more information if this takes too long.
Applying migration '20260926063336_Initiale'.
Applying migration '20260926063408_AjoutIsbn'.
Done.// ---- Migrations/20260926063408_AjoutIsbn.cs, tel que dotnet ef l'a genere
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Catalogue.Migrations
{
/// <inheritdoc />
public partial class AjoutIsbn : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "Isbn",
table: "Ouvrages",
type: "TEXT",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Isbn",
table: "Ouvrages");
}
}
} Une migration se relit avant d'être appliquée. EF Core devine un renommage de propriété quand il le peut, et la documentation prévient qu'une migration peut supprimer une colonne qu'on voulait renommer ; quand il génère une opération destructrice, l'outil l'annonce par An operation was scaffolded that may result in the loss of data. has-pending-model-changes, qui sort en code 1 quand le modèle a changé sans migration, a sa place dans l'intégration continue : depuis EF Core 9, Migrate() lève dans ce cas une InvalidOperationException au nom de PendingModelChangesWarning, au lieu d'appliquer un schéma incomplet.
Un script tiré de migrations script ne vaut que pour une base qui en est exactement au point de départ. Le script idempotent vérifie, migration par migration, l'historique de la base et n'applique que ce qui manque, à condition de le générer sans migration de départ, qui le bornerait comme l'autre ; son support dépend du fournisseur, et sous SQLite la commande échoue sur Generating idempotent scripts for migrations is not currently supported for SQLite.
# Le meme modele et les memes deux migrations, avec le fournisseur
# Npgsql.EntityFrameworkCore.PostgreSQL 10.0.3 au lieu de SQLite. Aucun serveur
# n'est necessaire pour generer un script. (Autre projet : autre horodatage.)
# Sans migration de depart, le script couvre tout l'historique.
$ dotnet ef migrations script --idempotent
Build started...
Build succeeded.
CREATE TABLE IF NOT EXISTS "__EFMigrationsHistory" (
"MigrationId" character varying(150) NOT NULL,
"ProductVersion" character varying(32) NOT NULL,
CONSTRAINT "PK___EFMigrationsHistory" PRIMARY KEY ("MigrationId")
);
START TRANSACTION;
DO $EF$
BEGIN
IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260926062753_Initiale') THEN
CREATE TABLE "Auteurs" (
"Id" integer GENERATED BY DEFAULT AS IDENTITY,
"Nom" character varying(100) NOT NULL,
"AnneeDeces" integer,
CONSTRAINT "PK_Auteurs" PRIMARY KEY ("Id")
);
END IF;
END $EF$;
DO $EF$
BEGIN
IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260926062753_Initiale') THEN
CREATE TABLE "Ouvrages" (
"Id" integer GENERATED BY DEFAULT AS IDENTITY,
"Titre" character varying(200) NOT NULL,
"Annee" integer NOT NULL,
"AuteurId" integer NOT NULL,
CONSTRAINT "PK_Ouvrages" PRIMARY KEY ("Id"),
CONSTRAINT "FK_Ouvrages_Auteurs_AuteurId" FOREIGN KEY ("AuteurId") REFERENCES "Auteurs" ("Id") ON DELETE RESTRICT
);
END IF;
END $EF$;
DO $EF$
BEGIN
IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260926062753_Initiale') THEN
CREATE TABLE "Prix" (
"Id" integer GENERATED BY DEFAULT AS IDENTITY,
"Nom" text NOT NULL,
"Annee" integer NOT NULL,
"AuteurId" integer NOT NULL,
CONSTRAINT "PK_Prix" PRIMARY KEY ("Id"),
CONSTRAINT "FK_Prix_Auteurs_AuteurId" FOREIGN KEY ("AuteurId") REFERENCES "Auteurs" ("Id") ON DELETE CASCADE
);
END IF;
END $EF$;
DO $EF$
BEGIN
IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260926062753_Initiale') THEN
CREATE UNIQUE INDEX "IX_Ouvrages_AuteurId_Titre" ON "Ouvrages" ("AuteurId", "Titre");
END IF;
END $EF$;
DO $EF$
BEGIN
IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260926062753_Initiale') THEN
CREATE INDEX "IX_Prix_AuteurId" ON "Prix" ("AuteurId");
END IF;
END $EF$;
DO $EF$
BEGIN
IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260926062753_Initiale') THEN
INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion")
VALUES ('20260926062753_Initiale', '10.0.12');
END IF;
END $EF$;
COMMIT;
START TRANSACTION;
DO $EF$
BEGIN
IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260926062805_AjoutIsbn') THEN
ALTER TABLE "Ouvrages" ADD "Isbn" text;
END IF;
END $EF$;
DO $EF$
BEGIN
IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260926062805_AjoutIsbn') THEN
INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion")
VALUES ('20260926062805_AjoutIsbn', '10.0.12');
END IF;
END $EF$;
COMMIT; Appliquer les migrations au démarrage, par Migrate(), est le plus simple. L'application doit alors détenir le droit de modifier le schéma, personne ne relit le SQL, et le retour arrière n'est pas outillé. Depuis EF Core 9, un verrou empêche au moins deux instances de migrer ensemble : la documentation d'EF Core, lue le 26 septembre 2026, admet cette voie pour une application qui en accepte les compromis, quand celle d'ASP.NET Core la déconseille en production. L'écart tient à l'argument de cette dernière, plusieurs serveurs qui migrent en même temps, que le verrou traite ; il reste valable pour les requêtes qui touchent la base pendant qu'elle change. Pour un déploiement, EF Core recommande un script quand le SQL doit être relu, et sinon un bundle : dotnet ef migrations bundle produit un exécutable autonome, efbundle, qui applique les migrations sans SDK ni sources et prend la chaîne de connexion par --connection. Enfin, EnsureCreated, que les tests emploient, crée le schéma sans passer par les migrations : un Migrate() sur une telle base échoue sur table "Auteurs" already exists. Le cours EnsureCreated sur une base SQLite de test.
Le N+1
Le N+1 est une requête pour la liste, puis une par élément de la liste. Il naît d'une boucle qui interroge la base pour chaque élément : le code est juste, et le nombre de requêtes croît avec les données. Ici, trois auteurs coûtent quatre commandes ; mille en coûteraient mille et une.
using System;
using System.Linq;
static class NPlusUnFaux
{
public static void Executer()
{
using var connexion = Base.Remplie();
var journal = new JournalSql();
using var db = Base.Ouvrir(connexion, journal);
var auteurs = db.Auteurs.OrderBy(a => a.Nom).ToList();
foreach (var auteur in auteurs)
{
// Une requete par auteur, dans la boucle.
var titres = db.Livres.Where(l => l.AuteurId == auteur.Id).OrderBy(l => l.Annee).Select(l => l.Titre).ToList();
Console.WriteLine($"{auteur.Nom} : {string.Join(", ", titres)}");
}
Console.WriteLine($"{journal.Commandes} commandes SQL");
}
}
// SELECT "a"."Id", "a"."AnneeDeces", "a"."Nom"
// FROM "Auteurs" AS "a"
// ORDER BY "a"."Nom"
//
// SELECT "o"."Titre"
// FROM "Ouvrages" AS "o"
// WHERE "o"."AuteurId" = @auteur_Id
// ORDER BY "o"."Annee"
//
// Frank Herbert : Dune, Dune Messiah
// SELECT "o"."Titre"
// FROM "Ouvrages" AS "o"
// WHERE "o"."AuteurId" = @auteur_Id
// ORDER BY "o"."Annee"
//
// Isaac Asimov : Foundation, The Caves of Steel
// SELECT "o"."Titre"
// FROM "Ouvrages" AS "o"
// WHERE "o"."AuteurId" = @auteur_Id
// ORDER BY "o"."Annee"
//
// Ursula K. Le Guin : A Wizard of Earthsea, The Left Hand of Darkness, The Dispossessed
// 4 commandes SQL Le chargement différé le rend invisible : avec le paquet Microsoft.EntityFrameworkCore.Proxies, UseLazyLoadingProxies() et des navigations virtual sur des classes non scellées, ce que le modèle scellé de ce cours n'est pas, lire auteur.Livres lance la requête sans qu'aucun appel ne la trahisse, et la documentation recommande de l'éviter pour cette raison. Sans lui, une navigation non chargée reste vide : trois auteurs lus seuls donnent trois collections de zéro livre, un résultat faux plutôt que lent. La correction demande tout en une fois, ici par une projection :
using System;
using System.Linq;
static class NPlusUnJuste
{
public static void Executer()
{
using var connexion = Base.Remplie();
var journal = new JournalSql();
using var db = Base.Ouvrir(connexion, journal);
// Une projection : les seules colonnes utiles, en une requete.
var auteurs = db.Auteurs
.OrderBy(a => a.Nom)
.Select(a => new { a.Nom, Titres = a.Livres.OrderBy(l => l.Annee).Select(l => l.Titre).ToList() })
.ToList();
foreach (var auteur in auteurs)
{
Console.WriteLine($"{auteur.Nom} : {string.Join(", ", auteur.Titres)}");
}
Console.WriteLine($"{journal.Commandes} commande SQL");
}
}
// SELECT "a"."Nom", "a"."Id", "o"."Titre", "o"."Id"
// FROM "Auteurs" AS "a"
// LEFT JOIN "Ouvrages" AS "o" ON "a"."Id" = "o"."AuteurId"
// ORDER BY "a"."Nom", "a"."Id", "o"."Annee"
//
// Frank Herbert : Dune, Dune Messiah
// Isaac Asimov : Foundation, The Caves of Steel
// Ursula K. Le Guin : A Wizard of Earthsea, The Left Hand of Darkness, The Dispossessed
// 1 commande SQLLa projection vers un type anonyme ne lit que les colonnes utiles et ne suit rien, puisque ni le nom ni les titres ne sont des entités. Le compteur du journal est le test : une page qui envoie une requête par ligne affichée se voit au premier coup d'œil dans le journal, et un test d'intégration peut l'affirmer.
Include, AsSplitQuery et projections
Quand il faut les entités elles-mêmes, pour les modifier, Include charge une navigation dans la même requête, par une jointure. Deux collections au même niveau multiplient les lignes : chaque livre d'un auteur est répété pour chacun de ses prix.
using System;
using System.Linq;
using Microsoft.EntityFrameworkCore;
static class Chargement
{
public static void Executer()
{
using var connexion = Base.Remplie();
var journal = new JournalSql();
using (var db = Base.Ouvrir(connexion, journal))
{
// Deux collections au meme niveau, dans une seule requete.
var auteurs = db.Auteurs.Include(a => a.Livres).Include(a => a.Prix).ToList();
Console.WriteLine($"{auteurs.Count} auteurs, {journal.Commandes} commande");
}
journal = new JournalSql();
using (var db = Base.Ouvrir(connexion, journal))
{
var auteurs = db.Auteurs.Include(a => a.Livres).Include(a => a.Prix).AsSplitQuery().ToList();
Console.WriteLine($"{auteurs.Count} auteurs, {journal.Commandes} commandes");
}
}
}
// SELECT "a"."Id", "a"."AnneeDeces", "a"."Nom", "o"."Id", "o"."Annee", "o"."AuteurId", "o"."Titre", "p"."Id", "p"."Annee", "p"."AuteurId", "p"."Nom"
// FROM "Auteurs" AS "a"
// LEFT JOIN "Ouvrages" AS "o" ON "a"."Id" = "o"."AuteurId"
// LEFT JOIN "Prix" AS "p" ON "a"."Id" = "p"."AuteurId"
// ORDER BY "a"."Id", "o"."Id"
//
// 3 auteurs, 1 commande
// SELECT "a"."Id", "a"."AnneeDeces", "a"."Nom"
// FROM "Auteurs" AS "a"
// ORDER BY "a"."Id"
//
// SELECT "o"."Id", "o"."Annee", "o"."AuteurId", "o"."Titre", "a"."Id"
// FROM "Auteurs" AS "a"
// INNER JOIN "Ouvrages" AS "o" ON "a"."Id" = "o"."AuteurId"
// ORDER BY "a"."Id"
//
// SELECT "p"."Id", "p"."Annee", "p"."AuteurId", "p"."Nom", "a"."Id"
// FROM "Auteurs" AS "a"
// INNER JOIN "Prix" AS "p" ON "a"."Id" = "p"."AuteurId"
// ORDER BY "a"."Id"
//
// 3 auteurs, 3 commandes La requête unique renvoie 14 lignes pour 3 auteurs, 7 livres et 6 prix : 3 × 2 + 2 × 2 + 2 × 2. Avec cent livres et vingt prix par auteur, ce serait deux mille lignes pour cent vingt entités ; c'est l'explosion cartésienne. EF Core le signale au journal, niveau Warning, par MultipleCollectionIncludeWarning : la requête charge plusieurs collections sans qu'un comportement de découpage ait été choisi. Il ne le signale pas pour une seule collection. AsSplitQuery envoie une requête pour les auteurs, puis une par collection, et chacune ne transporte que ses propres lignes.
Le découpage a son prix, que la documentation détaille : un aller-retour par requête, et aucune garantie de cohérence si la base change entre deux requêtes, sauf transaction sérialisable ou instantané. Sur ces petites données, il lit d'ailleurs 16 lignes en trois allers-retours au lieu de 14 en un. Le choix se fait requête par requête, ou pour tout le contexte par UseQuerySplittingBehavior. Et la projection reste la meilleure réponse quand on ne fait que lire : elle évite à la fois les colonnes inutiles et le suivi.
Le cache de requêtes et les requêtes compilées
Traduire un arbre d'expression en SQL coûte cher, et EF Core met le résultat en cache, indexé par la forme de l'arbre. Une variable capturée par le lambda devient un paramètre et garde la forme intacte : la même requête, avec d'autres valeurs, ne se recompile pas. Une constante, en revanche, fait partie de la forme. Un filtre construit avec l'API Expression et Expression.Constant, cas fréquent des écrans de recherche génériques, recompile à chaque valeur, et envoie un SQL différent dont la base doit elle aussi calculer le plan.
using System;
using System.Linq;
using System.Linq.Expressions;
static class CacheFaux
{
// Un filtre construit a la main, comme dans un ecran de recherche generique :
// la valeur entre dans l'arbre comme une constante.
static IQueryable<Livre> ParTitre(IQueryable<Livre> source, string titre)
{
var l = Expression.Parameter(typeof(Livre), "l");
var egal = Expression.Equal(Expression.Property(l, nameof(Livre.Titre)), Expression.Constant(titre));
return source.Where(Expression.Lambda<Func<Livre, bool>>(egal, l));
}
public static void Executer()
{
using var connexion = Base.Remplie();
var journal = new JournalSql();
using var db = Base.Ouvrir(connexion, journal);
foreach (var titre in new[] { "Dune", "Foundation", "The Caves of Steel" })
{
ParTitre(db.Livres, titre).Single();
}
Console.WriteLine($"{journal.Compilations} compilations");
}
}
// SELECT "o"."Id", "o"."Annee", "o"."AuteurId", "o"."Titre"
// FROM "Ouvrages" AS "o"
// WHERE "o"."Titre" = 'Dune'
// LIMIT 2
//
// SELECT "o"."Id", "o"."Annee", "o"."AuteurId", "o"."Titre"
// FROM "Ouvrages" AS "o"
// WHERE "o"."Titre" = 'Foundation'
// LIMIT 2
//
// SELECT "o"."Id", "o"."Annee", "o"."AuteurId", "o"."Titre"
// FROM "Ouvrages" AS "o"
// WHERE "o"."Titre" = 'The Caves of Steel'
// LIMIT 2
//
// 3 compilations La correction met dans l'arbre ce que le compilateur C# y met pour une variable capturée : l'accès au champ d'une fermeture. Envelopper la constante dans un appel à EF.Parameter, depuis EF Core 9, produit le même effet. En production, les métriques compiled_query_cache_hits et compiled_query_cache_misses, depuis EF Core 9, révèlent un cache qui ne se remplit jamais.
using System;
using System.Linq;
using System.Linq.Expressions;
static class CacheJuste
{
static IQueryable<Livre> ParTitre(IQueryable<Livre> source, string titre)
{
var l = Expression.Parameter(typeof(Livre), "l");
// Le corps d'un lambda qui capture titre : un acces au champ d'une
// fermeture, que le compilateur C# produit, et qu'EF Core parametre.
Expression<Func<string>> valeur = () => titre;
var egal = Expression.Equal(Expression.Property(l, nameof(Livre.Titre)), valeur.Body);
return source.Where(Expression.Lambda<Func<Livre, bool>>(egal, l));
}
public static void Executer()
{
using var connexion = Base.Remplie();
var journal = new JournalSql();
using var db = Base.Ouvrir(connexion, journal);
foreach (var titre in new[] { "Dune", "Foundation", "The Caves of Steel" })
{
ParTitre(db.Livres, titre).Single();
}
Console.WriteLine($"{journal.Compilations} compilation");
}
}
// SELECT "o"."Id", "o"."Annee", "o"."AuteurId", "o"."Titre"
// FROM "Ouvrages" AS "o"
// WHERE "o"."Titre" = @titre
// LIMIT 2
//
// SELECT "o"."Id", "o"."Annee", "o"."AuteurId", "o"."Titre"
// FROM "Ouvrages" AS "o"
// WHERE "o"."Titre" = @titre
// LIMIT 2
//
// SELECT "o"."Id", "o"."Annee", "o"."AuteurId", "o"."Titre"
// FROM "Ouvrages" AS "o"
// WHERE "o"."Titre" = @titre
// LIMIT 2
//
// 1 compilation Même servie par le cache, une requête a un coût fixe : EF Core compare son arbre à ceux du cache pour la retrouver. EF.CompileAsyncQuery supprime cette étape : la requête devient un délégué, qu'on appelle avec un contexte et des paramètres scalaires.
using System;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore;
static class Compilee
{
// Un delegue, cree une fois, sur lequel plusieurs contextes peuvent
// s'executer en meme temps. La requete est compilee a son premier appel.
private static readonly Func<Bibliotheque, string, Task<Livre?>> LivreParTitre =
EF.CompileAsyncQuery((Bibliotheque db, string titre) => db.Livres.SingleOrDefault(l => l.Titre == titre));
public static async Task ExecuterAsync()
{
using var connexion = Base.Remplie();
var journal = new JournalSql { Afficher = false };
foreach (var titre in new[] { "Dune", "Foundation", "Neuromancer" })
{
await using var db = Base.Ouvrir(connexion, journal);
var livre = await LivreParTitre(db, titre);
Console.WriteLine(livre is null ? $"{titre} : absent" : $"{livre.Titre} ({livre.Annee}), {db.Entry(livre).State}");
}
Console.WriteLine($"{journal.Compilations} compilation, {journal.Commandes} commandes");
}
}
// Dune (1965), Unchanged
// Foundation (1951), Unchanged
// Neuromancer : absent
// 1 compilation, 3 commandes
// (La meme boucle avec db.Livres.SingleOrDefault donne aussi 1 compilation : le
// gain n'est pas la compilation, deja en cache, mais la recherche dans le cache.) Le gain est modeste : la documentation dit négligeable, pour la plupart des applications, le coût que cette étape ajoute à l'exécution, et son banc d'essai, sur une table d'un seul blog, mesure 564,2 µs avec la requête compilée contre 671,6 µs sans. Il vaut pour une requête très fréquente, sur un chemin chaud. Le délégué se crée une fois, dans un champ statique, et ne se compose plus : pas de Where ajouté après, pas de requête dynamique.
ExecuteUpdate et ExecuteDelete
Modifier cent lignes par le suivi, c'est les charger toutes, les modifier une à une, et laisser SaveChanges envoyer un UPDATE par ligne. ExecuteUpdate et ExecuteDelete, depuis EF Core 7, traduisent la requête en une seule instruction UPDATE ou DELETE, exécutée tout de suite, qui rend le nombre de lignes touchées.
using System;
using System.Linq;
using Microsoft.EntityFrameworkCore;
static class Masse
{
public static void Executer()
{
using var connexion = Base.Remplie();
var journal = new JournalSql();
using var db = Base.Ouvrir(connexion, journal);
// Une instruction UPDATE, executee tout de suite : ni chargement, ni
// SaveChanges. Le second lambda lit la valeur actuelle de la ligne.
var modifies = db.Livres
.Where(l => l.Annee < 1960)
.ExecuteUpdate(s => s.SetProperty(l => l.Titre, l => l.Titre + " (classique)"));
Console.WriteLine($"{modifies} livres modifies");
var supprimes = db.Set<Prix>().Where(p => p.Nom == "Nebula").ExecuteDelete();
Console.WriteLine($"{supprimes} prix supprimes");
Console.WriteLine($"{journal.Commandes} commandes SQL");
}
}
// UPDATE "Ouvrages" AS "o"
// SET "Titre" = "o"."Titre" || ' (classique)'
// WHERE "o"."Annee" < 1960
//
// 2 livres modifies
// DELETE FROM "Prix" AS "p"
// WHERE "p"."Nom" = 'Nebula'
//
// 3 prix supprimes
// 2 commandes SQL Depuis EF Core 10, le paramètre des setters est un lambda ordinaire et non plus un arbre d'expression : un if peut y ajouter un SetProperty sous condition, ce qui demandait avant de construire l'arbre à la main. Ces méthodes passent à côté du ChangeTracker, et c'est leur piège : les entités déjà suivies ne voient rien, ni le changement, ni la suppression. Ici, SaveChanges tente de mettre à jour une ligne supprimée :
using System;
using System.Linq;
using Microsoft.EntityFrameworkCore;
static class MasseFaux
{
public static void Executer()
{
using var connexion = Base.Remplie();
using var db = Base.Ouvrir(connexion);
var dune = db.Livres.Single(l => l.Titre == "Dune");
db.Livres.Where(l => l.Annee < 1970).ExecuteUpdate(s => s.SetProperty(l => l.Titre, l => l.Titre + " (classique)"));
Console.WriteLine(dune.Titre); // Dune : l'instance suivie n'a pas bouge
db.Livres.Where(l => l.AuteurId == dune.AuteurId).ExecuteDelete();
Console.WriteLine(db.Entry(dune).State); // Unchanged : la ligne n'existe plus
dune.Annee = 1966;
try
{
db.SaveChanges();
}
catch (DbUpdateConcurrencyException erreur)
{
Console.WriteLine(erreur.Message);
// The database operation was expected to affect 1 row(s), but actually
// affected 0 row(s); data may have been modified or deleted since
// entities were loaded. See https://go.microsoft.com/fwlink/?LinkId=527962
// for information on understanding and handling optimistic concurrency
// exceptions.
}
}
} Faute de suivi, elles n'appliquent pas non plus le contrôle de concurrence : un jeton de concurrence se vérifie à la main, par un Where sur sa valeur et le nombre de lignes rendu. Elles n'ouvrent pas non plus de transaction : deux appels successifs en font deux, et si le second échoue, le premier reste écrit ; BeginTransaction les réunit. Le plus simple est de faire passer l'opération de masse avant toute lecture. Quand des entités sont déjà suivies, le contexte les oublie et relit ce dont il a besoin :
using System;
using System.Linq;
using Microsoft.EntityFrameworkCore;
static class MasseJuste
{
public static void Executer()
{
using var connexion = Base.Remplie();
using var db = Base.Ouvrir(connexion);
var dune = db.Livres.Single(l => l.Titre == "Dune");
db.Livres.Where(l => l.Annee < 1970).ExecuteUpdate(s => s.SetProperty(l => l.Titre, l => l.Titre + " (classique)"));
// La base a change sous le contexte : on oublie ce qu'il suit, et on relit.
db.ChangeTracker.Clear();
dune = db.Livres.Single(l => l.Id == dune.Id);
Console.WriteLine(dune.Titre); // Dune (classique)
dune.Annee = 1966;
Console.WriteLine(db.SaveChanges()); // 1
}
}