

- Description
- Une presse d'imprimerie
- Source
- John Farey · page d’origine
- Licence
- PD Domaine public
Générer du code : inlining et valeurs en dur
Payer une fois pour que chaque exécution coûte moins : le résultat d'un SELECT de référence devient une valeur littérale que l'optimiseur peut exploiter.
En bref
- Problème : l'optimiseur ignore le contenu des référentiels joints.
- Mesure : 100 lignes réelles ; 25 000 estimées avec jointure, variable ou paramètre table ; 100 avec littéral,
RECOMPILEou objet généré. - Inline : fonction table en ligne, inlining scalaire (2019), dépliage généré.
- Règle : un objet généré, déterministe, reproductible, régénéré quand le référentiel change.
Sur cette page
- 1. Le problème : l'optimiseur ne connaît pas le contenu de vos référentiels
- 2. Mesure : ce que l'optimiseur croit selon l'écriture
- 3. L'idée : déplier le référentiel dans un objet généré
- 4. Les trois sens d'« inline »
- 5. Générer une fonction déterministe
- 6. Index filtrés : le littéral fait la différence
- 7. Quand cela vaut-il la peine ?
- 8. Générer proprement
- 9. Risques
- 10. Alternatives
- Sources et vérification
« Compiler à la volée » consiste à produire le texte d'un objet T-SQL à partir de données, puis à le faire compiler par le moteur. Appliqué à un petit référentiel, cela permet d'écrire en dur le résultat d'un SELECT dans le code, et de donner à l'optimiseur des valeurs qu'il n'aurait pas devinées. Cette page explique l'intérêt, le mécanisme, ses limites et la bonne manière de s'y prendre.
Les scripts de cette page ont été exécutés sur SQL Server 2019 (niveau de compatibilité 150). Les nombres de lignes estimées sont déterministes pour un jeu de données donné : vous les retrouverez à l'identique en rejouant les scripts. Aucune durée n'est donnée, car elle dépend de la machine.
1. Le problème : l'optimiseur ne connaît pas le contenu de vos référentiels#
Une requête qui filtre par « la zone Amérique » doit d'abord retrouver, par jointure, les pays de cette zone. Au moment de choisir son plan, l'optimiseur ne sait pas lesquels, donc quelle part des données ils représentent. Il s'appuie alors sur des statistiques moyennes. Avec des données déséquilibrées, l'écart à la réalité peut être de plusieurs ordres de grandeur.
La génération coûte du temps (écrire le générateur, le tester, le déployer). Elle se justifie quand ce coût, payé une fois à chaque changement du référentiel, est inférieur au gain obtenu à chaque exécution : meilleure estimation, plan plus simple, absence d'appel par ligne.
2. Mesure : ce que l'optimiseur croit selon l'écriture#
Jeu d'essai : 100 000 faits répartis très inégalement entre quatre pays (la zone « AM » ne contient que les États-Unis, 100 lignes).
CREATE TABLE dbo.Zones (ZoneId int NOT NULL PRIMARY KEY, Code nvarchar(10) NOT NULL);
CREATE TABLE dbo.Pays (PaysId int NOT NULL PRIMARY KEY, Code nchar(2) NOT NULL, ZoneId int NOT NULL REFERENCES dbo.Zones (ZoneId));
CREATE TABLE dbo.Faits (FaitId int NOT NULL PRIMARY KEY, PaysId int NOT NULL, Statut tinyint NOT NULL, Montant decimal(18,2) NOT NULL);
CREATE INDEX IX_Faits_Pays ON dbo.Faits (PaysId) INCLUDE (Statut, Montant);
INSERT INTO dbo.Zones VALUES (1, N'EU'), (2, N'AM');
INSERT INTO dbo.Pays VALUES (1, N'FR', 1), (2, N'DE', 1), (3, N'IT', 1), (4, N'US', 2);
-- 100 000 faits très déséquilibrés : FR 98 000 · DE 1 000 · IT 900 · US 100
WITH N AS (SELECT TOP (100000) ROW_NUMBER() OVER (ORDER BY (SELECT NULL)) AS n
FROM sys.all_columns AS a CROSS JOIN sys.all_columns AS b)
INSERT INTO dbo.Faits (FaitId, PaysId, Statut, Montant)
SELECT n,
CASE WHEN n <= 98000 THEN 1 WHEN n <= 99000 THEN 2 WHEN n <= 99900 THEN 3 ELSE 4 END,
CASE WHEN n % 1000 = 0 THEN 9 ELSE 1 END, -- Statut 9 : 100 lignes seulement
(n % 500) / 10.0
FROM N;
UPDATE STATISTICS dbo.Faits WITH FULLSCAN;
-- a) jointure vers les référentiels
SELECT f.FaitId, f.Montant
FROM dbo.Faits AS f
JOIN dbo.Pays AS p ON p.PaysId = f.PaysId
JOIN dbo.Zones AS z ON z.ZoneId = p.ZoneId
WHERE z.Code = N'AM';
GO
-- b) variable locale
DECLARE @p int = 4;
SELECT FaitId, Montant FROM dbo.Faits WHERE PaysId = @p;
GO
-- c) variable + OPTION (RECOMPILE)
DECLARE @p int = 4;
SELECT FaitId, Montant FROM dbo.Faits WHERE PaysId = @p OPTION (RECOMPILE);
GO
-- d) littéral
SELECT FaitId, Montant FROM dbo.Faits WHERE PaysId = 4;
-- Pour lire les estimations : SET STATISTICS XML ON avant la requête,
-- ou « Inclure le plan d'exécution réel » dans SSMS, puis comparer « Estimated Number of Rows ».
| Écriture | Lignes estimées (accès à Faits) | Réel | Pourquoi |
|---|---|---|---|
| a) jointure vers les référentiels | 25 000 (50 000 en sortie de jointure) | 100 | Le moteur ne sait pas quels pays composent la zone : densité moyenne par pays |
| b) variable locale | 25 000 | 100 | La valeur d'une variable locale n'est pas connue à l'optimisation : estimation par la densité moyenne |
| paramètre table (une valeur) | 25 000 | 100 | Aucune statistique sur les colonnes d'un paramètre table |
c) variable + OPTION (RECOMPILE) | 100 | 100 | La variable est traitée comme une constante à la compilation |
| d) littéral | 100 | 100 | L'histogramme de la colonne donne le nombre exact |
e) vue générée avec IN (4) | 100 | 100 | Le littéral est dans le texte de l'objet : même effet que (d), sans recompiler à chaque appel |
Les trois écritures qui connaissent la valeur (c, d, e) estiment 100 lignes ; les trois qui ne la connaissent pas (a, b, paramètre table) estiment 25 000 ou 50 000. Les explications s'appuient sur la documentation : « la valeur d'une variable locale n'est pas connue à l'optimisation », alors que OPTION (RECOMPILE) « transforme les paramètres et variables locales en constantes de compilation ».
3. L'idée : déplier le référentiel dans un objet généré#
Au lieu de joindre le référentiel à chaque exécution, on génère un objet dont le texte contient les valeurs :
-- Génération (au déploiement, ou à chaque mise à jour du référentiel)
DECLARE @liste nvarchar(max) =
(SELECT STRING_AGG(CAST(p.PaysId AS nvarchar(20)), N',') WITHIN GROUP (ORDER BY p.PaysId)
FROM dbo.Pays AS p
JOIN dbo.Zones AS z ON z.ZoneId = p.ZoneId
WHERE z.Code = N'AM');
DECLARE @sql nvarchar(max) =
N'CREATE OR ALTER VIEW dbo.v_Faits_AM AS' + NCHAR(10) +
N'SELECT FaitId, PaysId, Montant FROM dbo.Faits WHERE PaysId IN (' + ISNULL(@liste, N'NULL') + N');';
PRINT @sql; -- on garde une trace du code généré
EXEC sys.sp_executesql @sql; -- compilation de l'objet
-- Texte produit :
-- CREATE OR ALTER VIEW dbo.v_Faits_AM AS
-- SELECT FaitId, PaysId, Montant FROM dbo.Faits WHERE PaysId IN (4);
-- Équivalence avec la version par jointure, dans les deux sens (aucune ligne attendue)
SELECT f.FaitId FROM dbo.Faits AS f JOIN dbo.Pays AS p ON p.PaysId = f.PaysId JOIN dbo.Zones AS z ON z.ZoneId = p.ZoneId
WHERE z.Code = N'AM'
EXCEPT SELECT FaitId FROM dbo.v_Faits_AM;
SELECT FaitId FROM dbo.v_Faits_AM
EXCEPT
SELECT f.FaitId FROM dbo.Faits AS f JOIN dbo.Pays AS p ON p.PaysId = f.PaysId JOIN dbo.Zones AS z ON z.ZoneId = p.ZoneId
WHERE z.Code = N'AM';
La vue générée est estimée à 100 lignes (ligne e du tableau) : même précision qu'un littéral, sans recompilation à chaque appel. Si la zone n'existe pas, la liste vide est remplacée par IN (NULL) et la vue retourne zéro ligne.
D'après le guide d'architecture de traitement des requêtes, un plan est réutilisé si le texte correspond caractère pour caractère à une instruction déjà compilée. Un objet généré (vue, fonction, procédure) a un seul texte et donc un seul plan, rangé dans le cache des objets. Du SQL ad hoc généré avec une valeur différente à chaque exécution produirait en revanche un plan par texte ; le guide recommande sp_executesql avec paramètres pour le SQL dynamique.
4. Les trois sens d'« inline »#
| « Inline » | Ce que c'est | Qui le fait | Condition |
|---|---|---|---|
| Fonction table en ligne | Une fonction dont le corps est un seul SELECT : le moteur la développe dans la requête appelante, comme une vue paramétrée | Le moteur | Syntaxe RETURNS TABLE AS RETURN (SELECT …) |
| Inlining des fonctions scalaires | Une fonction scalaire T-SQL est transformée en expression ou sous-requête dans la requête appelante | Le moteur (SQL Server 2019, niveau de compatibilité 150) | Corps admissible : pas de fonction dépendante du temps ou à effet de bord, pas de STRING_AGG, un seul RETURN… |
| Dépliage généré | Le résultat d'un SELECT de référence est écrit en dur dans le texte d'un objet | Un générateur de code, sous votre responsabilité | Référentiel stable ou régénération automatisée |
Le dépliage généré est le seul des trois qui ne dépend d'aucune règle du moteur : c'est vous qui décidez de ce qui est « mis en ligne ». Il répond aussi à des cas que les deux autres ne couvrent pas (valeurs de référence, index filtrés).
5. Générer une fonction déterministe#
Le même principe permet de remplacer une fonction scalaire qui interroge un référentiel par une fonction qui contient ce référentiel (un CASE) :
-- Génération d'une fonction scalaire déterministe à partir du référentiel
DECLARE @cases nvarchar(max) =
(SELECT STRING_AGG(CAST(N'WHEN ' + CAST(PaysId AS nvarchar(20)) + N' THEN N' + QUOTENAME(Code, '''') AS nvarchar(max)), NCHAR(10))
WITHIN GROUP (ORDER BY PaysId)
FROM dbo.Pays);
DECLARE @sql nvarchar(max) =
N'CREATE OR ALTER FUNCTION dbo.f_CodePays (@PaysId int) RETURNS nchar(2) WITH SCHEMABINDING AS' + NCHAR(10) +
N'BEGIN' + NCHAR(10) +
N'RETURN CASE @PaysId' + NCHAR(10) + @cases + NCHAR(10) + N'ELSE NULL END;' + NCHAR(10) +
N'END;';
PRINT @sql;
EXEC sys.sp_executesql @sql;
-- Vérifications : déterministe, et candidate à l'inlining (SQL Server 2019)
SELECT OBJECTPROPERTY(OBJECT_ID(N'dbo.f_CodePays'), 'IsDeterministic') AS est_deterministe;
SELECT is_inlineable FROM sys.sql_modules WHERE object_id = OBJECT_ID(N'dbo.f_CodePays');
GO
-- Parce qu'elle est déterministe : colonne calculée persistée et indexée
ALTER TABLE dbo.Faits ADD CodePays AS dbo.f_CodePays(PaysId) PERSISTED;
GO
CREATE INDEX IX_Faits_CodePays ON dbo.Faits (CodePays);
GO
Texte produit :
CREATE OR ALTER FUNCTION dbo.f_CodePays (@PaysId int) RETURNS nchar(2) WITH SCHEMABINDING AS
BEGIN
RETURN CASE @PaysId
WHEN 1 THEN N'FR'
WHEN 2 THEN N'DE'
WHEN 3 THEN N'IT'
WHEN 4 THEN N'US'
ELSE NULL END;
END;
Les vérifications donnent est_deterministe = 1 et is_inlineable = 1. Comme la fonction est déterministe et liée au schéma, elle peut alimenter une colonne calculée persistée et indexée (voir Déterminisme) : le code de pays est stocké et indexé, plus aucun calcul à la lecture.
6. Index filtrés : le littéral fait la différence#
Pour qu'un index filtré serve une requête, le prédicat de la requête doit être un sous-ensemble de celui de l'index (documentation). Avec une variable, le plan doit rester valable quelle que soit la valeur : l'optimiseur ne peut pas toujours démontrer l'inclusion.
CREATE INDEX IX_Faits_Statut9 ON dbo.Faits (PaysId) INCLUDE (Montant) WHERE Statut = 9; -- index filtré
GO
-- a) littéral
SELECT PaysId, Montant FROM dbo.Faits WHERE Statut = 9;
GO
-- b) variable
DECLARE @s tinyint = 9;
SELECT PaysId, Montant FROM dbo.Faits WHERE Statut = @s;
GO
-- c) variable + OPTION (RECOMPILE)
DECLARE @s tinyint = 9;
SELECT PaysId, Montant FROM dbo.Faits WHERE Statut = @s OPTION (RECOMPILE);
| Écriture | Index utilisé | Lignes estimées | Remarque |
|---|---|---|---|
a) littéral Statut = 9 | IX_Faits_Statut9 (filtré) | 100 | |
b) variable @s | IX_Faits_Pays (complet) | 50 000 | Le plan contient un avertissement UnmatchedIndexes de type Parameterization : l'index filtré n'a pas pu être apparié |
c) variable + OPTION (RECOMPILE) | IX_Faits_Statut9 (filtré) | 100 | La valeur est connue à la compilation |
7. Quand cela vaut-il la peine ?#
| Plutôt oui… | Plutôt non… |
|---|---|
| Référentiel petit (dizaines à centaines de valeurs) et stable, ou régénération automatisée à chaque changement | Référentiel volumineux ou qui change à chaque exécution |
| Requêtes fréquentes ou sur de gros volumes | Requête ponctuelle : la jointure suffit |
| Données déséquilibrées (quelques valeurs très fréquentes, d'autres rares), où la densité moyenne trompe | Données uniformes : la densité moyenne est déjà juste |
| Fonctions scalaires appelées par ligne sur le même référentiel | Fonction appelée rarement, sur peu de lignes |
| Besoin d'un index filtré, d'une colonne calculée ou d'une vue indexée | Équipe sans outillage de déploiement : le code généré deviendrait un fardeau |
8. Générer proprement#
- Un générateur déterministe : à référentiel identique, texte identique (ordre explicite, pas d'horodatage dans le corps) ; les différences entre deux générations deviennent lisibles.
- Idempotent :
CREATE OR ALTER(SQL Server 2016 SP1 et ultérieur) ; rejouable à volonté. - Sûr :
QUOTENAME,CAST, liste vide traitée, taille maîtrisée. - Tracé : texte généré journalisé, date et empreinte du référentiel en propriété étendue.
- Déclenché au bon moment : à la mise à jour du référentiel, ou dans le déploiement.
- Testé : équivalence avec la version par jointure (
EXCEPTdans les deux sens), puis estimation et plan. - Déterministe côté objet :
SCHEMABINDINGet aucune fonction dépendante du temps, pour ouvrir l'inlining et l'indexation.
-- Générer proprement : validations et gabarit
DECLARE @liste nvarchar(max) = (SELECT STRING_AGG(CAST(PaysId AS nvarchar(20)), N',') WITHIN GROUP (ORDER BY PaysId)
FROM dbo.Pays AS p JOIN dbo.Zones AS z ON z.ZoneId = p.ZoneId WHERE z.Code = N'XX'); -- zone inconnue
-- 1) liste vide : IN () est une erreur de syntaxe -> IN (NULL), qui ne retourne aucune ligne
-- 2) valeurs numériques : CAST AS nvarchar ; chaînes : QUOTENAME(valeur, '''') ; jamais de concaténation brute
-- 3) ordre explicite (WITHIN GROUP) : le texte généré est identique d'une exécution à l'autre
-- 4) pas d'horodatage dans le corps de l'objet : la date de génération va dans une propriété étendue
DECLARE @sql nvarchar(max) =
N'CREATE OR ALTER VIEW dbo.v_Faits_XX AS' + NCHAR(10) +
N'SELECT FaitId, PaysId, Montant FROM dbo.Faits WHERE PaysId IN (' + ISNULL(@liste, N'NULL') + N');';
EXEC sys.sp_executesql @sql;
SELECT COUNT(*) AS nb_lignes FROM dbo.v_Faits_XX; -- 0
9. Risques#
| Risque | Parade |
|---|---|
| Code périmé : le référentiel change, l'objet généré non | Régénérer à chaque changement du référentiel (job de chargement, pipeline de déploiement) ; contrôle automatisé de cohérence |
| Injection SQL | QUOTENAME, CAST des valeurs numériques, jamais de concaténation de texte libre, droits réduits |
| Pollution du cache des plans (du SQL ad hoc différent à chaque exécution) | Générer des objets persistés (vue, fonction, procédure) plutôt que du SQL ad hoc par exécution : un objet, un plan |
| Code illisible ou édité à la main | En-tête explicite « généré », source de génération, aucune édition manuelle |
| Dérive entre environnements | Générer dans le déploiement, versionner le générateur et ses entrées |
| Diagnostic difficile | Conserver le texte généré (journal ou fichier) et la date dans une propriété étendue |
10. Alternatives#
| Alternative | Apport | Limite |
|---|---|---|
OPTION (RECOMPILE) | Les valeurs de variables et de paramètres deviennent des constantes à la compilation | Recompilation à chaque exécution ; ne renseigne pas sur un paramètre table (pas de statistiques) |
| Paramètre table | Fige l'ensemble traité, isole des autres transactions | Aucune statistique de colonnes (voir types table) |
Paramétrage (sp_executesql) | Un plan réutilisable pour toutes les valeurs ; recommandé par la documentation pour le SQL dynamique | Plan « moyen » : sensibilité au paramètre (parameter sniffing) |
| Jointure + bons index + statistiques | Aucun code généré à maintenir | Estimations imprécises si les valeurs sont très déséquilibrées |
Sources et vérification#
- Microsoft Learn — Query processing architecture guide — repliement de constantes, variables et paramètres, RECOMPILE, plans en cache, sp_executesql
- Microsoft Learn — Create filtered indexes — le prédicat de la requête doit être un sous-ensemble de celui de l'index
- Microsoft Learn — Scalar UDF Inlining — inlining des fonctions scalaires (SQL Server 2019)
- Microsoft Learn — Deterministic and nondeterministic functions — déterminisme et SCHEMABINDING
- Microsoft Learn — STRING_AGG — agrégation du texte généré
- Microsoft Learn — Query hints (RECOMPILE) — indice RECOMPILE