EF Core : des requêtes vérifiées à la compilation

Ce notebook digère l’axe EF Core — requêtes vérifiées à la compilation de la série The Unexpected AI Stack: C#/.NET (issue #10473). La Part 1 de l’auteur promettait ce contraste sans jamais le porter dans le dépôt : c’est la ligne « Non distillable / À ouvrir » du registre des axes, que ce notebook ferme.

La thèse est la même que celle du notebook Roslyn — analyseurs statiques (livré) : le compilateur est un garde-fou. Un analyseur Roslyn attrape un mauvais usage de la librairie pendant la compilation ; ici, on montre que la requête elle-même — en LINQ-to-Entities — est un arbre d’expression typé dont chaque membre est vérifié par Roslyn avant tout déploiement.

En Python, les ORM populaires (peewee, SQLAlchemy en expression language) construisent le SQL à l’exécution : une faute de frappe dans un nom de colonne ne se voit qu’au premier execute() — souvent en production. La même faute, en C#, est une erreur de compilation. C’est la différentielle que ce notebook rend visible, cellule par cellule.

Ce que le notebook démontre :

  1. le domaine + SQLite in-memory — des jobs de transcription, cohérents avec la série Aspire (TranscriptionJob), sur une base in-memory CPU-only, déterministe, sans Docker ;
  2. la requête typée — chaque membre est contrasté contre un contre-exemple Python qui, lui, passe la compilation et échoue seulement à l’exécution ;
  3. ToQueryString() — le SQL généré par EF Core rendu transparent dans l’output ;
  4. la requête compilée (EF.CompileQuery / EF.CompileAsyncQuery) — compilée une fois, délégué mis en cache, mesurée avant/après avec une lecture honnête du gain ;
  5. la table de parité Python ⇄ .NET (livrable transversal du registre) ;
  6. trois exercices exécutables, stubs sans erreur volontaire.

Position dans la série : le dossier GenAI/Integrations-DotNet/Aspire/ couvre l’AppHost et l’observabilité ; GenAI/Integrations-DotNet/CopilotSDK/ traite le binding C# (Part 3). Ce notebook se place dans le hub GenAI/Integrations-DotNet/EFCore/, en amont de l’outillage : il exécute la librairie (EF Core en .NET Interactive local), pas un orchestrateur. L’axe est provider-indépendant : la vérification à la compilation porte sur le modèle LD (types), pas sur le moteur de base. On choisit SQLite in-memory pour rester CPU-only, déterministe, sans conteneur — mais le point pédagogique est identique sur PostgreSQL. Précision d’implémentation : le kernel .net-csharp de ce notebook tourne en net9.0, donc on épingle EF Core 9.0.7 — le dernier jalon compatible ce TFM. La série Aspire (applications net10.0) aligne la même librairie sur Npgsql 10.0.3 : la différentielle n’en dépend pas, seule la version épinglée change.

Ce notebook part d’une hypothèse qu’il refuse de laisser en l’air : la vérification à la compilation est un avantage réel, mais mesurable. On ne se contente pas d’affirmer la differential, on l’exécute — d’un côté la requête typée qui doit compiler pour exister, de l’autre la mesure du surcoût de la traduction LINQ→SQL, pour que le lecteur juge par lui-même où la pertinence s’arrête.

// Pin EF Core SQLite 9.0.7 — 9.x est le dernier jalon compatible net9.0, le TFM du kernel
// .net-csharp. (La série Aspire, elle, cible net10.0/Npgsql 10.0.3 — la différentielle est
// indépendante du moteur.) Version exacte mise en cache d'abord (recette `dotnet add package`),
// sinon le kernel headless mourrait sur une re-resolvion réseau (blocker nuget réparable localement).
#r "nuget: Microsoft.EntityFrameworkCore.Sqlite, 9.0.7"
using Microsoft.EntityFrameworkCore;
using Microsoft.Data.Sqlite;
using System.Diagnostics;

Console.WriteLine("EF Core charge : Microsoft.EntityFrameworkCore.Sqlite 9.0.7");
Installed Packages
  • Microsoft.EntityFrameworkCore.Sqlite, 9.0.7
EF Core charge : Microsoft.EntityFrameworkCore.Sqlite 9.0.7

1 · Le domaine : des jobs de transcription

On reprend un domaine simple et cohérent avec la série : des jobs de transcription audio. Chaque job a un fichier, une durée, un score de confiance du moteur, un statut et un message d’erreur optionnel. Rien de complexe — le but n’est pas la modélisation, c’est la requête.

