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

Concatenação Agrupada no SQL Server


A concatenação agrupada é um problema comum no SQL Server, sem recursos diretos e intencionais para suportá-la (como XMLAGG no Oracle, STRING_AGG ou ARRAY_TO_STRING(ARRAY_AGG()) no PostgreSQL e GROUP_CONCAT no MySQL). Foi solicitado, mas ainda sem sucesso, conforme evidenciado nestes itens do Connect:
  • Conexão nº 247118:SQL precisa da versão da função group_Concat do MySQL (adiada)
  • Conexão nº 728969 :Funções de conjunto ordenadas - Cláusula DENTRO DO GRUPO (fechado como não corrigirá)

** ATUALIZAÇÃO de janeiro de 2017 ** :STRING_AGG() estará no SQL Server 2017; leia sobre isso aqui, aqui e aqui.

O que é concatenação agrupada?


Para os não iniciados, a concatenação agrupada é quando você deseja obter várias linhas de dados e compactá-las em uma única string (geralmente com delimitadores como vírgulas, tabulações ou espaços). Alguns podem chamar isso de "junção horizontal". Um exemplo visual rápido demonstrando como compactaríamos uma lista de animais de estimação pertencentes a cada membro da família, desde a fonte normalizada até a saída "achatada":



Houve muitas maneiras de resolver este problema ao longo dos anos; aqui estão apenas alguns, com base nos seguintes dados de exemplo:
    CREATE TABLE dbo.FamilyMemberPets
    (
      Name SYSNAME,
      Pet SYSNAME,
      PRIMARY KEY(Name,Pet)
    );
     
    INSERT dbo.FamilyMemberPets(Name,Pet) VALUES
    (N'Madeline',N'Kirby'),
    (N'Madeline',N'Quigley'),
    (N'Henry',   N'Piglet'),
    (N'Lisa',    N'Snowball'),
    (N'Lisa',    N'Snowball II');

    Não vou demonstrar uma lista exaustiva de cada abordagem de concatenação agrupada já concebida, pois quero me concentrar em alguns aspectos da minha abordagem recomendada, mas quero apontar alguns dos mais comuns:
    UDF escalar
    CREATE FUNCTION dbo.ConcatFunction
    (
      @Name SYSNAME
    )
    RETURNS NVARCHAR(MAX)
    WITH SCHEMABINDING 
    AS 
    BEGIN
      DECLARE @s NVARCHAR(MAX);
     
      SELECT @s = COALESCE(@s + N', ', N'') + Pet
        FROM dbo.FamilyMemberPets
    	WHERE Name = @Name
    	ORDER BY Pet;
     
      RETURN (@s);
    END
    GO
     
    SELECT Name, Pets = dbo.ConcatFunction(Name)
      FROM dbo.FamilyMemberPets
      GROUP BY Name
      ORDER BY Name;

    Nota:há uma razão para não fazermos isso:
    SELECT DISTINCT Name, Pets = dbo.ConcatFunction(Name)
      FROM dbo.FamilyMemberPets
      ORDER BY Name;

    Com DISTINCT , a função é executada para cada linha e as duplicatas são removidas; com GROUP BY , as duplicatas são removidas primeiro.
    Common Language Runtime (CLR)

    Isso usa o GROUP_CONCAT_S função encontrada em http://groupconcat.codeplex.com/:
    SELECT Name, Pets = dbo.GROUP_CONCAT_S(Pet, 1)
      FROM dbo.FamilyMemberPets
      GROUP BY Name
      ORDER BY Name;
    CTE recursiva

    Existem várias variações dessa recursão; este puxa um conjunto de nomes distintos como âncora:
    ;WITH x as 
    (
      SELECT Name, Pet = CONVERT(NVARCHAR(MAX), Pet),
        r1 = ROW_NUMBER() OVER (PARTITION BY Name ORDER BY Pet)
      FROM dbo.FamilyMemberPets
    ),
    a AS 
    (
      SELECT Name, Pet, r1 FROM x WHERE r1 = 1
    ),
    r AS
    (
      SELECT Name, Pet, r1 FROM a WHERE r1 = 1
      UNION ALL
      SELECT x.Name, r.Pet + N', ' + x.Pet, x.r1
        FROM x INNER JOIN r
    	ON r.Name = x.Name
    	AND x.r1 = r.r1 + 1
    )
    SELECT Name, Pets = MAX(Pet)
      FROM r
      GROUP BY Name 
      ORDER BY Name
      OPTION (MAXRECURSION 0);
    Cursor

    Não há muito a dizer aqui; os cursores geralmente não são a abordagem ideal, mas essa pode ser sua única opção se você estiver preso no SQL Server 2000:
    DECLARE @t TABLE(Name SYSNAME, Pets NVARCHAR(MAX),
      PRIMARY KEY (Name));
     
    INSERT @t(Name, Pets)
      SELECT Name, N'' 
      FROM dbo.FamilyMemberPets GROUP BY Name;
     
    DECLARE @name SYSNAME, @pet SYSNAME, @pets NVARCHAR(MAX);
     
    DECLARE c CURSOR LOCAL FAST_FORWARD
      FOR SELECT Name, Pet 
      FROM dbo.FamilyMemberPets
      ORDER BY Name, Pet;
     
    OPEN c;
     
    FETCH c INTO @name, @pet;
     
    WHILE @@FETCH_STATUS = 0
    BEGIN
      UPDATE @t SET Pets += N', ' + @pet
        WHERE Name = @name;
     
      FETCH c INTO @name, @pet;
    END
     
    CLOSE c; DEALLOCATE c;
     
    SELECT Name, Pets = STUFF(Pets, 1, 1, N'') 
      FROM @t
      ORDER BY Name;
    GO
    Atualização peculiar

    Algumas pessoas *amam* essa abordagem; Eu não compreendo a atração em tudo.
    DECLARE @Name SYSNAME, @Pets NVARCHAR(MAX);
     
    DECLARE @t TABLE(Name SYSNAME, Pet SYSNAME, Pets NVARCHAR(MAX),
      PRIMARY KEY (Name, Pet));
     
    INSERT @t(Name, Pet)
      SELECT Name, Pet FROM dbo.FamilyMemberPets
      ORDER BY Name, Pet;
     
    UPDATE @t SET @Pets = Pets = COALESCE(
        CASE COALESCE(@Name, N'') 
          WHEN Name THEN @Pets + N', ' + Pet
          ELSE Pet END, N''), 
    	@Name = Name;
     
    SELECT Name, Pets = MAX(Pets)
      FROM @t
      GROUP BY Name
      ORDER BY Name;
    PARA O CAMINHO XML

    Muito facilmente meu método preferido, pelo menos em parte porque é a única maneira de *garantir* o pedido sem usar um cursor ou CLR. Dito isto, esta é uma versão muito bruta que não aborda alguns outros problemas inerentes que discutirei mais adiante:
    SELECT Name, Pets = STUFF((SELECT N', ' + Pet 
      FROM dbo.FamilyMemberPets AS p2
       WHERE p2.name = p.name 
       ORDER BY Pet
       FOR XML PATH(N'')), 1, 2, N'')
    FROM dbo.FamilyMemberPets AS p
    GROUP BY Name
    ORDER BY Name;

