31

Can anyone help me make this query work for SQL Server 2014?

This is working on Postgresql and probably on SQL Server 2017. On Oracle it is listagg instead of string_agg.

Here is the SQL:

select 
    string_agg(t.id,',') AS id
from 
    Table t

I checked on the site some xml option should be used but I could not understand it.

marc_s
  • 732,580
  • 175
  • 1,330
  • 1,459
xMilos
  • 1,519
  • 4
  • 21
  • 36
  • 2
    Possible duplicate of [How to make a query with group\_concat in sql server](https://stackoverflow.com/questions/17591490/how-to-make-a-query-with-group-concat-in-sql-server) – Sebastian Brosch Mar 19 '18 at 10:50

2 Answers2

54

In SQL Server pre-2017, you can do:

select stuff( (select ',' + cast(t.id as varchar(max))
               from tabel t
               for xml path ('')
              ), 1, 1, ''
            );

The only purpose of stuff() is to remove the initial comma. The work is being done by for xml path.

Gordon Linoff
  • 1,242,037
  • 58
  • 646
  • 786
  • 2
    This is not going to give expected results if the text contains characters such as `<`, `>`, etc. – TT. Mar 19 '18 at 11:04
  • 6
    To address the xml entity references issue, `select stuff( (select ',' + cast(t.id as varchar(max)) from tabel t for xml path (''), TYPE ).value('.', 'varchar(MAX)'), 1, 1, '' );` – Dan Guzman Mar 19 '18 at 11:08
  • @TT. . . . This does assume that the `id` is numeric, which is why there is an explicit `cast()`. – Gordon Linoff Mar 19 '18 at 12:21
  • @GordonLinoff I was more or less going off on the title, seeking a replacement for string_agg in the broadest sense. Personally, I go for the TYPE + value construct in all cases, just as a sort of a template. – TT. Mar 19 '18 at 16:14
  • A good explanation about For Xml Path [here](https://stackoverflow.com/a/45463960/7786739). – 劉鎮瑲 Aug 27 '20 at 06:30
  • 1
    Is there a method to mimic the 'Within Group' feature as well? – BGDev Apr 01 '21 at 17:31
  • See the below answer which explains how to do this, @BGDev . – Thom A Oct 30 '22 at 16:42
  • If making this into a UDF (to mimic `STRING_AGG`) what would the parameter type be? – M.M Dec 09 '22 at 03:41
  • User Defined functions can't be aggregates, @M.M . You would have to use CLR for that. – Thom A Jan 12 '23 at 08:39
23

Note that for some characters, the values will be escaped when using FOR XML PATH, for example:

SELECT STUFF((SELECT ',' + V.String
              FROM (VALUES('7 > 5'),('Salt & pepper'),('2
lines'))V(String)
              FOR XML PATH('')),1,1,'');

This returns the string below:

7 &gt; 5,Salt &amp; pepper,2&#x0D;
lines'

This is unlikely desired. You can get around this using TYPE and then getting the value of the XML:

SELECT STUFF((SELECT ',' + V.String
              FROM (VALUES('7 > 5'),('Salt & pepper'),('2
lines'))V(String)
              FOR XML PATH(''),TYPE).value('(./text())[1]','varchar(MAX)'),1,1,'');

This returns the string below:

7 > 5,Salt & pepper,2
lines

This would replicate the behaviour of the following:

SELECT STRING_AGG(V.String,',')
FROM VALUES('7 > 5'),('Salt & pepper'),('2
lines'))V(String);

Of course, there might be times where you want to group the data, which the above doesn't demonstrate. To achieve this you would need to use a correlated subquery. Take the following sample data:

CREATE TABLE dbo.MyTable (ID int IDENTITY(1,1),
                          GroupID int,
                          SomeCharacter char(1));

INSERT INTO dbo.MyTable (GroupID, SomeCharacter)
VALUES (1,'A'), (1,'B'), (1,'D'),
       (2,'C'), (2,NULL), (2,'Z');

From this wanted the below results:

GroupID Characters
1 A,B,D
2 C,Z

To achieve this you would need to do something like this:

SELECT MT.GroupID,
       STUFF((SELECT ',' + sq.SomeCharacter 
              FROM dbo.MyTable sq
              WHERE sq.GroupID = MT.GroupID --This is your correlated join and should be on the same columns as your GROUP BY
                                            --You "JOIN" on the columns that would have been in the PARTITION BY
              FOR XML PATH(''),TYPE).value('(./text())[1]','varchar(MAX)'),1,1,'')
FROM dbo.MyTable MT
GROUP BY MT.GroupID; --I use GROUP BY rather than DISTINCT as we are technically aggregating here

So, if you were grouping on 2 columns, then you would have 2 clauses your sub query's WHERE: WHERE MT.SomeColumn = sq.SomeColumn AND MT.AnotherColumn = sq.AnotherColumn, and your outer GROUP BY would be GROUP BY MT.SomeColumn, MT.AnotherColumn.


Finally, let's add an ORDER BY into this, which you also define in the subquery. Let's, for example, assume you wanted to sort the data by the value of the ID descending in the string aggregation:

SELECT MT.GroupID,
       STUFF((SELECT ',' + sq.SomeCharacter 
              FROM dbo.MyTable sq
              WHERE sq.GroupID = MT.GroupID
              ORDER BY sq.ID DESC --This is identical to the ORDER BY you would have in your OVER clause
              FOR XML PATH(''),TYPE).value('(./text())[1]','varchar(MAX)'),1,1,'')
FROM dbo.MyTable MT
GROUP BY MT.GroupID;

For would produce the following results:

GroupID Characters
1 D,B,A
2 Z,C

Unsurprisingly, this will never be as efficient as a STRING_AGG, due to having the reference the table multiple times (if you need to perform multiple aggregations, then you need multiple sub queries), but a well indexed table will greatly help the RDBMS. If performance really is a problem, because you're doing multiple string aggregations in a single query, then I would either suggest you need to reconsider if you need the aggregation, or it's about time you conisidered upgrading.

Thom A
  • 88,727
  • 11
  • 45
  • 75