PagedList Strikes Back

July 8, 2008 1:54 AM

A few months ago I posted about my changes to Rob Conery's PagedList class. Since writing that article many comments have been left about how to further improve the design, which I have since incorporated into a new, further improved PagedList class. For those who aren't familiar, the PagedList class allows scenarios such as the following:

   1: public void ListProducts( int pageIndex )
   2: {
   3:     int pageSize = 10;
   4:     var products = productRepository.GetAllProducts()
   5:         .ToPagedList( pageIndex, pageSize );
   6:     return View( products );
   7: }

So in the above scenario (an example MVC action), if you had a list of 30 products with IDs 1-30 and passed in a pageIndex of 2 you would pass the products with IDs 21-30 into your view. Best of all it uses IQueryable, so if you pass a LINQ expression or IQueryable result into ToPagedList(...) it will do the filtering on the database side! Take a look at the new improved PagedList:

   1: using System.Linq;
   2:  
   3: namespace System.Collections.Generic
   4: {
   5:     public interface IPagedList
   6:     {
   7:         int PageCount { get; }
   8:         int TotalItemCount { get; }
   9:         int PageIndex { get; }
  10:         int PageNumber { get; }
  11:         int PageSize { get; }
  12:         bool HasPreviousPage { get; }
  13:         bool HasNextPage { get; }
  14:         bool IsFirstPage { get; }
  15:         bool IsLastPage { get; }
  16:     }
  17:  
  18:     public class PagedList<T> : List<T>, IPagedList
  19:     {
  20:         public PagedList( IEnumerable<T> source, int index, int pageSize )
  21:         {
  22:             if( source is IQueryable<T> )
  23:                 Initialize( source as IQueryable<T>, index, pageSize );
  24:             else
  25:                 Initialize( source.AsQueryable(), index, pageSize );
  26:         }
  27:  
  28:         public PagedList( IQueryable<T> source, int index, int pageSize )
  29:         {
  30:             Initialize( source, index, pageSize );
  31:         }
  32:  
  33:         #region IPagedList Members
  34:  
  35:         public int PageCount { get; private set; }
  36:         public int TotalItemCount { get; private set; }
  37:         public int PageIndex { get; private set; }
  38:         public int PageNumber { get { return PageIndex + 1; } }
  39:         public int PageSize { get; private set; }
  40:         public bool HasPreviousPage { get; private set; }
  41:         public bool HasNextPage { get; private set; }
  42:         public bool IsFirstPage { get; private set; }
  43:         public bool IsLastPage { get; private set; }
  44:  
  45:         #endregion
  46:  
  47:         protected void Initialize( IQueryable<T> source, int index, int pageSize )
  48:         {
  49:             //### set source to blank list if source is null to prevent exceptions
  50:             if( source == null )
  51:                 source = new List<T>().AsQueryable();
  52:  
  53:             //### set properties
  54:             TotalItemCount = source.Count();
  55:             PageSize = pageSize;
  56:             PageIndex = index;
  57:             if( TotalItemCount > 0 )
  58:                 PageCount = (int)Math.Ceiling( TotalItemCount / (double)PageSize );
  59:             else
  60:                 PageCount = 0;
  61:             HasPreviousPage = ( PageIndex > 0 );
  62:             HasNextPage = ( PageIndex < ( PageCount - 1 ) );
  63:             IsFirstPage = ( PageIndex <= 0 );
  64:             IsLastPage = ( PageIndex >= ( PageCount - 1 ) );
  65:  
  66:             //### argument checking
  67:             if( index < 0 )
  68:                 throw new ArgumentOutOfRangeException( "PageIndex cannot be below 0." );
  69:             if( pageSize < 1 )
  70:                 throw new ArgumentOutOfRangeException( "PageSize cannot be less than 1." );
  71:  
  72:             //### add items to internal list
  73:             if( TotalItemCount > 0 )
  74:                 AddRange( source.Skip( ( index ) * pageSize ).Take( pageSize ).ToList() );
  75:         }
  76:     }
  77:  
  78:     public static class Pagination
  79:     {
  80:         public static PagedList<T> ToPagedList<T>( this IQueryable<T> source, int index, int pageSize )
  81:         {
  82:             return new PagedList<T>( source, index, pageSize );
  83:         }
  84:  
  85:         public static PagedList<T> ToPagedList<T>( this IEnumerable<T> source, int index, int pageSize )
  86:         {
  87:             return new PagedList<T>( source, index, pageSize );
  88:         }
  89:     }
  90: }

