213

To illustrate, assume that I have two tables as follows:

VehicleID Name
1         Chuck
2         Larry

LocationID VehicleID City
1          1         New York
2          1         Seattle
3          1         Vancouver
4          2         Los Angeles
5          2         Houston

I want to write a query to return the following results:

VehicleID Name    Locations
1         Chuck   New York, Seattle, Vancouver
2         Larry   Los Angeles, Houston

I know that this can be done using server side cursors, ie:

DECLARE @VehicleID int
DECLARE @VehicleName varchar(100)
DECLARE @LocationCity varchar(100)
DECLARE @Locations varchar(4000)
DECLARE @Results TABLE
(
  VehicleID int
  Name varchar(100)
  Locations varchar(4000)
)

DECLARE VehiclesCursor CURSOR FOR
SELECT
  [VehicleID]
, [Name]
FROM [Vehicles]

OPEN VehiclesCursor

FETCH NEXT FROM VehiclesCursor INTO
  @VehicleID
, @VehicleName
WHILE @@FETCH_STATUS = 0
BEGIN

  SET @Locations = ''

  DECLARE LocationsCursor CURSOR FOR
  SELECT
    [City]
  FROM [Locations]
  WHERE [VehicleID] = @VehicleID

  OPEN LocationsCursor

  FETCH NEXT FROM LocationsCursor INTO
    @LocationCity
  WHILE @@FETCH_STATUS = 0
  BEGIN
    SET @Locations = @Locations + @LocationCity

    FETCH NEXT FROM LocationsCursor INTO
      @LocationCity
  END
  CLOSE LocationsCursor
  DEALLOCATE LocationsCursor

  INSERT INTO @Results (VehicleID, Name, Locations) SELECT @VehicleID, @Name, @Locations

END     
CLOSE VehiclesCursor
DEALLOCATE VehiclesCursor

SELECT * FROM @Results

However, as you can see, this requires a great deal of code. What I would like is a generic function that would allow me to do something like this:

SELECT VehicleID
     , Name
     , JOIN(SELECT City FROM Locations WHERE VehicleID = Vehicles.VehicleID, ', ') AS Locations
FROM Vehicles

Is this possible? Or something similar?

DineshDB
  • 5,998
  • 7
  • 33
  • 49
Templar
  • 5,067
  • 7
  • 34
  • 39

13 Answers13

281

If you're using SQL Server 2005, you could use the FOR XML PATH command.

SELECT [VehicleID]
     , [Name]
     , (STUFF((SELECT CAST(', ' + [City] AS VARCHAR(MAX)) 
         FROM [Location] 
         WHERE (VehicleID = Vehicle.VehicleID) 
         FOR XML PATH ('')), 1, 2, '')) AS Locations
FROM [Vehicle]

It's a lot easier than using a cursor, and seems to work fairly well.

Update

For anyone still using this method with newer versions of SQL Server, there is another way of doing it which is a bit easier and more performant using the STRING_AGG method that has been available since SQL Server 2017.

SELECT  [VehicleID]
       ,[Name]
       ,(SELECT STRING_AGG([City], ', ')
         FROM [Location]
         WHERE VehicleID = V.VehicleID) AS Locations
FROM   [Vehicle] V

This also allows a different separator to be specified as the second parameter, providing a little more flexibility over the former method.

