Méthode optimale pour concaténer / agréger des chaînes

104

Je trouve un moyen d'agréger des chaînes de différentes lignes en une seule ligne. Je cherche à faire cela dans de nombreux endroits différents, donc avoir une fonction pour faciliter ce serait bien. J'ai essayé des solutions utilisant COALESCEet FOR XML, mais elles ne me conviennent pas.

L'agrégation de chaînes ferait quelque chose comme ceci:

id | Name                    Result: id | Names
-- - ----                            -- - -----
1  | Matt                            1  | Matt, Rocks
1  | Rocks                           2  | Stylus
2  | Stylus

J'ai examiné les fonctions d'agrégation définies par CLR en remplacement de COALESCEet FOR XML, mais apparemment, SQL Azure ne prend pas en charge les éléments définis par CLR, ce qui est pénible pour moi car je sais que pouvoir l'utiliser résoudrait beaucoup de problèmes. problèmes pour moi.

Existe-t-il une solution de contournement possible, ou une méthode similaire optimale (qui n'est peut-être pas aussi optimale que CLR, mais bon je vais prendre ce que je peux obtenir) que je peux utiliser pour agréger mes données?

mat
la source
De quelle manière ne for xmlfonctionne pas pour vous?
Mikael Eriksson
4
Cela fonctionne, mais j'ai jeté un coup d'œil au plan d'exécution et chacun for xmlmontre une utilisation de 25% en termes de performances de requête (une grande partie de la requête!)
mat
2
Il existe différentes manières de faire la for xml pathrequête. Certains plus rapides que d'autres. Cela pourrait dépendre de vos données, mais celles qui utilisent distinctsont, selon mon expérience, plus lentes que celles utilisées group by. Et si vous utilisez .value('.', nvarchar(max))pour obtenir les valeurs concaténées, vous devriez changer cela en.value('./text()[1]', nvarchar(max))
Mikael Eriksson
3
Votre réponse acceptée ressemble à ma réponse sur stackoverflow.com/questions/11137075/… que je pensais plus rapide que XML. Ne vous laissez pas berner par le coût des requêtes, vous avez besoin de suffisamment de données pour voir lequel est le plus rapide. XML est plus rapide, ce qui se trouve être la réponse de @ MikaelEriksson à la même question . Optez pour l'approche XML
Michael Buen
2
Veuillez voter pour une solution native pour cela ici: connect.microsoft.com/SQLServer/feedback/details/1026336
JohnLBevan

Réponses:

67

SOLUTION

La définition de l' optimal peut varier, mais voici comment concaténer des chaînes de différentes lignes à l'aide de Transact SQL standard, ce qui devrait fonctionner correctement dans Azure.

;WITH Partitioned AS
(
    SELECT 
        ID,
        Name,
        ROW_NUMBER() OVER (PARTITION BY ID ORDER BY Name) AS NameNumber,
        COUNT(*) OVER (PARTITION BY ID) AS NameCount
    FROM dbo.SourceTable
),
Concatenated AS
(
    SELECT 
        ID, 
        CAST(Name AS nvarchar) AS FullName, 
        Name, 
        NameNumber, 
        NameCount 
    FROM Partitioned 
    WHERE NameNumber = 1

    UNION ALL

    SELECT 
        P.ID, 
        CAST(C.FullName + ', ' + P.Name AS nvarchar), 
        P.Name, 
        P.NameNumber, 
        P.NameCount
    FROM Partitioned AS P
        INNER JOIN Concatenated AS C 
                ON P.ID = C.ID 
                AND P.NameNumber = C.NameNumber + 1
)
SELECT 
    ID,
    FullName
FROM Concatenated
WHERE NameNumber = NameCount

EXPLICATION

L'approche se résume à trois étapes:

  1. Numéroter les lignes à l' aide OVERet de PARTITIONregroupement et leur ordonnant au besoin pour la concaténation. Le résultat est PartitionedCTE. Nous conservons le nombre de lignes dans chaque partition pour filtrer les résultats ultérieurement.

  2. L'utilisation récursive de CTE ( Concatenated) permet de parcourir les numéros de ligne ( NameNumbercolonne) en ajoutant des Namevaleurs à la FullNamecolonne.

  3. Filtrez tous les résultats, sauf ceux avec les plus élevés NameNumber.

Veuillez garder à l'esprit que pour rendre cette requête prévisible, il faut définir à la fois le regroupement (par exemple, dans votre scénario, les lignes avec les mêmes IDsont concaténées) et le tri (j'ai supposé que vous triiez simplement la chaîne par ordre alphabétique avant la concaténation).

