Pivot reports with dynamic pivoted columns (by day)

This is similar to the troller pattern documented elsewhere. It's a pattern for bulk display of pivotted data.

You need a view which returns all of the unpivoted data, but with all the metrics "Packaged" into a single fields.

There's two basic types of columns:

  1. Those that you want to group/filter by (e.g. Country, postcode, customerid) -- we can call these facets

  2. All the metrics, the finest granular details. (These are packed into a single column called 'Details' -- see the + '|' + concatenations below)

See how the two types of columns are treated below.

Note also, that this relies on a nums table. A nums table is a handy thing to have! (I believe the writings of Itzik Ben-Gan are where I learned of a nums (or Numbers) table.)


CREATE View dbo.[CustomerPerformance_NumberedDays]
as

with Days as (
	select
	num,
	DateAdd(d, num*-1, DATEADD(DAY, DATEDIFF(DAY, 0, GETDATE()), 0)) as [Date] from nums
	where num <= 31 and num > 1
)
Select --top 1000
	cj.Country,				-- Facets
	cj.Region,				-- Facets
	cj.PostCode,			-- Facets
	cj.Name as Customer,	-- Facets
	cj.Id as CustomerId,	-- Facets
	d.[Date],				-- Facets
	d.num,
	p.SalesTotal + '|' +
	NumberComplaints + '|' +
	NumberCallsOutbound + '|' +
	TargetSalesTotal + '|' +
	s.supportPerson + '|' +
	s.supportComment
	as Details
from
	CustomerJourney cj
cross join [Days] d
left outer join
... e.g.SalesHistory p
on p.id = cp.CustomerID and DATEADD(DAY, DATEDIFF(DAY, 0, p.Time), -1) = d.[Date]
outer apply
	(Select top 1
		i.Value as supportComment,
		i.CreatedUser as supportPerson,
		i.CallDateTime as CallTime
	from CustomerCalls i
	where callType = 'Support'
		and i.CustomerID = cj.id
	and DATEADD(DAY, DATEDIFF(DAY, 0, i.CallDateTime), 0) = d.[Date]) as s

outer apply (select top 1 -- or aggregate...
... other metrics on that date for that customer, e.g. Complaints, expenses, time spent, refunds... anything...
) as Intervention

GO

That view is, in turn, used by a dynamic pivotting stored procedure

CREATE PROC dbo.CustomerPerformance_NumberedDays_Pivoted
as
BEGIN
	DECLARE @cols AS NVARCHAR(MAX),
		@query  AS NVARCHAR(MAX)

	  IF EXISTS (SELECT *
				 FROM   dbo.sysobjects o
				 WHERE  o.xtype IN ( 'U' )
						AND o.id = Object_id(N'dbo.CustomerPerformanceResult'))
		DROP TABLE dbo.CustomerPerformanceResult

	select @cols = STUFF((SELECT ',' + QUOTENAME([num])
						from dbo.CustomerPerformance_NumberedDays as w
						group by [num]
						order by [num] desc
				FOR XML PATH(''), TYPE
				).value('.', 'NVARCHAR(MAX)')
			,1,1,'')

	set @query = 'SELECT url_Country, url_Region, url_Postcode, url_Customer, Country, Region, Postcode, Customer, ' + @cols + '
				into dbo.CustomerPerformanceResult
				from
				 (
					select
					''/Pivotted/CustomerPerformance?countryFilter='' + Replace(country,'' '',''%20'') as url_Country,
					''/Pivotted/CustomerPerformance?regionFilter='' + Replace(region,'' '',''%20'') as url_Region,
					''/Pivotted/CustomerPerformance?postcodeFilter='' + Replace(postcode,'' '',''%20'') as url_Postcode,
					''/Pivotted/CustomerPerformance?customerFilter='' + Replace(customerId,'' '',''%20'') as url_Customer,
					Conuntry,
					Region,
					Postcode,
					Customer,
					[details]
					from dbo.CustomerPerformance_NumberedDays
				) x
				pivot
				(
					min([details])
					for [num] in (' + @cols + ')
				) p '

	execute(@query)
end

Column names are looked up from a little dictionary. So the num of "3" will be given a display label based on the date returned from this view:

CREATE View dbo.CustomerPerformance_DayHeadings
as
select
	num,
	DateAdd(d, num*-1, DATEADD(DAY, DATEDIFF(DAY, 0, GETDATE()), 0)) as [Date]
from
	nums
where
	num <= 31 and num > 1 --@NumDays

Assuming the website receives this as a DataTable, it can be displayed via a View like this....

