PagedList Strikes Back

There is an updated version of the PagedList<T> code available here.

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:

Update: Per a suggestion in a post by Martijn Boland, I have tweaked the code below by converting IPagedList to IPagedList<T> and having it implement IList<T>. This will allow you to pass the IPagedList<T> around and still enumerate it in a strongly-typed manner. Also of note is Martijn's use of the PagedList class to create an MVC pager control.

   1: using System.Linq;
   2:
   3: namespace System.Collections.Generic
   4: {
   5:     public interface IPagedList<T> : IList<T>
   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!