Le choix du SQLite in-memory est délibéré : la base vit dans une connexion :memory: ouverte, est peuplée par un seed déterministe (un Random(42) → les outputs sont reproductibles, pas de dérive C.4), et ne demande ni Docker ni service externe. C’est le compromis classique d’un notebook : la démonstration porte sur LINQ-to-Entities et sa traduction en SQL, pas sur les performances d’un moteur de base de production.

Le modèle est déclaré en C#, et c’est déjà le premier point de la differential : la table n’existe que comme propriété typée du DbContext (Transactions.Jobs). Un DbSet<T> est un IQueryable<T>, et IQueryable<T> porte des expressions (pas des chaînes de SQL). C’est ce qui permet à Roslyn de vérifier la requête avant l’exécution.

// Modele + contexte, puis creation de la base in-memory et seed deterministe.
public enum StatutJob { Termine, Echec, EnCours }

public class TranscriptionJob
{
    public int Id { get; set; }
    public string Fichier { get; set; } = "";
    public int DureeSecondes { get; set; }
    public double ScoreConfiance { get; set; }
    public StatutJob Statut { get; set; }
    public string? MessageErreur { get; set; }
}

public class TranscriptionContext : DbContext
{
    public DbSet<TranscriptionJob> Jobs => Set<TranscriptionJob>();
    public TranscriptionContext(DbContextOptions<TranscriptionContext> options) : base(options) { }
    protected override void OnModelCreating(ModelBuilder b)
    {
        b.Entity<TranscriptionJob>().ToTable("TranscriptionJobs");
        b.Entity<TranscriptionJob>().HasIndex(x => x.Fichier).IsUnique();
    }
}

// SQLite `:memory:` est PAR CONNEXION. On ouvre UNE seule connexion et on la passe au contexte :
// sinon EF Core en ouvrirait une nouvelle à chaque opération -> base vide -> "no such table".
var connection = new SqliteConnection("DataSource=:memory:");
connection.Open();
var options = new DbContextOptionsBuilder<TranscriptionContext>().UseSqlite(connection).Options;
var ctx = new TranscriptionContext(options);
ctx.Database.EnsureCreated();

// Seed deterministe -> outputs reproductibles (pas de derive C.4).
var rng = new Random(42);
for (int i = 1; i <= 30; i++)
{
    var statut = (i % 3) == 0 ? StatutJob.Echec : ((i % 3) == 1 ? StatutJob.Termine : StatutJob.EnCours);
    ctx.Jobs.Add(new TranscriptionJob
    {
        Fichier = $"job_{i:D3}.wav",
        DureeSecondes = 30 + rng.Next(0, 240),
        ScoreConfiance = rng.NextDouble(),
        Statut = statut,
        MessageErreur = statut == StatutJob.Echec ? "audio illisible au segment 7" : null
    });
}
ctx.SaveChanges();
Console.WriteLine($"Seed OK : {ctx.Jobs.Count()} jobs dans la base SQLite en memoire");
Seed OK : 30 jobs dans la base SQLite en memoire

2 · La requête typée : le compilateur comme garde-fou

La requête suivante est écrite en LINQ-to-Entities. Chaque nom qu’elle manipule — j.Statut, j.DureeSecondes, j.Fichier, j.ScoreConfiance — est un membre du type TranscriptionJob, et l’expression j => ... est un Expression<Func<...>> que le compilateur vérifie membre par membre. Si un membre était mal orthographié ou d’un type incompatible, la compilation elle-même échouerait : la faute serait attrapée avant tout déploiement, à la milliseconde où l’on écrit le code.

C’est la differential avec l’écosystème Python. Voici le même critère en peewee, où la même faute est silencieuse :

# Anti-pattern Python (peewee) : la colonne s'appelle `duree_secondes`, mais l'erreur
# de frappe `duree_seconeds` ne declenche AUCUNE erreur a la compilation (il n'y en a pas).
critere = TranscriptionJob.duree_seconeds > 100   # <- passe a la compilation (ou pas de compile)
liste   = TranscriptionJob.select().where(critere)  # construit le SQL... a l'execution
resultats = list(liste.execute())                   # erreur seulement ICI, souvent en prod

Avec SQLAlchemy (expression language) le mécanisme est le même : les critères sont des Column objets réunis en un arbre à l’exécution, et le nom de colonne fautif ne saute aux yeux qu’au moment où l’ORM le résout contre le schéma — très loin du moment où il a été frappé. Le point n’est pas « Python est mal » : c’est que là où il n’y a pas de compilateur, le filet est plus bas. C# déplace ce filet au moment de la frappe.

