Tag Archive for: T-SQL

Auditing Data Modification Statements Using Trigger

Here is an interesting problem: how to audit data modification statements against a table? The goal is to log the SQL executed to modify the table using a trigger. Of course, SELECT statements cannot be logged since only INSERT/UPDATE/DELETE can invoke a trigger. One way to implement this is using a server side trace (which actually can capture the SELECT statements too), however that brings unnecessary overhead.

The following query will get the current SQL statement in SQL Server 2005.

SELECT [text]

FROM sys.dm_exec_requests AS R

CROSS APPLY sys.dm_exec_sql_text(plan_handle) AS S

WHERE session_id = @@SPID;

However, when executed in a trigger body it simply returns the CREATE TRIGGER statement, not the actual modification SQL statement. A better approach that works is using DBCC INPUTBUFFER. The only tricky part is that the DBCC output cannot be directly saved to a table. The workaround is to use INSERT EXEC to execute DBCC INPUTBUFFER as dynamic SQL and insert the results to a table. Here is the complete solution.

-- SQL log table

CREATE TABLE SQLLog (

 language_event NVARCHAR(100),

 parameters INT,

 event_info NVARCHAR(4000),

 event_time DATETIME DEFAULT CURRENT_TIMESTAMP);

 

-- Sample table to audit actions for

CREATE TABLE Foo (

 keycol INT PRIMARY KEY,

 datacol CHAR(1));

 

-- Sample data

INSERT INTO Foo VALUES (1, 'a');

INSERT INTO Foo VALUES (2, 'b');

INSERT INTO Foo VALUES (3, 'c');

 

GO

 

-- Audit trigger

CREATE TRIGGER LogMySQL

ON Foo

AFTER INSERT, UPDATE, DELETE

AS

 INSERT INTO SQLLog (language_event, parameters, event_info)

 EXEC('DBCC INPUTBUFFER(@@SPID);');

GO

 

-- Perform some logged actions

GO

 

INSERT INTO Foo VALUES (4, 'd');

 

GO

 

DELETE Foo

WHERE keycol = 1;

 

GO

 

UPDATE Foo

SET datacol = 'f'

WHERE keycol = 2;

 

GO

 

-- Perform non-logged action

-- SELECT cannot be logged

SELECT datacol

FROM Foo

WHERE keycol = 4;

 

GO

 

-- Check what we have in the log

SELECT event_info, event_time

FROM SQLLog;

 

/*

 

-- Results

 

event_info                                        event_time

--------------------------------                 -----------------------

INSERT INTO Foo VALUES (4, 'd');                 2008-04-24 22:24:31.153

DELETE Foo WHERE keycol = 1;                     2008-04-24 22:24:31.170

UPDATE Foo SET datacol = 'f' WHERE keycol = 2;    2008-04-24 22:24:31.170

 

*/

