How did I do?*

Writing and testing custom OrderBy extension methods

Introduction

Sorting values alphabetically or by date is pretty common, but sometimes you may want to group a set of results based on a secondary parameter. If this value is some arbitrary, opinion- or business-based decision, you may need to implement a custom ordering method, and writing the logic into an extension method makes it easy to reuse across the solution.

Defining the types

The following example uses a list of vehicles, which will be initially sorted alphabetically by manufacturer (Make).

public class Vehicle
{
    public string Make { get; set; }

    public string Model { get; set; }

    public FuelType FuelType { get; set; }

    public DateOnly DateOfManufacture { get; set; }
}

We also want to group the results by their perceived emissions (low to high), based on their FuelType, defined here as an enum. Keep in mind that enums will be ordered numerically based on the order they appear in the definition if an order is not explicitly defined.

public enum FuelType
{
    Petrol,
    Diesel,
    PetrolHybrid,
    DieselHybrid,
    Electric
}

Defining the extension method

The ordering function below is defined using a numerical value which differs from the default enum value, and uses nested ternaries to define the order. Nested ternaries are usually frowned upon as they can be quite hard to read (and most IDEs will shout at you for using them), but the following structure is fairly easy to read and understand.

public static class OrderedQueryableExtensions
{
    public static IOrderedQueryable<Vehicle> ThenByFuelType(this IOrderedQueryable<Vehicle> orderedQueryable)
    {
        return orderedQueryable.ThenBy(vehicle =>
            vehicle.FuelType == FuelType.Electric ? 0 :
            vehicle.FuelType == FuelType.PetrolHybrid ? 1 :
            vehicle.FuelType == FuelType.DieselHybrid ? 2 :
            vehicle.FuelType == FuelType.Petrol ? 3 : 4);
    }
}

Essentially, if there are five vehicles from the same manufacturer, each using a different fuel type, they will receive secondary ordering which places Electric first, and Diesel last.

When querying data from a database using Entity Framework, this structure generates SQL as a sequence of CASE statements, similar to the following:

ORDER BY CASE
    WHEN [Vehicle].[FuelType] = N'Electric' THEN 0
    WHEN [Vehicle].[FuelType] = N'PetrolHybrid' THEN 1
    WHEN [Vehicle].[FuelType] = N'DieselHybrid' THEN 2
    WHEN [Vehicle].[FuelType] = N'Petrol' THEN 3
    ELSE 4

Testing

Testing is fairly simple, but there is one consideration when using manually created test data, in that the value needs to be converted to an IOrderedQueryable before calling the extension method under test.

Ordinarily, this would be done by first ordering by another value (i.e. Make), but to make testing simpler, and more targeted to the property being ordered, the list only comprises of vehicles from the same manufacturer. This list can be ordered by a meaningless value which doesn't change the ordering, but still performs the conversion.

[Fact]
public void ThenByFuelType_OrdersVehiclesByTheirFuelType()
{
    // Arrange
    var vehicles = new List<Vehicle>
    {
        new() { Make = "Toyota", FuelType = FuelType.Petrol },
        new() { Make = "Toyota", FuelType = FuelType.DieselHybrid },
        new() { Make = "Toyota", FuelType = FuelType.Diesel },
        new() { Make = "Toyota", FuelType = FuelType.Electric },
        new() { Make = "Toyota", FuelType = FuelType.PetrolHybrid },
    };

    var orderedQueryable = vehicles
        .AsQueryable() // Convert the list to an IQueryable
        .OrderBy(p => 1); // Convert the IQueryable to an IOrderedQueryable

    // Act
    var result = orderedQueryable
        .ThenByFuelType()!
        .ToList();

    // Assert
    Assert.Equal(FuelType.Electric, result[0].FuelType);
    Assert.Equal(FuelType.PetrolHybrid, result[1].FuelType);
    Assert.Equal(FuelType.DieselHybrid, result[2].FuelType);
    Assert.Equal(FuelType.Petrol, result[3].FuelType);
    Assert.Equal(FuelType.Diesel, result[4].FuelType);
}