Remarque d’honnêteté : un ORM mappé Python (SQLAlchemy declarative) attrape une partie de ces erreurs au premier accès à la session, et un IDE avec un linter attrape des fautes de frappe. La difference que ce notebook illustre est le lieu du contrôle : dans l’arbre d’expression vérifié à la compilation (Expression), pas dans un objet sqlalchemy.sql assemblé au runtime.

Concrètement, une requête EF Core n’est pas une chaîne : c’est un délégué d’expression qui décrit ce que l’on veut, et le provider (SQLite ici) le traduit en SQL au dernier moment. Chaque étape — Where, OrderBy, Take, Select — produit un nouvel arbre immuable. C’est ce modèle d’expression, et non une chaîne, que Roslyn type : un nom de membre inconnu n’a simplement pas d’arbre d’expression valide à créer.

// La requete typee : chaque membre est verifie par Roslyn a la compilation.
// `j.DureeSecondes` existe dans le modele -> la compilation passe. Un faux nom ferait
// echouer le build AVANT l'execution (la differential demonstree).
var requete = ctx.Jobs
    .Where(j => j.Statut == StatutJob.Termine && j.DureeSecondes > 100)
    .OrderBy(j => j.DureeSecondes)
    .Take(5)
    .Select(j => new { j.Fichier, j.DureeSecondes, j.ScoreConfiance });

Console.WriteLine($"Requete typée compilee et executee : {requete.Count()} job(s) termine(s) de plus de 100s");
foreach (var j in requete)
    Console.WriteLine($"  {j.Fichier}  {j.DureeSecondes}s  confiance={j.ScoreConfiance:F3}");
Requete typée compilee et executee : 5 job(s) termine(s) de plus de 100s
  job_007.wav  151s  confiance=0,320
  job_028.wav  175s  confiance=0,516
  job_001.wav  190s  confiance=0,141
  job_016.wav  196s  confiance=0,516
  job_004.wav  203s  confiance=0,513

3 · ToQueryString() : rendre le SQL transparent

Un reproche classique contre les ORM est d’obscurcir le SQL : on écrit du C# et on ne sait pas ce qui part en base. ToQueryString() répond exactement à cela : il renvoie le SQL que la requête va produire, sans l’exécuter. On voit ici les clauses SELECT, WHERE, ORDER BY générées par EF Core à partir de l’arbre d’expression.

La transparence n’est pas un luxe — c’est ce qui permet de déboguer une requête lente ou de vérifier qu’un index est utilisé sans instrumenter la base. En Python, SQLAlchemy offre l’équivalent (select().compile(...)), peewee l’expose aussi ; là encore ce n’est pas l’absence d’outil qui distingue les piles, c’est le moment où l’erreur apparaît : la traduction LINQ→SQL est déterministe et vérifiée côté .NET, on peut l’inspecter avant d’exécuter.

// ToQueryString() : le SQL genere, visible avant execution.
Console.WriteLine("--- SQL genere (non execute) ---");
var sql = ctx.Jobs
    .Where(j => j.Statut == StatutJob.Echec)
    .OrderByDescending(j => j.DureeSecondes)
    .ToQueryString();
Console.WriteLine(sql);
Console.WriteLine("--- fin ---");
--- SQL genere (non execute) ---
SELECT "t"."Id", "t"."DureeSecondes", "t"."Fichier", "t"."MessageErreur", "t"."ScoreConfiance", "t"."Statut"
FROM "TranscriptionJobs" AS "t"
WHERE "t"."Statut" = 1
ORDER BY "t"."DureeSecondes" DESC
--- fin ---

4 · La requête compilée : compilée une fois, mis en cache

Le point le plus utile de la Part 1 : EF.CompileQuery (synchrone) et EF.CompileAsyncQuery (asynchrone). À chaque exécution d’une requête LINQ-to-Entities “ordinaire”, EF Core traduit l’arbre d’expression en SQL (et le recalcule la plupart du temps), puis l’exécute. Cette traduction a un coût. Pour une requête appelée en boucle, le bon réflexe est de la compiler une fois : EF.CompileQuery retourne un délégué que l’on invoque ensuite, sans re-traduction.

