How to phrase a T-SQL horizontal join? - sql-server

I have a database which is including two tables, Labs and LabUsers
How do I join a column from LabUsers into a cell in Labs.
To be specific, I would like to have the user names of a lab to be concatenated and separated with a bullet ( • is Alt+0149), and to have these usernames sorted alphabetically left to right.
Here is an example.
The Labs table looks like this:
LabID LabName LabStudents
----- ---------- -----------
1 North NULL
2 North East NULL
3 South West NULL
and the LabUsers looks like this:
LabUserID LabUserName LabID
--------- ----------- -----
1 Diana 1
2 Paul 2
3 Paula 2
4 Romeo 1
5 Julia 1
6 Rose 2
7 Diana 2
I would like to get this outcome in the Labs table:
LabID LabName LabUsers
----- ---------- ---------------------
1 North Diana•Julia•Romeo
2 North East Diana•Paul•Paula•Rose
3 South West NULL
Here is the script to create the tables:
USE [tempdb];
GO
CREATE TABLE [dbo].[LabUsers]
(
[LabUserID] [int] PRIMARY KEY CLUSTERED,
[LabUserName] [nvarchar](50) NOT NULL,
[LabID] [int] NOT NULL
);
GO
INSERT [dbo].[LabUsers] SELECT 1, N'Diana', 1;
INSERT [dbo].[LabUsers] SELECT 2, N'Paul', 2;
INSERT [dbo].[LabUsers] SELECT 3, N'Paula', 2;
INSERT [dbo].[LabUsers] SELECT 4, N'Romeo', 1;
INSERT [dbo].[LabUsers] SELECT 5, N'Julia', 1;
INSERT [dbo].[LabUsers] SELECT 6, N'Rose', 2;
INSERT [dbo].[LabUsers] SELECT 7, N'Diana', 2;
CREATE TABLE [dbo].[Labs]
(
[LabID] [int] PRIMARY KEY CLUSTERED,
[LabName] [nvarchar](50) NOT NULL,
[LabUsers] [nvarchar](max) NULL
);
GO
INSERT [dbo].[Labs] SELECT 1, N'North', NULL;
INSERT [dbo].[Labs] SELECT 2, N'North East', NULL;
INSERT [dbo].[Labs] SELECT 3, N'South West', NULL;

SELECT l.LabID, l.LabName, LabUsers = STUFF((SELECT N'•' + lu.LabUserName
FROM dbo.LabUsers AS lu
WHERE lu.LabID = l.LabID
ORDER BY lu.LabUserName
FOR XML PATH(''),
TYPE).value(N'./text()[1]', N'nvarchar(max)'), 1, 1, N'')
FROM dbo.Labs AS l;
I see absolutely no reason to store this in the table, since you can always generate the code at runtime when you run a query. If you store it in the table, then you have to update it every single time you change any row in the table.
However, if I can't convince you not to do this (it really is bad to store redundant data like this), you can try this way:
;WITH x AS
(
SELECT l.LabID, l.LabName, x = STUFF((SELECT N'•' + lu.LabUserName
FROM dbo.LabUsers AS lu
WHERE lu.LabID = l.LabID
ORDER BY lu.LabUserName
FOR XML PATH(''),
TYPE).value(N'./text()[1]', N'nvarchar(max)'), 1, 1, N'')
FROM dbo.Labs AS l
)
UPDATE l
SET LabUsers = x.x
FROM dbo.Labs AS l
INNER JOIN x ON l.LabID = x.LabID;
As for the performance tests, I'd compare the above version with this variation:
SELECT l.LabID, l.LabName, LabUsers = STUFF((SELECT N'•' + lu.LabUserName
FROM dbo.LabUsers AS lu
WHERE lu.LabID = l.LabID
ORDER BY lu.LabUserName
FOR XML PATH('')), 1, 1, '')
FROM dbo.Labs AS l;
On my system I see the initial version at the top of this answer to be far more expensive. Also note that stuffing (no pun intended) these approaches into a user-defined function will bring it closer to the concatenation method #RThomas proposed.

