Depuis SQL Server 2005, l'astuce consistant à utiliser FOR XML PATH
dénormaliser les chaînes et les combiner en une seule liste (généralement séparée par des virgules) a été très populaire. Dans SQL Server 2017, cependant, STRING_AGG()
a finalement répondu aux appels de longue date et répandus de la communauté pour simuler GROUP_CONCAT()
et des fonctionnalités similaires trouvées sur d'autres plates-formes. J'ai récemment commencé à modifier plusieurs de mes réponses Stack Overflow en utilisant l'ancienne méthode, à la fois pour améliorer le code existant et pour ajouter un exemple supplémentaire mieux adapté aux versions modernes.
J'ai été un peu consterné par ce que j'ai trouvé.
À plusieurs reprises, j'ai dû vérifier que le code était bien le mien.
Un exemple rapide
Regardons une démonstration simple du problème. Quelqu'un a un tableau comme celui-ci :
CREATE TABLE dbo.FavoriteBands ( UserID int, BandName nvarchar(255) ); INSERT dbo.FavoriteBands ( UserID, BandName ) VALUES (1, N'Pink Floyd'), (1, N'New Order'), (1, N'The Hip'), (2, N'Zamfir'), (2, N'ABBA');
Sur la page affichant les groupes préférés de chaque utilisateur, ils souhaitent que le résultat ressemble à ceci :
UserID Bands ------ --------------------------------------- 1 Pink Floyd, New Order, The Hip 2 Zamfir, ABBA
A l'époque de SQL Server 2005, j'aurais proposé cette solution :
SELECT DISTINCT UserID, Bands = (SELECT BandName + ', ' FROM dbo.FavoriteBands WHERE UserID = fb.UserID FOR XML PATH('')) FROM dbo.FavoriteBands AS fb;
Mais quand je repense à ce code maintenant, je vois de nombreux problèmes que je ne peux pas résister à résoudre.
TRUCS
Le défaut le plus fatal dans le code ci-dessus est qu'il laisse une virgule à la fin :
UserID Bands ------ --------------------------------------- 1 Pink Floyd, New Order, The Hip, 2 Zamfir, ABBA,
Pour résoudre ce problème, je vois souvent des gens envelopper la requête dans une autre, puis entourer les Bands
sortie avec LEFT(Bands, LEN(Bands)-1)
. Mais c'est un calcul supplémentaire inutile; à la place, nous pouvons déplacer la virgule au début de la chaîne et supprimer le ou les deux premiers caractères en utilisant STUFF
. Ensuite, nous n'avons pas à calculer la longueur de la chaîne car cela n'a pas d'importance.
SELECT DISTINCT UserID, Bands = STUFF( --------------------------------^^^^^^ (SELECT ', ' + BandName --------------^^^^^^ FROM dbo.FavoriteBands WHERE UserID = fb.UserID FOR XML PATH('')), 1, 2, '') --------------------------^^^^^^^^^^^ FROM dbo.FavoriteBands AS fb;
Vous pouvez ajuster cela davantage si vous utilisez un délimiteur plus long ou conditionnel.
DISTINCT
Le problème suivant est l'utilisation de DISTINCT
. La façon dont le code fonctionne est que la table dérivée génère une liste séparée par des virgules pour chaque UserID
valeur, les doublons sont supprimés. Nous pouvons le voir en examinant le plan et en voyant l'opérateur lié à XML s'exécuter sept fois, même si seules trois lignes sont finalement renvoyées :
Figure 1 :Plan montrant le filtre après l'agrégation
Si nous modifions le code pour utiliser GROUP BY
au lieu de DISTINCT
:
SELECT /* DISTINCT */ UserID, Bands = STUFF( (SELECT ', ' + BandName FROM dbo.FavoriteBands WHERE UserID = fb.UserID FOR XML PATH('')), 1, 2, '') FROM dbo.FavoriteBands AS fb GROUP BY UserID; --^^^^^^^^^^^^^^^
C'est une différence subtile, et cela ne change pas les résultats, mais nous pouvons voir que le plan s'améliore. Fondamentalement, les opérations XML sont différées jusqu'à ce que les doublons soient supprimés :
Figure 2 :Plan affichant le filtre avant l'agrégation
A cette échelle, la différence est insignifiante. Mais que se passe-t-il si nous ajoutons des données supplémentaires ? Sur mon système, cela ajoute un peu plus de 11 000 lignes :
INSERT dbo.FavoriteBands(UserID, BandName) SELECT [object_id], name FROM sys.all_columns;
Si nous exécutons à nouveau les deux requêtes, les différences de durée et de CPU sont immédiatement évidentes :
Figure 3 :résultats d'exécution comparant DISTINCT et GROUP BY
Mais d'autres effets secondaires sont également évidents dans les plans. Dans le cas de DISTINCT
, l'UDX s'exécute à nouveau pour chaque ligne de la table, il y a une bobine d'index excessivement impatiente, il y a un tri distinct (toujours un drapeau rouge pour moi) et la requête a une allocation de mémoire élevée, ce qui peut sérieusement nuire à la concurrence :
Figure 4 :Plan DISTINCT à grande échelle
Pendant ce temps, dans le GROUP BY
requête, l'UDX ne s'exécute qu'une seule fois pour chaque UserID
unique , le spool avide lit un nombre beaucoup plus faible de lignes, il n'y a pas d'opérateur de tri distinct (il a été remplacé par une correspondance de hachage) et l'allocation de mémoire est minuscule en comparaison :
Figure 5 :Plan GROUP BY à l'échelle
Il faut un certain temps pour revenir en arrière et corriger l'ancien code comme celui-ci, mais depuis un certain temps maintenant, j'ai été très enrégimenté pour toujours utiliser GROUP BY
au lieu de DISTINCT
.
Préfixe N
Trop d'anciens exemples de code que j'ai rencontrés supposaient qu'aucun caractère Unicode ne serait jamais utilisé, ou du moins les exemples de données ne suggéraient pas cette possibilité. Je proposerais ma solution comme ci-dessus, puis l'utilisateur reviendrait et dirait, "mais sur une ligne j'ai 'просто красный'
, et il revient sous la forme '?????? ???????'
!" Je rappelle souvent aux gens qu'ils doivent toujours préfixer les littéraux de chaîne Unicode potentiels avec le préfixe N à moins qu'ils ne sachent absolument qu'ils n'auront jamais affaire qu'à varchar
des chaînes ou des entiers. J'ai commencé à être très explicite et probablement même trop prudent à ce sujet :
SELECT UserID, Bands = STUFF( (SELECT N', ' + BandName --------------^ FROM dbo.FavoriteBands WHERE UserID = fb.UserID FOR XML PATH(N'')), 1, 2, N'') ----------------------^ -----------^ FROM dbo.FavoriteBands AS fb GROUP BY UserID;
Entitisation XML
Un autre "et si?" scénario qui n'est pas toujours présent dans les exemples de données d'un utilisateur est celui des caractères XML. Par exemple, que se passe-t-il si mon groupe préféré s'appelle "Bob & Sheila <> Strawberries
” ? La sortie avec la requête ci-dessus est sécurisée pour XML, ce qui n'est pas ce que nous voulons toujours (par exemple, Bob & Sheila <> Strawberries
). Les recherches Google à l'époque suggéraient "vous devez ajouter TYPE
," et je me souviens d'avoir essayé quelque chose comme ça :
SELECT UserID, Bands = STUFF( (SELECT N', ' + BandName FROM dbo.FavoriteBands WHERE UserID = fb.UserID FOR XML PATH(N''), TYPE), 1, 2, N'') --------------------------^^^^^^ FROM dbo.FavoriteBands AS fb GROUP BY UserID;
Malheureusement, le type de données de sortie de la sous-requête dans ce cas est xml
. Cela conduit au message d'erreur suivant :
Le type de données d'argument xml n'est pas valide pour l'argument 1 de la fonction stuff.
Vous devez indiquer à SQL Server que vous souhaitez extraire la valeur résultante sous forme de chaîne en indiquant le type de données et que vous souhaitez le premier élément. À l'époque, j'ajoutais ceci comme suit :
SELECT UserID, Bands = STUFF( (SELECT N', ' + BandName FROM dbo.FavoriteBands WHERE UserID = fb.UserID FOR XML PATH(N''), TYPE).value(N'.', N'nvarchar(max)'), --------------------------^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 1, 2, N'') FROM dbo.FavoriteBands AS fb GROUP BY UserID;
Cela renverrait la chaîne sans entité XML. Mais est-ce le plus efficace ? L'année dernière, Charlieface m'a rappelé que Mister Magoo avait effectué des tests approfondis et trouvé ./text()[1]
était plus rapide que les autres approches (plus courtes) comme .
et .[1]
. (J'ai d'abord entendu cela dans un commentaire que Mikael Eriksson m'a laissé ici.) J'ai à nouveau ajusté mon code pour qu'il ressemble à ceci :
SELECT UserID, Bands = STUFF( (SELECT N', ' + BandName FROM dbo.FavoriteBands WHERE UserID = fb.UserID FOR XML PATH(N''), TYPE).value(N'./text()[1]', N'nvarchar(max)'), ------------------------------------------^^^^^^^^^^^ 1, 2, N'') FROM dbo.FavoriteBands AS fb GROUP BY UserID;
Vous pouvez observer que l'extraction de la valeur de cette manière conduit à un plan légèrement plus complexe (vous ne le sauriez pas simplement en regardant la durée, qui reste assez constante tout au long des changements ci-dessus) :
Figure 6 :Planifier avec ./text()[1]
L'avertissement sur la racine SELECT
l'opérateur provient de la conversion explicite en nvarchar(max)
.
Commander
Parfois, les utilisateurs expriment l'importance de la commande. Souvent, il s'agit simplement de commander par la colonne que vous ajoutez, mais parfois, elle peut être ajoutée ailleurs. Les gens ont tendance à croire que s'ils ont vu une fois une commande spécifique sortir de SQL Server, c'est la commande qu'ils verront toujours, mais il n'y a aucune fiabilité ici. La commande n'est jamais garantie sauf si vous le dites. Dans ce cas, disons que nous voulons commander par BandName
alphabétiquement. Nous pouvons ajouter cette instruction dans la sous-requête :
SELECT UserID, Bands = STUFF( (SELECT N', ' + BandName FROM dbo.FavoriteBands WHERE UserID = fb.UserID ORDER BY BandName ---------^^^^^^^^^^^^^^^^^ FOR XML PATH(N''), TYPE).value(N'./text()[1]', N'nvarchar(max)'), 1, 2, N'') FROM dbo.FavoriteBands AS fb GROUP BY UserID;
Notez que cela peut ajouter un peu de temps d'exécution en raison de l'opérateur de tri supplémentaire, selon qu'il existe ou non un index de support.
STRING_AGG()
Au fur et à mesure que je mets à jour mes anciennes réponses, qui devraient toujours fonctionner sur la version pertinente au moment de la question, l'extrait final ci-dessus (avec ou sans le ORDER BY
) est le formulaire que vous verrez probablement. Mais vous pourriez également voir une mise à jour supplémentaire pour le formulaire plus moderne.
STRING_AGG()
est sans doute l'une des meilleures fonctionnalités ajoutées à SQL Server 2017. Elle est à la fois plus simple et beaucoup plus efficace que toutes les approches ci-dessus, ce qui permet d'obtenir des requêtes ordonnées et performantes comme celle-ci :
SELECT UserID, Bands = STRING_AGG(BandName, N', ') FROM dbo.FavoriteBands GROUP BY UserID;
Ce n'est pas une blague; c'est ça. Voici le plan. Plus important encore, il n'y a qu'un seul scan par rapport à la table :
Figure 7 :plan STRING_AGG()
Si vous souhaitez commander, STRING_AGG()
le prend également en charge (tant que vous êtes au niveau de compatibilité 110 ou supérieur, comme le souligne Martin Smith ici) :
SELECT UserID, Bands = STRING_AGG(BandName, N', ') WITHIN GROUP (ORDER BY BandName) ----^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ FROM dbo.FavoriteBands GROUP BY UserID;
Le plan semble le même que celui sans tri, mais la requête est un peu plus lente dans mes tests. C'est toujours beaucoup plus rapide que n'importe lequel des FOR XML PATH
variantes.
Index
Un tas n'est pas juste. Si vous avez même un index non clusterisé que la requête peut utiliser, le plan est encore meilleur. Par exemple :
CREATE INDEX ix_FavoriteBands ON dbo.FavoriteBands(UserID, BandName);
Voici le plan pour la même requête ordonnée en utilisant STRING_AGG()
— notez l'absence d'opérateur de tri, puisque le scan peut être ordonné :
Figure 8 :plan STRING_AGG() avec un index de support
Cela permet également de gagner du temps, mais pour être juste, cet index aide le FOR XML PATH
des variantes aussi. Voici le nouveau plan pour la version commandée de cette requête :
Figure 9 :plan FOR XML PATH avec un index de prise en charge
Le plan est un peu plus convivial qu'avant, y compris une recherche au lieu d'un balayage à un endroit, mais cette approche est toujours beaucoup plus lente que STRING_AGG()
.
Une mise en garde
Il y a une petite astuce pour utiliser STRING_AGG()
où, si la chaîne résultante est supérieure à 8 000 octets, vous recevrez ce message d'erreur :
Le résultat de l'agrégation STRING_AGG a dépassé la limite de 8 000 octets. Utilisez des types LOB pour éviter la troncature des résultats.
Pour éviter ce problème, vous pouvez injecter une conversion explicite :
SELECT UserID, Bands = STRING_AGG(CONVERT(nvarchar(max), BandName), N', ') --------------------------^^^^^^^^^^^^^^^^^^^^^^ FROM dbo.FavoriteBands GROUP BY UserID;
Cela ajoute une opération scalaire de calcul au plan et un CONVERT
sans surprise avertissement sur la racine SELECT
opérateur, mais sinon, cela a peu d'impact sur les performances.
Conclusion
Si vous êtes sur SQL Server 2017+ et que vous avez un FOR XML PATH
l'agrégation de chaînes dans votre base de code, je vous recommande fortement de passer à la nouvelle approche. J'ai effectué des tests de performances plus approfondis lors de la préversion publique de SQL Server 2017 ici et ici, vous voudrez peut-être revoir.
Une objection courante que j'ai entendue est que les gens utilisent SQL Server 2017 ou une version supérieure, mais toujours à un niveau de compatibilité plus ancien. Il semble que l'appréhension soit due au fait que STRING_SPLIT()
n'est pas valide sur les niveaux de compatibilité inférieurs à 130, ils pensent donc STRING_AGG()
fonctionne de cette façon aussi, mais c'est un peu plus indulgent. Ce n'est un problème que si vous utilisez WITHIN GROUP
et un niveau de compatibilité inférieur à 110. Alors améliorez-vous !