Sqlserver
 sql >> Base de Dados >  >> RDS >> Sqlserver

Agregação de strings ao longo dos anos no SQL Server


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 &amp; Sheila &lt;&gt; 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!