Changes Since Previous Version:

  • Changed "TotalPages" property to "PageCount".
    This was done to more clearly illustrate the purpose of this property (more in line with the standard List<T> .Count property).
  • Changed "TotalCount" property to "TotalItemCount".
    This was done to differentiate between Pages and Items more clearly.
  • Switched "PageIndex" back to being a zero-based index, rather than one-based.
    The PageIndex was set to be one-based because the most common use-case that I could think of, outputting a pager (i.e.: Previous - 1 - 2 - 3 - Next), was one-based and it would be nice to not have to do "obj.PageIndex + 1" everywhere. This was probably a poor decision. Some people found it confusing and others found that it didn't work with the standard .Net DataPager.
  • Added "PageNumber" property.
    As suggested via comment on my previous post, PageNumber is just PageIndex + 1.
  • Treat all data as IQueryable for the duration of class initialization.
    Tgmdbm, a frequent contributer on the ASP.Net MVC forums, pointed out some good reasons why everything should be treated as IQueryable before the .Skip(x).Take(y). I'm not 100% sold that treating it as IEnumerable would also work for IQueryable objects (since IQueryable supports IEnumerable), but to be safe I have defaulted everything to using IQueryable. You can still pass in a strict IEnumerable, but it will be converted to IQueryable for the duration of the PagedList class initialization process.
  • Tested with xUnit.net.
    While the PagedList class isn't exactly rocket science, I want to strive to improve the quality of the code that I'm presenting to the community. I also enjoyed having an excuse to try out xUnit's data-driven testing using the [Theory] attribute (similar to MbUnit's [RowTest]) for the first time. Not much has been written about it, but I was able to find some examples from Ben Hall which got me up and testing quickly.