Já vi muitas pessoas assumirem erroneamente que o novo CONCAT() A função introduzida no SQL Server 2012 foi a resposta a essas solicitações de recursos. Essa função serve apenas para operar em colunas ou variáveis ​​em uma única linha; ele não pode ser usado para concatenar valores entre linhas.

Mais sobre FOR XML PATH


FOR XML PATH('') por si só não é bom o suficiente – ele tem problemas conhecidos com a entidade XML. Por exemplo, se você atualizar um dos nomes de animais de estimação para incluir um colchete HTML ou um e comercial:
UPDATE dbo.FamilyMemberPets
  SET Pet = N'Qui>gle&y'
  WHERE Pet = N'Quigley';

Eles são traduzidos para entidades seguras para XML em algum lugar ao longo do caminho:
Qui>gle&y

Então eu sempre uso PATH, TYPE).value() , do seguinte modo:
SELECT Name, Pets = STUFF((SELECT N', ' + Pet 
  FROM dbo.FamilyMemberPets AS p2
   WHERE p2.name = p.name 
   ORDER BY Pet
   FOR XML PATH(N''), TYPE).value(N'.[1]', N'nvarchar(max)'), 1, 2, N'')
FROM dbo.FamilyMemberPets AS p
GROUP BY Name
ORDER BY Name;