Voici une mesure honnête — pas un benchmark de production. On compare la requête « directe » (chaque appel re-traduit l’expression et bâtit la closure) à la requête compilée (délégué déjà traduit, en cache), sur 3 répliques de 200 itérations, en prenant la médiane. Le point à retenir, une fois la mesure lue : le gain est relatif (la requête compilée est souvent plus rapide — même sur une petite base in-memory — parce que le coût dominant, traduire LINQ→SQL et allouer la structure, est payé une seule fois), mais absolu de l’ordre de la microseconde. C’est exactement pourquoi la doc EF réserve les requêtes compilées aux hot paths : une requête appelée à chaque requête HTTP cumule ce gain à chaque appel, alors que sur un notebook ponctuel la différence est imperceptible. Le coût économisé croît avec la complexité de la requête et la fréquence d’appel, pas avec la taille de la base.

Une nuance de signature à connaître : EF.CompileQuery est synchrone (renvoie un délégué qui retourne une liste ou un objet), EF.CompileAsyncQuery est asynchrone (retourne un IAsyncEnumerable<T> ou un Task<T>). Les deux prennent un Expression, pas une chaîne — c’est ce qui garantit que la vérification type fait à la compilation reste faite une fois, et que la traduction SQL n’est jouée qu’une fois, au premier appel du délégué.

// Requete compilee : le delegue retourne la QUERY (IQueryable) deja traduite une fois ;
// `.ToList()` se materialise a l'invocation, hors du delegate. (CompileQuery n'accepte pas
// un terminal `.ToList()` dans son expression — blocage du translator du chemin compile.)
// On filtre sur une colonne NUMERIQUE pour que la meme forme de requete soit traduisible
// dans les deux chemins (un filtre enum se normaliserait en `(int)Statut == 1`, non traduit
// en acces compile sur SQLite).
// NB : EF.CompileQuery retourne par default Func<Ctx, IEnumerable<T>> (pas IQueryable).
Func<TranscriptionContext, IEnumerable<TranscriptionJob>> requeteCompilee =
    EF.CompileQuery((TranscriptionContext c) => c.Jobs.Where(j => j.DureeSecondes > 100));

long MesurerCompilee(int reps)
{
    var sw = Stopwatch.StartNew();
    for (int i = 0; i < reps; i++) requeteCompilee(ctx).ToList();
    sw.Stop();
    return sw.Elapsed.Ticks;
}
long MesurerDirecte(int reps)
{
    var sw = Stopwatch.StartNew();
    for (int i = 0; i < reps; i++) ctx.Jobs.Where(j => j.DureeSecondes > 100).ToList();
    sw.Stop();
    return sw.Elapsed.Ticks;
}

// BenchmarkDotNet-lite : 3 repliques externes de 200 iterations, mediane (pas la moyenne,
// moins sensible aux serrages du GC).
var tCompilee = Enumerable.Range(0, 3).Select(_ => MesurerCompilee(200)).OrderBy(t => t).ToArray();
var tDirecte  = Enumerable.Range(0, 3).Select(_ => MesurerDirecte(200)).OrderBy(t => t).ToArray();

Console.WriteLine($"requete directe   : mediane {tDirecte[1]} ticks");
Console.WriteLine($"requete compilee  : mediane {tCompilee[1]} ticks");
Console.WriteLine($"ratio directe/compilee : {(double)tDirecte[1] / tCompilee[1]:F2}x");
Console.WriteLine("(lecture honnete : gain relatif du a la traduction payee une fois ; en absolu, microsecondes -> la doc EF reserve les compiled queries aux hot paths)");
requete directe   : mediane 192702 ticks
requete compilee  : mediane 69417 ticks
ratio directe/compilee : 2,78x
(lecture honnete : gain relatif du a la traduction payee une fois ; en absolu, microsecondes -> la doc EF reserve les compiled queries aux hot paths)

5 · Table de parité Python ⇄ .NET

Le livrable transversal du registre : où chaque pile attrape l’erreur, qui génère le SQL, comment chacune se protège de l’injection. C’est le tableau que la série promettait et que le registre exigeait avec du code exécuté, pas de la prose.

Question Python (peewee / SQLAlchemy) C# / .NET (EF Core)
Où l’erreur est-elle attrapée ? À l’exécution (l’ORM construit le SQL au execute()) À la compilation (l’Expression est typée, vérifiée par Roslyn)
Qui génère le SQL ? L’ORM, à l’exécution (compiler.compile(...)) EF Core, à partir de l’arbre d’expression (ToQueryString())
Nom de colonne fautif duree_seconeds > 100 passe, échoue au runtime j.DureeSeconeds = erreur de compilation, avant tout déploiement
Requête « dynamique » Naturelle : on assemble des Column à la volée Demande de l’Expression.Lambda / DynamicLinq (plus verbeux)
Injection SQL text() brut à éviter ; paramétrage typé possible FromSqlInterpolated → paramètre lié (injection impossible)
Coût de traduction Payé à chaque execute() Payé à chaque exécution, sauf EF.CompileQuery (délégué en cache)
Hot path → requête en cache Selon l’ORM (peu standardisé) EF.CompileAsyncQuery, idiome officiel