Here is the test class:

   1: using System;
   2: using System.Collections.Generic;
   3: using Xunit;
   4: using XunitExt;
   5:  
   6: namespace SquaredRoot.Collections.Generic.Tests
   7: {
   8:     public class PagedListFacts
   9:     {
  10:         [Fact]
  11:         public void Null_Data_Set_Doesnt_Throw_Exception()
  12:         {
  13:             //act
  14:             Assert.ThrowsDelegate act = () => new PagedList<object>( null, 0, 10 );
  15:  
  16:             //assert
  17:             Assert.DoesNotThrow( act );
  18:         }
  19:  
  20:         [Fact]
  21:         public void PageIndex_Below_Zero_Throws_ArgumentOutOfRange()
  22:         {
  23:             //arrange
  24:             var data = new[]{ 1, 2, 3 };
  25:  
  26:             //act
  27:             Assert.ThrowsDelegate act = () => data.ToPagedList( -1, 1 );
  28:  
  29:             //assert
  30:             Assert.Throws<ArgumentOutOfRangeException>( act );
  31:         }
  32:  
  33:         [Fact]
  34:         public void PageIndex_Above_RecordCount_Returns_Empty_List()
  35:         {
  36:             //arrange
  37:             var data = new[]{ 1, 2, 3 };
  38:  
  39:             //act
  40:             var pagedList = data.ToPagedList( 2, 3 );
  41:  
  42:             //assert
  43:             Assert.Equal( 0, pagedList.Count );
  44:         }
  45:  
  46:         [Fact]
  47:         public void PageSize_Below_One_Throws_ArgumentOutOfRange()
  48:         {
  49:             //arrange
  50:             var data = new[]{ 1, 2, 3 };
  51:  
  52:             //act
  53:             Assert.ThrowsDelegate act = () => data.ToPagedList( 0, 0 );
  54:  
  55:             //assert
  56:             Assert.Throws<ArgumentOutOfRangeException>( act );
  57:         }
  58:  
  59:         [Fact]
  60:         public void Null_Data_Set_Doesnt_Return_Null()
  61:         {
  62:             //act
  63:             var pagedList = new PagedList<object>( null, 0, 10 );
  64:  
  65:             //assert
  66:             Assert.NotNull( pagedList );
  67:         }
  68:  
  69:         [Fact]
  70:         public void Null_Data_Set_Returns_Zero_Pages()
  71:         {
  72:             //act
  73:             var pagedList = new PagedList<object>( null, 0, 10 );
  74:  
  75:             //assert
  76:             Assert.Equal( 0, pagedList.PageCount );
  77:         }
  78:  
  79:         [Fact]
  80:         public void Zero_Item_Data_Set_Returns_Zero_Pages()
  81:         {
  82:             //arrange
  83:             var data = new List<object>();
  84:  
  85:             //act
  86:             var pagedList = data.ToPagedList( 0, 10 );
  87:  
  88:             //assert
  89:             Assert.Equal( 0, pagedList.PageCount );
  90:         }
  91:  
  92:         [Fact]
  93:         public void DataSet_Of_One_Through_Five_PageSize_Of_Two_PageIndex_Of_One_First_Item_Is_Three()
  94:         {
  95:             //arrange
  96:             var data = new[]{ 1, 2, 3, 4, 5 };
  97:  
  98:             //act
  99:             var pagedList = data.ToPagedList( 1, 2 );
 100:  
 101:             //assert
 102:             Assert.Equal( 3, pagedList[0] );
 103:         }
 104:  
 105:         [Fact]
 106:         public void TotalCount_Is_Preserved()
 107:         {
 108:             //arrange
 109:             var data = new[]{ 1, 2, 3, 4, 5 };
 110:  
 111:             //act
 112:             var pagedList = data.ToPagedList( 1, 2 );
 113:  
 114:             //assert
 115:             Assert.Equal( 5, pagedList.TotalItemCount );
 116:         }
 117:  
 118:         [Fact]
 119:         public void PageIndex_Is_Preserved()
 120:         {
 121:             //arrange
 122:             var data = new[]{ 1, 2, 3, 4, 5 };
 123:  
 124:             //act
 125:             var pagedList = data.ToPagedList( 1, 2 );
 126:  
 127:             //assert
 128:             Assert.Equal( 1, pagedList.PageIndex );
 129:         }
 130:  
 131:         [Fact]
 132:         public void PageSize_Is_Preserved()
 133:         {
 134:             //arrange
 135:             var data = new[]{ 1, 2, 3, 4, 5 };
 136:  
 137:             //act
 138:             var pagedList = data.ToPagedList( 1, 2 );
 139:  
 140:             //assert
 141:             Assert.Equal( 2, pagedList.PageSize );
 142:         }
 143:  
 144:         [Fact]
 145:         public void Data_Is_Filtered_By_PageSize()
 146:         {
 147:             //arrange
 148:             var data = new[]{ 1, 2, 3, 4, 5 };
 149:  
 150:             //act
 151:             var pagedList = data.ToPagedList( 1, 2 );
 152:  
 153:             //assert
 154:             Assert.Equal( 2, pagedList.Count );
 155:  
 156:             //### related test below
 157:  
 158:             //act
 159:             pagedList = data.ToPagedList( 2, 2 );
 160:  
 161:             //assert
 162:             Assert.Equal( 1, pagedList.Count );
 163:         }
 164:  
 165:         [Fact]
 166:         public void DataSet_OneThroughThree_PageSize_One_PageIndex_Two_HasNextPage_False()
 167:         {
 168:             //arrange
 169:             var data = new[]{ 1, 2, 3 };
 170:  
 171:             //act
 172:             var pagedList = data.ToPagedList( 2, 1 );
 173:  
 174:             //assert
 175:             Assert.Equal( false, pagedList.HasNextPage );
 176:         }
 177:  
 178:         [Fact]
 179:         public void DataSet_OneThroughThree_PageSize_One_PageIndex_Two_IsLastPage_True()
 180:         {
 181:             //arrange
 182:             var data = new[]{ 1, 2, 3 };
 183:  
 184:             //act
 185:             var pagedList = data.ToPagedList( 2, 1 );
 186:  
 187:             //assert
 188:             Assert.Equal( true, pagedList.IsLastPage );
 189:         }
 190:  
 191:         [Theory]
 192:         [InlineData( new[] { 1, 2, 3 }, 0, 1 )]
 193:         [InlineData( new[] { 1, 2, 3 }, 1, 2 )]
 194:         [InlineData( new[] { 1, 2, 3 }, 2, 3 )]
 195:         public void Theory_PageNumber_Is_PageIndex_Plus_One( int[] integers, int pageIndex, int expectedPageNumber )
 196:         {
 197:             //arrange
 198:             var data = integers;
 199:  
 200:             //act
 201:             var pagedList = data.ToPagedList( pageIndex, 1 );
 202:  
 203:             //assert
 204:             Assert.Equal( expectedPageNumber, pagedList.PageNumber );
 205:         }
 206:  
 207:         [Theory]
 208:         [InlineData( new[]{ 1, 2, 3 }, 0, 1, false, true )]
 209:         [InlineData( new[]{ 1, 2, 3 }, 1, 1, true, true )]
 210:         [InlineData( new[]{ 1, 2, 3 }, 2, 1, true, false )]
 211:         public void Theory_HasPreviousPage_And_HasNextPage_Are_Correct( int[] integers, int pageIndex, int pageSize, bool expectedHasPrevious, bool expectedHasNext )
 212:         {
 213:             //arrange
 214:             var data = integers;
 215:  
 216:             //act
 217:             var pagedList = data.ToPagedList( pageIndex, pageSize );
 218:  
 219:             //assert
 220:             Assert.Equal( expectedHasPrevious, pagedList.HasPreviousPage );
 221:             Assert.Equal( expectedHasNext, pagedList.HasNextPage );
 222:         }
 223:  
 224:         [Theory]
 225:         [InlineData( new[]{ 1, 2, 3 }, 0, 1, true, false )]
 226:         [InlineData( new[]{ 1, 2, 3 }, 1, 1, false, false )]
 227:         [InlineData( new[]{ 1, 2, 3 }, 2, 1, false, true )]
 228:         public void Theory_IsFirstPage_And_IsLastPage_Are_Correct( int[] integers, int pageIndex, int pageSize, bool expectedIsFirstPage, bool expectedIsLastPage )
 229:         {
 230:             //arrange
 231:             var data = integers;
 232:  
 233:             //act
 234:             var pagedList = data.ToPagedList( pageIndex, pageSize );
 235:  
 236:             //assert
 237:             Assert.Equal( expectedIsFirstPage, pagedList.IsFirstPage );
 238:             Assert.Equal( expectedIsLastPage, pagedList.IsLastPage );
 239:         }
 240:  
 241:         [Theory]
 242:         [InlineData( new[]{ 1, 2, 3 }, 1, 3 )]
 243:         [InlineData( new[]{ 1, 2, 3 }, 3, 1 )]
 244:         [InlineData( new[]{ 1 }, 1, 1 )]
 245:         [InlineData( new[]{ 1, 2, 3 }, 2, 2 )]
 246:         [InlineData( new[]{ 1, 2, 3, 4 }, 2, 2 )]
 247:         [InlineData( new[]{ 1, 2, 3, 4, 5 }, 2, 3 )]
 248:         public void Theory_PageCount_Is_Correct( int[] integers, int pageSize, int expectedNumberOfPages )
 249:         {
 250:             //arrange
 251:             var data = integers;
 252:  
 253:             //act
 254:             var pagedList = data.ToPagedList( 0, pageSize );
 255:  
 256:             //assert
 257:             Assert.Equal( expectedNumberOfPages, pagedList.PageCount );
 258:         }
 259:     }
 260: }

