SQL Server: Using conditional group by based on parameter and STUFF() - sql-server

I have a piece of code that I would like to turn into a function. The purpose of the code is to group certain records together on variable criteria and create a grouped concatenation using STUFF(). I would like to be able to toggle the parameters on which the group by happens (and therefore also the parameters for the STUFF).
However, the following gives me errors where the optional parameters (e.g. OwnerName in the example below) are invalid in the select list because they are not contained in either an aggregation function or the GROUP BY clause.
Consider a simplified example like the below (the real life version has a lot of parameters, hence why I would like to be able to get these all into one query):
SELECT CarMake, CarModel, CASE WHEN #FlagOwnerName = 1 THEN OwnerName ELSE NULL END AS [OwnerName], SUM(CarValue),
LicenseIDs = STUFF((SELECT ',' + CONVERT(VARCHAR(20),Cars2.LicenseID) AS [text()]
FROM DB.dbo.Cars Cars2
WHERE Cars2.CarMake = Cars1.CarMake
AND Cars2.CarModel = Cars1.CarModel
AND (#FlagOwnerName = 0 OR Cars2.OwnerName = Cars1.OwnerName)
FOR XML PATH('')), 1, 1, '')
FROM DB.dbo.Cars Cars1
GROUP BY CarMake,
CarModel,
CASE WHEN #FlagOwnerName = 1 THEN OwnerName ELSE NULL END
EDIT: if I change the below, then it 'seems' to return the correct concatenation except if it is NULL, then the concatenation is NULL itself. Additionally, if I try to change the values to ISNULL(Cars1.OwnerName, 'Placeholder') or similarly with COALESCE, it gives me the same error (not valid in select statement as above).
AND (#FlagOwnerName = 0 OR Cars2.OwnerName = Cars1.OwnerName)
to
AND CASE WHEN #FlagOwnerName = 1 THEN Cars1.OwnerName = Cars2.OwnerName

Based on your comments, I don't think using STUFF with FOR XML if the best way to approach this. Typically the best way to concatenate multiple rows into a single string is to use a recursive Common Table Expression (CTE).
There are some examples of using CTEs (and some alternative approaches) here.
I've adapted one of the CTE options there to do something similar to what you've described.
First, I've set up a simple table that resembles the data you've described:
create table #cars (CarMake varchar(50), CarModel varchar(50), CarValue INT, OwnerName varchar(50), LicenseID varchar(50));
insert into #cars(CarMake, CarModel, CarValue, OwnerName, LicenseID) values ('Toyota','Camry', 12000, 'Steve','ABC123');
insert into #cars(CarMake, CarModel, CarValue, OwnerName, LicenseID) values ('Toyota','Camry', 12000, 'Bob','HED999');
insert into #cars(CarMake, CarModel, CarValue, OwnerName, LicenseID) values ('Toyota','Camry', 19000, 'Helen','WKS444');
insert into #cars(CarMake, CarModel, CarValue, OwnerName, LicenseID) values ('Ford','Mustang',30000, 'Amy','JKJL88');
insert into #cars(CarMake, CarModel, CarValue, OwnerName, LicenseID) values ('Ford','Mustang',30000, 'Billy-Bob','EZ1111');
insert into #cars(CarMake, CarModel, CarValue, OwnerName, LicenseID) values ('Aston Martin','Vantage',90000, 'Mike','HY7733');
I've then used a CTE to build a dataset where the car licenses and values are appended/aggregated by make/model. The variable #FlagOwnerName controls whether these values from the CTE or the base values from the source table are used in the final SELECT statement:
DECLARE #FlagOwnerName bit = 1;
WITH cte (CarMake, CarModel, CarValueTotal, Car_Val, LicenseList, License_ID, length_)
AS
(
SELECT
CarMake, CarModel, 0, 0, CAST( '' AS VARCHAR(8000) ), CAST( '' AS VARCHAR(8000) ), 0
FROM #cars
GROUP BY CarMake, CarModel
UNION ALL
SELECT c.CarMake, c.CarModel, cte.CarValueTotal + c.CarValue, c.CarValue,
CAST(cte.LicenseList + CASE WHEN length_ = 0 THEN '' ELSE ', ' END + c.LicenseID AS VARCHAR(8000) ),
CAST( LicenseID AS VARCHAR(8000)),
length_ + 1
FROM cte
INNER JOIN #cars c ON cte.CarMake = c.CarMake AND cte.CarModel = c.CarModel
WHERE c.LicenseID > cte.License_ID
)
SELECT
cars.CarMake,
cars.CarModel,
CASE WHEN #FlagOwnerName = 1 THEN cars.OwnerName ELSE 'ALL' END as OwnerName,
CASE WHEN #FlagOwnerName = 1 THEN cars.CarValue ELSE totals.CarValueTotal END as CarValue,
CASE WHEN #FlagOwnerName = 1 THEN cars.LicenseID ELSE totals.LicenseList END as LicenseID
FROM #cars cars
INNER JOIN
(
SELECT CarMake, CarModel, LicenseList, CarValueTotal
FROM (
SELECT CarMake, CarModel, LicenseList, CarValueTotal,
RANK() OVER ( PARTITION BY CarMake, CarModel ORDER BY length_ DESC )
FROM CTE
) D ( CarMake, CarModel, LicenseList, CarValueTotal, rank )
WHERE rank = 1
) totals ON cars.CarMake = totals.CarMake AND cars.CarModel = totals.CarModel
GROUP BY
cars.CarMake,
cars.CarModel,
CASE WHEN #FlagOwnerName = 1 THEN cars.OwnerName ELSE 'ALL' END,
CASE WHEN #FlagOwnerName = 1 THEN cars.CarValue ELSE totals.CarValueTotal END,
CASE WHEN #FlagOwnerName = 1 THEN cars.LicenseID ELSE totals.LicenseList END
So when #FlagOwnerName = 1 we get:
CarMake CarModel OwnerName CarValue LicenseID
Aston Martin Vantage Mike 90000 HY7733
Ford Mustang Amy 30000 JKJL88
Ford Mustang Billy-Bob 30000 EZ1111
Toyota Camry Bob 12000 HED999
Toyota Camry Helen 19000 WKS444
Toyota Camry Steve 12000 ABC123
And when #FlagOwnerName = 0 we get:
CarMake CarModel OwnerName CarValue LicenseID
Aston Martin Vantage ALL 90000 HY7733
Ford Mustang ALL 60000 EZ1111, JKJL88
Toyota Camry ALL 43000 ABC123, HED999, WKS444
Note that in your comment you implied you didn't want OwnerName to be returned when #FlagOwnerName = 0, while this is possible inside a stored procedure (i.e. executing different queries based on a parameter) I wouldn't recommend it. Better to have a consistent set of columns returned, if you're using a reporting tool over the top of it then you can potentially include some logic there to hide the column based on the parameter value.

Related

Rename column name based on the Value

I need to rename the column base on the value I give in it.
SELECT TOP 1
[Pratice_Id] = [OID]// id
, [Province] = '' //province
, [Country] = 'US'// country
FROM [tbl_Office]
if the country equals to US I want to change columnName [Province] to State
I would strongly suggest a column STATE_PROVINCE, however, if you MUST and you are not opposed to a temp table, perhaps something like this
Example
Select Top 1 *
Into #Temp
From YourTable
Where ...
Order By ...
If ( Select Top 1 Country From #Temp ) = 'US'
Select [Pratice_Id]
,[State] = [Province]
,[Country]
From #Temp
Else
Select * from #Temp
Returns
Pratice_Id State Country
1 RI US
Personally, I would provide both
Declare #YourTable table ([Pratice_Id] int,[Province] varchar(50),[Country] varchar(50))
Insert Into #YourTable values
(1,'RI','US')
,(2,'Alberta','Canada')
Select [Pratice_Id]
,[State] = case when [Country]='US' then Province else '' end
,[Province] = case when [Country]<>'US' then Province else '' end
,[Country]
From #YourTable
Returns
Pratice_Id State Province Country
1 RI US
2 Alberta Canada

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

Issue while using 'case when ' in 'where' clause sql server

While using case when in where clause in sql query it's not working.
Problem :
I have two tables named TblEmployee and TblAssociate.Both tables contains common columns PeriodId, EmpId and AssociateId. My requirement is to fetch values from
TblEmployee with combination of EmpId and AssociateId from TblAssociate should be excluded.And the exclusion should be based on PeriodId condition.`
If(#PeriodID<50)
BEGIN
SELECT *
FROM TblEmployee
WHERE (EmpId+AssociateId) NOT IN (SELECT EmpId+AssociateId FROM TblAssociate)
END
ELSE
BEGIN
SELECT *
FROM TblEmployee
WHERE (EmpId) NOT IN (SELECT EmpId FROM TblAssociate)
END
The above code is working, but I need to avoid that IF-ELSE condition and I wish to use 'case when' in where clause.Please help
Try this:
SELECT *
FROM TblEmployee
WHERE (EmpId + CASE WHEN #PeriodID<50 THEN AssociateId ELSE 0 END) NOT IN
(SELECT EmpId + CASE WHEN #PeriodID<50 THEN AssociateId ELSE 0 END FROM TblAssociate)
You say your code is working but this is rather odd, since it doesn't make much sense to add together id values. In any case, the above statement produces a result that is equivalent to the code originally posted.
You could use AND-OR combination in the WHERE clause. Additionally, you should not be using + as it may lead to incorrect result. You can rewrite your query as:
SELECT e.*
FROM TblEmployee e
WHERE
(
#PeriodID < 50
AND NOT EXISTS(
SELECT 1
FROM TblAssociate a
WHERE
a.EmpId = e.EmpId
AND a.AssociateId = e.AssociateId
)
)
OR
(
#PeriodID >= 50
AND NOT EXISTS(
SELECT 1
FROM TblAssociate a
WHERE a.EmpId = e.EmpId
)
)
The addition of IDs do not guarantee uniqueness. For instance, if EmpId is 5 and AssociateId is 6, then EmpId + AssociateId = 11, while EmpId + AssociateId = 11 even if EmpId is 6 and AssociateId is 5. In the query below, I made sure that the subquery will stop searching when the first record is found and will return a single record, having the value of 1. We select the employee if and only if 1 is among the results. In the subquery we check the operand we are sure of first and then check if we are not in a period where AssociateId must be checked, or it matches.
select *
from TblEmployee
where 1 in (select top 1 1
from TblAssociate
where TblEmployee.EmpId = TblAssociate.EmpId and
(#PeriodID >= 50 or TblEmployee.AssociateId = TblAssociate.AssociateId))

Refactoring T-SQL nested SELECT query to use in a case statement

I have the following table:
maker model type
B 1121 pc
A 1233 pc
E 1260 pc
A 1752 laptop
A 1276 printer
D 1288 printer
I need to receive a result in the form: maker, pc. If a particular maker has models in a given type, I need to concatenate the word 'yes' with the number of models in parentheses. Ex. yes(1) for maker 'A'. So, how can I avoid the following duplication?
CASE
WHEN SELECT COUNT(*) WHERE ... > 0
THEN 'yes(' + CAST((SELECT COUNT(*) WHERE ...) AS varchar) + ')'
This is not a real world problem. I just need to understand how to save a subquery result to use it in a branch statement. The result of this branch statement may contain the subquery result itself.
Creating tables:
create table #t (maker varchar(100), model varchar(100), type varchar(100) );
insert into #t ( maker, model, type ) values
( 'B', '1121', 'pc'),
( 'A', '1233', 'pc'),
( 'E', '1260', 'pc');
Query in easy steps:
;with
totals as (
select maker, type,
count( * ) as n
from
#t
group by
maker, type
) ,
maker_type as (
select distinct maker, type
from #t
)
select
mm.*, t.n,
case when t.n is null then 'No' else 'Yes' end as yes_no
from
maker_type mm
left outer join
totals t
on mm.maker = t.maker and
mm.type = t.type
Results:
maker type n yes_no
----- ---- - ------
A pc 1 Yes
B pc 1 Yes
E pc 1 Yes
I don't extend solution concatenating strings because I see that you know how to do it. Be free to change first or second CTE query to match yours requirements.

sql query to split a single column value into multiple rows

I want to create a sql query to split a single column value into multiple rows like:
SELECT ID, PRODUCT_COUNT FROM MERCHANT WHERE ID = 3050
ID PRODUCT_COUNT
----------- -------------
3050 591
Based on this result, I want 6 rows as follows:
ID RANGE
3050 0-100
3050 101-200
3050 201-300
3050 301-400
3050 401-500
3050 501-591
How can I acheive this in a query ?
WITH cte AS (
SELECT
m.ID,
PRODUCT_COUNT,
LoBound = (v.number - 1) * 100 + 1,
HiBound = v.number * 100
FROM MERCHANT m
INNER JOIN master..spt_values v
ON v.type = 'P' AND v.number BETWEEN 1 AND (m.PRODUCT_COUNT - 1) / 100 + 1
WHERE m.ID = 3050
)
SELECT
ID,
RANGE = CAST(CASE LoBound
WHEN 1 THEN 0
ELSE LoBound
END AS varchar)
+ '-'
+ CAST(CASE
WHEN HiBound < PRODUCT_COUNT THEN HiBound
ELSE PRODUCT_COUNT
END AS varchar)
FROM cte
The first CASE makes sure the first range starts with 0, not with 1, same as in your sample output.
Sorry... code removed. I made a mistake where if the Product_Count was evenly divisible by 100, it gave an incorrect final row.
UPDATE:
Andriy's code is still correct. I was missing a "-1" in mine. I've repaired that and reposted both the test setup and my alternative solution.
Both Andriy's and my code produce the output in the correct order for this experiment, but I added an ORDER BY to guarantee it.
Here's the code for the test setup...
--===== Conditionally drop and create a test table for
-- everyone to work against.
IF OBJECT_ID('tempdb..#Merchant','U') IS NOT NULL
DROP TABLE #Merchant
;
SELECT TOP 10000
ID = IDENTITY(INT,1,1),
Product_Count = ABS(CHECKSUM(NEWID()))%100000
INTO #Merchant
FROM sys.all_columns ac1
CROSS JOIN sys.all_columns ac2
;
ALTER TABLE #Merchant
ADD PRIMARY KEY CLUSTERED (ID)
;
--===== Make several entries where there's a known test setup.
UPDATE #Merchant
SET Product_Count = CASE
WHEN ID = 1 THEN 0
WHEN ID = 2 THEN 1
WHEN ID = 3 THEN 99
WHEN ID = 4 THEN 100
WHEN ID = 5 THEN 101
WHEN ID = 6 THEN 99999
WHEN ID = 7 THEN 100000
WHEN ID = 8 THEN 100001
END
WHERE ID < = 8
;
Here's the alternative I posted before with the -1 correction.
WITH
cteCreateRanges AS
(--==== This determines what the ranges are
SELECT m.ID,
RangeStart = t.Number*100+SIGN(t.Number),
RangeEnd =(t.Number+1)*100,
Product_Count
FROM master.dbo.spt_Values t
CROSS JOIN #Merchant m
WHERE t.Number BETWEEN 0 AND (m.Product_Count-1)/100
AND t.Type = 'P'
AND m.ID BETWEEN 1 AND 8 -- = #FindID -<<<---<<< Or use a single variable to find.
)--==== This makes the output "pretty" and sorts in correct order
SELECT ID,
[Range] = CAST(RangeStart AS VARCHAR(10)) + '-'
+ CASE
WHEN RangeEnd <= Product_Count
THEN CAST(RangeEnd AS VARCHAR(10))
ELSE CAST(Product_Count AS VARCHAR(10))
END
FROM cteCreateRanges
ORDER BY ID, RangeStart
;
Sorry about the earlier mistake. Thanks, Andriy, for catching it.
You could create a table like this (I am changing the first range to include 100 elements like the others to make it easier, and basing it at one, so that the indexes will match the total count):
CountRangeBoundary
MinIndexInRange
---------------
1
101
201
301
401
501
601
...
Then do a θ-join like this:
SELECT m.ID,
crb.MinIndexInRange AS RANGE_MIN,
MIN( crb.MinIndexInRange + 100, m.PRODUCT_COUNT) AS RANGE_MAX
FROM MERCHANT m
JOIN CountRangeBoundry crb ON crb.MinIndexInRange <= m.PRODUCT_COUNT
WHERE m.ID = 3050
It looks like those ranges are a piece of data, so they should really be in a table (even if you don't expect them to change, because they will). That has the nice side benefit of making this task trivial:
CREATE TABLE My_Ranges ( -- Use a more descriptive name
range_start SMALLINT NOT NULL,
range_end SMALLINT NOT NULL,
CONSTRAINT PK_My_Ranges PRIMARY KEY CLUSTERED (range_start)
)
GO
SELECT
P.id,
R.range_start,
CASE
WHEN R.range_end < P.product_count THEN R.range_end
ELSE P.product_count
END AS range_end
FROM
Products P
INNER JOIN My_Ranges R ON
R.range_start <= P.product_count
If your ranges will always be contiguous then you can omit the range_end column. Your query will become a little more complex, but you won't have to worry about ranges overlapping or gaps in your ranges.
You can try a recursive CTE.
WITH CTE AS
(
SELECT Id, 0 MinB, 100 MaxB, [Range]
FROM YourTable
UNION ALL
SELECT Id, CASE WHEN MinB = 0 THEN MinB+101 ELSE MinB+100 END, MaxB + 100, [Range]
FROM CTE
WHERE MinB < [Range]
)
SELECT Id,
CAST(MinB AS VARCHAR) + ' - ' + CAST(CASE WHEN MaxB>[Range] THEN [Range] ELSE MaxB END AS VARCHAR) [Range]
FROM CTE
WHERE MinB < [Range]
ORDER BY Id, [Range]
OPTION(MAXRECURSION 5000)
I put a limit to the recursion level on 5000, but you can change it (or leave it at zero, that means basically to keep doing recursion until it can)

Resources