Anyone building a web application will eventually have to deal with sorting tabular data. It’s a rule established into the constitution of the internet (if there was one). While it may be simple in concept, its implementation can be daunting based on many application-specific factors.
In this post, we’ll see how we can work our way from C# implementation, to ASP.NET Core Razor Pages, and finally in the UI/UX elements of our page.
Rules Of Sorting
Baked into every programming language are mechanisms for sorting. C# has an excellent Language Integrated Query language (LINQ) with methods like OrderBy
and OrderByDescending
that make short work of sorting any collection. Our goal in this blog post is to convert the following query string into a construct that LINQ will understand.
http://example.com/data?sort=Id,-CreatedAt
Let’s talk about the rules:
- Each sortable field will match a property name on our target type.
- Ascending order is signified by the property name.
- Descending order is indicated by the property name with a
-
(dash/minus) prefix. - Multiple property names indicate sorting precedence, from most to least important.
We can translate the above query string into the following LINQ expression:
Items
.OrderBy(x =>x.Id)
.ThenByDescending(x => x.CreatedAt)
.ToList();
Ultimately, we want an interface like the one that follows:
Items.OrderBy("Id,-CreatedAt")
Pretty cool, right?! Let’s get to building it.
Building a SortCollection Class
We will be building a SortCollection
class, which will parse our query string value into a C# construct that interfaces with LINQ. Let’s look at the public methods we will need:
ctor(string value)
List<ISort> Sorts { get; }
void Apply(IQueryable<TSource> queryable)
string ToString()
string AddOrUpdate(string property)
string Remove(string property)
It will likely be most natural to show you the final implementation and explain the complicated parts. First, there is the extension method that we will use to hang off of any IQueryable<T>
interface.
public static class OrderByExtensions
{
public static IQueryable<TSource> OrderBy<TSource>(
this IQueryable<TSource> queryable,
string sorts)
{
var sort = new SortCollection<TSource>(sorts);
return sort.Apply(queryable);
}
public static IQueryable<TSource> OrderBy<TSource>(
this IQueryable<TSource> queryable,
params string[] sorts)
{
var sort = new SortCollection<TSource>(sorts);
return sort.Apply(queryable);
}
public static IQueryable<TSource> OrderBy<TSource>(
this IQueryable<TSource> queryable,
string sorts,
out SortCollection<TSource> sortCollection)
{
sortCollection = new SortCollection<TSource>(sorts);
return sortCollection.Apply(queryable);
}
}
Next, we’ll implement the SortCollection<TSource>
class, which will have nested classes for the sake of denoising our codebase.
public class SortCollection<TSource>
{
private List<ISortProperty<TSource>> Properties { get; set; }
= new List<ISortProperty<TSource>>();
public IReadOnlyList<ISort> Sorts => Properties.AsReadOnly();
public SortCollection()
{}
public SortCollection(string value)
:this(value?.Split(new[] {","}, StringSplitOptions.RemoveEmptyEntries))
{
}
public SortCollection(IEnumerable<string> elements)
{
if (elements == null)
return;
foreach (var element in elements)
{
var sortElement = Parse(element);
// garbage property name, skip it
if (sortElement is null) continue;
Properties.Add(sortElement);
}
}
private static ISortProperty<TSource>? Parse(string element)
{
var name = element.Trim();
var properties = typeof(TSource).GetProperties().ToList();
var direction = ListSortDirection.Ascending;
if (element.StartsWith("-"))
{
direction = ListSortDirection.Descending;
name = name.Substring(1);
}
// property name cased properly
var propertyInfo = properties
.Find(p => p.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
if (propertyInfo == null)
return null;
var type = typeof(SortProperty<>);
var sortedElementType = type
.MakeGenericType(
typeof(TSource),
propertyInfo.PropertyType
);
var ctor = sortedElementType
.GetConstructor(new[] {
typeof(PropertyInfo),
typeof(ListSortDirection)
});
return ctor.Invoke(new object?[] {
propertyInfo,
direction
}) as ISortProperty<TSource>;
}
public IQueryable<TSource> Apply(IQueryable<TSource> queryable)
{
var query = queryable;
foreach (var element in Properties)
{
query = element.Apply(query);
}
return query;
}
public override string ToString()
{
return string.Join(",", Properties);
}
/// <summary>
/// Creates a string with the Property Sort or Flips the direction if it exists
/// </summary>
/// <param name="element"></param>
/// <returns></returns>
public string AddOrUpdate(string element)
{
var parse = Parse(element);
if (parse == null)
return ToString();
var sort = new SortCollection<TSource>(ToString());
var property = sort
.Properties
.Find(x => x.PropertyName == parse.PropertyName);
if (property == null)
{
sort.Properties.Add(parse);
return sort.ToString();
}
property.Direction =
property.Direction == ListSortDirection.Ascending
? ListSortDirection.Descending
: ListSortDirection.Ascending;
return sort.ToString();
}
public string Remove(string element)
{
var parse = Parse(element);
if (parse == null)
return ToString();
var sort = new SortCollection<TSource>(ToString());
var property = sort
.Properties
.Find(x => x.PropertyName == parse.PropertyName);
if (property != null)
{
sort.Properties.Remove(property);
}
return sort.ToString();
}
private class SortProperty<TKey> : ISortProperty<TSource>
{
public SortProperty(
PropertyInfo propertyInfo,
ListSortDirection direction)
{
PropertyName = propertyInfo.Name;
Direction = direction;
var source = Expression.Parameter(typeof(TSource), "x");
var member = Expression.Property(source, propertyInfo);
Filter = Expression.Lambda<Func<TSource, TKey>>(member, source);
}
public string PropertyName { get; private set; }
public ListSortDirection Direction { get; set; }
public Expression<Func<TSource, TKey>> Filter { get; private set; }
public IQueryable<TSource> Apply(IQueryable<TSource> queryable)
{
var visitor = new OrderingMethodFinder();
visitor.Visit(queryable.Expression);
if (visitor.OrderingMethodFound)
{
queryable = Direction == ListSortDirection.Ascending
? ((IOrderedQueryable<TSource>)queryable).ThenBy(Filter)
: ((IOrderedQueryable<TSource>)queryable).ThenByDescending(Filter);
}
else
{
queryable = Direction == ListSortDirection.Ascending
? queryable.OrderBy(Filter)
: queryable.OrderByDescending(Filter);
}
return queryable;
}
public override string ToString()
{
return Direction == ListSortDirection.Ascending
? PropertyName
: $"-{PropertyName}";
}
private class OrderingMethodFinder : ExpressionVisitor
{
public bool OrderingMethodFound { get; set; }
protected override Expression VisitMethodCall(MethodCallExpression node)
{
var name = node.Method.Name;
if (node.Method.DeclaringType == typeof(Queryable) && (
name.StartsWith("OrderBy", StringComparison.Ordinal) ||
name.StartsWith("ThenBy", StringComparison.Ordinal)))
{
OrderingMethodFound = true;
}
return base.VisitMethodCall(node);
}
}
}
private interface ISortProperty<T> : ISort
{
new ListSortDirection Direction { get; set; }
IQueryable<T> Apply(IQueryable<T> queryable);
}
public interface ISort
{
string PropertyName { get; }
ListSortDirection Direction { get; }
}
}
The most important part of our SortCollection
implementation is the Parse
method along with the SortProperty
class. Let’s take a look at how it can parse a string like Id
into a valid Expression<Func<TSource,TKey>>
.
First let’s look at the Parse
method.
private static ISortProperty<TSource>? Parse(string element)
{
var name = element.Trim();
var properties = typeof(TSource).GetProperties().ToList();
var direction = ListSortDirection.Ascending;
if (element.StartsWith("-"))
{
direction = ListSortDirection.Descending;
name = name.Substring(1);
}
// property name cased properly
var propertyInfo = properties
.Find(p => p.Name
.Equals(name, StringComparison.OrdinalIgnoreCase)
);
if (propertyInfo == null)
return null;
var type = typeof(SortProperty<>);
var sortedElementType = type
.MakeGenericType(
typeof(TSource),
propertyInfo.PropertyType
);
var ctor = sortedElementType
.GetConstructor(new[] {
typeof(PropertyInfo),
typeof(ListSortDirection)
});
return ctor.Invoke(new object?[] {
propertyInfo,
direction
}) as ISortProperty<TSource>;
}
We start by doing some basic string parsing to determine the sort direction. We then use reflection to determine if the property being passed in matches a property name on the TSource
type. If it does not, we skip this property. Finally, we construct a generic type of SortProperty<TKey>: ISortProperty<TSource>
. Leaning on the use of generics makes the construction of SortProperty
much more straightforward.
public SortProperty(PropertyInfo propertyInfo, ListSortDirection direction)
{
PropertyName = propertyInfo.Name;
Direction = direction;
var source = Expression.Parameter(typeof(TSource), "x");
var member = Expression.Property(source, propertyInfo);
Filter = Expression.Lambda<Func<TSource, TKey>>(member, source);
}
We can construct the necessary expression given a PropertyInfo
and the source type. The Filter
value will is used later when we apply each ordering expression.
public IQueryable<TSource> Apply(IQueryable<TSource> queryable)
{
var visitor = new OrderingMethodFinder();
visitor.Visit(queryable.Expression);
if (visitor.OrderingMethodFound)
{
queryable = Direction == ListSortDirection.Ascending
? ((IOrderedQueryable<TSource>)queryable).ThenBy(Filter)
: ((IOrderedQueryable<TSource>)queryable).ThenByDescending(Filter);
}
else
{
queryable = Direction == ListSortDirection.Ascending
? queryable.OrderBy(Filter)
: queryable.OrderByDescending(Filter);
}
return queryable;
}
Don’t worry if this seems overwhelming. Understanding the implementation of this code is nice, but not critical to using it although we should attempt to understand code before copying it into our projects.
Using SortCollection In Tests
Before we jump into using theSortCollection
in our ASP.NET Core project, let’s take a look at some unit test examples. The implementation leans towards making it easier to work with HTTP requests, but is not constrained to only working with HTML and HTTP.
Let’s start by generating 100 Thing
classes.
public OrderBy(ITestOutputHelper output)
{
this.output = output;
Things = Enumerable
.Range(1, 100)
.Select(x => new Thing {Id = x})
.AsQueryable();
}
We can see that we can flip the order of our collection by passing in a -Id
string value.
public void By_QueryString()
{
var result = Things
.OrderBy($"-{nameof(Thing.Id)}, {nameof(Thing.CreatedAt)}")
.ToList();
Assert.Equal(100, result[0].Id);
}
We can also recreate the string value of our SortCollection
by calling ToString
.
[Fact]
public void ToString_Returns_String()
{
var value = $"{nameof(Thing.Id)},{nameof(Thing.CreatedAt)}";
var sort = new SortCollection<Thing>(value);
var actual = sort.ToString();
output.WriteLine(actual);
Assert.Equal(value, actual);
}
Essential for building UIs is the ability to append, flip, and remove values from our sort collection.
[Fact]
public void With_NotExisting_Appends_Property()
{
var value = $"{nameof(Thing.Id)}";
var sort = new SortCollection<Thing>(value);
var actual = sort.AddOrUpdate("CreatedAt");
output.WriteLine(actual);
Assert.Equal("Id,CreatedAt", actual);
}
[Fact]
public void With_Existing_Flips_Property()
{
var value = $"{nameof(Thing.Id)}";
var sort = new SortCollection<Thing>(value);
var actual = sort.AddOrUpdate("Id");
output.WriteLine(actual);
Assert.Equal("-Id", actual);
}
[Fact]
public void Remove_Property()
{
var value = $"{nameof(Thing.Id)},{nameof(Thing.CreatedAt)}";
var sort = new SortCollection<Thing>(value);
var actual = sort.Remove("CreatedAt");
output.WriteLine(actual);
Assert.Equal("Id", actual);
}
Let’s use the following methods in a Razor Page to see the full effect.
Using SortCollection In Razor
Let’s start by looking at the C# code of a simple Razor page. In the Razor page, we’ll want to create a few constructs:
- A static variable that holds our sortable data (using Bogus).
- A query string property for
Sort
which will be modified by our users - An instance of
SortCollection
, which is our parsed query, - Finally,
Result
which has sorted results.
Let’s look at the implementation of our Razor page.
using System;
using System.Collections.Generic;
using System.Linq;
using Bogus;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.Extensions.Logging;
namespace Sorting.Pages
{
public class IndexModel : PageModel
{
private readonly ILogger<IndexModel> logger;
private static Faker<Widget> Builder = new Faker<Widget>()
.RuleFor(m => m.Id, f => f.IndexFaker)
.RuleFor(m => m.Name, f => f.Commerce.ProductName())
.RuleFor(m => m.CreatedAt, f => f.Date.Soon());
public static IQueryable<Widget> Database
= Builder.Generate(100).AsQueryable();
[BindProperty(SupportsGet = true)]
public string Sort { get; set; }
public SortCollection<Widget> Sorting { get; set; }
public IndexModel(ILogger<IndexModel> logger)
{
this.logger = logger;
}
public void OnGet()
{
Result =
Database
.OrderBy(Sort, out var sorting)
.ToList();
Sorting = sorting;
}
public List<Widget> Result { get; set; }
}
public class Widget
{
public int Id { get; set; }
public string Name { get; set; }
public DateTime CreatedAt { get; set; }
}
}
Let’s look at what it takes on the UI side of our Razor Page.
First, we want to show the applied sorts at the top of the page. We can loop through our SortCollection
and display each one. We can use the Remove
method to create a query string without a particular property name.
<div class="row">
<p>
@foreach (var s in Model.Sorting.Sorts)
{
<a class="btn btn-primary" href="@Url.PageLink("Index", values: new {sort = Model.Sorting.Remove(s.PropertyName)})">
<span class="badge badge-light">x</span> @s.PropertyName (@s.Direction)
</a>
}
</p>
</div>
Next, we can use the AddOrUpdate
method to either append a property name to the query string value or flip the direction of the property.
<th>
<a href="@Url.PageLink("Index", values: new {sort = Model.Sorting.AddOrUpdate(nameof(Widget.Id))})">
Id
</a>
</th>
We will do this with all the properties.
Seeing It In Action
When we wire it up, it works seamlessly from the frontend to the backend, taking advantage of our Razor backend and LINQ constructs to filter our static data. ThenBy
ordering will only make a noticeable difference when tie-breaking sorts, otherwise they aren’t that helpful.
Conclusion
Taking advantage of LINQ syntax can help us create fantastic user experiences with little effort. The SortCollection
class does the heavy lifting and contains the complicated bits: Expressions, Generics, and string parsing. I’ve taken care of that, so no need to worry about it :).
We could also modify the behavior of our SortCollection
implementation to meet our specific needs, but this approach should work for most folks out there with little to no changes. By utilizing strings, we’ve allowed for interoperability between our backend and the HTTP request. We also get the added benefit of picking a format that’s readable and easy to understand.
If you would like to play with this example, you can get the code from my GitHub Repository and feel free to submit pull requests or changes if I missed anything.