Add post about C# pagination

This commit is contained in:
Neil Brommer 2023-07-08 16:50:21 -07:00
parent 5bb6f043d9
commit 69b423b565

View file

@ -0,0 +1,329 @@
---
title: C# Pagination Tools
description: A couple of classes that make pagination much easier.
tags: [ Programming, C#, Entity Framework ]
---
A couple of classes that make pagination much easier.
There are two core classes:
* `PaginationOptions` contains the the details needed for fetching data
* `PaginatedResults` extends `PaginationOptions` and contains the results and the number of total results. It also contains a bunch of shortcut properties for calculating common pagination values like the total number of pages, the number of the first result on the current page, etc.
## How To Use It
```cs
int pageNumber = 1;
int resultsPerPage = 25;
PaginatedResults<Thing> results = await db.Things
.Where(t => t.RecordInactiveDate == null)
.ToPaginatedResultsAsync(new PaginationOptions(pageNumber, resultsPerPage));
```
## Code
```cs
using System.Runtime.Serialization;
using System.Text.Json.Serialization;
namespace ASIS.Shared;
/// <summary>Defines how to get paginated data</summary>
[DataContract(IsReference = true)]
public class PaginationOptions
{
/// <summary>
/// This constructor exists so that MVC can instantiate the object before mapping is contents
/// </summary>
public PaginationOptions() { }
public PaginationOptions(int pageNumber, int? resultsPerPage)
{
this.PageNumber = pageNumber;
this.ResultsPerPage = resultsPerPage;
}
private int? _resultsPerPage;
private int _pageNumber;
/// <summary>The number of results per page</summary>
[DataMember]
public int? ResultsPerPage
{
get => this._resultsPerPage;
set
{
if (value is not null and <= 0)
throw new ArgumentOutOfRangeException(nameof(this.ResultsPerPage),
$"{this.ResultsPerPage} must be greater than 0");
this._resultsPerPage = value;
}
}
/// <summary>
/// The page number to get
/// </summary>
/// <remarks>One indexed</remarks>
[DataMember]
public int PageNumber
{
get => this._pageNumber;
set
{
if (value <= 0)
throw new ArgumentOutOfRangeException(nameof(this.PageNumber),
$"{this.PageNumber} must be greater than 0");
this._pageNumber = value;
}
}
}
/// <summary>
/// An extension of <see cref="PaginationOptions"/> that contains the results from a query and uses
/// them to calculate additional pagination info
/// </summary>
/// <typeparam name="T">
/// The <see cref="IEnumerable{U}"/> type that will contain the data (<typeparamref name="U"/>)
/// </typeparam>
/// <typeparam name="U">The data type of the results</typeparam>
[DataContract(IsReference = true)]
public class PaginatedResults<T> : PaginationOptions where T : class
{
[JsonConstructor]
public PaginatedResults(int pageNumber, int? resultsPerPage, IEnumerable<T> results,
int totalResultsCount) : base(pageNumber, resultsPerPage)
{
this.Results = results;
this.TotalResultsCount = totalResultsCount;
}
public PaginatedResults(PaginationOptions? paginationOptions, IEnumerable<T> results,
int totalResultsCount)
: base(paginationOptions?.PageNumber ?? 1, paginationOptions?.ResultsPerPage)
{
this.Results = results;
this.TotalResultsCount = totalResultsCount;
}
/// <summary>
/// The total number of results being paginated. This is used for determining where you are
/// in the list.
/// </summary>
[DataMember]
public int TotalResultsCount { get; set; }
/// <summary>
/// The number of results on the current page
/// </summary>
[DataMember]
public int? CurrentPageResultsCount { get => this.Results.Count(); }
/// <summary>
/// The number of the first result on the current page. This is calculated using
/// <see cref="ResultsPerPage"/>, <see cref="PageNumber"/>, and <see cref="TotalResults"/>.
/// </summary>
[DataMember]
public int? FirstResultNumber
{
get
{
if (this.ResultsPerPage is null)
return 1;
return (this.ResultsPerPage * (this.PageNumber - 1)) + 1;
}
}
/// <summary>
/// The number of the last result on the current page. This is calculated using
/// <see cref="ResultsPerPage"/>, <see cref="PageNumber"/>, and <see cref="TotalResults"/>.
/// </summary>
[DataMember]
public int? LastResultNumber
{
get
{
if (this.ResultsPerPage is null)
return this.Results.Count();
return (this.ResultsPerPage * (this.PageNumber - 1)) + this.CurrentPageResultsCount;
}
}
/// <summary>
/// The total number of pages based on the <see cref="ResultsPerPage"/> and
/// <see cref="TotalResultsCount"/>
/// </summary>
[DataMember]
public int? LastPageNumber
{
get
{
if (this.ResultsPerPage is null)
return 1;
return (int)Math.Ceiling(this.TotalResultsCount
/ (double)this.ResultsPerPage);
}
}
/// <summary>
/// A shortcut that checks if <see cref="PaginationOptions.PageNumber"/> is equal to 1
/// </summary>
[DataMember]
public bool IsFirstPage { get => this.PageNumber == 1; }
/// <summary>
/// A shortcut that checks if <see cref="PaginationOptions.PageNumber"/> is equal to
/// <see cref="LastPageNumber"/>
/// </summary>
[DataMember]
public bool IsLastPage { get => this.PageNumber == this.LastPageNumber; }
/// <summary>A shortcut to check if the current page is not <c>1</c></summary>
[DataMember]
public bool HasPreviousPage { get => !this.IsFirstPage; }
/// <summary>
/// A shortcut to check if the current page is not <see cref="LastPageNumber"/>
/// </summary>
[DataMember]
public bool HasNextPage { get => !this.IsLastPage; }
/// <summary>
/// The number of the previous page or <c>1</c> if <see cref="IsFirstPage"/>
/// </summary>
[DataMember]
public int PreviousPageNumber
{
get
{
if (this.IsFirstPage)
return 1;
return this.PageNumber - 1;
}
}
/// <summary>
/// The number of the next page or <see cref="LastPageNumber"/> if <see cref="IsLastPage"/>
/// </summary>
[DataMember]
public int NextPageNumber
{
get
{
if (this.IsLastPage)
return this.LastPageNumber;
return this.PageNumber + 1;
}
}
[DataMember]
public IEnumerable<T> Results { get; set; }
}
public static class PaginationOptionsExtensionMethods
{
/// <summary>
/// Applies a Skip/Take to the query and returns it
/// <para>
/// Simply returns the unmodified <paramref name="query"/> if the <paramref name="options"/> is
/// null (i.e. null means don't paginate)
/// </para>
/// </summary>
/// <param name="query">The query to paginate</param>
/// <param name="options">The options for paginating the query</param>
public static IQueryable<T> ApplyPaginationOptions<T>(this IQueryable<T> query,
PaginationOptions? options)
{
if (options is null || options.ResultsPerPage is null)
return query;
return query
.Skip((options.PageNumber - 1) * (int)options.ResultsPerPage)
.Take((int)options.ResultsPerPage);
}
/// <summary>
/// Execute the <paramref name="query"/> with the <paramref name="paginationOptions"/> to create
/// a <see cref="PaginatedResults{T, U}"/>
/// </summary>
/// <remarks>
/// This executes the <paramref name="query"/> twice: once with <c>ApplyPaginationOptions</c>
/// and <c>ToList</c> to get the <see cref="PaginatedResults{T, U}.Results"/> and a second
/// time with <c>Count</c> to get the <see cref="PaginatedResults{T, U}.TotalResultsCount"/>
/// </remarks>
/// <typeparam name="T">The result data type</typeparam>
/// <param name="query">
/// The query to execute to get the <see cref="PaginatedResults{T, U}.Results"/> and
/// <see cref="PaginatedResults{T, U}.TotalResultsCount"/>
/// </param>
/// <param name="paginationOptions">
/// The pagination options to apply to the <paramref name="query"/> to get the
/// <see cref="PaginatedResults{T, U}.Results"/>
/// </param>
public static PaginatedResults<T> ToPaginatedResults<T>(this IQueryable<T> query,
PaginationOptions? paginationOptions)
where T : class
{
return new PaginatedResults<T>(
paginationOptions,
query.ApplyPaginationOptions(paginationOptions).ToList(),
query.Count());
}
}
```
This async extension method requires the project it's in to have the Microsoft.EntityFrameworkCore installed:
```cs
using ASIS.Shared;
using Microsoft.EntityFrameworkCore;
namespace ASIS.Shared;
/// <summary>
/// The async versions of <see cref="IQueryable{T}"/> functions require EF Core, so it's better to
/// put functions that need those here than in <see cref="UREC.Attic.Shared"/>
/// </summary>
public static class PaginationTools
{
/// <summary>
/// Execute the <paramref name="query"/> with the <paramref name="paginationOptions"/> to create
/// a <see cref="PaginatedResults{T}"/>
/// </summary>
/// <remarks>
/// This executes the <paramref name="query"/> twice: once with <c>ApplyPaginationOptions</c>
/// and <c>ToListAsync</c> to get the <see cref="PaginatedResults{T}.Results"/> and a second
/// time with <c>CountAsync</c> to get the <see cref="PaginatedResults{T}.TotalResultsCount"/>
/// </remarks>
/// <typeparam name="T">The result data type</typeparam>
/// <param name="query">
/// The query to execute to get the <see cref="PaginatedResults{T}.Results"/> and
/// <see cref="PaginatedResults{T}.TotalResultsCount"/>
/// </param>
/// <param name="paginationOptions">
/// The pagination options to apply to the <paramref name="query"/> to get the
/// <see cref="PaginatedResults{T}.Results"/>
/// </param>
public static async Task<PaginatedResults<T>> ToPaginatedResultsAsync<T>(
this IQueryable<T> query, PaginationOptions paginationOptions,
CancellationToken cancellationToken = default)
where T : class
{
var results = await query
.ApplyPaginationOptions(paginationOptions)
.ToListAsync(cancellationToken);
var count = await query.CountAsync(cancellationToken);
return new PaginatedResults<T>(
paginationOptions,
results,
count);
}
}
```