Give this a try
SELECT LabName ,
STUFF(( SELECT ',' + LabUsers.LabUserName
FROM dbo.LabUsers
WHERE LabUsers.LabID = Labs.LabID
ORDER BY LabName
FOR
XML PATH('')
), 1, 1, '') AS Labusers
FROM dbo.Labs
ORDER BY LabName
The FOR XML PATH('') concatenates your strings together into one XML result and the STUFF puts a "nothing" character at the first character, e.g. wipes out the unneeded first comma.

Another way to do it is set up a UDF that returns all the lab users as a single string like this:
CREATE FUNCTION LabUserString
(
#pLabId Int
)
RETURNS NVarChar(Max)
AS
BEGIN
Declare #pResult NVarChar(Max)
SELECT #pResult = COALESCE(#pResult + N'•', '') + [LabUserName]
FROM LabUsers WHERE LabId = #pLabId
Return #pResult
END
And then a query like this:
Select LabID, LabName, dbo.LabUserString(LabID) AS LabUsers FROM Labs

Related

Generate XML in SQL Server

I have a table like this -
Version itemid sampleid
--------------------------------
1 3 23
1 3 24
1 4 45
2 5 24
2 5 23
Where for each version there can be multiple itemid, and for each itemid there can be multiple sampleid.
I want to generate XML for this table in the following manner
<UserVersioningHistory>
<History>
<Version>1</Version>
<itemid>3</itemid>
<sampleid>23,24</sampleid>
</History>
<History>
<Version>1</Version>
<itemid>4</itemid>
<sampleid>45</sampleid>
</History>
<History>
<Version>2</Version>
<BusinessId>5</BusinessId>
<sampleid>24,23</sampleid>
</History>
</UserVersioningHistory>
Each node here can have only one version and itemid but can have multiple sampleid for corresponding itemid and Version Pair.
As I am not familiar in with generating XML in SQL Server, can someone give me a hint to what approach I should use?
Can I accomplish this task using while loop, or I should do this writing a subquery?
Try it out:
select * from
(SELECT
version, itemid,
STUFF(
(SELECT ',' + sampleid
FROM test
WHERE version = a.version AND itemid = a.itemid
FOR XML PATH (''))
, 1, 1, '') AS sampleid
FROM test AS a
GROUP BY version, itemid) as History
for xml auto, root ('UserVersioningHistory')
Always avoid WHILE loops unless when truly necessary.
Preferably you would have multiple tags for the SampleId section if more than one exists.
But to give you the result that you want the following would work. (I created a temp table to imitate your situation.)
I used FOR XML to do the XML formatting and had to use a second FOR XML to concatenate the SampleId sepparated by a comma.
STUFF is only used to remove the first comma in the string.
SET XACT_ABORT ON;
BEGIN TRANSACTION;
SELECT
X.Version,
X.ItemId,
X.SampleId
INTO
#Temp
FROM
(VALUES
(1, 3, 23),
(1, 3, 24),
(1, 4, 45),
(2, 5, 24),
(2, 5, 23)
) X ([Version], ItemId, SampleId)
SELECT
T.Version,
T.ItemId,
STUFF((
SELECT
',' + CONVERT(varchar(MAX), T2.SampleId)
FROM
#Temp T2
WHERE
T2.Version = T.Version AND
T2.ItemId = T.ItemId
FOR XML PATH ('')
),
1,
1,
''
) AS [SampleId]
FROM
#Temp T
GROUP BY
T.Version,
T.ItemId
FOR XML RAW ('History'), ROOT ('UserVersioningHistory'), ELEMENTS
ROLLBACK TRANSACTION;

Comma separated string total count by variable by row