A big thanks to everyone who has thrown their two cents in to help improve this small but useful class!

Tags: , , ,
Categories: C#
Actions: E-mail | Permalink | Comments (8) RSS Feed for this post's comments.

Comments

7/8/2008 2:59 AM #

Damien Guard

Surely there is a risk that the line:

TotalItemCount = source.Count();

is going to cause the underling IQueryable provider to have to go off and process a lot of data...

[)amien

Damien Guard ca

7/8/2008 9:12 AM #

Troy Goode

Damien,

Assuming you are using the Linq-To-Sql provider, that isn't the case. To determine the .Count(), Linq-To-Sql will only fire off:

"SELECT COUNT(*) FROM ..."

Troy Goode us

7/8/2008 2:04 PM #

Justin Etheredge

Another good asp.net MVC article. I am really enjoying your blog...subscribed.

Justin Etheredge us

7/8/2008 6:41 PM #

Troy Goode

Thanks Justin, I'm glad to see another Virginia boy representin'. =)

Troy Goode us

7/8/2008 8:43 PM #

configurator

Assuming you are using the Linq-To-Sql provider, that isn't the case.
Actually, you're assuming, and that can never be good.
Let's say I send an automatically generated list of 10,000 dynamically generated datums. Since this list is generator with a yield in one of my functions, it is not IQueryable. When you use AsQueryable(), it will be turned to an IQueryable, but when you use Count(), the entire list will bee traversed, only to be traversed again later. I'd either enable a property for a max count or something (which would show only 10 pages), or use ToList().AsQueryable() instead of just AsQueryable().

