Desde o SQL Server 2005, o truque de usar
FOR XML PATH
desnormalizar strings e combiná-las em uma única lista (geralmente separada por vírgulas) tem sido muito popular. No SQL Server 2017, no entanto, STRING_AGG()
finalmente respondeu a pedidos de longa data e generalizados da comunidade para simular GROUP_CONCAT()
e funcionalidades semelhantes encontradas em outras plataformas. Recentemente, comecei a modificar muitas das minhas respostas do Stack Overflow usando o método antigo, tanto para melhorar o código existente quanto para adicionar um exemplo adicional mais adequado para versões modernas. Fiquei um pouco chocado com o que encontrei.
Em mais de uma ocasião, tive que verificar novamente se o código era meu.
Um exemplo rápido
Vejamos uma demonstração simples do problema. Alguém tem uma tabela assim:
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');
Na página que mostra as bandas favoritas de cada usuário, eles querem que a saída fique assim:
UserID Bands ------ --------------------------------------- 1 Pink Floyd, New Order, The Hip 2 Zamfir, ABBA
Nos dias do SQL Server 2005, eu teria oferecido esta solução:
SELECT DISTINCT UserID, Bands = (SELECT BandName + ', ' FROM dbo.FavoriteBands WHERE UserID = fb.UserID FOR XML PATH('')) FROM dbo.FavoriteBands AS fb;
Mas quando olho para trás neste código agora, vejo muitos problemas que não consigo resistir a corrigir.
COISAS
A falha mais fatal no código acima é deixar uma vírgula à direita:
UserID Bands ------ --------------------------------------- 1 Pink Floyd, New Order, The Hip, 2 Zamfir, ABBA,
Para resolver isso, muitas vezes vejo pessoas envolvendo a consulta dentro de outra e, em seguida, cercam as
Bands
saída com LEFT(Bands, LEN(Bands)-1)
. Mas este é um cálculo adicional desnecessário; em vez disso, podemos mover a vírgula para o início da string e remover os primeiros um ou dois caracteres usando STUFF
. Então, não precisamos calcular o comprimento da string porque é irrelevante. SELECT DISTINCT UserID, Bands = STUFF( --------------------------------^^^^^^ (SELECT ', ' + BandName --------------^^^^^^ FROM dbo.FavoriteBands WHERE UserID = fb.UserID FOR XML PATH('')), 1, 2, '') --------------------------^^^^^^^^^^^ FROM dbo.FavoriteBands AS fb;
Você pode ajustar ainda mais se estiver usando um delimitador mais longo ou condicional.
DISTINTO
O próximo problema é o uso de
DISTINCT
. A maneira como o código funciona é que a tabela derivada gera uma lista separada por vírgulas para cada UserID
valor, as duplicatas são removidas. Podemos ver isso olhando para o plano e vendo o operador relacionado a XML ser executado sete vezes, mesmo que apenas três linhas sejam retornadas:Figura 1:plano mostrando filtro após agregação
Se alterarmos o código para usar
GROUP BY
em vez 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; --^^^^^^^^^^^^^^^
É uma diferença sutil e não altera os resultados, mas podemos ver que o plano melhora. Basicamente, as operações XML são adiadas até que as duplicatas sejam removidas:
Figura 2:plano mostrando o filtro antes da agregação
Nesta escala, a diferença é irrelevante. Mas e se adicionarmos mais alguns dados? No meu sistema, isso adiciona um pouco mais de 11.000 linhas:
INSERT dbo.FavoriteBands(UserID, BandName) SELECT [object_id], name FROM sys.all_columns;
Se executarmos as duas consultas novamente, as diferenças de duração e CPU são imediatamente óbvias:
Figura 3:resultados de tempo de execução comparando DISTINCT e GROUP BY
Mas outros efeitos colaterais também são óbvios nos planos. No caso de
DISTINCT
, o UDX mais uma vez é executado para cada linha na tabela, há um spool de índice excessivamente ansioso, há uma classificação distinta (sempre uma bandeira vermelha para mim) e a consulta tem uma alta concessão de memória, o que pode prejudicar seriamente a simultaneidade :Figura 4:plano DISTINTO em escala
Enquanto isso, no
GROUP BY
consulta, o UDX é executado apenas uma vez para cada UserID
exclusivo , o spool ansioso lê um número muito menor de linhas, não há operador de classificação distinto (ele foi substituído por uma correspondência de hash) e a concessão de memória é pequena em comparação:Figura 5:plano GROUP BY em escala
Demora um pouco para voltar e corrigir o código antigo como este, mas há algum tempo tenho sido muito disciplinado sobre sempre usar
GROUP BY
em vez de DISTINCT
. N Prefixo
Muitos exemplos de código antigos que encontrei presumiam que nenhum caractere Unicode estaria em uso, ou pelo menos os dados de exemplo não sugeriam a possibilidade. Eu oferecia minha solução como acima, e então o usuário voltava e dizia, “mas em uma linha eu tenho
'просто красный'
, e ele volta como '?????? ???????'
!” Costumo lembrar às pessoas que elas sempre precisam prefixar potenciais literais de string Unicode com o prefixo N, a menos que saibam absolutamente que só estarão lidando com varchar
strings ou inteiros. Comecei a ser muito explícito e provavelmente até cauteloso com isso: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;
Entidade XML
Outro “e se?” cenário nem sempre presente nos dados de amostra de um usuário são caracteres XML. Por exemplo, e se minha banda favorita se chamar “
Bob & Sheila <> Strawberries
”? A saída com a consulta acima é segura para XML, o que não é o que sempre queremos (por exemplo, Bob & Sheila <> Strawberries
). As pesquisas do Google na época sugeriam "você precisa adicionar TYPE
”, e lembro-me de tentar algo assim: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;
Infelizmente, o tipo de dados de saída da subconsulta neste caso é
xml
. Isso leva à seguinte mensagem de erro:Msg 8116, Level 16, State 1
O tipo de dados do argumento xml é inválido para o argumento 1 da função stuff.
Você precisa informar ao SQL Server que deseja extrair o valor resultante como uma string, indicando o tipo de dados e que deseja o primeiro elemento. Naquela época, eu adicionaria isso como o seguinte:
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;
Isso retornaria a string sem a entidade XML. Mas é o mais eficiente? No ano passado, Charlieface me lembrou que Mister Magoo realizou alguns testes extensivos e encontrou
./text()[1]
foi mais rápido que as outras abordagens (mais curtas) como .
e .[1]
. (Ouvi isso originalmente de um comentário que Mikael Eriksson deixou para mim aqui.) Mais uma vez ajustei meu código para ficar assim: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;
Você pode observar que extrair o valor dessa maneira leva a um plano um pouco mais complexo (você não saberia apenas observando a duração, que permanece bastante constante durante as alterações acima):
Figura 6:planejar com ./text()[1]
O aviso na raiz
SELECT
operador vem da conversão explícita para nvarchar(max)
. Encomenda
Ocasionalmente, os usuários expressam que o pedido é importante. Muitas vezes, isso é simplesmente ordenar pela coluna que você está anexando, mas às vezes pode ser adicionado em outro lugar. As pessoas tendem a acreditar que se viram uma ordem específica sair do SQL Server uma vez, é a ordem que sempre verão, mas não há confiabilidade aqui. A ordem nunca é garantida, a menos que você diga. Nesse caso, digamos que queremos ordenar por
BandName
alfabeticamente. Podemos adicionar esta instrução dentro da subconsulta: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;
Observe que isso pode adicionar um pouco de tempo de execução devido ao operador de classificação adicional, dependendo se há um índice de suporte.
STRING_AGG()
Conforme atualizo minhas respostas antigas, que ainda devem funcionar na versão que era relevante no momento da pergunta, o trecho final acima (com ou sem o
ORDER BY
) é o formulário que você provavelmente verá. Mas você também pode ver uma atualização adicional para a forma mais moderna. STRING_AGG()
é sem dúvida um dos melhores recursos adicionados no SQL Server 2017. É mais simples e muito mais eficiente do que qualquer uma das abordagens acima, levando a consultas organizadas e de bom desempenho como esta:SELECT UserID, Bands = STRING_AGG(BandName, N', ') FROM dbo.FavoriteBands GROUP BY UserID;
Isso não é uma piada; é isso. Aqui está o plano - o mais importante, há apenas uma única varredura na mesa:
Figura 7:plano STRING_AGG()
Se você quiser ordenar,
STRING_AGG()
suporta isso também (desde que você esteja no nível de compatibilidade 110 ou superior, como Martin Smith aponta aqui):SELECT UserID, Bands = STRING_AGG(BandName, N', ') WITHIN GROUP (ORDER BY BandName) ----^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ FROM dbo.FavoriteBands GROUP BY UserID;
O plano aparência o mesmo que sem classificação, mas a consulta é um pouco mais lenta em meus testes. Ainda é muito mais rápido do que qualquer um dos
FOR XML PATH
variações. Índices
Uma pilha dificilmente é justa. Se você tiver um índice não clusterizado que a consulta possa usar, o plano ficará ainda melhor. Por exemplo:
CREATE INDEX ix_FavoriteBands ON dbo.FavoriteBands(UserID, BandName);
Aqui está o plano para a mesma consulta ordenada usando
STRING_AGG()
—observe a falta de um operador de classificação, pois a varredura pode ser solicitada:Figura 8:plano STRING_AGG() com um índice de suporte
Isso também reduz o tempo, mas para ser justo, esse índice ajuda o
FOR XML PATH
variações também. Aqui está o novo plano para a versão ordenada dessa consulta:Figura 9:plano FOR XML PATH com um índice de suporte
O plano é um pouco mais amigável do que antes, incluindo uma busca em vez de uma varredura em um ponto, mas essa abordagem ainda é significativamente mais lenta que
STRING_AGG()
. Uma advertência
Há um pequeno truque para usar
STRING_AGG()
onde, se a string resultante tiver mais de 8.000 bytes, você receberá esta mensagem de erro:Msg 9829, Level 16, State 1
Resultado da agregação STRING_AGG excedeu o limite de 8000 bytes. Use tipos LOB para evitar truncamento de resultados.
Para evitar esse problema, você pode injetar uma conversão explícita:
SELECT UserID, Bands = STRING_AGG(CONVERT(nvarchar(max), BandName), N', ') --------------------------^^^^^^^^^^^^^^^^^^^^^^ FROM dbo.FavoriteBands GROUP BY UserID;
Isso adiciona uma operação escalar de computação ao plano e um surpreendente
CONVERT
aviso na raiz SELECT
operador—mas fora isso, tem pouco impacto no desempenho. Conclusão
Se você estiver no SQL Server 2017+ e tiver algum
FOR XML PATH
agregação de strings em sua base de código, eu recomendo mudar para a nova abordagem. Realizei alguns testes de desempenho mais completos durante a visualização pública do SQL Server 2017 aqui e aqui você pode querer revisitar. Uma objeção comum que ouvi é que as pessoas estão no SQL Server 2017 ou superior, mas ainda em um nível de compatibilidade mais antigo. Parece que a apreensão é porque
STRING_SPLIT()
é inválido em níveis de compatibilidade inferiores a 130, então eles pensam que STRING_AGG()
funciona desta forma também, mas é um pouco mais branda. É apenas um problema se você estiver usando WITHIN GROUP
e um nível de compatibilidade inferior a 110. Então melhore!