web-dev-qa-db-fra.com

Combiner une colonne de plusieurs lignes en une seule ligne

J'en ai customer_comments divisé en plusieurs lignes en raison de la conception de la base de données, et pour un rapport, je dois combiner le comments de chaque id unique en une seule ligne. J'ai déjà essayé quelque chose avec cette astuce liste délimitée de la clause SELECT et COALESCE mais je ne m'en souviens pas et je ne dois pas l'avoir enregistrée. Je n'arrive pas à le faire fonctionner dans ce cas non plus, ne semble fonctionner que sur une seule ligne.

Les données ressemblent à ceci:

id  row_num  customer_code comments
-----------------------------------
1   1        Dilbert        Hard
1   2        Dilbert        Worker
2   1        Wally          Lazy

Mes résultats doivent ressembler à ceci:

id  customer_code comments
------------------------------
1   Dilbert        Hard Worker
2   Wally          Lazy

Donc pour chaque row_num il n'y a vraiment qu'une seule ligne de résultats; les commentaires doivent être combinés dans l'ordre de row_num. L'astuce SELECT liée ci-dessus fonctionne pour obtenir toutes les valeurs d'une requête spécifique sur une seule ligne, mais je ne sais pas comment la faire fonctionner dans le cadre d'une instruction SELECT qui crache tout ces rangées.

Ma requête doit parcourir toute la table seule et sortir ces lignes. Je ne les combine pas en plusieurs colonnes, une pour chaque ligne, donc PIVOT ne semble pas applicable.

14
Ben Brocka

Ceci est relativement trivial pour une sous-requête corrélée. Vous ne pouvez pas utiliser la méthode COALESCE mise en évidence dans le billet de blog que vous mentionnez, sauf si vous extrayez cela dans une fonction définie par l'utilisateur (ou si vous ne souhaitez renvoyer qu'une ligne à la fois). Voici comment je fais généralement cela:

DECLARE @x TABLE 
(
  id INT, 
  row_num INT, 
  customer_code VARCHAR(32), 
  comments VARCHAR(32)
);

INSERT @x SELECT 1,1,'Dilbert','Hard'
UNION ALL SELECT 1,2,'Dilbert','Worker'
UNION ALL SELECT 2,1,'Wally','Lazy';

SELECT id, customer_code, comments = STUFF((SELECT ' ' + comments 
    FROM @x AS x2 WHERE id = x.id
     ORDER BY row_num
     FOR XML PATH('')), 1, 1, '')
FROM @x AS x
GROUP BY id, customer_code
ORDER BY id;

Si vous avez un cas où les données dans les commentaires peuvent contenir des caractères dangereux pour XML (>, <, &), vous devez modifier ceci:

     FOR XML PATH('')), 1, 1, '')

Pour cette approche plus élaborée:

     FOR XML PATH(''), TYPE).value(N'(./text())[1]', N'varchar(max)'), 1, 1, '')

