Sqlserver
 sql >> Base de données >  >> RDS >> Sqlserver

Agrégation de chaînes au fil des ans dans SQL Server

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 :

Msg 8116, Niveau 16, État 1
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 :

Msg 9829, Niveau 16, État 1
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 !