It is good to note here that DBCC INPUTBUFFER requires the user executing to be member of the sysadmin fixed server role. One way to handle this is to specify user or login with sufficient privileges in EXEC (by using EXEC AS LOGIN or USER:

INSERT INTO SQLLog (language_event, parameters, event_info)

EXEC('DBCC INPUTBUFFER(@@SPID);') AS LOGIN = 'admin_login';

Table Valued Parameters

Passing multiple rows of data to stored procedures has always intrigued application developers. Many different methods have been used, more evolving around XML or some form of concatenated list. Those approaches require a method for parsing before the data can be used in normalized table format.

SQL Server 2008 adds another option: table valued parameters. This allows sending a table as parameter to the stored procedure. The current implementation still has shortcomings because it is a read-only parameter, and it cannot be used as output parameter.

Here is one example to demonstrate using table valued parameters to pass a table as stored procedure input.

CREATE TABLE Loans (

 loan_nbr INT PRIMARY KEY,

 loan_date DATETIME,

 loan_amount DECIMAL(15, 2));

 

-- User-defined table type

CREATE TYPE LoanTable

AS TABLE (

 loan_nbr INT PRIMARY KEY,

 loan_date DATETIME,

 loan_amount DECIMAL(15, 2));

 

GO

 

-- Procedure with table valued parameter

-- Must use the READONLY clause

CREATE PROCEDURE InsertLoans

 @Loans LoanTable READONLY

AS

 

 INSERT INTO Loans

 SELECT loan_nbr, loan_date, loan_amount

 FROM @Loans;

 

GO

 

-- Declare table variable of the new type

DECLARE @NewLoans LoanTable;

 

-- Initialize the table variable with data

INSERT INTO @NewLoans

VALUES (1, '20080101', 10000.00),

       (2, '20080203', 15000.00),

       (3, '20080315', 25000.00);

 

 

-- Insert new loans using the table variable as parameter

EXEC InsertLoans @Loans = @NewLoans;

The really cool part here is that table valued parameters are fully supported on the client side via ADO.NET (.NET Framework 3.5 or later) . That means we could easily have a data bound control like grid on the client side that can be used to feed a table parameter to update database table via a stored procedure. Here is a look at what that client code will be.

DataTable loans;

 

loans = new DataTable();

loans.Columns.Add("loan_nbr", typeof(int));

loans.Columns.Add("loan_date", typeof(System.DateTime));

loans.Columns.Add("loan_amount", typeof(decimal));

 

using(SqlCommand cmd = new SqlCommand("InsertLoans", conn)

{

  cmd.CommandType = CommandType.StoredProcedure;

  cmd.Parameters.AddWithValue("Loans", loans);

  cmd.ExecuteNonQuery();

}

Resources:

Table-Valued Parameters
http://msdn2.microsoft.com/en-us/library/bb510489(SQL.100).aspx

Filtered Indexes

Filtered indexes are another welcome addition in SQL Server 2008. They allow creating an index filtered with WHERE clause. While this was doable in SQL Server 2005 using indexed views on the table, the natural approach of directly defining a filtered index is much more appealing.

Below are some examples of using filtered indexes. The first one demonstrate how filtered index will assist on commonly searched values allowing to use INDEX SEEK for those, and a TABLE SCAN for less frequently searched values. The other example implements a very common request to define unique values for column by allowing multiple NULL values (using UNIQUE index allows only a single NULL value).

CREATE TABLE Regions (

 region_cd CHAR(2),

 region_name VARCHAR(35),

 region_phone VARCHAR(12) NULL);

 

INSERT INTO Regions VALUES ('NE', 'North East', NULL),

                           ('SW', 'South West', NULL),

                           ('SE', 'South East', '902-202-1234');

 

-- Index to filter on frequently queried values

CREATE NONCLUSTERED INDEX ix_SouthEastRegion

  ON Regions (region_cd)

  INCLUDE(region_name, region_phone)

  WHERE region_cd = 'SE';

 

GO

SET SHOWPLAN_TEXT ON;

GO

 

-- Typical user query

SELECT region_cd, region_name, region_phone

FROM Regions

WHERE region_cd = 'SE';

 

/*

 

-- Execution plan

StmtText

--------------------------------------------------------------

  |--Index Seek(OBJECT:([Testing].[dbo].[Regions].[ix_SouthEastRegion]),

           SEEK:([Testing].[dbo].[Regions].[region_cd]='SE') ORDERED FORWARD)

 

*/

 

-- Less frequent user query

SELECT region_cd, region_name, region_phone

FROM Regions

WHERE region_cd = 'NE';

 

/*

 

-- Execution plan

StmtText

--------------------------------------------------------------

  |--Table Scan(OBJECT:([Testing].[dbo].[Regions]),

           WHERE:([Testing].[dbo].[Regions].[region_cd]='NE'))

 

*/

 

GO

SET SHOWPLAN_TEXT OFF;

GO

 

-- Guarantee unique values excluding NULLs

CREATE UNIQUE NONCLUSTERED INDEX ix_RegionPhone

  ON Regions (region_phone)

  WHERE region_phone IS NOT NULL;

 

-- OK, multiple NULLs allowed because filtered out from the UNIQUE index

INSERT INTO Regions VALUES ('NW', 'North West', NULL); 

 

-- Attempt to insert duplicate non NULL value

INSERT INTO Regions VALUES ('NW', 'North West', '902-202-1234'); 

 

/*

 

-- Error

Msg 2601, Level 14, State 1, Line 11

Cannot insert duplicate key row in object 'dbo.Regions' with unique index 'ix_RegionPhone'.

The statement has been terminated.

 

*/

Composable DML

Another powerful T-SQL enhancement in SQL Server 2008 is the ability to write composable DML. It allows to consume the OUTPUT clause result set and to feed it as source for a query.

Here is one example to demonstrate that. The scenario is to merge data from daily updated branch office table to central office static table. Additional requirement is to store auditing data for any changes in the company name column. In prior versions of SQL Server this would require a couple SQL statements to accomplish. In SQL Server 2008 the new MERGE statement allows to perform all merge actions in a single statement, and then feed via the OUTPUT clause data for the auditing table. That way a single SQL statement completes the task.

-- Create central accounts static table

CREATE TABLE CentralOfficeAccounts (

 account_nbr INT PRIMARY KEY,

 company_name VARCHAR(35),

 primary_contact VARCHAR(35),

 contact_phone VARCHAR(12));

 

-- Daily updated branch data

CREATE TABLE BranchOfficeAccounts (

 account_nbr INT PRIMARY KEY,

 company_name VARCHAR(35),

 primary_contact VARCHAR(35),

 contact_phone VARCHAR(12));

 

-- Insert sample data

INSERT INTO CentralOfficeAccounts

VALUES (1, 'Bayside Motors', 'Jim Smith', '902-203-1234'),

       (2, 'Dallas Industries', 'Joe Doe', '301-663-9134'),

       (3, 'Sky Computer Systems', 'Jane Brown', '201-943-6053');

 

INSERT INTO BranchOfficeAccounts

VALUES (2, 'Dallas Industries, Inc.', 'Rick Gross', '301-663-9134'),    -- changed

       (3, 'Sky Computer Systems', 'Jane Brown', '201-943-6053'),       -- same

       (4, 'Auto Insurance Co.', 'Chris Jefferson', '313-601-6201');    -- new

 

-- Table for auditing

CREATE TABLE AccountsAudit (

 account_nbr INT,

 change_action NVARCHAR(10),

 change_date DATETIME DEFAULT CURRENT_TIMESTAMP,

 old_company_name VARCHAR(35),

 new_company_name VARCHAR(35),

 PRIMARY KEY(account_nbr, change_action, change_date));

 

-- Single SQL statement performing the merge and auditing actions

INSERT INTO AccountsAudit

 (account_nbr, change_action, old_company_name, new_company_name)

SELECT account_nbr, merge_action, old_company_name, new_company_name

FROM (MERGE INTO CentralOfficeAccounts AS C   

      USING BranchOfficeAccounts AS B       

        ON C.account_nbr = B.account_nbr

      WHEN MATCHED                       

       AND C.company_name <> B.company_name THEN                       

           UPDATE SET C.company_name = B.company_name

      WHEN NOT MATCHED THEN           

           INSERT (account_nbr, company_name,

                   primary_contact, contact_phone)

           VALUES (B.account_nbr, B.company_name,

                   B.primary_contact, B.contact_phone)

    WHEN SOURCE NOT MATCHED THEN       

          DELETE

    OUTPUT $action,

            COALESCE(inserted.account_nbr, deleted.account_nbr),

            deleted.company_name,

            inserted.company_name

    ) AS T(merge_action, account_nbr, old_company_name, new_company_name);

 

SELECT account_nbr, change_action, change_date,

       old_company_name, new_company_name

FROM AccountsAudit;

 

/*

 

-- Results

account_nbr change_action change_date old_company_name   new_company_name

----------- ------------- ----------- ------------------ -----------------------

1           DELETE        2008-04-15 Bayside Motors     NULL

2           UPDATE        2008-04-15 Dallas Industries Dallas Industries, Inc.

4           INSERT        2008-04-15 NULL               Auto Insurance Co.

 

*/

Note:

This code has been tested with SQL Server 2008 CTP 6 (February 2008). As of SQL Server 2008 Release Candidate 0 (June 2008) the clause “WHEN SOURCE NOT MATCHED” has been replaced with “WHEN NOT MATCHED BY SOURCE”. This makes the wording clearer. It is good to note that this clause of MERGE is not standard.

Delighters in SQL Server 2008

Two of the new T-SQL enhancements in SQL Server 2008 are the inline variable initialization and compound assignment (also called delighters). We had those for a long time in languages like C, C++, C#, VB, and they are just coming to T-SQL. While not of significant value, it will make writing code easier and more compact.

Here are a couple examples to illustrate their use.

-- Declare and initialize variable

DECLARE @count INT = 1;

 

-- Compound assignments

SET @count += 1;

SET @count /= 2;

SET @count *= 5;

SET @count %= 3;

SET @count -= 1;

 

SELECT @count;

 

/*

 

-- Result

1

 

*/

 

-- Create sample table for employee pay rates

CREATE TABLE PayRates (

 employee_nbr INT PRIMARY KEY,

 pay_rate DECIMAL(15, 2),

 performance_score INT);

 

-- Insert data using the new row constructors

INSERT INTO PayRates VALUES (1, 40.00, 5), (2, 45.50, 4), (3, 39.50, 6);

 

-- Apply pay rate increase

-- Compound assignments can be used with columns on the right side

UPDATE PayRates

SET pay_rate *= performance_score * 0.25;

 

SELECT employee_nbr, pay_rate, performance_score

FROM PayRates;

 

-- Declare variable and initialize using query

DECLARE @max_pay_rate DECIMAL(15, 2) = (SELECT MAX(pay_rate)

                                        FROM PayRates);

 

SELECT @max_pay_rate AS max_pay_rate;

Convert Hex String to Binary String

Here is another use of table with numbers and concatenation using FOR XML PATH. Given a hexadecimal string value like ‘7FE0’, convert it to the binary representation ‘0111111111100000’. The table with numbers generated by the CTE is used to slice the hex string, then replace it with the corresponding binary value and concatenate using FOR XML PATH with blank element.

CREATE FUNCTION dbo.Hex2Bin(@hex VARCHAR(256))

RETURNS VARCHAR(1024)

AS

BEGIN

 

DECLARE @bin VARCHAR(1024);

 

SET @bin = '';

 

WITH Converter (hex, bin)

AS

(SELECT '0', '0000' UNION ALL

 SELECT '1', '0001' UNION ALL

 SELECT '2', '0010' UNION ALL

 SELECT '3', '0011' UNION ALL

 SELECT '4', '0100' UNION ALL

 SELECT '5', '0101' UNION ALL

 SELECT '6', '0110' UNION ALL

 SELECT '7', '0111' UNION ALL

 SELECT '8', '1000' UNION ALL

 SELECT '9', '1001' UNION ALL

 SELECT 'A', '1010' UNION ALL

 SELECT 'B', '1011' UNION ALL

 SELECT 'C', '1100' UNION ALL

 SELECT 'D', '1101' UNION ALL

 SELECT 'E', '1110' UNION ALL

 SELECT 'F', '1111'),

N1 (n) AS (SELECT 1 UNION ALL SELECT 1),

N2 (n) AS (SELECT 1 FROM N1 AS X, N1 AS Y),

N3 (n) AS (SELECT 1 FROM N2 AS X, N2 AS Y),

N4 (n) AS (SELECT ROW_NUMBER() OVER(ORDER BY X.n)

           FROM N3 AS X, N3 AS Y)

SELECT @bin = (SELECT REPLACE(SUBSTRING(@hex, n, 1), hex, bin)

               FROM N4 AS Nums

               JOIN Converter AS C

                ON SUBSTRING(@hex, n, 1) = hex

               WHERE n <= LEN(@hex)

               FOR XML PATH(''));

 

RETURN @bin;

 

END

 

GO

 

SELECT dbo.Hex2Bin('7FE0') AS bin;

 

-- Result

bin

----------------

0111111111100000

Obscure Real Data for Testing

Very often we have a good set of production data but it cannot be used for training or testing because of data confidentiality. And the solution sometimes is just to tweak pieces of the data so it cannot be linked to the original data. Here is one method for obscure data in SQL Server 2005. It is based on a couple techniques:

– Using table with numbers (here generated on the fly by cross joining common table expressions)

– Using the table with numbers to slice each value into individual characters

– Generating random values in the normal character range that will be used to replace the real characters

– Using FOR XML PATH with empty element to concatenate back the obscured value.

CREATE TABLE Patients (

 pname VARCHAR(35),

 pname_masked VARCHAR(35));

 

INSERT INTO Patients VALUES('John Doe', NULL);

INSERT INTO Patients VALUES('Jeff Smith', NULL);

 

WITH

N1 (n) AS (SELECT 1 UNION ALL SELECT 1),

N2 (n) AS (SELECT 1 FROM N1 AS X, N1 AS Y),

N3 (n) AS (SELECT 1 FROM N2 AS X, N2 AS Y),

N4 (n) AS (SELECT ROW_NUMBER()

           OVER(ORDER BY X.n) FROM N3 AS X, N3 AS Y)

UPDATE Patients

SET pname_masked =

    CAST(

    CAST((SELECT CASE

                  WHEN SUBSTRING(pname, n, 1) = ' '

                  THEN ' '

                  ELSE CHAR(x + ASCII(SUBSTRING(pname, n, 1)))

                END

          FROM N4 AS Nums

          CROSS APPLY

          (SELECT TOP(1) T.n AS x

           FROM N4 AS T

           WHERE T.n + ASCII(UPPER(SUBSTRING(pname, Nums.n, 1))) BETWEEN 65 AND 90

           ORDER BY CHECKSUM(NEWID())) AS N

          WHERE Nums.n <= LEN(pname)

          FOR XML PATH('')) AS XML) AS VARCHAR(35));

 

SELECT pname, pname_masked

FROM Patients;

 

-- Results

 

pname        pname_masked

------------ -------------

John Doe    Xwlv Lyi

Jeff Smith   Qmol Yuyvs

UPSERT and More with MERGE

One very common requirement is to merge source data to a target table. For example, merge the daily changes to accounts data from branch office table to central accounts table. On SQL Server version 2005 and prior this was accomplished using separate INSERT and UPDATE statements. This involved checking if key column exists to perform update and insert if not, or attempt an update first and then if not rows were affected perform insert. Not to mention if we have to check if account is missing from the branch office data and needs to be deleted from the central accounts table. That way we end up with multiple (sometimes complex) statements to implement one transaction, accessing both source and target tables multiple times.

SQL Server 2008 offers a lot more elegant way using the MERGE statement (MERGE is supported by ANSI SQL). It makes data merging very simple and elegant, as well as efficient.

Here is the base syntax for MERGE:

[ WITH <common_table_expression> [,...n] ]

MERGE

[ TOP ( expression ) [ PERCENT ] ]

[ INTO ] target_table [ [ AS ] table_alias ]

        [ WITH ( <merge_hint> ) ]

USING <table_source>

ON <search_condition>

[ WHEN MATCHED [ AND <search_condition> ]

        THEN <merge_matched> ]

[ WHEN [TARGET] NOT MATCHED [ AND <search_condition> ]

        THEN <merge_not_matched> ]

[ WHEN SOURCE NOT MATCHED [ AND <search_condition> ]

        THEN <merge_matched> ]

<output_clause>

[ OPTION ( <query_hint> [ ,...n ] ) ]   

;

Below is the initial setup for the source and target tables.

-- Static table as target for merging data

CREATE TABLE CentralOfficeAccounts (

 account_nbr INT PRIMARY KEY,

 company_name VARCHAR(35),

 primary_contact VARCHAR(35),

 contact_phone VARCHAR(12));

 

-- Dynamic table with daily updates to be merged

CREATE TABLE BranchOfficeAccounts (

 account_nbr INT PRIMARY KEY,

 company_name VARCHAR(35),

 primary_contact VARCHAR(35),

 contact_phone VARCHAR(12));

 

-- Sample centrall office static data

INSERT INTO CentralOfficeAccounts

VALUES (1, 'Bayside Motors', 'Jim Smith', '902-203-1234'),

       (2, 'Dallas Industries', 'Joe Doe', '301-663-9134'),

       (3, 'Sky Computer Systems', 'Jane Brown', '201-943-6053');

 

-- Daily updated branch office data

INSERT INTO BranchOfficeAccounts

VALUES (2, 'Dallas Industries, Inc.', 'Rick Gross', '301-663-9134'), -- changed

       (3, 'Sky Computer Systems', 'Jane Brown', '201-943-6053'),    -- same

       (4, 'Auto Insurance Co.', 'Chris Jefferson', '313-601-6201'); -- new

Here is how MERGE can be used to perform an update on existing accounts and insert of new accounts.

-- Update existing and add missing

MERGE INTO CentralOfficeAccounts AS C    -- Target

USING BranchOfficeAccounts AS B          -- Source

   ON C.account_nbr = B.account_nbr

WHEN MATCHED THEN                        -- On match update

    UPDATE SET C.company_name = B.company_name,

                C.primary_contact = B.primary_contact,

                C.contact_phone = B.contact_phone

WHEN NOT MATCHED THEN                    -- Add missing

    INSERT (account_nbr, company_name, primary_contact, contact_phone)

    VALUES (B.account_nbr, B.company_name, B.primary_contact, B.contact_phone);

 

SELECT account_nbr, company_name, primary_contact, contact_phone

FROM CentralOfficeAccounts;

 

/*

 

-- Results:

 

account_nbr company_name             primary_contact contact_phone

----------- ------------------------ ---------------- -------------

1           Bayside Motors           Jim Smith        902-203-1234

2           Dallas Industries, Inc. Rick Gross       301-663-9134

3           Sky Computer Systems     Jane Brown       201-943-6053

4           Auto Insurance Co.       Chris Jefferson 313-601-6201

 

*/

Next step is to enhance the statement including predicates to check and update only accounts that have changed. The only difference here is the additional conditions in the MATCHED clause, results will be the same as before.

-- Update existing that changed and add missing

-- Use of predicates

MERGE INTO CentralOfficeAccounts AS C   -- Target

USING BranchOfficeAccounts AS B         -- Source

   ON C.account_nbr = B.account_nbr

WHEN MATCHED                            -- On match update

 AND (C.company_name <> B.company_name  -- Additional search conditions

   OR C.primary_contact <> B.primary_contact

   OR C.contact_phone <> B.contact_phone) THEN                       

    UPDATE SET C.company_name = B.company_name,

                C.primary_contact = B.primary_contact,

                C.contact_phone = B.contact_phone

WHEN NOT MATCHED THEN                   -- Add missing

    INSERT (account_nbr, company_name, primary_contact, contact_phone)

    VALUES (B.account_nbr, B.company_name, B.primary_contact, B.contact_phone);

Going one step further, let’s add the option to delete accounts that do not exist in the source table from the target table.

--Update existing that changed and add missing, delete missing in source

MERGE INTO CentralOfficeAccounts AS C   -- Target

USING BranchOfficeAccounts AS B         -- Source

   ON C.account_nbr = B.account_nbr

WHEN MATCHED                            -- On match update

 AND (C.company_name <> B.company_name  -- Additional search conditions

   OR C.primary_contact <> B.primary_contact

   OR C.contact_phone <> B.contact_phone) THEN                       

    UPDATE SET C.company_name = B.company_name,

                C.primary_contact = B.primary_contact,

                C.contact_phone = B.contact_phone

WHEN NOT MATCHED THEN                   -- Add missing

    INSERT (account_nbr, company_name, primary_contact, contact_phone)

    VALUES (B.account_nbr, B.company_name, B.primary_contact, B.contact_phone)

WHEN SOURCE NOT MATCHED THEN            -- Delete missing from source

    DELETE;

 

SELECT account_nbr, company_name, primary_contact, contact_phone

FROM CentralOfficeAccounts;

 

/*

 

-- Results:

 

account_nbr company_name             primary_contact contact_phone

----------- ------------------------ ---------------- -------------

2           Dallas Industries, Inc. Rick Gross       301-663-9134

3           Sky Computer Systems     Jane Brown       201-943-6053

4           Auto Insurance Co.       Chris Jefferson 313-601-6201

 

*/

There is a lot more to MERGE, but this just shows the tremendous power it provides for merging data.

Note:

This code has been tested with SQL Server 2008 CTP 6 (February 2008). As of SQL Server 2008 Release Candidate 0 (June 2008) the clause “WHEN SOURCE NOT MATCHED” has been replaced with “WHEN NOT MATCHED BY SOURCE”. This makes the wording clearer. It is good to note that this clause of MERGE is not standard.

References:

MERGE (Transact-SQL)
http://technet.microsoft.com/en-us/library/bb510625(SQL.100).aspx

Row Concatenation with FOR XML PATH

Many times it is needed for reporting purposes to summarize normalized data into groups or lists of values. This is also known as rows concatenation. Some reporting and client side tools support this directly. Here is one approach to solve this in T-SQL using FOR XML PATH.

CREATE TABLE Products (

 sku INT PRIMARY KEY,

 product_desc VARCHAR(35));

 

CREATE TABLE Departments (

 department_nbr INT PRIMARY KEY,

 department_title VARCHAR(35));

 

CREATE TABLE DepartmentProducts (

 department_nbr INT

   REFERENCES Departments (department_nbr),

 sku INT

   REFERENCES Products (sku),

 PRIMARY KEY (department_nbr, sku));

 

INSERT INTO Products VALUES (1, 'Book');

INSERT INTO Products VALUES (2, 'Magazine');

INSERT INTO Products VALUES (3, 'DVD');

INSERT INTO Products VALUES (4, 'Video');

INSERT INTO Products VALUES (5, 'CD');

INSERT INTO Products VALUES (6, 'Map');

 

INSERT INTO Departments VALUES (1, 'Reference');

INSERT INTO Departments VALUES (2, 'Periodicals');

INSERT INTO Departments VALUES (3, 'Entertainment');

 

INSERT INTO DepartmentProducts VALUES (1, 1);

INSERT INTO DepartmentProducts VALUES (1, 6);

INSERT INTO DepartmentProducts VALUES (2, 2);

INSERT INTO DepartmentProducts VALUES (3, 3);

INSERT INTO DepartmentProducts VALUES (3, 4);

INSERT INTO DepartmentProducts VALUES (3, 5);

 

-- Using correlated subquery

SELECT D.department_nbr,

       D.department_title,

       STUFF((SELECT ',' + product_desc

              FROM DepartmentProducts AS DP

              JOIN Products AS P

                ON P.sku = DP.sku

              WHERE DP.department_nbr = D.department_nbr

              ORDER BY product_desc

              FOR XML PATH('')), 1, 1, '') AS product_list

FROM Departments AS D;

 

-- Using CROSS APPLY

SELECT D.department_nbr,

       D.department_title,

       STUFF(P.product_list, 1, 1, '') AS product_list

FROM Departments AS D

CROSS APPLY (SELECT ',' + product_desc

            FROM DepartmentProducts AS DP

            JOIN Products AS P

               ON P.sku = DP.sku

            WHERE DP.department_nbr = D.department_nbr

            ORDER BY product_desc

            FOR XML PATH('')) AS P (product_list);

 

-- Results            

department_nbr department_title product_list

-------------- ---------------- ------------

1              Reference        Book,Map

2              Periodicals      Magazine

3              Entertainment    CD,DVD,Video

While this method is often called the XML blackbox method, the explanation of this effect using FOR XML PATH is simple. Normally the PATH clause is used with input string that indicates the name of the wrapper element that will be created. However, using the PATH clause with empty string as input results in skipping the wrapper element generation. And since the content is retrieved as text it achieves the effect of concatenation.

Resources:

What’s New in FOR XML in Microsoft SQL Server 2005
http://msdn2.microsoft.com/en-us/library/ms345137.aspx

Grouping with ROW_NUMBER

The new ranking functions in SQL Server 2005 have great impact on how problems can be solved. Here is just another example of that.

Given a table with events at different venues (all event dates are unique), find the duration intervals of start/end date of uninterrupted performances at a given venue. Solving this without the use of ROW_NUMBER would require a lot more complex SQL, not to mention that this solution has excellent performance. Running a test with 10 years of random sample data completes on average 15 milliseconds.

-- Create the sample events table

CREATE TABLE Events

(event_date DATETIME NOT NULL PRIMARY KEY,

 event_venue VARCHAR(20) NOT NULL);

 

-- Insert venue event dates

INSERT INTO Events VALUES ('20080101', 'The Palace');

INSERT INTO Events VALUES ('20080201', 'The Palace');

INSERT INTO Events VALUES ('20080301', 'The Palace');

INSERT INTO Events VALUES ('20080401', 'The Palace');

INSERT INTO Events VALUES ('20080501', 'The Palace');

INSERT INTO Events VALUES ('20080601', 'Fox Theater');

INSERT INTO Events VALUES ('20080701', 'Fox Theater');

INSERT INTO Events VALUES ('20080801', 'Grand Hall');

INSERT INTO Events VALUES ('20080901', 'Grand Hall');

INSERT INTO Events VALUES ('20081001', 'Grand Hall');

INSERT INTO Events VALUES ('20081101', 'The Palace');

INSERT INTO Events VALUES ('20071201', 'The River Place');

INSERT INTO Events VALUES ('20081202', 'The River Place');

 

-- Group the event period dates at each venue

SELECT MIN(event_venue) AS venue,

       MIN(event_date) AS venue_start_date,

       MAX(event_date) AS venue_end_date

FROM (

SELECT event_venue , event_date ,

       ROW_NUMBER() OVER (ORDER BY event_venue, event_date) -

       ROW_NUMBER() OVER (PARTITION BY event_venue

                          ORDER BY event_date),

       ROW_NUMBER() OVER (ORDER BY event_date) -

       ROW_NUMBER() OVER (PARTITION BY event_venue

                          ORDER BY event_date)

FROM Events) AS X(event_venue, event_date, grp1, grp2)

GROUP BY grp1, grp2;

 

/*

 

-- Results

 

venue                venue_start_date venue_end_date

-------------------- ----------------- --------------

The River Place     2007-12-01        2007-12-01

The Palace           2008-01-01        2008-05-01

Fox Theater         2008-06-01        2008-07-01

The Palace           2008-11-01        2008-11-01

Grand Hall           2008-08-01        2008-10-01

The River Place     2008-12-02        2008-12-02

 

*/