J'ai rapidement testé la solution sur SQL Server 2012 avec les données suivantes:

INSERT dbo.SourceTable (ID, Name)
VALUES 
(1, 'Matt'),
(1, 'Rocks'),
(2, 'Stylus'),
(3, 'Foo'),
(3, 'Bar'),
(3, 'Baz')

Le résultat de la requête:

ID          FullName
----------- ------------------------------
2           Stylus
3           Bar, Baz, Foo
1           Matt, Rocks
Serge Belov
la source
5
J'ai vérifié la consommation de temps de cette façon par rapport à xmlpath et j'ai atteint environ 4 millisecondes contre environ 54 millisecondes. donc la méthode xmplath est meilleure, spécialement dans les grands cas. J'écrirai le code de comparaison dans une réponse séparée.
QMaster
C'est bien mieux puisque cette approche ne fonctionne que pour 100 valeurs maximum.
Romano Zumbé
@ romano-zumbé Utilisez MAXRECURSION pour définir la limite CTE à ce dont vous avez besoin.
Serge Belov
1
Étonnamment, CTE a été beaucoup plus lent pour moi. sqlperformance.com/2014/08/t-sql-queries/… compare un tas de techniques, et semble être d'accord avec mes résultats.
Nickolay
Cette solution pour une table avec plus d'un million d'enregistrements ne fonctionne pas. De plus, nous avons une limite sur la profondeur récursive
Ardalan Shahgholi
52

Les méthodes utilisant FOR XML PATH comme ci-dessous sont-elles vraiment aussi lentes? Itzik Ben-Gan écrit que cette méthode a de bonnes performances dans son livre T-SQL Querying (M. Ben-Gan est une source digne de confiance, à mon avis).

create table #t (id int, name varchar(20))

insert into #t
values (1, 'Matt'), (1, 'Rocks'), (2, 'Stylus')

select  id
        ,Names = stuff((select ', ' + name as [text()]
        from #t xt
        where xt.id = t.id
        for xml path('')), 1, 2, '')
from #t t
group by id
slachterman
la source
N'oubliez pas de mettre un index sur cette idcolonne une fois que la taille d'une table devient un problème.
milivojeviCH
2
Et après avoir lu comment fonctionnent les trucs / pour xml path ( stackoverflow.com/a/31212160/1026 ), je suis convaincu que c'est une bonne solution malgré XML dans son nom :)
Nickolay
1
@slackterman Dépend du nombre d'enregistrements à exploiter. Je pense que XML est déficient aux faibles comptes, par rapport à CTE, mais aux comptes de volume supérieurs, allège la limitation du département de récursivité et est plus facile à naviguer, si cela est fait correctement et succinctement.
GoldBishop
Les méthodes FOR XML PATH explosent si vous avez des emojis ou des caractères spéciaux / substituts dans vos données !!!
devinbost
1
Ce code produit un texte codé en xml ( &basculé vers &, etc.). Une for xmlsolution plus correcte est fournie ici .
Frédéric
34

Pour ceux d'entre nous qui ont trouvé ça et n'utilisez pas Azure SQL Database:

STRING_AGG()dans PostgreSQL, SQL Server 2017 et Azure SQL
https://www.postgresql.org/docs/current/static/functions-aggregate.html
https://docs.microsoft.com/en-us/sql/t-sql/ fonctions / string-agg-transact-sql

GROUP_CONCAT()dans MySQL
http://dev.mysql.com/doc/refman/5.7/en/group-by-functions.html#function_group-concat

(Merci à @Brianjorden et @milanio pour la mise à jour Azure)

Exemple de code:

select Id
, STRING_AGG(Name, ', ') Names 
from Demo
group by Id

SQL Fiddle: http://sqlfiddle.com/#!18/89251/1

