A festive post for the twelfth day of Christmas and one way to avoid taking the decorations down for a while longer!
If you have each gift from the “Twelve days of Christmas” song as an individual record in a SQL Server database table, can you write a query that returns one row for each verse of the song?
Yes you can. This problem is similar to other cases where you need to flatten multiple rows into a single string. In this case for each day we want a comma separated list of the gifts for that day and all preceding days.
Another case you may have come across is flattening multiple access rights per user into a single row per user, where each row has a comma separated list of that user’s rights:
User | Access Rights |
---|---|
Anne | Read, Write |
Bob | Read |
Chad | Admin, Read, Write |
Back to the twelve days of Christmas, first create a table to contain the gifts:
CREATE TABLE #christmas_gifts (day_num INT, gift VARCHAR(30))
And insert the data:
INSERT INTO #christmas_gifts VALUES (1, 'a Partridge in a Pear Tree') INSERT INTO #christmas_gifts VALUES (2, '2 Turtle Doves') INSERT INTO #christmas_gifts VALUES (3, '3 French Hens') INSERT INTO #christmas_gifts VALUES (4, '4 Calling Birds') INSERT INTO #christmas_gifts VALUES (5, '5 Gold Rings') INSERT INTO #christmas_gifts VALUES (6, '6 Geese-a-Laying') INSERT INTO #christmas_gifts VALUES (7, '7 Swans-a-Swimming') INSERT INTO #christmas_gifts VALUES (8, '8 Maids-a-Milking') INSERT INTO #christmas_gifts VALUES (9, '9 Ladies Dancing') INSERT INTO #christmas_gifts VALUES (10, '10 Lords-a-Leaping') INSERT INTO #christmas_gifts VALUES (11, '11 Pipers Piping') INSERT INTO #christmas_gifts VALUES (12, '12 Drummers Drumming')
And here is the query to return each verse of the song as a single row:
SELECT 'On the ' + CAST(theday.day_num AS VARCHAR) + CASE WHEN theday.day_num%10=1 AND theday.day_num%100<>11 THEN 'st' WHEN theday.day_num%10=2 AND theday.day_num%100<>12 THEN 'nd' WHEN theday.day_num%10=3 AND theday.day_num%100<>13 THEN 'rd' ELSE 'th' END + ' day of Christmas SQL gave to me ' + REPLACE( REPLACE( REPLACE( REPLACE( (SELECT todate.gift FROM #christmas_gifts todate WHERE todate.day_num <= theday.day_num ORDER BY todate.day_num DESC FOR XML PATH('')), '</gift><gift>a ', ' and a '), '</gift><gift>', ', '), '</gift>', ''), '<gift>', '') AS verse FROM #christmas_gifts theday
The approach used above has been around for a while and a quick Google will turn up plenty of background material. The inner query selects the list of gifts for the day and its previous days as an XML structure (SELECT … WHERE todate.day_num <= theday.day_num … FOR XML). We then replace the XML tags with commas or “and”.
If you want to get a feel for the XML that is returned by the inner select then you can run that part of the query and take a look, e.g. if you run the inner select for day 2:
SELECT todate.gift
FROM #christmas_gifts todate
WHERE todate.day_num <= 2
ORDER BY todate.day_num DESC
FOR XML PATH('')
The XML you should get back is:
<gift>2 Turtle Doves</gift><gift>a Partridge in a Pear Tree</gift>
You may spot that this isn’t well formed XML, because there is no containing element (such as <gifts>…</gifts>). The absence of a containing element was specified with the empty string in the PATH(”) part of the FOR XML clause. Leaving out the containing element made the subsequent string replacements a bit simpler (arguably).