(Not shown: you'll always need a custom class for unpacking the Detail column. And you need to load a datatable with that info.

class PivottedReport {
	public Dictionary<int, ColumnInfo> ColumnInfo { get; set; }
    public DataSet data { get; set; }
	public IEnumerable<Filter> filters { get; set; }
}

@using System.Data
@model PivottedReport

@{
	ViewBag.Title = Model.Name;
	int i = -1;
	int colName = 0;
	var urlColumnNames = new HashSet<string>();
}

<style>
	@@media (min-width: 1200px) {
		.container {
			max-width: calc(100% - 30px);
		}
	}
</style>

<div class="breadcrumb">
	@Html.ActionLink("Projects", "Index", "Home") &rsaquo;
	@Html.ActionLink(Model.Project.Name, "Index", "Project", new { project = Model.Project.Slug }, null)  &rsaquo;
	@Html.ActionLink("Pivotted", "Index", "Pivotted", new { project = Model.Project.Slug, filter = (string)null }, null)
	<text>&rsaquo;</text>
	@Model.Name
</div>

@Html.ActionLink(" ", "Export", "PivottedReport", new { project = Model.Project.Slug, PivottedReport = Model.Slug }, new { @Class = "glyphicon glyphicon-cloud-download btn pull-right", title = "Export CSV" })

<h1>
	@Model.Name
</h1>
@if (Model.yyFilter != null)
{
	<div class="display-label">
		Filter:
		<a class="" href="/Pivotted/CustomerPerformance" title="Remove yy filter"> @Model.yyFilter &times;</a>
		@if (Model.xxFilter != null)
		{
			<text> &rarr; </text>
			<a class="" href="/Pivotted/CustomerPerformance?yyFilter=@Model.yyFilter" title="Remove xx filter">@Model.xxFilter &times;</a>
		}
	</div>
}
else {
	<p>Select a xx or a yy below.</p>
}

@foreach (DataTable dataTable in Model.data.Tables)
{
	int r = 0;
	<table class="display PivottedReport">
		<thead>
			<tr>
				@foreach (DataColumn column in dataTable.Columns)
				{
					colName = -1;

					if (column.ColumnName.StartsWith("url_")) {

						urlColumnNames.Add(column.ColumnName.Substring(4));
						continue;
					}
					if (int.TryParse(column.ColumnName, out colName)) {
						<th data-type="@column.DataType.ToString().Replace('.', '_')"
							data-orderable="false"
							class="nosort" title="@Model.ColumnInfo[colName].Title">
						@Model.ColumnInfo[colName].Display
						</th>
					} else {
						<th data-type="@column.DataType.ToString().Replace('.', '_')">
							@column.ColumnName
						</th>
					}
				}
			</tr>
		</thead>
		<tbody>
			@foreach (DataRow row in dataTable.Rows)
			{
				r++;
				i = -1;

				<tr>
					@foreach (var cell in row.ItemArray)
					{
						i++;

						if (dataTable.Columns[i].ColumnName.StartsWith("Key_")
							|| dataTable.Columns[i].ColumnName.StartsWith("url_")) {
							continue;
						}

						if (cell != null && cell is string && cell.ToString().StartsWith("http"))
						{
							<td>
								<a href="@cell.ToString().Split("|".ToCharArray()).First()" target="_blank" title="click to view in new tab">@cell.ToString().Split("|".ToCharArray()).Last()</a>
							</td>
						}
						else if (int.TryParse(dataTable.Columns[i].ColumnName, out colName))
						{
							// Column name is numeric? then it is part of the pivot section, and is given a nicer name and decoded here.
							var detailCell = new DetailCell(cell.ToString());

							if (detailCell.SalesTotal == 0.0)
							{
								<td class="zero @detailCell.Class" title="@detailCell.Title">
								</td>
							}
							else {
								<td  class="@detailCell.Class" title="@detailCell.Title" style="position:relative">
									<div
										class='dataBar'
										style="height:@(detailCell.Height)px;">
									</div>
								</td>
							}
						} else
						{
							<td class="plain">
								@if (urlColumnNames.Contains(dataTable.Columns[i].ColumnName)) {
									<a href="@row[dataTable.Columns["url_"+dataTable.Columns[i].ColumnName]].ToString()">@cell.ToString(cell.GetType())</a>
								} else {
									@cell.ToString(cell.GetType())
								}
							</td>
						}
					}
				</tr>
			}
		</tbody>
		<tfoot>
		</tfoot>
	</table>
}

See also