Hrobky
la source
1
Je viens de le tester et maintenant cela fonctionne bien avec Azure SQL Database.
milanio
5
STRING_AGGa été repoussé à 2017. Il n'est pas disponible en 2016.
Morgan Thrapp
1
Merci, Aamir et Morgan Thrapp pour le changement de version de SQL Server. Actualisé. (Au moment de la rédaction de cet article, il était
supposé
26

Bien que la réponse @serge soit correcte, j'ai comparé la consommation de temps de son chemin à xmlpath et j'ai trouvé que xmlpath était tellement plus rapide. J'écrirai le code de comparaison et vous pourrez le vérifier vous-même. C'est la manière @serge:

DECLARE @startTime datetime2;
DECLARE @endTime datetime2;
DECLARE @counter INT;
SET @counter = 1;

set nocount on;

declare @YourTable table (ID int, Name nvarchar(50))

WHILE @counter < 1000
BEGIN
    insert into @YourTable VALUES (ROUND(@counter/10,0), CONVERT(NVARCHAR(50), @counter) + 'CC')
    SET @counter = @counter + 1;
END

SET @startTime = GETDATE()

;WITH Partitioned AS
(
    SELECT 
        ID,
        Name,
        ROW_NUMBER() OVER (PARTITION BY ID ORDER BY Name) AS NameNumber,
        COUNT(*) OVER (PARTITION BY ID) AS NameCount
    FROM @YourTable
),
Concatenated AS
(
    SELECT ID, CAST(Name AS nvarchar) AS FullName, Name, NameNumber, NameCount FROM Partitioned WHERE NameNumber = 1

    UNION ALL

    SELECT 
        P.ID, CAST(C.FullName + ', ' + P.Name AS nvarchar), P.Name, P.NameNumber, P.NameCount
    FROM Partitioned AS P
        INNER JOIN Concatenated AS C ON P.ID = C.ID AND P.NameNumber = C.NameNumber + 1
)
SELECT 
    ID,
    FullName
FROM Concatenated
WHERE NameNumber = NameCount

SET @endTime = GETDATE();

SELECT DATEDIFF(millisecond,@startTime, @endTime)
--Take about 54 milliseconds

Et c'est la manière xmlpath:

DECLARE @startTime datetime2;
DECLARE @endTime datetime2;
DECLARE @counter INT;
SET @counter = 1;

set nocount on;

declare @YourTable table (RowID int, HeaderValue int, ChildValue varchar(5))

WHILE @counter < 1000
BEGIN
    insert into @YourTable VALUES (@counter, ROUND(@counter/10,0), CONVERT(NVARCHAR(50), @counter) + 'CC')
    SET @counter = @counter + 1;
END

SET @startTime = GETDATE();

set nocount off
SELECT
    t1.HeaderValue
        ,STUFF(
                   (SELECT
                        ', ' + t2.ChildValue
                        FROM @YourTable t2
                        WHERE t1.HeaderValue=t2.HeaderValue
                        ORDER BY t2.ChildValue
                        FOR XML PATH(''), TYPE
                   ).value('.','varchar(max)')
                   ,1,2, ''
              ) AS ChildValues
    FROM @YourTable t1
    GROUP BY t1.HeaderValue

SET @endTime = GETDATE();

SELECT DATEDIFF(millisecond,@startTime, @endTime)
--Take about 4 milliseconds
QMaster
la source
2
+1, vous QMaster (des forces du mal) vous! J'ai eu un diff encore plus dramatique. (~ 3000 ms CTE contre ~ 70 ms de XML sur SQL Server 2008 R2 sur Windows Server 2008 R2 sur Intel Xeon E5-2630 v4 @ 2,20 GHZ x2 avec ~ 1 Go d'espace libre). Seules les suggestions sont: 1) Soit utiliser des OP ou (de préférence) des termes génériques pour les deux versions, 2) Puisque le Q. de OP est comment «concaténer / agréger des chaînes » et cela n'est nécessaire que pour les chaînes (par rapport à une valeur numérique ), générique les termes sont trop génériques. Utilisez simplement "GroupNumber" et "StringValue", 3) Déclarez et utilisez une variable "Delimiter" et utilisez "Len (Delimiter)" contre "2".
Tom
1
+1 pour ne pas étendre le caractère spécial au codage XML (par exemple, «&» ne devient pas «& amp;» comme dans tant d'autres solutions inférieures)
Reversed Engineer
13

Mise à jour: Ms SQL Server 2017+, Azure SQL Database

Vous pouvez utiliser: STRING_AGG .

L'utilisation est assez simple pour la demande d'OP:

SELECT id, STRING_AGG(name, ', ') AS names
FROM some_table
GROUP BY id

Lire la suite

Eh bien, mon ancienne non-réponse a été supprimée à juste titre (laissée intacte ci-dessous), mais si quelqu'un arrive à atterrir ici à l'avenir, il y a de bonnes nouvelles. Ils ont également implémenté STRING_AGG () dans Azure SQL Database. Cela devrait fournir la fonctionnalité exacte initialement demandée dans cet article avec un support natif et intégré. @hrobky l'a mentionné précédemment en tant que fonctionnalité SQL Server 2016 à l'époque.

--- Old Post: Pas assez de réputation ici pour répondre directement à @hrobky, mais STRING_AGG a l'air génial, mais il n'est actuellement disponible que dans SQL Server 2016 vNext. Espérons que cela suivra bientôt Azure SQL Databse.

Brian Jorden
la source
2
Je viens de le tester et cela fonctionne comme un charme dans Azure SQL Database
Milan
4
STRING_AGG()est censé devenir disponible dans SQL Server 2017, quel que soit le niveau de compatibilité. docs.microsoft.com/en-us/sql/t-sql/functions/…
utilisateur
1
Oui. STRING_AGG n'est pas disponible dans SQL Server 2016.
Magne
2

Vous pouvez utiliser + = pour concaténer des chaînes, par exemple:

declare @test nvarchar(max)
set @test = ''
select @test += name from names

si vous sélectionnez @test, il vous donnera tous les noms concaténés

jvc
la source
Veuillez spécifier le dialecte ou la version SQL depuis quand est-il pris en charge.
Hrobky
Cela fonctionne dans SQL Server 2012. Notez qu'une liste séparée par des virgules peut être créée avecselect @test += name + ', ' from names
Art Schmidt
4
Cela utilise un comportement non défini et n'est pas sûr. Ceci est particulièrement susceptible de donner un résultat étrange / incorrect si vous avez un ORDER BYdans votre requête. Vous devez utiliser l'une des alternatives répertoriées.
Dannnno
1
Ce type de requête n'a jamais été un comportement défini, et dans SQL Server 2019, nous avons constaté qu'il avait le comportement incorrect de manière plus cohérente que dans les versions précédentes. N'utilisez pas cette approche.
Matthew Rodatus le
2

J'ai trouvé la réponse de Serge très prometteuse, mais j'ai également rencontré des problèmes de performance avec celle-ci telle qu'elle était écrite. Cependant, lorsque je l'ai restructuré pour utiliser des tables temporaires et ne pas inclure de tables doubles CTE, les performances sont passées de 1 minute 40 secondes à sous-seconde pour 1000 enregistrements combinés. Voici pour tous ceux qui ont besoin de le faire sans FOR XML sur les anciennes versions de SQL Server:

DECLARE @STRUCTURED_VALUES TABLE (
     ID                 INT
    ,VALUE              VARCHAR(MAX) NULL
    ,VALUENUMBER        BIGINT
    ,VALUECOUNT         INT
);

INSERT INTO @STRUCTURED_VALUES
SELECT   ID
        ,VALUE
        ,ROW_NUMBER() OVER (PARTITION BY ID ORDER BY VALUE) AS VALUENUMBER
        ,COUNT(*) OVER (PARTITION BY ID)    AS VALUECOUNT
FROM    RAW_VALUES_TABLE;

WITH CTE AS (
    SELECT   SV.ID
            ,SV.VALUE
            ,SV.VALUENUMBER
            ,SV.VALUECOUNT
    FROM    @STRUCTURED_VALUES SV
    WHERE   VALUENUMBER = 1

    UNION ALL

    SELECT   SV.ID
            ,CTE.VALUE + ' ' + SV.VALUE AS VALUE
            ,SV.VALUENUMBER
            ,SV.VALUECOUNT
    FROM    @STRUCTURED_VALUES SV
    JOIN    CTE 
        ON  SV.ID = CTE.ID
        AND SV.VALUENUMBER = CTE.VALUENUMBER + 1

)
SELECT   ID
        ,VALUE
FROM    CTE
WHERE   VALUENUMBER = VALUECOUNT
ORDER BY ID
;
Tom Halladay
la source