Also, you said:
I'm not 100% sold that treating it as IEnumerable would also work for IQueryable objects (since IQueryable supports IEnumerable), but to be safe I have defaulted everything to using IQueryable.
You were right not to keep them as IEnumerable. Had you done that, the naive IEnumerable implementation of the extention methods would have been used, and you'd lose all of your IQueryable goodness.

configurator il

7/8/2008 10:14 PM #

Troy Goode

Hmmm, interesting. The issue with ToList().AsQueryable() is that it specifically degrades the experience of using this with Linq-To-Sql as currently the SQL statement execute only returns the results of the current page (other than the separate COUNT(*) statement previously mentioned).

It would appear that there is really a need for two implementations of this functionality. One that works as posted in this article and one which takes TotalItemCount in as an argument (which you may have to continually increment as the pageIndex increases -- based upon your own use case).

Troy Goode us

7/10/2008 9:22 AM #

tgmdbm

Hey Troy, nice post.

No need to test source is IQueriable<T>, because AsQueriable does that for you.

For obvious reasons it's not a good idea to call ToList() before the Skip and Take!

Assuming you are using the Linq-To-Sql provider, that isn't the case.

I guess a more appropriate assumption is "Assuming whatever IQueriable provider you're using does the most efficient thing"

In the case of Linq-to-Sql that's to execute "SELECT COUNT(*) FROM...", in the case of IEnumerable, the most efficient thing to do is check to see if it's actually an ICollection (i.e. it has a .Count property), otherwise, you have to enumerate and count all the elements by hand.

Let's say I send an automatically generated list of 10,000 dynamically generated datums.

If the list really is a List<T> or any other ICollection<T>, source.Count() will actually call source.Count. It won't iterate over the whole enumerable again.

tgmdbm gb

7/31/2008 3:41 PM #

Gregoryy

Hi Troy!
Your class it is interesting. But I have stupid question. I can not understand how used this class. I am a new man herein and understood not still

Gregoryy ua

Add Comment


(Will show your Gravatar icon)  

  Country flag

biuquote
  • Comment
  • Preview
Loading




Troy Goode

Troy Goode
Microsoft Certified Professional Developer
AddThis Feed Button

Disclaimer

The opinions expressed herein are my own personal opinions and do not represent my employer's view in  anyway.

© Copyright 2008

Colophon

Powered by:
BlogEngine.NET 1.4
Template:
Designs by Darren
Header Font:
Stamper
Syntax Highlighting:
WLW Code Snippet Plugin