C# Pagination Tools

Posted
7/8/2023
Tags
Programming
C#
Entity Framework

A couple of classes that make pagination much easier.

There are two core classes:

How To Use It

int pageNumber = 1;
int resultsPerPage = 25;
PaginatedResults<Thing> results = await db.Things
	.Where(t => t.RecordInactiveDate == null)
	.ToPaginatedResultsAsync(new PaginationOptions(pageNumber, resultsPerPage));

Code

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
{
	private int? _resultsPerPage;
	private int _pageNumber;

	/// <summary>The number of results per page</summary>
	/// <remarks><c>null</c> for unlimited</remarks>
	[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>
	/// 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;
	}
}

/// <summary>
/// This exists because <see cref="TagHelper"/>s can't be generic, so passing them an actual
/// <see cref="PaginatedResults{T}"/> wouldn't work
/// </summary>
public interface IPaginatedResults
{
	public int PageNumber { get; }
	public int LastPageNumber { get; }
	public int? ResultsPerPage { get; }
	public int CurrentPageResultsCount { get; }
	public int TotalResultsCount { get; }
	public int FirstResultNumber { get; }
	public int LastResultNumber { get; }
	public bool IsFirstPage { get; }
	public bool IsLastPage { get; }
	public int PreviousPageNumber { get; }
	public int NextPageNumber { get; }
}


/// <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, IPaginatedResults 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 ((int)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 ((int)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:

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);
	}
}