DEV Community

Cover image for Our First Issue: Enhancing Queries With Sorting
Pierre Bouillon
Pierre Bouillon

Posted on

Our First Issue: Enhancing Queries With Sorting

Welcome back for the next step on our journey building PocketBase.Net!

Today we will be working on enhancing queries, by working on the issue #5 Add sorting support on queries.


Table of Contents

  1. The Issue
  2. Development Process
  3. Closing Thoughts

The issue

So far, PocketBase.Net allows for developers to filter results and paginate a search, but not for sorting.

For instance, if you would like the ten first users whose name starts with P, but starting from the youngest ones, it is not something you can do natively (unless fetching all users whose name start with P and then do the sorting yourself).

However, this is not a PocketBase limitation, since the feature does exist and is documented in the web API reference:

PocketBase web API reference about sorting

Development Process

Defining Our Goals

Before diving head first into the task, we must first have a look at how queries are structured as of now. For that, we can have a look at the repository integration tests:

var singlePagedCompletedTodoItem = await repository
    .Query()
    .WithFilter(completedTodoItemsSearchFilter)
    .WithPagination(new PaginationOptions
    {
        ItemsPerPage = 1,
        PageNumber = 1,
    })
    .ExecuteAsync();
Enter fullscreen mode Exit fullscreen mode

We can notice that sorting and filtering share some common ground. Both of them consist of a string and both of them should respect a specific syntax (despite the sorting string being much more simple since it does not need grouping or logic operators).

Based on the existing code and this remark, we can think about building the feature in the same way we built the filtering: by accepting a raw string as a sorting parameter, but also providing a C# class that would help us building one with a fluent pattern.

Something like this:

var singlePagedCompletedTodoItem = await repository
    .Query()
    .WithFilter(completedTodoItemsSearchFilter)
    // πŸ‘‡ Using a raw sorting string
    .WithSorting("-viewCount,title")
    .WithPagination(...)
    .ExecuteAsync();
Enter fullscreen mode Exit fullscreen mode

With the possibility to build the filter separately:

var sortByMostViewedThenAlphabetically = Sort
    .ByDescending("viewCount")
    .ThenBy("title")
    .Build();

var singlePagedCompletedTodoItem = await repository
    .Query()
    .WithFilter(completedTodoItemsSearchFilter)
    .WithSorting(sortByMostViewedThenAlphabetically)
    .WithPagination(...)
    .ExecuteAsync();
Enter fullscreen mode Exit fullscreen mode