Mun
  • 14,098
  • 11
  • 59
  • 83
  • 13
    This will work well with this data, but if your data might have xml special characters (e.g. <, >, &) they will be replaced (<, etc.) – GilM Oct 12 '08 at 00:39
  • 4
    @James You could use a CTE to accomplish this: WITH MyCTE(VehicleId, Name, Locations) AS ( SELECT [VehicleID] , [Name] , (SELECT CAST(City + ', ' AS VARCHAR(MAX)) FROM [Location] WHERE (VehicleID = Vehicle.VehicleID) FOR XML PATH ('') ) AS Locations FROM [Vehicle] ) SELECT VehicleId, Name, REPLACE(Locations, ',', CHAR(10)) AS Locations FROM MyCTE – Mun May 24 '11 at 15:27
  • 1
    i became a little confused whilst modifiny this code, so have now posted [my own question](http://stackoverflow.com/questions/6113450/how-to-use-sql-for-xml-path-but-retain-carriage-returns) – James Parish May 24 '11 at 16:14
  • 1
    You can wrap the subquery in the STUFF function to get rid of the comma. Just have the query lead with ', ' and then wrap the subquery in: STUFF( _subquery_,1,2,'') – MickJuice Dec 20 '13 at 04:06
  • The line break characters can be inserted like this: 'some text' + CHAR(13) + CHAR(10) + 'text on next line'. – thefellow3j Feb 16 '16 at 17:20
  • It ssems you need repalce index 2 on 1 in STUFF function like STUFF(..., 1, 1, '') to avoid first symbol lost – user3873265 Mar 15 '23 at 13:39
89

Note that Matt's code will result in an extra comma at the end of the string; using COALESCE (or ISNULL for that matter) as shown in the link in Lance's post uses a similar method but doesn't leave you with an extra comma to remove. For the sake of completeness, here's the relevant code from Lance's link on sqlteam.com:

DECLARE @EmployeeList varchar(100)
SELECT @EmployeeList = COALESCE(@EmployeeList + ', ', '') + 
    CAST(EmpUniqueID AS varchar(5))
FROM SalesCallsEmployees
WHERE SalCal_UniqueID = 1
Community
  • 1
  • 1
Mike Powell
  • 5,914
  • 4
  • 28
  • 28
49

I don't belive there's a way to do it within one query, but you can play tricks like this with a temporary variable:

declare @s varchar(max)
set @s = ''
select @s = @s + City + ',' from Locations

select @s

It's definitely less code than walking over a cursor, and probably more efficient.

Matt Hamilton
  • 200,371
  • 61
  • 386
  • 320
  • 12
    I'm fairly certain you can take the "probably" out the last line. – Marc Gravell Oct 12 '08 at 09:13
  • 1
    *"I don't belive there's a way to do it within one query"* Yes, there is. SQL Server 2005 had both `FOR XML` and CTEs. – T.J. Crowder May 23 '15 at 11:57
  • 2
    It's not reliable, depends on execution plan, rows could be lost. See [KB](https://connect.microsoft.com/SQLServer/feedback/details/345947/n-varchar-building-from-resultset-fails-when-order-by-is-added). – Der_Meister Feb 09 '16 at 03:52
  • 2
    What is this technique or feature called? When a `SELECT @s = @s` variable assignment is made that includes its existing value, and made again for each row in the result set? – Baodad May 16 '16 at 18:08
  • Great answer, but please pay attention that this solution appears to be undocumented, so it is possible that Microsoft remove the ability to do this in the future without warning. – Pouria Moosavi Sep 04 '20 at 10:54
  • One of our automated tests is failing now, so perhaps support of this solution is no longer there. I'm seeing that String_Agg is preferred. – gdbj Feb 10 '23 at 02:48
25

In a single SQL query, without using the FOR XML clause.
A Common Table Expression is used to recursively concatenate the results.

-- rank locations by incrementing lexicographical order
WITH RankedLocations AS (
  SELECT
    VehicleID,
    City,
    ROW_NUMBER() OVER (
        PARTITION BY VehicleID 
        ORDER BY City
    ) Rank
  FROM
    Locations
),
-- concatenate locations using a recursive query
-- (Common Table Expression)
Concatenations AS (
  -- for each vehicle, select the first location
  SELECT
    VehicleID,
    CONVERT(nvarchar(MAX), City) Cities,
    Rank
  FROM
    RankedLocations
  WHERE
    Rank = 1

  -- then incrementally concatenate with the next location
  -- this will return intermediate concatenations that will be 
  -- filtered out later on
  UNION ALL

  SELECT
    c.VehicleID,
    (c.Cities + ', ' + l.City) Cities,
    l.Rank
  FROM
    Concatenations c -- this is a recursion!
    INNER JOIN RankedLocations l ON
        l.VehicleID = c.VehicleID 
        AND l.Rank = c.Rank + 1
),
-- rank concatenation results by decrementing length 
-- (rank 1 will always be for the longest concatenation)
RankedConcatenations AS (
  SELECT
    VehicleID,
    Cities,
    ROW_NUMBER() OVER (
        PARTITION BY VehicleID 
        ORDER BY Rank DESC
    ) Rank
  FROM 
    Concatenations
)
-- main query
SELECT
  v.VehicleID,
  v.Name,
  c.Cities
FROM
  Vehicles v
  INNER JOIN RankedConcatenations c ON 
    c.VehicleID = v.VehicleID 
    AND c.Rank = 1
ZunTzu
  • 406
  • 5
  • 6
  • 4
    Thanks for this. This is one of the few solutions to this problem that doesn't use variables, functions, the FOR XML clause, or CLR code. This means I was able to adapt your solution to solve [TSQL Beginners Challenge 4 - Concatenating values from multiple rows](http://beyondrelational.com/blogs/tcb/archive/2010/03/29/TSQL-Beginners-Challenge-4-Concatenating-values-from-multiple-rows.aspx). – Iain Samuel McLean Elder Aug 14 '11 at 16:20
  • 1
    Thanks! I have to convert a series of SQL code fragments expressed as separate rows of boolean phrases into a single complex code expression, and am excited to try your method. – Paul Chernoch Feb 18 '13 at 16:43
  • 2
    Does this have performance benefits over the other solutions? – PeonProgrammer Sep 09 '15 at 22:10
  • 3
    @PeonProgrammer no, it works very poorly for large result sets and is likely to give you the error, "The maximum recursion 100 has been exhausted before statement completion." (You can workaround that by specifying `OPTION (MAXRECURSION 0)` at the end, but then your query might just take forever to run. – Kirk Woll Mar 25 '16 at 17:10
23

From what I can see FOR XML (as posted earlier) is the only way to do it if you want to also select other columns (which I'd guess most would) as the OP does. Using COALESCE(@var... does not allow inclusion of other columns.

Update: Thanks to programmingsolutions.net there is a way to remove the "trailing" comma to. By making it into a leading comma and using the STUFF function of MSSQL you can replace the first character (leading comma) with an empty string as below:

stuff(
    (select ',' + Column 
     from Table
         inner where inner.Id = outer.Id 
     for xml path('')
), 1,1,'') as Values
Pure.Krome
  • 84,693
  • 113
  • 396
  • 647
John B
  • 1,129
  • 14
  • 23
23

In SQL Server 2005

SELECT Stuff(
  (SELECT N', ' + Name FROM Names FOR XML PATH(''),TYPE)
  .value('text()[1]','nvarchar(max)'),1,2,N'')

In SQL Server 2016

you can use the FOR JSON syntax

i.e.

SELECT per.ID,
Emails = JSON_VALUE(
   REPLACE(
     (SELECT _ = em.Email FROM Email em WHERE em.Person = per.ID FOR JSON PATH)
    ,'"},{"_":"',', '),'$[0]._'
) 
FROM Person per

And the result will become

Id  Emails
1   abc@gmail.com
2   NULL
3   def@gmail.com, xyz@gmail.com

This will work even your data contains invalid XML characters

the '"},{"":"' is safe because if you data contain '"},{"":"', it will be escaped to "},{\"_\":\"

You can replace ', ' with any string separator


And in SQL Server 2017, Azure SQL Database

You can use the new STRING_AGG function

teamchong
  • 1,326
  • 3
  • 16
  • 13
  • 1
    I took out this chunk: `TYPE ).value('text()[1]', 'nvarchar(max)')` and it still works great... not sure what that is supposed to do. – Adam Nofsinger Jul 06 '11 at 20:43
  • 5
    supposed to decode the xml, if [City] had char like & < >, the output will become, & < > , if you sure [City] don't have those special chars, then it's safe to remove it. – Steven Chong – teamchong Jun 04 '12 at 09:44
  • 2
    +1. This answer is underrated. You should edit it to mention that this is one of the only answers that will not escape special characters like & < > etc. Also, won't the results be the same if we use: `.value('.', 'nvarchar(max)')`? – Baodad Apr 27 '16 at 19:27
  • 1
    Hi Baodad, the results are the same but as i tested the performance is better when using 'text()[1]' instead of '.', no big difference tho – teamchong Apr 28 '16 at 01:44
13

The below code will work for Sql Server 2000/2005/2008

CREATE FUNCTION fnConcatVehicleCities(@VehicleId SMALLINT)
RETURNS VARCHAR(1000) AS
BEGIN
  DECLARE @csvCities VARCHAR(1000)
  SELECT @csvCities = COALESCE(@csvCities + ', ', '') + COALESCE(City,'')
  FROM Vehicles 
  WHERE VehicleId = @VehicleId 
  return @csvCities
END

-- //Once the User defined function is created then run the below sql

SELECT VehicleID
     , dbo.fnConcatVehicleCities(VehicleId) AS Locations
FROM Vehicles
GROUP BY VehicleID
Binoj Antony
  • 15,886
  • 25
  • 88
  • 96
  • 1
    That VARCHAR(1000), that's some kind of limit, isn't it? Becuase when I run a similar concatenation query on a column list it will stop just around ~950 characters, no matter the size specified. – John Leidegren Aug 25 '09 at 07:29
6

I've found a solution by creating the following function:

CREATE FUNCTION [dbo].[JoinTexts]
(
  @delimiter VARCHAR(20) ,
  @whereClause VARCHAR(1)
)
RETURNS VARCHAR(MAX)
AS 
BEGIN
    DECLARE @Texts VARCHAR(MAX)

    SELECT  @Texts = COALESCE(@Texts + @delimiter, '') + T.Texto
    FROM    SomeTable AS T
    WHERE   T.SomeOtherColumn = @whereClause

    RETURN @Texts
END
GO

Usage:

SELECT dbo.JoinTexts(' , ', 'Y')
Gil
  • 1,113
  • 2
  • 12
  • 13
  • 1
    This is very much like [Mike Powell](http://stackoverflow.com/questions/6899/is-there-a-way-to-create-a-sql-server-function-to-join-multiple-rows-from-a-sub/7192#7192)'s and [Binoj Antony](http://stackoverflow.com/questions/6899/is-there-a-way-to-create-a-sql-server-function-to-join-multiple-rows-from-a-sub/1012432#1012432)'s answers. – Andriy M May 30 '11 at 18:22
  • 2
    Great solution because readability was superior to other answers +1 – PeonProgrammer Sep 09 '15 at 22:09
3

Mun's answer didn't work for me so I made some changes to that answer to get it to work. Hope this helps someone. Using SQL Server 2012:

SELECT [VehicleID]
     , [Name]
     , STUFF((SELECT DISTINCT ',' + CONVERT(VARCHAR,City) 
         FROM [Location] 
         WHERE (VehicleID = Vehicle.VehicleID) 
         FOR XML PATH ('')), 1, 2, '') AS Locations
FROM [Vehicle]
nurseybushc
  • 364
  • 3
  • 10
2

VERSION NOTE: You must be using SQL Server 2005 or greater with Compatibility Level set to 90 or greater for this solution.

See this MSDN article for the first example of creating a user-defined aggregate function that concatenates a set of string values taken from a column in a table.

My humble recommendation would be to leave out the appended comma so you can use your own ad-hoc delimiter, if any.

Referring to the C# version of Example 1:

change:  this.intermediateResult.Append(value.Value).Append(',');
    to:  this.intermediateResult.Append(value.Value);

And

change:  output = this.intermediateResult.ToString(0, this.intermediateResult.Length - 1);
    to:  output = this.intermediateResult.ToString();

That way when you use your custom aggregate, you can opt to use your own delimiter, or none at all, such as:

SELECT dbo.CONCATENATE(column1 + '|') from table1

NOTE: Be careful about the amount of the data you attempt to process in your aggregate. If you try to concatenate thousands of rows or many very large datatypes you may get a .NET Framework error stating "[t]he buffer is insufficient."

JustinStolle
  • 4,182
  • 3
  • 37
  • 48
2

With the other answers, the person reading the answer must be aware of the vehicle table and create the vehicle table and data to test a solution.

Below is an example that uses SQL Server "Information_Schema.Columns" table. By using this solution, no tables need to be created or data added. This example creates a comma separated list of column names for all tables in the database.

SELECT
    Table_Name
    ,STUFF((
        SELECT ',' + Column_Name
        FROM INFORMATION_SCHEMA.Columns Columns
        WHERE Tables.Table_Name = Columns.Table_Name
        ORDER BY Column_Name
        FOR XML PATH ('')), 1, 1, ''
    )Columns
FROM INFORMATION_SCHEMA.Columns Tables
GROUP BY TABLE_NAME 
Mike Barlow - BarDev
  • 11,087
  • 17
  • 62
  • 83
1

Try this query

SELECT v.VehicleId, v.Name, ll.LocationList
FROM Vehicles v 
LEFT JOIN 
    (SELECT 
     DISTINCT
        VehicleId,
        REPLACE(
            REPLACE(
                REPLACE(
                    (
                        SELECT City as c 
                        FROM Locations x 
                        WHERE x.VehicleID = l.VehicleID FOR XML PATH('')
                    ),    
                    '</c><c>',', '
                 ),
             '<c>',''
            ),
        '</c>', ''
        ) AS LocationList
    FROM Locations l
) ll ON ll.VehicleId = v.VehicleId
1

If you're running SQL Server 2005, you can write a custom CLR aggregate function to handle this.

C# version:

using System;
using System.Data;
using System.Data.SqlClient;
using System.Data.SqlTypes;
using System.Text;
using Microsoft.SqlServer.Server;
[Serializable]
[Microsoft.SqlServer.Server.SqlUserDefinedAggregate(Format.UserDefined,MaxByteSize=8000)]
public class CSV:IBinarySerialize
{
    private StringBuilder Result;
    public void Init() {
        this.Result = new StringBuilder();
    }

    public void Accumulate(SqlString Value) {
        if (Value.IsNull) return;
        this.Result.Append(Value.Value).Append(",");
    }
    public void Merge(CSV Group) {
        this.Result.Append(Group.Result);
    }
    public SqlString Terminate() {
        return new SqlString(this.Result.ToString());
    }
    public void Read(System.IO.BinaryReader r) {
        this.Result = new StringBuilder(r.ReadString());
    }
    public void Write(System.IO.BinaryWriter w) {
        w.Write(this.Result.ToString());
    }
}
Ian Kemp
  • 28,293
  • 19
  • 112
  • 138
HS.
  • 15,442
  • 8
  • 42
  • 48