Eu também sempre uso NVARCHAR , porque você nunca sabe quando alguma coluna subjacente conterá Unicode (ou posteriormente será alterada para isso).

Você pode ver as seguintes variedades dentro de .value() , ou ainda outros:
... TYPE).value(N'.', ...
... TYPE).value(N'(./text())[1]', ...

Eles são intercambiáveis, todos representando a mesma string; as diferenças de desempenho entre eles (mais abaixo) foram insignificantes e possivelmente completamente não determinísticas.

Outro problema que você pode encontrar são determinados caracteres ASCII que não são possíveis de representar em XML; por exemplo, se a string contiver o caractere 0x001A (CHAR(26) ), você receberá esta mensagem de erro:
Msg 6841, Level 16, State 1, Line 51
FOR XML não pôde serializar os dados para o nó 'NoName' porque contém um caractere (0x001A) que não é permitido em XML. Para recuperar esses dados usando FOR XML, converta-os para o tipo de dados binary, varbinary ou image e use a diretiva BINARY BASE64.
Isso parece muito complicado para mim, mas espero que você não precise se preocupar com isso porque não está armazenando dados como este ou pelo menos não está tentando usá-lo em concatenação agrupada. Se estiver, talvez seja necessário recorrer a uma das outras abordagens.

Desempenho


Os dados de amostra acima facilitam a comprovação de que todos esses métodos fazem o que esperamos, mas é difícil compará-los de forma significativa. Então eu preenchi a tabela com um conjunto muito maior:
TRUNCATE TABLE dbo.FamilyMemberPets;
 
INSERT dbo.FamilyMemberPets(Name,Pet)
  SELECT o.name, c.name
  FROM sys.all_objects AS o
  INNER JOIN sys.all_columns AS c
  ON o.[object_id] = c.[object_id]
  ORDER BY o.name, c.name;

Para mim, foram 575 objetos, com um total de 7.080 linhas; o objeto mais largo tinha 142 colunas. Agora, novamente, admito, não pretendi comparar todas as abordagens concebidas na história do SQL Server; apenas alguns destaques que postei acima. Aqui ficaram os resultados:



Você pode notar a falta de alguns candidatos; a UDF usando DISTINCT e o CTE recursivo estavam tão fora dos gráficos que distorceriam a escala. Aqui estão os resultados de todas as sete abordagens em forma de tabela:
Abordagem Duração
(milissegundos)
PARA O CAMINHO XML 108,58
CLR 80,67
Atualização peculiar 278,83
UDF (GROUP BY) 452,67
UDF (DISTINTO) 5.893,67
Cursor 2.210,83
CTE recursiva 70.240,58

Duração média, em milissegundos, para todas as abordagens

Observe também que as variações de FOR XML PATH foram testados independentemente, mas mostraram diferenças muito pequenas, então eu apenas os combinei para a média. Se você realmente quer saber, o .[1] notação funcionou mais rápido em meus testes; YMMV.

Conclusão


Se você não está em uma loja onde o CLR é um obstáculo de alguma forma, e especialmente se você não está lidando apenas com nomes simples ou outras strings, você deve definitivamente considerar o projeto CodePlex. Não tente reinventar a roda, não tente truques e truques não intuitivos para fazer CROSS APPLY ou outras construções funcionam um pouco mais rápido do que as abordagens não CLR acima. Apenas pegue o que funciona e conecte-o. E diabos, já que você também obtém o código-fonte, você pode melhorá-lo ou estendê-lo, se quiser.

Se o CLR for um problema, então FOR XML PATH é provavelmente sua melhor opção, mas você ainda precisa tomar cuidado com personagens complicados. Se você estiver preso no SQL Server 2000, sua única opção viável é a UDF (ou código similar não encapsulado em uma UDF).

Próxima vez


Algumas coisas que quero explorar em um post seguinte:remover duplicatas da lista, ordenar a lista por algo diferente do valor em si, casos em que colocar qualquer uma dessas abordagens em uma UDF pode ser doloroso e casos de uso práticos para esta funcionalidade.