Add post about C# pagination
This commit is contained in:
parent
5bb6f043d9
commit
69b423b565
329
src/posts/CsPaginationTools.md
Normal file
329
src/posts/CsPaginationTools.md
Normal 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);
|
||||
}
|
||||
}
|
||||
```
|
Loading…
Reference in a new issue