This procedure generates a 'cross-tab' (or 'pivot table') result set from (almost) any query, even a stored procedure and optionally allows you to specify a column sort order.
The procedure makes extensive use dynamic SQL and hence 'sp_executesql'. Because the scope of temporary tables does not extend outside of an sp_executesql transaction, I've had to use global temporary tables, which means that this procedure could become a bottleneck if used too frequently by multiple sessions.
This stored procedure is provided 'as is' and without any warranty - I've only tested it on a relatively small number of queries. For more information (and my contact details) see http://nwsoft.blogspot.com/2006/05/generic-cross-tab-pivot-table-query.html
This work is licensed under a Creative Commons Attribution 2.5 License.
1
2 CREATE PROCEDURE [dbo].[genericCrosstab]
3
4 -- @tableSpec
5 -- A comma delimited list of columns with datatypes (and null constraints, if required)
6 -- e.g. 'col1 INT, col2 VARCHAR(255) NULL'
7 -- N.B. You only need provide this list if the source of the data is a stored procedure
8 @tableSpec NVARCHAR(4000) = '',
9
10 -- @sqlSelect
11 -- The SELECT clause (or 'EXEC <stored_procedure>' statement) for the input data
12 -- e.g. 'SELECT a.au_id, a.au_fname, a.au_lname, a.phone, LEFT(t.title, 10) AS [title], LEFT(s.stor_name, 10) AS [store], sales.qty'
13 -- e.g. 'EXEC dbo.myStoredProcedure <param1>, <param2>'
14 @sqlSelect NVARCHAR(512),
15
16 -- @sqlFromWhere
17 -- The FROM and WHERE clauses for the input data (if a SELECT statement)
18 -- e.g. 'FROM authors a, titleauthor ta, titles t, stores s, sales WHERE a.au_id = ta.au_id AND ta.title_id = t.title_id AND t.title_id = sales.title_id AND s.stor_id = sales.stor_id'
19 -- N.B. Leave this blank if the source of the data is a stored procedure
20 @sqlFromWhere NVARCHAR(512) = '',
21
22 -- @pivotExpr
23 -- The column name or expression to use as the pivot (specifies which values are the column headings for the crosstabbed data)
24 -- e.g. 'store' or '''Store: '' + [store]'
25 @pivotExpr NVARCHAR(255),
26
27 -- @valueExpr
28 -- A column name or expression to use as the values in the crosstabbed data
29 -- e.g. 'qty'
30 @valueExpr NVARCHAR(512),
31
32 -- @function
33 -- The aggregation function to use to combine values in the crosstabbed data
34 -- e.g. 'MIN'
35 -- N.B. If this parameter is left blank, the procedure will count up occurences and create additional column headings as necessary, e.g. '[X], [Y], [Y (2)], [Z]' if there are two occurences for label 'Y'
36 @function NVARCHAR(20) = '',
37
38 -- @groupBy
39 -- The column list to group by (these columns will appear to the left of the crosstabbed data)
40 -- e.g. 'au_id, au_fname, au_lname, phone'
41 @groupBy NVARCHAR(512),
42
43 -- @sortLookup
44 -- A table or view which can be queried to provide a sort order for the columns in the crosstabbed data
45 -- e.g. 'days_of_the_week'
46 -- N.B. The specified table or view must have 'label' and 'sort_order' columns
47 -- N.B. If column order is irrelevant, leave this parameter blank
48 -- N.B. Feature: if @sortLookup is given as '*', the columns are placed in ascending alphabetical order
49 @sortLookup NVARCHAR(40) = '',
50
51 -- @collation
52 -- The collation sequence
53 -- e.g. 'Latin1_general_CI_AS'
54 @collation NVARCHAR(40) = 'Latin1_general_CI_AS',
55
56 -- @verbose
57 -- Set to 1 if you want debug messages
58 @verbose BIT = 0
59
60 AS
61 BEGIN
62
63 -- Check that the mandatory parameters are not empty strings
64 IF LEN(RTRIM(ISNULL(@sqlSelect, ''))) = 0
65 BEGIN
66 RAISERROR ('The @sqlSelect parameter cannot be an empty string.', 16, 1)
67 ROLLBACK TRANSACTION
68 END
69
70 IF LEN(RTRIM(ISNULL(@pivotExpr, ''))) = 0
71 BEGIN
72 RAISERROR ('The @pivotExpr parameter cannot be an empty string.', 16, 1)
73 ROLLBACK TRANSACTION
74 END
75
76 IF LEN(RTRIM(ISNULL(@valueExpr, ''))) = 0
77 BEGIN
78 RAISERROR ('The @valueExpr parameter cannot be an empty string.', 16, 1)
79 ROLLBACK TRANSACTION
80 END
81
82 IF LEN(RTRIM(ISNULL(@groupBy, ''))) = 0
83 BEGIN
84 RAISERROR ('The @groupBy parameter cannot be an empty string.', 16, 1)
85 ROLLBACK TRANSACTION
86 END
87
88 -- Local variables
89 DECLARE @i INTEGER
90 DECLARE @sql NVARCHAR(4000)
91 DECLARE @sqlX NVARCHAR(4000)
92 DECLARE @col NVARCHAR(4000)
93 DECLARE @pivot NVARCHAR(4000)
94 DECLARE @indx NVARCHAR(10)
95 DECLARE @cols NVARCHAR(4000)
96 DECLARE @where NVARCHAR(4000)
97 DECLARE @update NVARCHAR(4000)
98 DECLARE @value NVARCHAR(532)
99 DECLARE @select NVARCHAR(4000)
100
101 -- Drop the global temporary tables we will use (if they already exist)
102 -- N.B. we have to use global temp tables, as local temp tables are dropped at the end of a batch (so are not accessible outside of the sp_executesql context)
103 -- This syntax is a bit long winded because I couldn't make the substitution syntax for sp_executesql work (for this example only! - a bug in T-SQL?)
104 SELECT @sql = N'IF EXISTS (SELECT 1 FROM tempdb.dbo.sysobjects WHERE name = ''@table'' AND xtype = ''U'') DROP TABLE @table'
105 SELECT @sqlX = REPLACE(@sql, N'@table', N'
106 EXEC sp_executesql @sqlX
107 SELECT @sqlX = REPLACE(@sql, N'@table', N'##grpCount')
108 EXEC sp_executesql @sqlX
109 SELECT @sqlX = REPLACE(@sql, N'@table', N'##colList')
110 EXEC sp_executesql @sqlX
111 SELECT @sqlX = REPLACE(@sql, N'@table', N'##lookup')
112 EXEC sp_executesql @sqlX
113 SELECT @sqlX = REPLACE(@sql, N'@table', N'##results')
114 EXEC sp_executesql @sqlX
115 SELECT @sqlX = REPLACE(@sql, N'@table', N'##temp')
116 EXEC sp_executesql @sqlX
117
118 IF @verbose = 1 SELECT 'Global temporary tables dropped (if present).' AS [Message]
119
120 -- * The real work starts here
121
122 -- Store the input dataset
123 IF LEN(RTRIM(@tableSpec)) = 0
124 BEGIN
125 -- Create and populate the temporary table in one step
126 SELECT @sql = @sqlSelect + ' INTO ##input ' + @sqlFromWhere
127 IF @verbose = 1 SELECT @sql AS [Create and populate the temporary table in one step]
128 EXEC sp_executesql @sql
129 END
130 ELSE
131 BEGIN
132 -- Create the temporary table first (the only method that can be used if the data is coming from a stored proc)
133 SELECT @sql = 'CREATE TABLE ##input (' + @tableSpec + ')'
134 IF @verbose = 1 SELECT @sql AS [Create the temporary table]
135 EXEC sp_executesql @sql
136 -- Populate it
137 SELECT @sql = 'INSERT INTO ##input ' + @sqlSelect + ' ' + @sqlFromWhere
138 IF @verbose = 1 SELECT @sql AS [Populate the temporary table]
139 EXEC sp_executesql @sql
140 END
141 IF @verbose = 1 SELECT * FROM
142
143 -- Does the query we want to cross-tab already have a column called 'crossTabRowID'? This is a reserved column name!
144 IF EXISTS(SELECT 1 FROM tempdb.dbo.sysobjects o, tempdb.dbo.syscolumns c WHERE o.id = c.id AND o.name = '##input' AND c.name = 'crossTabRowID')
145 BEGIN
146 RAISERROR ('The query passed to the [genericCrosstab] procedure uses a reserved column name (''crossTabRowID'').', 16, 1)
147 ROLLBACK TRANSACTION
148 END
149
150 -- Add an identity column (we need a 'row ID')
151 ALTER TABLE
152
153 -- Generate the column list
154 SELECT @sql = 'SELECT ' + @groupBy + ' INTO ##colList FROM ##input WHERE 1 = 0'
155 IF @verbose = 1 SELECT @sql AS [Generate the column list]
156 EXEC sp_executesql @sql
157 IF @verbose = 1 SELECT * FROM
158
159 -- If no aggregation function is specified, we have to check first that there are no duplicates, and - if there are - we need to handle them
160 IF RTRIM(ISNULL(@function, '')) = ''
161 BEGIN
162 SELECT @sql = 'SELECT ' + @groupBy + ', ' + @pivotExpr + ' COLLATE ' + @collation + ' AS [pivot], COUNT(1) AS [count] INTO ##grpCount FROM ##input GROUP BY ' + @groupBy + ', ' + @pivotExpr + ' COLLATE ' + @collation
163 END
164 ELSE
165 BEGIN
166 SELECT @sql = 'SELECT ' + @groupBy + ', ' + @pivotExpr + ' COLLATE ' + @collation + ' AS [pivot], 1 AS [count] INTO ##grpCount FROM ##input GROUP BY ' + @groupBy + ', ' + @pivotExpr + ' COLLATE ' + @collation
167 END
168 IF @verbose = 1 SELECT @sql AS [Create the
169 EXEC sp_executesql @sql
170 IF @verbose = 1 SELECT * FROM
171
172 -- Create a temporary table that will act as a lookup (containing all of the non-pivot / non-group columns names)
173 SELECT [pivot], [count] AS [index], [pivot] AS [column_name] INTO
174
175 -- Build the results table; one row per group
176 SELECT @sql = 'SELECT ' + @groupBy + ' INTO ##results FROM ##grpCount GROUP BY ' + @groupBy
177 IF @verbose = 1 SELECT @sql AS [Create the
178 EXEC sp_executesql @sql
179 IF @verbose = 1 SELECT * FROM
180
181 -- Build the column list, taking into account duplicate occurences of pivotal values
182 DECLARE xcursor CURSOR FOR SELECT [pivot], MAX([count]) FROM
183 OPEN xcursor
184
185 FETCH NEXT FROM xcursor INTO @pivot, @indx
186
187 WHILE @@FETCH_STATUS = 0
188 BEGIN
189
190 SELECT @i = 1
191
192 -- Loop over indx
193 WHILE @i <= @indx
194 BEGIN
195
196 -- Build the column list
197 SELECT @col = CASE @i WHEN 1 THEN @pivot ELSE @pivot + ' (' + CAST(@i AS VARCHAR(10)) + ')' END
198 INSERT INTO
199 SELECT @col = '[' + @col + '] NVARCHAR(255) NULL'
200 SELECT @cols = ISNULL(@cols + ', ', '') + @col
201
202 -- Add the column to the results table
203 SELECT @sql = 'ALTER TABLE ##results ADD ' + @col
204 IF @verbose = 1 SELECT @sql AS [Add column to the
205 EXEC sp_executesql @sql
206
207 -- Continue
208 SELECT @i = @i + 1
209
210 END
211
212 FETCH NEXT FROM xcursor INTO @pivot, @indx
213
214 END
215
216 CLOSE xcursor
217 DEALLOCATE xcursor
218
219 IF @verbose = 1 SELECT * FROM
220 IF @verbose = 1 SELECT * FROM
221
222 -- Loop over the column list (using the syscolumns table in the temp database) to build the WHERE clause
223 DECLARE xcursor CURSOR FOR SELECT DISTINCT c.[colid], c.[name] FROM tempdb.dbo.sysobjects o, tempdb.dbo.syscolumns c WHERE o.[id] = c.[id] AND o.[name] = '##colList' ORDER BY c.[colid]
224 OPEN xcursor
225
226 FETCH NEXT FROM xcursor INTO @indx, @col
227
228 WHILE @@FETCH_STATUS = 0
229 BEGIN
230
231 -- Build the WHERE clause
232 SELECT @where = ISNULL(@where + ' AND ', '') + 'ISNULL(t1.[' + @col + '], '''') = ISNULL(t3.[' + @col + '], '''')'
233
234 FETCH NEXT FROM xcursor INTO @indx, @col
235
236 END
237
238 CLOSE xcursor
239 DEALLOCATE xcursor
240
241 IF @verbose = 1 SELECT @where AS [WHERE clause]
242
243 -- Create a temp table that will help us build the UPDATE statements to set the values in the pivot table, and the SELECT statement (with columns in preferred order) afterwards
244 IF RTRIM(ISNULL(@sortLookup, '')) = ''
245 BEGIN
246
247 -- If no sorting table was specified, just select all columns
248 SELECT @select = '*'
249
250 -- Sort alphabetically
251 SELECT @sql = 'SELECT DISTINCT t5.[index], c.[name], t5.[pivot] INTO ##temp FROM tempdb.dbo.sysobjects o, tempdb.dbo.syscolumns c, ##lookup t5 WHERE o.[id] = c.[id] AND o.[name] = ''##results'' AND c.[name] COLLATE ' + @collation + ' = t5.[column_name] COLLATE ' + @collation + ' ORDER BY c.[name], t5.[index]'
252
253 END
254 ELSE
255 BEGIN
256
257 -- The 'group by' column(s) always come(s) first
258 SELECT @select = @groupBy
259
260 -- Sort in proscribed order
261 SELECT @sql = 'SELECT DISTINCT t5.[index], c.[name], t5.[pivot], l.[sort_order] INTO ##temp FROM tempdb.dbo.sysobjects o, tempdb.dbo.syscolumns c, ##lookup t5, ' + @sortLookup + ' l WHERE o.[id] = c.[id] AND o.[name] = ''##results'' AND t5.[pivot] COLLATE ' + @collation + ' *= l.[label] COLLATE ' + @collation + ' AND c.[name] COLLATE ' + @collation + ' = t5.[column_name] COLLATE ' + @collation + ' ORDER BY l.[sort_order], c.[name], t5.[index]'
262
263 END
264
265 IF @verbose = 1 SELECT @sql AS [Build the
266 EXEC sp_executesql @sql
267 IF @verbose = 1 SELECT * FROM
268
269 -- The value expression
270 SELECT @value = @valueExpr
271
272 -- If a function has been specified
273 IF RTRIM(ISNULL(@function, '')) != ''
274 BEGIN
275 SELECT @value = @function + '(' + @value + ')'
276 END
277
278 DECLARE xcursor CURSOR FOR SELECT [index], [name], [pivot] FROM
279 OPEN xcursor
280
281 FETCH NEXT FROM xcursor INTO @indx, @col, @pivot
282
283 WHILE @@FETCH_STATUS = 0
284 BEGIN
285
286 -- Build the SELECT expression
287 IF @select != '*' SELECT @select = @select + ', ' + @col
288
289 -- Create the SET clause of the UPDATE sql
290 IF RTRIM(ISNULL(@function, '')) = ''
291 BEGIN
292 -- No function specified
293 SELECT @update = '[' + @col + '] = (SELECT ' + @value + ' FROM ##input t1 WHERE ' + @where + ' AND ' + @pivotExpr + ' COLLATE ' + @collation + ' = ''' + @pivot + ''' COLLATE ' + @collation + ' AND t1.crossTabRowID = (SELECT MIN(t0.crossTabRowID) FROM ##input t0 WHERE ' + REPLACE(@where, 't3.', 't0.') + ' AND ' + @pivotExpr + ' COLLATE ' + @collation + ' = ''' + @pivot + '''' + ' COLLATE ' + @collation + ') + (' + CAST(@indx AS VARCHAR(3)) + ' - 1))'
294 END
295 ELSE
296 BEGIN
297 -- Function specified
298 SELECT @update = '[' + @col + '] = (SELECT ' + @value + ' FROM ##input t1 WHERE ' + @where + ' AND ' + @pivotExpr + ' COLLATE ' + @collation + ' = ''' + @pivot + ''' COLLATE ' + @collation + ' AND t1.crossTabRowID IN (SELECT t0.crossTabRowID FROM ##input t0 WHERE ' + REPLACE(@where, 't3.', 't0.') + ' AND ' + @pivotExpr + ' COLLATE ' + @collation + ' = ''' + @pivot + '''' + ' COLLATE ' + @collation + '))'
299 END
300 SELECT @sql = 'UPDATE ##results SET ' + @update + ' FROM ##results t3'
301 IF @verbose = 1 SELECT @sql AS [Create the SET clause of the UPDATE sql]
302 EXEC sp_executesql @sql
303
304 FETCH NEXT FROM xcursor INTO @indx, @col, @pivot
305
306 END
307
308 CLOSE xcursor
309 DEALLOCATE xcursor
310
311 -- Return the results
312 SELECT @sql = 'SELECT ' + @select + ' FROM ##results'
313 IF @verbose = 1 SELECT @sql AS [Create the SELECT statement that will return the results]
314 EXEC sp_executesql @sql
315
316 -- Tidy up: drop the global temporary tables
317 SELECT @sql = N'IF EXISTS (SELECT 1 FROM tempdb.dbo.sysobjects WHERE name = ''@table'' AND xtype = ''U'') DROP TABLE @table'
318 SELECT @sqlX = REPLACE(@sql, N'@table', N'##input')
319 EXEC sp_executesql @sqlX
320 SELECT @sqlX = REPLACE(@sql, N'@table', N'##grpCount')
321 EXEC sp_executesql @sqlX
322 SELECT @sqlX = REPLACE(@sql, N'@table', N'##colList')
323 EXEC sp_executesql @sqlX
324 SELECT @sqlX = REPLACE(@sql, N'@table', N'##lookup')
325 EXEC sp_executesql @sqlX
326 SELECT @sqlX = REPLACE(@sql, N'@table', N'##results')
327 EXEC sp_executesql @sqlX
328 SELECT @sqlX = REPLACE(@sql, N'@table', N'##temp')
329 EXEC sp_executesql @sqlX
330
331 END
The following script creates two tables (one of test data, the other to specify a sort order) and runs the genericCrosstab procedure to generate a report.
1
2 IF EXISTS (SELECT *
3 FROM dbo.sysobjects
4 WHERE [id] = object_id(N'[dbo].[testTable]')
5 AND OBJECTPROPERTY(id, N'IsTable') = 1)
6 BEGIN
7 DROP TABLE [dbo].[testTable]
8 END
9 GO
10
11 CREATE TABLE testTable
12 ([who] VARCHAR(10) NOT NULL,
13 [when] DATETIME NOT NULL,
14 [what] VARCHAR(255) NOT NULL,
15 [qty] INT NOT NULL)
16 GO
17
18 DELETE FROM testTable
19 GO
20
21 INSERT INTO testTable VALUES ('Fred', '2005-APR-01', 'Coding', 5)
22 INSERT INTO testTable VALUES ('Jo', '2005-APR-01', 'Coding', 1)
23 INSERT INTO testTable VALUES ('Jo', '2005-APR-01', 'Testing', 7)
24 INSERT INTO testTable VALUES ('Fred', '2005-APR-01', 'Coding', 3)
25 INSERT INTO testTable VALUES ('Tim', '2005-APR-01', 'Coding', 4)
26 INSERT INTO testTable VALUES ('Fred', '2005-APR-02', 'Coding', 8)
27 INSERT INTO testTable VALUES ('Jo', '2005-APR-02', 'Coding', 2)
28 INSERT INTO testTable VALUES ('Jo', '2005-APR-02', 'Testing', 6)
29 INSERT INTO testTable VALUES ('Tim', '2005-APR-02', 'Coding', 4)
30 GO
31
32 SELECT * FROM testTable
33 GO
34
35 EXEC [dbo].[genericCrosstab]
36 @sqlSelect = 'SELECT *',
37 @sqlFromWhere = 'FROM testTable',
38 @pivotExpr = 'what',
39 @valueExpr = 'qty',
40 @function = 'SUM',
41 @groupBy = 'who'
42 GO
43
44 -- Extending the example to proscribe column order...
45
46 IF EXISTS (SELECT *
47 FROM dbo.sysobjects
48 WHERE [id] = object_id(N'[dbo].[testSorter]')
49 AND OBJECTPROPERTY(id, N'IsTable') = 1)
50 BEGIN
51 DROP TABLE [dbo].[testSorter]
52 END
53 GO
54
55 CREATE TABLE testSorter
56 ([label] VARCHAR(255) NOT NULL,
57 [sort_order] INT NOT NULL)
58 GO
59
60 DELETE FROM testSorter
61 GO
62
63 INSERT INTO testSorter VALUES ('Testing', 1)
64 INSERT INTO testSorter VALUES ('Coding', 2)
65 GO
66
67 SELECT * FROM testSorter
68 GO
69
70 EXEC [dbo].[genericCrosstab]
71 @sqlSelect = 'SELECT *',
72 @sqlFromWhere = 'FROM testTable',
73 @pivotExpr = 'what',
74 @valueExpr = 'qty',
75 @function = 'SUM',
76 @groupBy = 'who',
77 @sortLookup = 'testSorter',
78 @verbose = 0
79 GO
You can change @verbose to 1 to see what is going on.