This is the schema:
User_ID Page_ID Timestamp
1 48,51,94 7/26/2017 8:30
2 42,11,84 7/26/2017 9:40
3 4,16,24 7/26/2017 16:20
4 7,2,94 7/27/2017 8:00
1 48,22,94 7/27/2017 13:50
2 42,11 7/27/2017 14:00
3 4,24 7/27/2017 18:15
The code below gives aggregate count of page ids ran per user (non-unique on purpose):
SELECT User_ID, sum(len(Page_ID) - len(replace(Page_ID, ',', '')) +1) as TotalPageCount
FROM DBTABLE
group by User_ID
Output:
User_ID TotalPageCount
1 6
2 5
3 5
4 3
However, I am looking to add a (comma separated) column with page count per page id per user id. ie. a column as newsletter id 1: count, newsletter id 2: count, etc. (essentially a dictionary). Can be a different format, but needs to be descriptive at the page id level, with its respective count.
Something like this:
User_ID PageIDCount TotalPageCount
1 48:2, 51:1, 94:2, 22:1, 6
2 42:2, 11:2, 84:1, 5
3 4:2, 16:1, 24:2, 5
4 7:1, 2:1, 94:1, 3
Your help is greatly appreciated!
Edit:
As per SeanLange's amazing solution, you can change the definition to MyCTE to the below, in order to avoid using any functions:
select user_id, page_id, page_count = count(*)
FROM (
SELECT user_id, Split.a.value('.', 'NVARCHAR(max)') AS page_id FROM
( SELECT user_id, CAST ('<M>' + REPLACE(page_id, ',', '</M><M>') + '</M>' AS XML) page_id
FROM #temp
) AS A
CROSS APPLY page_id.nodes ('/M') AS Split(a)
) x
group by user_id, page_id
Wow this is a nightmare. You are going to need a string splitter to start with. My personal favorite is this one. http://www.sqlservercentral.com/articles/Tally+Table/72993/ There are a number of other excellent choices here. https://sqlperformance.com/2012/07/t-sql-queries/split-strings
Starting with your data you will need to do something like this.
declare #Something table
(
User_ID int
, Page_ID varchar(100)
, MyDate datetime
)
insert #Something
select 1, '48,51,94', '7/26/2017 8:30' union all
select 2, '42,11,84', '7/26/2017 9:40' union all
select 3, '4,16,24', '7/26/2017 16:20' union all
select 4, '7,2,94', '7/27/2017 8:00' union all
select 1, '48,22,94', '7/27/2017 13:50' union all
select 2, '42,11', '7/27/2017 14:00' union all
select 3, '4,24', '7/27/2017 18:15'
select User_ID
, Page_ID = x.Item
, count(*)
from #Something s
cross apply dbo.DelimitedSplit8K(s.Page_ID, ',') x
group by User_ID
, x.Item
order by User_ID
, x.Item
This gets the data with the counts you want. From there you are going to have to shove this back into the denormalized structure that you want. You can do this with FOR XML. Here is an article that explains how to do that part of this. Simulating group_concat MySQL function in Microsoft SQL Server 2005?
-----EDIT-----
OK here is the complete working solution. You have obviously been working hard at trying to get this sorted out. I am using the DelimitedSplit8K function here so I didn't have to inline XML like your solution was doing.
with MyCTE as
(
select User_ID
, Page_ID = x.Item
, PageCount = count(*)
from #Something s
cross apply dbo.DelimitedSplit8K(s.Page_ID, ',') x
group by User_ID
, x.Item
)
, GroupedPageViews as
(
select c.User_ID
, sum(c.PageCount) as TotalPageCount
, PageViews = STUFF((select ', ' + convert(varchar(4), c2.Page_ID) + ':' + convert(varchar(4), c2.PageCount)
from MyCTE c2
where c.User_ID = c2.User_ID
order by c2.Page_ID
for xml path('')), 1, 1, '')
from MyCTE c
group by c.User_ID
)
select gpv.User_ID
, gpv.PageViews
, gpv.TotalPageCount
from GroupedPageViews gpv
join MyCTE c on c.User_ID = gpv.User_ID
group by gpv.PageViews
, gpv.User_ID
, gpv.TotalPageCount
order by gpv.User_ID
This will return your data like this.
User_ID PageViews TotalPageCount
1 22:1, 48:2, 51:1, 94:2 6
2 11:2, 42:2, 84:1 5
3 16:1, 24:2, 4:2 5
4 2:1, 7:1, 94:1 3
Here you go
SELECT DISTINCT User_Id
, (
SELECT CAST(t.Value AS VARCHAR) + ':' + CAST(COUNT(t.value) AS VARCHAR) + ', '
FROM TBL_46160346_DBTABLE ii
CROSS APPLY (
SELECT *
FROM fn_ParseText2Table(Page_ID, ',')
) t
WHERE pp.User_Id = ii.User_Id
GROUP BY User_Id
, VALUE
ORDER BY User_Id
FOR XML PATH('')
) PageIDCount
, (
SELECT COUNT(*)
FROM TBL_46160346_DBTABLE ii
CROSS APPLY (
SELECT *
FROM fn_ParseText2Table(Page_ID, ',')
) t
WHERE pp.User_Id = ii.User_Id
GROUP BY User_Id
) TotalPageCount
FROM TBL_46160346_DBTABLE pp
fn_ParseText2Table function
ALTER FUNCTION [dbo].[fn_ParseText2Table] (
#p_SourceText VARCHAR(8000), #p_Delimeter VARCHAR(10) = ',' --default comma
)
RETURNS #retTable TABLE (Value BIGINT)
AS
BEGIN
DECLARE #w_Continue INT, #w_StartPos INT, #w_Length INT, #w_Delimeter_pos INT, #w_tmp_txt VARCHAR(48), #w_Delimeter_Len TINYINT
IF LEN(#p_SourceText) = 0
BEGIN
SET #w_Continue = 0 -- force early exit
END
ELSE
BEGIN
-- parse the original #p_SourceText array into a temp table
SET #w_Continue = 1
SET #w_StartPos = 1
SET #p_SourceText = RTRIM(LTRIM(#p_SourceText))
SET #w_Length = DATALENGTH(RTRIM(LTRIM(#p_SourceText)))
SET #w_Delimeter_Len = LEN(#p_Delimeter)
END
WHILE #w_Continue = 1
BEGIN
SET #w_Delimeter_pos = CHARINDEX(#p_Delimeter, SUBSTRING(#p_SourceText, #w_StartPos, #w_Length - #w_StartPos + #w_Delimeter_Len))
IF #w_Delimeter_pos > 0 -- delimeter(s) found, get the value
BEGIN
SET #w_tmp_txt = LTRIM(RTRIM(SUBSTRING(#p_SourceText, #w_StartPos, #w_Delimeter_pos - 1)))
SET #w_StartPos = #w_Delimeter_pos + #w_StartPos + #w_Delimeter_Len - 1
END
ELSE -- No more delimeters, get last value
BEGIN
SET #w_tmp_txt = LTRIM(RTRIM(SUBSTRING(#p_SourceText, #w_StartPos, #w_Length - #w_StartPos + #w_Delimeter_Len)))
SELECT #w_Continue = 0
END
INSERT INTO #retTable
VALUES (#w_tmp_txt)
END
RETURN
END

TSQL, change value on a comma delimited column

I have a column called empl_type_multi which is just a comma delimited column, each value is a link to another table called custom captions.
For instance, i might have the following as a value in empl_type_multi:
123, RHN, 458
Then in the custom_captions table these would be individual values:
123 = Dog
RHN = Cat
458 = Rabbit
All of these fields are NTEXT.
What i am trying to do is convert the empl_type_multi column and chance it to the respective names in the custom_captions table, so in the example above:
123, RHN, 458
Would become
Dog, Cat, Rabbit
Any help on this would be much appreciated.
----- EDIT ------------------------------------------------------------------
Ok so ive managed to convert the values to the corresponding caption and put it all into a temporary table, the following is the output from a CTE query on the table:
ID1 ID2 fName lName Caption_name Row_Number
10007 22841 fname1 lname1 DENTAL ASSISTANT 1
10007 22841 fname1 lname1 2
10007 22841 fname1 lname1 3
10008 23079 fname2 lname2 OPS WARD 1
10008 23079 fname2 lname2 DENTAL 2
10008 23079 fname2 lname2 3
How can i update this so that anything under caption name is added to the caption name of Row_Number 1 separated by a comma?
If i can do that all i need to do is delete all records where Row_Number != 1.
------ EDIT --------------------------------------------------
The solution to the first edit was:
WITH CTE AS
(
SELECT
p.ID1
, p.ID2
, p.fname
, p.lname
, p.caption_name--
, ROW_NUMBER() OVER (PARTITION BY p.id1ORDER BY caption_name DESC) AS RN
FROM tmp_cs p
)
UPDATE tblPerson SET empType = empType + ', ' + c.Data
FROM CTE c WHERE [DB1].dbo.tblPerson.personID = c.personID AND RN = 2
And then i just incremented RN = 2 until i got 0 rows affected.
This was after i ran:
DELETE FROM CTE WHERE RN != 1 AND Caption_name = ''
select ID1, ID2, fname, lname, left(captions, len(captions) - 1) as captions
from (
select distinct ID1, ID2, cast(fname as nvarchar) as fname, cast(lname as nvarchar) as lname, (
select cast(t1.caption_name as nvarchar) + ','
from #temp as t1
where t1.ID1 = t2.ID1
and t1.ID2 = t2.ID2
and cast(caption_name as nvarchar) != ''
order by t1.[row_number]
for xml path ('')) captions
from #temp as t2
) yay_concatenated_rows
This will give you what you want. You'll see casting from ntext to varchar. This is necessary for comparison because many logical ops can't be performed on ntext. It can be implicitly cast back the other way so no worries there. Note that when casting I did not specify length; this will default to 30, so adjust as varchar(length) as needed to avoid truncation. I also assumed that both ID1 and ID2 form a composite key (it appears this is so). Adjust the join as you need for the relationship.
you have just shared your part of problem,not exact problem.
try this,
DECLARE #T TABLE(ID1 VARCHAR(50),ID2 VARCHAR(50),fName VARCHAR(50),LName VARCHAR(50),Caption_name VARCHAR(50),Row_Number INT)
INSERT INTO #T VALUES
(10007,22841,'fname1','lname1','DENTAL ASSISTANT', 1)
,(10007,22841,'fname1','lname1', NULL, 2)
,(10007,22841,'fname1','lname1', NULL, 3)
,(10008,23079,'fname2','lname2','OPS WARD', 1)
,(10008,23079,'fname2','lname2','DENTAL', 2)
,(10008,23079,'fname2','lname2', NULL, 3)
SELECT *
,STUFF((SELECT ','+Caption_name
FROM #T T1 WHERE T.ID1=T1.ID1 FOR XML PATH('')
),1,1,'')
FROM #T T
You can construct the caption_name string easily by looping through while loop
declare #i int = 2,#Caption_name varchar(100)= (select series from
#temp where Row_Number= 1)
while #i <= (select count(*) from #temp)
begin
select #Caption_name = #Caption_name + Caption_name from #temp where Row_Number = #i)
set #i = #i+1
end
update #temp set Caption_name = #Caption_name where Row_Number = 1
and use case statement to remove null values
(select case when isnull(Caption_name ,'') = '' then
'' else ',' + Caption_name end

SQL Server array_agg (master - detail)

How can I convert this PostgreSQL code to SQL Server ?
select
countries.title,
(select array_to_json(array_agg(row_to_json(t)))
from postcodes t
where t.country_id = countries.id) as codes
from countries
My initial problem is that I need to select complete master table and with each row all details.
Countries:
id title
1 SLO
2 AUT
PostCodes:
id country_id code title
1 1 1000 Lj
2 1 2000 Mb
3 2 22180 Vi
4 2 22484 De
Desired result:
1 SLO 1000;Lj|2000;MB
2 AUT 22180;Vi|22484;De
Not:
1 SLO 1000 Lj
1 SLO 2000 Mb
2 AUT 22180 Vi
2 AUT 22484 De
The best solution would be using FOR JSON, but unfortunately I need support for 2008 or at least 2012.
With left join all master data are duplicated for detail count, but I do not want to do this. Even worse it would be to select all countries and then call select on post_codes for every country in for loop.
select countries.title,
STUFF((select '|' + t.code + ';' + t.title
from postcodes t
where t.country_id = countries.id
FOR XML PATH('')
),1,1,'') as codes
from countries
-- CAST t.code to VARCHAR if it's Number
try this:
Select Main.COUNTRY_ID,c.title,Left(Main.POSTCODES,Len(Main.POSTCODES)-1) As "POSTCODES"
From
(
Select distinct ST2.COUNTRY_ID,
(
Select ST1.CODE+';'+ST1.TITLE + '|' AS [text()]
From dbo.POSTCODES ST1
Where ST1.COUNTRY_ID = ST2.COUNTRY_ID
ORDER BY ST1.COUNTRY_ID
For XML PATH ('')
) [POSTCODES]
From dbo.POSTCODES ST2
) [Main]
inner join countries c on c.id=main.country_id
Using XML PATH for concatenation can increase the complexity of your code. It's better to implement a CLR aggregation function. Then, you can do the following:
SELECT C.[id]
,C.[title]
,REPLACE([dbo].[Concatenate] (P.[code] + ';' + P.[title]), ',', '|')
FROM #Countries C
INNER JOIN #PostCodes P
ON C.[id] = p.[country_id]
GROUP BY C.[id]
,C.[title];
You can create your own version of the concatenate aggregate - you can specify the delimiter, the order, etc. I can show you examples if you want.
DECLARE #Countries TABLE
(
[id] TINYINT
,[title] VARCHAR(12)
);
INSERT INTO #Countries ([id], [title])
VALUES (1, 'SLO')
,(2, 'AUT');
DECLARE #PostCodes TABLE
(
[id] TINYINT
,[country_id] TINYINT
,[code] VARCHAR(12)
,[title] VARCHAR(12)
);
INSERT INTO #PostCodes ([id], [country_id], [code], [title])
VALUES (1, 1, 1000, 'Lj')
,(2, 1, 2000, 'Mb')
,(3, 2, 22180, 'Vi')
,(4, 2, 22484, 'De');
SELECT C.[id]
,C.[title]
,REPLACE([dbo].[Concatenate] (P.[code] + ';' + P.[title]), ',', '|')
FROM #Countries C
INNER JOIN #PostCodes P
ON C.[id] = p.[country_id]
GROUP BY C.[id]
,C.[title];

Sql Multiple row merge into single row with specific pattern

Sql select query which gives merge multiple row into one single row
using sqlserver 2005 and above
I have two tables ie (tb_master,tb_tasks)
create table tb_tasks(
id int IDENTITY(1,1) NOT NULL,
id_tbmaster int NOT NULL,
Tasks nvarchar(max) NOT NULL
)
create table tb_master(
id int IDENTITY(1,1) NOT NULL,
grade nchar(10) NOT NULL,
name nvarchar(50) NOT NULL,
task_date datetime NOT NULL,
)
select * from tb_master
id grade name task_date
1 A John 2012-02-13 10:40:00.000
2 B Tom 2012-02-13 10:40:00.000
select tb_tasks
id id_tbmaster Tasks
1 1 cooking food.
2 1 Programing 2 hours
3 1 Attending meeting
4 2 Driving car
5 2 hangout with friends
Have tried this query
select tasks + ' , ' as 'data()' from tb_tasks for xml path('')
Gives Output
XML
cooking food , Programing 2 hours , Attending meeting , Driving car , hangout with friends ,
I need output like
id Name grade task_date tasksDetails
1 John A 2012-02-13 10:40:00.000 1)cooking food, 2)Programing 2 hours, 3)Attending meeting
2 Tom B 2012-02-13 10:40:00.000 1) Driving car, 2)hangout with friends
Query i tried
select a.name,a.task_date,b.tasks =replace(select (CONVERT(VARCHAR,(ROW_NUMBER() OVER(ORDER BY id DESC)))) + ') ' + tasks + ' , ' as 'data()'
from tb_tasks for xml path(''))
from tb_master a inner join tb_tasks b
on a.id=b.id_tbmaster
Thanks in advance
This query will create requested list of activities per tb_master. Two pointers: one should match ordering in row_number() over() and in query to get consistent results, and number 3 in last row of OUTER APPLY is number of characters in separator (in this case, ' , '). If you change separator you need to adjust this number.
select a.name,a.task_date, b.taskList
from tb_master a
OUTER APPLY
(
select stuff ((select ' , '
+ CONVERT(VARCHAR(10), ROW_NUMBER()
OVER(ORDER BY id DESC))
+ ') '
+ tasks
from tb_tasks b
where a.id = b.id_tbmaster
order by id desc
for xml path (''))
, 1, 3, '') taskList
) b
Live demo is at Sql Fiddle.

Resources