Learn how to implement an efficient and safe pagination system in C# using a projection-based design. Discover how to create immutable, read-only pages with dynamic offsets, leveraging IReadOnlyList<T>
for consistent and scalable data handling. Perfect for optimizing large datasets!
In this article, we’ll implement a list projection design for pagination, where each page acts as a "window" into a shared, immutable list. This approach ensures efficiency by avoiding unnecessary copying of list segments and provides safety by leveraging read-only collections.
Key Concepts
-
Projection Design:
- Each page is a view of a portion of a shared list, defined by its starting (lower offset) and ending (upper offset) positions.
- Pages share the same underlying list but expose different sections of it based on their ordinal position.
-
Read-Only List:
- To prevent accidental mutation of the shared list, we use a read-only wrapper.
- This ensures consistency across all pages and avoids potential bugs caused by list modifications.
-
Efficient Calculations:
- Page metadata (e.g.,
Count
, offsets) is calculated dynamically, ensuring that page objects are lightweight.
- Page metadata (e.g.,
-
Lazy Enumeration:
- Items in a page are enumerated lazily using
yield return
, providing efficient access to the underlying list.
- Items in a page are enumerated lazily using
Implementation Plan
We will:
- Create a class
Page<T>
implementing theIPage<T>
interface. - Use a read-only wrapper for the shared list to ensure immutability.
- Calculate offsets dynamically based on the page’s ordinal position and page size.
- Implement lazy enumeration for accessing items within the page.
Code Implementation
The Page Class
using System;
using System.Collections;
using System.Collections.Generic;
public class Page<T> : IPage<T>
{
private readonly IReadOnlyList<T> _source;
private readonly int _offset;
private readonly int _upperOffset;
public Page(IReadOnlyList<T> source, int ordinal, int pageSize)
{
if (source == null) throw new ArgumentNullException(nameof(source));
if (ordinal < 1) throw new ArgumentOutOfRangeException(nameof(ordinal), "Ordinal must be 1 or greater.");
if (pageSize <= 0) throw new ArgumentOutOfRangeException(nameof(pageSize), "Page size must be greater than zero.");
_source = source;
// Calculate lower and upper offsets
_offset = (ordinal - 1) * pageSize;
_upperOffset = Math.Min(_offset + pageSize, _source.Count);
// Ensure offset is valid
if (_offset >= _source.Count)
{
_offset = _source.Count;
_upperOffset = _source.Count;
}
Ordinal = ordinal;
PageSize = pageSize;
}
public int Ordinal { get; }
public int Count => _upperOffset - _offset;
public int PageSize { get; }
// Lazy enumeration of items
public IEnumerator<T> GetEnumerator()
{
for (int i = _offset; i < _upperOffset; i++)
{
yield return _source[i];
}
}
// Non-generic enumerator
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}
Explanation of the Code
-
Read-Only List:
- The
_source
is anIReadOnlyList<T>
, ensuring that the underlying list cannot be modified. - This guarantees that all page objects reflect the same, immutable dataset.
- The
-
Offset Calculations:
-
_offset
: The starting index for the current page, calculated as(Ordinal - 1) * PageSize
. -
_upperOffset
: The exclusive upper index, capped at the size of the list to handle cases where the page exceeds the list's bounds.
-
-
Count Calculation:
- The
Count
property dynamically calculates the number of items in the page as_upperOffset - _offset
.
- The
-
Lazy Enumeration:
- The
GetEnumerator
method usesyield return
to lazily return items within the calculated offsets. - This approach avoids creating unnecessary collections and ensures efficient memory usage.
- The
Integrating with the Paginated Collection
The SortedListPaginator
class will create instances of Page<T>
as projections onto the shared, sorted list.
Updated SortedListPaginator Class
public class SortedListPaginator<T> : IPaginatedCollection<T>
{
private readonly Lazy<IReadOnlyList<T>> _sortedData;
private readonly int _pageSize;
public SortedListPaginator(IEnumerable<T> source, int pageSize, Func<T, object> sortKeySelector)
{
if (source == null) throw new ArgumentNullException(nameof(source));
if (pageSize <= 0) throw new ArgumentOutOfRangeException(nameof(pageSize), "Page size must be greater than zero.");
if (sortKeySelector == null) throw new ArgumentNullException(nameof(sortKeySelector));
_pageSize = pageSize;
// Lazy initialization of sorted read-only list
_sortedData = new Lazy<IReadOnlyList<T>>(() => source.OrderBy(sortKeySelector).ToList().AsReadOnly());
}
public int PageCount => (int)Math.Ceiling((double)_sortedData.Value.Count / _pageSize);
public IPage<T> this[int index]
{
get
{
if (index < 0 || index >= PageCount)
throw new ArgumentOutOfRangeException(nameof(index), "Page index is out of range.");
return new Page<T>(_sortedData.Value, index + 1, _pageSize);
}
}
public IEnumerator<IPage<T>> GetEnumerator()
{
for (int i = 0; i < PageCount; i++)
{
yield return this[i];
}
}
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}
Example Usage
class Program
{
static void Main()
{
// Sample data: unsorted numbers
var data = new List<int> { 5, 3, 1, 4, 2, 10, 9, 8, 7, 6 };
// Create a SortedListPaginator
var paginator = new SortedListPaginator<int>(
data,
pageSize: 3,
sortKeySelector: x => x // Sort by value
);
Console.WriteLine($"Total Pages: {paginator.PageCount}");
foreach (var page in paginator)
{
Console.WriteLine($"Page {page.Ordinal}:");
foreach (var item in page)
{
Console.WriteLine(item);
}
}
}
}
Output
Total Pages: 4
Page 1:
1
2
3
Page 2:
4
5
6
Page 3:
7
8
9
Page 4:
10
Key Benefits of the Projection Design
-
Efficiency:
- No copying of data into new collections.
- Pages directly reference segments of the shared list.
-
Safety:
- The use of
IReadOnlyList<T>
ensures immutability, preventing accidental modifications.
- The use of
-
Simplicity:
- Offsets and counts are dynamically calculated, reducing complexity and avoiding indexing errors.
-
Scalability:
- This approach works seamlessly with large datasets and integrates well with other components.
Conclusion
The projection-based pagination design provides a powerful, efficient, and safe way to handle paginated data. By sharing a read-only list among page objects, we minimize memory usage and maintain consistency. This approach ensures that sorting and pagination remain encapsulated, offering a clean, reusable solution for developers.
Top comments (0)