(Assurez-vous d'utiliser le bon type de données de destination, varchar ou nvarchar, et la bonne longueur, et préfixez tous les littéraux de chaîne avec N si vous utilisez nvarchar .)

18
Aaron Bertrand

Si vous êtes autorisé à utiliser CLR dans votre environnement, il s'agit d'un cas sur mesure pour un agrégat défini par l'utilisateur.

En particulier, c'est probablement la voie à suivre si les données source ne sont pas trivialement grandes et/ou si vous avez besoin de faire ce genre de choses beaucoup dans votre application. Je soupçonne fortement que le plan de requête pour la solution d'Aaron n'évolue pas bien à mesure que la taille d'entrée augmente. (J'ai essayé d'ajouter un index à la table temporaire, mais cela n'a pas aidé.)

Cette solution, comme bien d'autres choses, est un compromis:

  • Politique/politique pour même utiliser l'intégration CLR dans votre environnement ou celui de votre client.
  • La fonction CLR est probablement plus rapide et évoluera mieux compte tenu d'un ensemble réel de données.
  • La fonction CLR sera réutilisable dans d'autres requêtes, et vous n'aurez pas à dupliquer (et déboguer) une sous-requête complexe à chaque fois que vous devez faire ce type de chose.
  • Straight T-SQL est plus simple que d'écrire et de gérer un morceau de code externe.
  • Peut-être que vous ne savez pas comment programmer en C # ou VB.
  • etc.

EDIT: Eh bien, je suis allé essayer de voir si c'était vraiment mieux, et il s'avère que l'exigence que les commentaires soient dans un ordre spécifique est actuellement pas possible de satisfaire en utilisant une fonction d'agrégation. :(

Voir SqlUserDefinedAggregateAttribute.IsInvariantToOrder . Fondamentalement, ce que vous devez faire est OVER(PARTITION BY customer_code ORDER BY row_num) mais ORDER BY n'est pas pris en charge dans la clause OVER lors de l'agrégation. Je suppose que l'ajout de cette fonctionnalité à SQL Server ouvre une boîte de vers, car ce qui devrait être modifié dans le plan d'exécution est trivial. Le lien susmentionné indique que cela est réservé pour une utilisation future, donc cela pourrait être implémenté à l'avenir (en 2005, vous n'avez probablement pas de chance, cependant).

Ceci pourrait toujours être accompli en compressant et en analysant le row_num valeur dans la chaîne agrégée, puis faire le tri dans l'objet CLR ... ce qui semble assez hackish.

Dans tous les cas, voici le code que j'ai utilisé au cas où quelqu'un d'autre trouverait cela utile même avec la limitation. Je vais laisser la partie de piratage comme un exercice pour le lecteur. Notez que j'ai utilisé AdventureWorks (2005) pour les données de test.

Assemblage d'agrégats:

using System;
using System.IO;
using System.Data.SqlTypes;
using Microsoft.SqlServer.Server;

namespace MyCompany.SqlServer
{
    [Serializable]
    [SqlUserDefinedAggregate
    (
        Format.UserDefined,
        IsNullIfEmpty = false,
        IsInvariantToDuplicates = false,
        IsInvariantToNulls = true,
        IsInvariantToOrder = false,
        MaxByteSize = -1
    )]
    public class StringConcatAggregate : IBinarySerialize
    {
        private string _accum;
        private bool _isEmpty;

        public void Init()
        {
            _accum = string.Empty;
            _isEmpty = true;
        }

        public void Accumulate(SqlString value)
        {
            if (!value.IsNull)
            {
                if (!_isEmpty)
                    _accum += ' ';
                else
                    _isEmpty = false;

                _accum += value.Value;
            }
        }

        public void Merge(StringConcatAggregate value)
        {
            Accumulate(value.Terminate());
        }

        public SqlString Terminate()
        {
            return new SqlString(_accum);
        }

        public void Read(BinaryReader r)
        {
            this.Init();

            _accum = r.ReadString();
            _isEmpty = _accum.Length == 0;
        }

        public void Write(BinaryWriter w)
        {
            w.Write(_accum);
        }
    }
}

T-SQL pour tester (CREATE Assembly, et sp_configure pour activer le CLR omis):

CREATE TABLE [dbo].[Comments]
(
    CustomerCode int NOT NULL,
    RowNum int NOT NULL,
    Comments nvarchar(25) NOT NULL
)

INSERT INTO [dbo].[Comments](CustomerCode, RowNum, Comments)
    SELECT
        DENSE_RANK() OVER(ORDER BY FirstName),
        ROW_NUMBER() OVER(PARTITION BY FirstName ORDER BY ContactID),
        Phone
        FROM [AdventureWorks].[Person].[Contact]
GO

CREATE AGGREGATE [dbo].[StringConcatAggregate]
(
    @input nvarchar(MAX)
)
RETURNS nvarchar(MAX)
EXTERNAL NAME StringConcatAggregate.[MyCompany.SqlServer.StringConcatAggregate]
GO


SELECT
    CustomerCode,
    [dbo].[StringConcatAggregate](Comments) AS AllComments
    FROM [dbo].[Comments]
    GROUP BY CustomerCode
6
Jon Seigel

Voici une solution basée sur un curseur qui garantit l'ordre des commentaires par row_num. (Voir mon autre réponse pour savoir comment le [dbo].[Comments] la table a été remplie.)

SET NOCOUNT ON

DECLARE cur CURSOR LOCAL FAST_FORWARD FOR
    SELECT
        CustomerCode,
        Comments
        FROM [dbo].[Comments]
        ORDER BY
            CustomerCode,
            RowNum

DECLARE @curCustomerCode int
DECLARE @lastCustomerCode int
DECLARE @curComment nvarchar(25)
DECLARE @comments nvarchar(MAX)

DECLARE @results table
(
    CustomerCode int NOT NULL,
    AllComments nvarchar(MAX) NOT NULL
)


OPEN cur

FETCH NEXT FROM cur INTO
    @curCustomerCode, @curComment

SET @lastCustomerCode = @curCustomerCode


WHILE @@FETCH_STATUS = 0
BEGIN

    IF (@lastCustomerCode != @curCustomerCode)
    BEGIN
        INSERT INTO @results(CustomerCode, AllComments)
            VALUES(@lastCustomerCode, @comments)

        SET @lastCustomerCode = @curCustomerCode
        SET @comments = NULL
    END

    IF (@comments IS NULL)
        SET @comments = @curComment
    ELSE
        SET @comments = @comments + N' ' + @curComment

    FETCH NEXT FROM cur INTO
        @curCustomerCode, @curComment

END

IF (@comments IS NOT NULL)
BEGIN
    INSERT INTO @results(CustomerCode, AllComments)
        VALUES(@curCustomerCode, @comments)
END

CLOSE cur
DEALLOCATE cur


SELECT * FROM @results
1
Jon Seigel
-- solution avoiding the cursor ...

DECLARE @idMax INT
DECLARE @idCtr INT
DECLARE @comment VARCHAR(150)

SELECT @idMax = MAX(id)
FROM [dbo].[CustomerCodeWithSeparateComments]

IF @idMax = 0
    return
DECLARE @OriginalTable AS Table
(
    [id] [int] NOT NULL,
    [row_num] [int] NULL,
    [customer_code] [varchar](50) NULL,
    [comment] [varchar](120) NULL
)

DECLARE @FinalTable AS Table
(
    [id] [int] IDENTITY(1,1) NOT NULL,
    [customer_code] [varchar](50) NULL,
    [comment] [varchar](120) NULL
)

INSERT INTO @FinalTable 
([customer_code])
SELECT [customer_code]
FROM [dbo].[CustomerCodeWithSeparateComments]
GROUP BY [customer_code]

INSERT INTO @OriginalTable
           ([id]
           ,[row_num]
           ,[customer_code]
           ,[comment])
SELECT [id]
      ,[row_num]
      ,[customer_code]
      ,[comment]
FROM [dbo].[CustomerCodeWithSeparateComments]
ORDER BY id, row_num

SET @idCtr = 1
SET @comment = ''

WHILE @idCtr < @idMax
BEGIN

    SELECT @comment = @comment + ' ' + comment
    FROM @OriginalTable 
    WHERE id = @idCtr
    UPDATE @FinalTable
       SET [comment] = @comment
    WHERE [id] = @idCtr 
    SET @idCtr = @idCtr + 1
    SET @comment = ''

END 

SELECT @comment = @comment + ' ' + comment
        FROM @OriginalTable 
        WHERE id = @idCtr

UPDATE @FinalTable
   SET [comment] = @comment
WHERE [id] = @idCtr

SELECT *
FROM @FinalTable
0
Gary