La colonne « navire » à retenir : la souplesse dynamique de Python est un avantage quand on assemble une requête à la volée (filtres d’un tableau de bord), mais elle recule le contrôle. C# rend le contrôle plus tôt, au prix d’un peu de verbosité quand la requête est vraiment dynamique. Le grain s’arrête là : les deux ne sont pas en compétition, ils placent le même filet à des hauteurs différentes.

// Injection : EF parametrique via FromSqlInterpolated — la valeur hostil n'est pas concatene au SQL.
var valeurHostile = "x' OR '1'='1";
var saines = ctx.Jobs
    .FromSqlInterpolated($"SELECT * FROM TranscriptionJobs WHERE Fichier = {valeurHostile}")
    .ToList();
Console.WriteLine($"lignes retournees pour la valeur hostile : {saines.Count} (0 attendu — la valeur est un parametre lie, pas du SQL concatene)");

// ANTI-PATTERN (NE PAS executer) : concaténer la chaîne dans le SQL laisse la porte ouverte.
// var dangereux = ctx.Jobs.FromSqlRaw($"SELECT * FROM TranscriptionJobs WHERE Id = {valeurHostile}").ToList();
Console.WriteLine("FromSqlInterpolated se demonte : la valeur hostile est traitee comme une donnee, pas comme du code SQL.");
lignes retournees pour la valeur hostile : 0 (0 attendu — la valeur est un parametre lie, pas du SQL concatene)
FromSqlInterpolated se demonte : la valeur hostile est traitee comme une donnee, pas comme du code SQL.

6 · Exercices

Trois exercices exécutables : les stubs s’exécutent sans erreur volontaire (le notebook tourne de bout en bout même non complétés) — c’est la règle C.1 du dépôt. À compléter, chaque stub aboutit au même résultat attendu que la section correspondante.

// Exercice 1 — ecrire une requete compilee qui renvoie les jobs en Echec, tries par duree desc.
// Indice : EF.CompileQuery prend un delegate (Expression) — meme forme que la cellule du §4.
Console.WriteLine("Exercice a completer");
var nbEchec = 0;   // TODO etudiant : ctx.Jobs.Count(j => j.Statut == StatutJob.Echec)
Console.WriteLine($"nb jobs en echec (a completer) = {nbEchec}");
Exercice a completer
nb jobs en echec (a completer) = 0
// Exercice 2 — afficher le SQL d'une requete avec Take(10) + Skip(5) et lire le LIMIT/OFFSET genere.
Console.WriteLine("Exercice a completer");
string sql2 = "";   // TODO etudiant : ctx.Jobs.OrderBy(j => j.Id).Skip(5).Take(10).ToQueryString()
Console.WriteLine(sql2);
Exercice a completer
// Exercice 3 — une projection typée. Ecrire la requete qui, pour chaque job Termine, renvoie
// fichier + duree, ordonnee par confiance descendante, limitee aux 3 premiers, puis son ToQueryString().
Console.WriteLine("Exercice a completer");
string sql3 = "";   // TODO etudiant : projection + ordre + Take(3) + ToQueryString()
Console.WriteLine(sql3);
Exercice a completer

Conclusion

Ce notebook a porté l’axe EF Core — requêtes vérifiées à la compilation que la Part 1 de la série promettait et que le registre distilled-axes-registry.md marquait « À ouvrir ». Il l’a fait avec du code exécuté : la requête typée démontre la differential (le compilateur attrape avant déploiement ce que Python laisse passer au runtime), ToQueryString() rend le SQL transparent, EF.CompileQuery est mesuré avec une lecture honnête du gain (faible sur une base in-memory triviale, pertinent sur un hot path), et FromSqlInterpolated montre la protection anti-injection paramétrée.

Où l’axe se raccroche dans la série : il complète Roslyn — analyseurs statiques (le compilateur comme garde-fou sur la librairie) en l’étendant au modèle de données. La prochaine tranche naturelle du registre reste la densification continue des notebooks Aspire (EPIC #10473), et — pour la Part 4 que l’auteur n’a pas publiée — la veille sur #10475.

Retour au sommet