Sql Server Paging – part 2
I left off the last post by saying that this is the code pattern that I was going to follow in future
declare @page int = 103; --Page 1 is the first page declare @pageSize int = 20; -- Variables above this line can if OBJECT_ID('tempdb..#PagingTable' ) is not null drop table #PagingTable declare @startRow int = ((@page - 1 ) * @pageSize) + 1; declare @endRow int = @startRow + (@pageSize - 1); create table #PagingTable( rowNum int not null IDENTITY(1,1) primary key clustered, [PurchaseOrderID] [int] NOT NULL, [SupplierID] [int] NOT NULL, [OrderDate] [date] NOT NULL, [DeliveryMethodID] [int] NOT NULL, [ContactPersonID] [int] NOT NULL, [ExpectedDeliveryDate] [date] NULL, [SupplierReference] [nvarchar](20) NULL, [IsOrderFinalized] [bit] NOT NULL, [Comments] [nvarchar](max) NULL, [InternalComments] [nvarchar](max) NULL, [LastEditedBy] [int] NOT NULL, [LastEditedWhen] [datetime2](7) NOT NULL, ) insert into #PagingTable( PurchaseOrderID, SupplierID, OrderDate, DeliveryMethodID, ContactPersonID, ExpectedDeliveryDate, SupplierReference, IsOrderFinalized, Comments, InternalComments, LastEditedBy, LastEditedWhen ) select po.PurchaseOrderID, po.SupplierID, po.OrderDate, po.DeliveryMethodID, po.ContactPersonID, po.ExpectedDeliveryDate, po.SupplierReference, po.IsOrderFinalized, po.Comments, po.InternalComments, po.LastEditedBy, po.LastEditedWhen from [Purchasing].[PurchaseOrders] as po order by PurchaseOrderID desc offset @startRow rows fetch next @pageSize rows only; select po.PurchaseOrderID, supplier.SupplierName, supplier.PhoneNumber, po.OrderDate, deliveryMethods.DeliveryMethodName, contactPeople.FullName, contactPeople.EmailAddress, po.ExpectedDeliveryDate, po.SupplierReference, po.IsOrderFinalized, po.Comments, po.InternalComments, people.FullName, people.EmailAddress, po.LastEditedWhen from #PagingTable as po join [Application].[People] as people on people.PersonID = po.LastEditedBy join [Purchasing].[Suppliers] as supplier on supplier.SupplierID = po.SupplierID join [Application].[DeliveryMethods] as DeliveryMethods on deliveryMethods.DeliveryMethodID = po.DeliveryMethodID join [Application].[People] as contactPeople on contactPeople.PersonID = po.ContactPersonID order by po.rowNum asc; select @startRow as startRow, @endRow as endRow, count(*) as rows from [Purchasing].[PurchaseOrders]
However, that it’s quite true.
Let’s carry on optimising this query batch and see if we can improve it some more…
First things first. Let’s cut out the stuff that’s not really needed.
There’s no need to calculate the end row, so lets loose that piece of wasted CPU time.
declare @endRow int = @startRow + (@pageSize - 1);
Whilst we are at it, let’s make the last query simpler, we don’t need to return the start and end rows.
select count(*) as rows from [Purchasing].[PurchaseOrders]
I’ve also got rid of the commented out lines as they’re no l0nger providing anything useful. Great, that’s the cosmetic stuff done, but it has no effect on the query execution.
Changing the sort order.
In the first post I mentioned that being able to change the sort order was an advantage of pulling down the entire dataset. There’s nothing stopping us from being able to change the sorting using SQL Server. Let’s implement that now.
For this let’s introduce two new control variables.
declare @sortColumn int = 1; declare @sortAscending bit = 1;
The @sortColumn will identify which column will be sorted whilst @sortAscending will define the direction.
As we are already using a temporary table with an identity to determine the output order all we need to do is modify the insert into the #PagingTable to change as we change the values of our new variables. This means making use of Dynamic SQL.
Dynamic SQL is a double edged sword. It allows us to create some very power SQL code. However at the same time there is an opportunity to open up the code to SQL Injection attacks. This implementation is going to protect against that.
Firstly, as the sortColumn is an integer, there’s no opportunity to send in SQL syntax to the procedure. The second thing is that we are still going to use parametised SQL even inside the dynamic SQL.
Here’s the code changed over to make use of Dynamic SQL
declare @page int = 103; --Page 1 is the first page declare @pageSize int = 20; declare @sortColumn int = 1; declare @sortAscending bit = 1; -- Variables above this line can if OBJECT_ID('tempdb..#PagingTable' ) is not null drop table #PagingTable declare @startRow int = ((@page - 1 ) * @pageSize) + 1; create table #PagingTable( rowNum int not null identity(1,1) primary key clustered, [PurchaseOrderID] [int] NOT NULL, [SupplierID] [int] NOT NULL, [OrderDate] [date] NOT NULL, [DeliveryMethodID] [int] NOT NULL, [ContactPersonID] [int] NOT NULL, [ExpectedDeliveryDate] [date] NULL, [SupplierReference] [nvarchar](20) NULL, [IsOrderFinalized] [bit] NOT NULL, [Comments] [nvarchar](max) NULL, [InternalComments] [nvarchar](max) NULL, [LastEditedBy] [int] NOT NULL, [LastEditedWhen] [datetime2](7) NOT NULL, ) declare @pagingSql NVarchar(max); set @pagingSql = 'insert into #PagingTable( PurchaseOrderID, SupplierID, OrderDate, DeliveryMethodID, ContactPersonID, ExpectedDeliveryDate, SupplierReference, IsOrderFinalized, Comments, InternalComments, LastEditedBy, LastEditedWhen ) select po.PurchaseOrderID, po.SupplierID, po.OrderDate, po.DeliveryMethodID, po.ContactPersonID, po.ExpectedDeliveryDate, po.SupplierReference, po.IsOrderFinalized, po.Comments, po.InternalComments, po.LastEditedBy, po.LastEditedWhen from [Purchasing].[PurchaseOrders] as po order by ' set @pagingSql = @pagingSql + ( select case @sortColumn when 1 then 'OrderDate' when 2 then 'ExpectedDeliveryDate' when 3 then 'LastEditedWhen' else 'PurchaseOrderID' end ); set @pagingSql = @pagingSql +' ' + ( select case @sortAscending when 1 then 'asc' else 'desc' end ); set @pagingSql = @pagingSql + ' offset @startRow rows fetch next @pageSize rows only;'; exec sp_executesql @stmt = @pagingSql, @params = N'@startRow int, @pageSize int', @startRow = @startRow, @pageSize = @pageSize; select po.PurchaseOrderID, supplier.SupplierName, supplier.PhoneNumber, po.OrderDate, deliveryMethods.DeliveryMethodName, contactPeople.FullName, contactPeople.EmailAddress, po.ExpectedDeliveryDate, po.SupplierReference, po.IsOrderFinalized, po.Comments, po.InternalComments, people.FullName, people.EmailAddress, po.LastEditedWhen from #PagingTable as po join [Application].[People] as people on people.PersonID = po.LastEditedBy join [Purchasing].[Suppliers] as supplier on supplier.SupplierID = po.SupplierID join [Application].[DeliveryMethods] as DeliveryMethods on deliveryMethods.DeliveryMethodID = po.DeliveryMethodID join [Application].[People] as contactPeople on contactPeople.PersonID = po.ContactPersonID order by po.rowNum asc; select count(*) as rows from [Purchasing].[PurchaseOrders]
Here’s the key points from the above changes.
The sql for the paging insert is now wrapped up in a variable
declare @pagingSql NVarchar(max);
This vaidable contains the entire sql statement that is going to be executed. However, it never contains data, and it never contains values directly from the user input – that would allow SQL Injection attacks.
The column for the sort order is defined using the following case statement
set @pagingSql = @pagingSql + ( select case @sortColumn when 1 then 'OrderDate' when 2 then 'ExpectedDeliveryDate' when 3 then 'LastEditedWhen' else 'PurchaseOrderID' end );
This is decoding the integer @sortColumn and replacing it with the real name of the column to sort on. The point here is that the calling code does not need to know what the column name is. The @sortColumns value determines this, and you can expand this as much as you like.
The sort order is then defined
set @pagingSql = @pagingSql +' ' + ( select case @sortAscending when 1 then 'asc' else 'desc' end );
Then the query is completed as normal.
the sp_executesql command is used to execute the procedure
exec sp_executesql @stmt = @pagingSql, @params = N'@startRow int, @pageSize int', @startRow = @startRow, @pageSize = @pageSize;
This looks a little strange when you first look at it, let me explain what’s happening here.
@stmt = @pagingSql – this passes in the SQL statement that we want to execute.
@Params this is used to tell the command about any parameters that are used inside the @stmt. In our case we have @startRow and @pageSize.
So far so good.
@startRow = @startRow,
@pageSize = @pageSize;
This looks really confusing at first, however, we are doing here is passing the parameters values into the dynamic sql. the @startRow on the left is the name of the variable as it appears in the @stmt. The one of the right is our locally defined @startRow.
If that’s still confusing, here’s a verison of the syntax, with the parameter names changed to make it easier to follow
declare @page int = 103; --Page 1 is the first page declare @pageSize int = 20; declare @sortColumn int = 1; declare @sortAscending bit = 1; -- Variables above this line can if OBJECT_ID('tempdb..#PagingTable' ) is not null drop table #PagingTable declare @startRow int = ((@page - 1 ) * @pageSize) + 1; create table #PagingTable( rowNum int not null identity(1,1) primary key clustered, [PurchaseOrderID] [int] NOT NULL, [SupplierID] [int] NOT NULL, [OrderDate] [date] NOT NULL, [DeliveryMethodID] [int] NOT NULL, [ContactPersonID] [int] NOT NULL, [ExpectedDeliveryDate] [date] NULL, [SupplierReference] [nvarchar](20) NULL, [IsOrderFinalized] [bit] NOT NULL, [Comments] [nvarchar](max) NULL, [InternalComments] [nvarchar](max) NULL, [LastEditedBy] [int] NOT NULL, [LastEditedWhen] [datetime2](7) NOT NULL, ) declare @pagingSql NVarchar(max); set @pagingSql = 'insert into #PagingTable( PurchaseOrderID, SupplierID, OrderDate, DeliveryMethodID, ContactPersonID, ExpectedDeliveryDate, SupplierReference, IsOrderFinalized, Comments, InternalComments, LastEditedBy, LastEditedWhen ) select po.PurchaseOrderID, po.SupplierID, po.OrderDate, po.DeliveryMethodID, po.ContactPersonID, po.ExpectedDeliveryDate, po.SupplierReference, po.IsOrderFinalized, po.Comments, po.InternalComments, po.LastEditedBy, po.LastEditedWhen from [Purchasing].[PurchaseOrders] as po order by ' set @pagingSql = @pagingSql + ( select case @sortColumn when 1 then 'OrderDate' when 2 then 'ExpectedDeliveryDate' when 3 then 'LastEditedWhen' else 'PurchaseOrderID' end ); set @pagingSql = @pagingSql +' ' + ( select case @sortAscending when 1 then 'asc' else 'desc' end ); set @pagingSql = @pagingSql + ' offset @startingRow rows fetch next @pageSizeToGet rows only;'; exec sp_executesql @stmt = @pagingSql, @params = N'@startingRow int, @pageSizeToGet int', @startingRow = @startRow, @pageSizeToGet = @pageSize; select po.PurchaseOrderID, supplier.SupplierName, supplier.PhoneNumber, po.OrderDate, deliveryMethods.DeliveryMethodName, contactPeople.FullName, contactPeople.EmailAddress, po.ExpectedDeliveryDate, po.SupplierReference, po.IsOrderFinalized, po.Comments, po.InternalComments, people.FullName, people.EmailAddress, po.LastEditedWhen from #PagingTable as po join [Application].[People] as people on people.PersonID = po.LastEditedBy join [Purchasing].[Suppliers] as supplier on supplier.SupplierID = po.SupplierID join [Application].[DeliveryMethods] as DeliveryMethods on deliveryMethods.DeliveryMethodID = po.DeliveryMethodID join [Application].[People] as contactPeople on contactPeople.PersonID = po.ContactPersonID order by po.rowNum asc; select count(*) as rows from [Purchasing].[PurchaseOrders]
There we have it. Wrap that into a stored procedure and hey presto you have paging optimised, and supporting custom sort ordering.
declare @page int = 103; --Page 1 is the first page declare @pageSize int = 20; declare @sortColumn int = 1; declare @sortAscending bit = 1; -- Variables above this line can if OBJECT_ID('tempdb..#PagingTable' ) is not null drop table #PagingTable declare @startRow int = ((@page - 1 ) * @pageSize) + 1; create table #PagingTable( rowNum int not null identity(1,1) primary key clustered, [PurchaseOrderID] [int] NOT NULL, [SupplierID] [int] NOT NULL, [OrderDate] [date] NOT NULL, [DeliveryMethodID] [int] NOT NULL, [ContactPersonID] [int] NOT NULL, [ExpectedDeliveryDate] [date] NULL, [SupplierReference] [nvarchar](20) NULL, [IsOrderFinalized] [bit] NOT NULL, [Comments] [nvarchar](max) NULL, [InternalComments] [nvarchar](max) NULL, [LastEditedBy] [int] NOT NULL, [LastEditedWhen] [datetime2](7) NOT NULL, ) declare @pagingSql NVarchar(max); set @pagingSql = 'insert into #PagingTable( PurchaseOrderID, SupplierID, OrderDate, DeliveryMethodID, ContactPersonID, ExpectedDeliveryDate, SupplierReference, IsOrderFinalized, Comments, InternalComments, LastEditedBy, LastEditedWhen ) select po.PurchaseOrderID, po.SupplierID, po.OrderDate, po.DeliveryMethodID, po.ContactPersonID, po.ExpectedDeliveryDate, po.SupplierReference, po.IsOrderFinalized, po.Comments, po.InternalComments, po.LastEditedBy, po.LastEditedWhen from [Purchasing].[PurchaseOrders] as po order by ' set @pagingSql = @pagingSql + ( select case @sortColumn when 1 then 'OrderDate' when 2 then 'ExpectedDeliveryDate' when 3 then 'LastEditedWhen' else 'PurchaseOrderID' end ); set @pagingSql = @pagingSql +' ' + ( select case @sortAscending when 1 then 'asc' else 'desc' end ); set @pagingSql = @pagingSql + ' offset @startRow rows fetch next @pageSize rows only;'; exec sp_executesql @stmt = @pagingSql, @params = N'@startRow int, @pageSize int', @startRow = @startRow, @pageSize = @pageSize; select po.PurchaseOrderID, supplier.SupplierName, supplier.PhoneNumber, po.OrderDate, deliveryMethods.DeliveryMethodName, contactPeople.FullName, contactPeople.EmailAddress, po.ExpectedDeliveryDate, po.SupplierReference, po.IsOrderFinalized, po.Comments, po.InternalComments, people.FullName, people.EmailAddress, po.LastEditedWhen from #PagingTable as po join [Application].[People] as people on people.PersonID = po.LastEditedBy join [Purchasing].[Suppliers] as supplier on supplier.SupplierID = po.SupplierID join [Application].[DeliveryMethods] as DeliveryMethods on deliveryMethods.DeliveryMethodID = po.DeliveryMethodID join [Application].[People] as contactPeople on contactPeople.PersonID = po.ContactPersonID order by po.rowNum asc; select count(*) as rows from [Purchasing].[PurchaseOrders]