πŸ“ NOTE
I tend to prefer Descending rather than Desc or Administrator rather than Admin since abbreviations can be unclear sometimes (for example, you probably don't want to abbreviate Analytics).

Looks like we're good to go! Now our goals are set:

  • Add a WithSorting method to queries
  • Implement a way to build a sorting string separately

Implementation

Setup

Before working on the issue, I will first start by creating my branch to work on this task, as stated in the CONTRIBUTING.md file:

git checkout -b 5-add-sorting-support-on-queries
Enter fullscreen mode Exit fullscreen mode

Make sure Docker is up and running, open the solution, and let's get started!

Implementing WithSorting

Writing the Acceptance Test

We will start by covering the new use case in the integration test, and work from here to get the implementation right.

Just so we can write the test, we have to define the method first:

// πŸ“‚ QueryBuilder.cs

/// <summary>
/// Defines how the results should be sorted for the query.
/// </summary>
/// <param name="sorting">The sorting to apply.</param>
/// <returns>The builder instance for method chaining.</returns>
public QueryBuilder<TRecord> WithSorting(string sorting)
    => this;
Enter fullscreen mode Exit fullscreen mode

πŸ“ NOTE
The logic is not yet added, we will do so to make our test pass once written.

Since in our tests we are working with TodoItem, we can design the following test:

  • Given one completed task and one pending task
  • Retrieve all of them, sorted first on their status in descending order and then on their name
  • Ensure the order is correct
  • Retrieve all of them again, sorted first on their status in ascending order and then on their name in descending order
  • Ensure the order is still correct

Here is what it looks like:

// πŸ“‚ RepositoryTests.cs

public async Task RepositoryOperations_ShouldBehaveAsExpected()
{
    // ...

    // Sorting results
    var todoItemsByDescendingStatusThenByAlphabeticalOrder = await repository
        .Query()
        .WithSorting("-isCompleted,description")
        .ExecuteAsync();

    todoItemsByDescendingStatusThenByAlphabeticalOrder.ShouldSatisfyAllConditions(
        (todoItems) => todoItems.Items[0].ShouldBeEquivalentTo(completedTodoItemEntity),
        (todoItems) => todoItems.Items[1].ShouldBeEquivalentTo(pendingTodoItemEntity));

    var todoItemsByStatusThenByDescendingAlphabeticalOrder = await repository
        .Query()
        .WithSorting("isCompleted,-description")
        .ExecuteAsync();

    todoItemsByStatusThenByDescendingAlphabeticalOrder.ShouldSatisfyAllConditions(
        (todoItems) => todoItems.Items[0].ShouldBeEquivalentTo(pendingTodoItemEntity),
        (todoItems) => todoItems.Items[1].ShouldBeEquivalentTo(completedTodoItemEntity));
Enter fullscreen mode Exit fullscreen mode

We now have a failing test that we can run to test our implementation.

Implementing the Sorting

In order to store the sorting, we will have to add a new property to the Query object to represent it, which is pretty straightforward:

// πŸ“‚ Query.cs

/// <summary>
/// Sorting to apply on the search results. Defaults to an empty string.
/// </summary>
public string Sorting { get; init; } = string.Empty;
Enter fullscreen mode Exit fullscreen mode

πŸ“ NOTE
null values can make our life harder sometimes. When possible, I prefer to use empty values instead, such as string.Empty.

Now that the property is added, we can update the logic of WithSorting:

// πŸ“‚ QueryBuilder.cs

public QueryBuilder<TRecord> WithSorting(string sorting)
{
    _query = _query with { Sorting = sorting };
    return this;
}
Enter fullscreen mode Exit fullscreen mode

With our query now successfully holding and passing the value of the filter, we now have to use the sorting upon executing the query. This will require us to update both the repository and the client:

// πŸ“‚ IRepository.cs

public class Repository<TRecord>(
    IPocketBaseClient pocketBaseClient,
    PocketBaseClientConfiguration configuration,
    RecordValidator<TRecord> validator
) : IRepository<TRecord>
    where TRecord : RecordBase
{
    // ... 

    public Task<Paged<TRecord>> GetRecords(
        Query<TRecord>? query,
        CancellationToken cancellationToken = default)
        => pocketBaseClient.GetRecords<TRecord>(
            CollectionName,
            query?.Filter,
            query?.PaginationOptions,
            // πŸ‘‡ Propagate the parameter
            query?.Sorting,
            cancellationToken);
}
Enter fullscreen mode Exit fullscreen mode

πŸ“ NOTE
I said I prefer not to use null where possible, but I started with default parameters as null. This is why I am using this value again, to keep the API consistent with the other parameters. In a future task, it could be better to smoother the DX by using empty values here too.

And now onto the client:

πŸ“‚ IPocketBaseClient.cs

public interface IPocketBaseClient
{
    // ...

    Task<Paged<TRecord>> GetRecords<TRecord>(
        string collectionIdOrName,
        string? filter = null,
        PaginationOptions? paginationOptions = null,
+       string? sorting = null,
        CancellationToken cancellation = default
    ) where TRecord : RecordBase;
}

public class PocketBaseClient(
    PocketBaseClientConfiguration configuration,
    PocketBaseHttpClientWrapper httpClientWrapper
) : IPocketBaseClient
{
    // ...

    /// <inheritdoc/>
    public Task<Paged<TRecord>> GetRecords<TRecord>(
        string collectionIdOrName,
        string? filter = null,
        PaginationOptions? paginationOptions = null,
+       string? sorting = null,
        CancellationToken cancellationToken = default
    ) where TRecord : RecordBase
        => httpClientWrapper.SendGet<TRecord>(
            collectionIdOrName,
            filter,
            paginationOptions,
+           sorting,
            cancellationToken);
Enter fullscreen mode Exit fullscreen mode

πŸ“ NOTE
At this point, parameters count are starting to grow everywhere, it might be worth considering propagating the query in itself at some point.

Finally, we will have to append the sorting to the query parameters:

πŸ“‚ PocketBaseHttpClientWrapper.cs

public Task<Paged<TRecord>> SendGet<TRecord>(
    string collectionIdOrName,
    string? filter = null,
    PaginationOptions? paginationOptions = null,
+   string? sorting = null,
    CancellationToken cancellationToken = default
) where TRecord : RecordBase
{
    var query = AppendQueryParameters(
        $"/api/collections/{collectionIdOrName}/records",
        string.IsNullOrEmpty(filter) ? null : $"filter=({filter})",
+       string.IsNullOrEmpty(sorting) ? null : $"sort={sorting}",
        paginationOptions?.ToQueryParameters());

    return SendRequest<Paged<TRecord>>(
            (httpClient) => httpClient.GetAsync(query, cancellationToken),
            onErrorThrow: (_) => new RecordSearchFailedException(),
            cancellationToken);
}
Enter fullscreen mode Exit fullscreen mode

πŸ“ NOTE
If we would be passing the Query here instead, we could delegate the construction of the query parameters to the class, resulting in a better encapsulation and cleaner code.

Running Our Tests

With the sorting parameter handled and propagated, we can now run our test again, and ensure it is passing:

Test result

Great! We can now commit and push our changes on GitHub.

Onto the next part!

Creating the Sorting String Builder

Writing the sorting ourselves is indeed possible, but people might want to use variables, or have a sorting a bit more complex.

To support that kind of use cases, we can create a builder in the same way a builder already allows for complex filters to be written, such as:

var filter = Filter.Field("id").Equal(37)
    .And().Grouped(
        Filter.Field("age").GreaterThanOrEqual(18)
        .Or().Field("role").Like("admin"))
    .And().Field("isVerified").Equal(true)
    .Build();

// Produces: id=37 && (age>=18 || role~"admin") && isVerified=true
Enter fullscreen mode Exit fullscreen mode

In the same spirit, here is how we could implement a builder for our sorting string:

// πŸ“‚ SortBuilder.cs

namespace PocketBase.Net.Client.Entities;

public sealed class Sort
{
    private readonly Queue<string> _sortSections = new();

    private Sort(string seed)
        => _sortSections.Enqueue(seed);

    public static Sort By(string fieldName)
        => new(fieldName);

    public static Sort ByDescending(string fieldName)
        => new(AsDescendingField(fieldName));

    public Sort ThenBy(string fieldName)
    {
        _sortSections.Enqueue(fieldName);
        return this;
    }

    public Sort ThenByDescending(string fieldName)
    {
        _sortSections.Enqueue(AsDescendingField(fieldName));
        return this;
    }

    private static string AsDescendingField(string fieldName)
        => '-' + fieldName;

    public string Build()
        => string.Join(',', _sortSections);
}
Enter fullscreen mode Exit fullscreen mode

Once the documentation added, we can add some unit tests to ensure that everything is working properly. For that, we can ask Le Chat to generate the following:

// πŸ“‚ SortBuilderTests.cs

public class SortBuilderTests
{
    [Fact(DisplayName = "Build should return correct sort string for multiple fields")]
    public void Build_ShouldReturnCorrectSortStringForMultipleFields()
    {
        // Arrange
        var sort = Sort.ByDescending("name")
            .ThenBy("age")
            .ThenByDescending("date");

        // Act & Assert
        sort.Build().ShouldBe("-name,age,-date");
    }
}
Enter fullscreen mode Exit fullscreen mode

Since the test is passing, we can now use that instead in our integration tests:

+ var sortByStatusDescendingThenName = Sort
+     .ByDescending("isCompleted")
+     .ThenBy("description")
+     .Build();

var todoItemsByStatusDescendingThenName = await repository
    .Query()
-     .WithSorting("-isCompleted,description)
+     .WithSorting(sortByStatusDescendingThenName)
    .ExecuteAsync();

...

+ var sortByStatusThenNameDescending = Sort
+     .By("isCompleted")
+     .ThenByDescending("description")
+     .Build();

var todoItemsByStatusThenNameDescending = await repository
    .Query()
-     .WithSorting("isCompleted,-description)
+     .WithSorting(sortByStatusThenNameDescending)
    .ExecuteAsync();
Enter fullscreen mode Exit fullscreen mode

We can run all our tests once more, to ensure everything is still working properly:

Tests results with new features

Great! We can now commit and push those changes as well.
You can have a look on GitHub at the commit.

Finishing Touches

We are almost done, but we still have to document our changes by updating the README:

README updated

We also have to remember to delete the part stating that sorting was not yet supported.

Now that everything is neatly finished, it's time to publish the PR: https://github.com/pBouillon/PocketBase.Net/pull/11

All checks are passing, the feature is implemented, tested and documented: we can now merge the changes, close the issue and delete our branch. πŸš€

Closing Thoughts

And voilΓ :

Git History After Merge

We successfully implemented the task, from its definition, to its final implementation passing by its design and test cases.

We still have plenty of other features to add but that's one less now!

Thanks for keeping up so far, and feel free to make your own suggestions in comments or directly by creating a new discussion on GitHub!

Top comments (0)