Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
bf54a60
Add line break in docs
Jan 13, 2025
3259a9c
Update deprecation of Default and Name in SortParameterBuilder
Jan 13, 2025
c99e1c1
Improve options in SortBuilder
Apr 16, 2025
005ffca
Fix ToFrozenDictionary not keeping the comparer
Apr 16, 2025
be6e0a9
Remove deprecated methods in SortParameterBuilder
Apr 16, 2025
80d05ad
Rewrite Sorter for improved testability and validation
Apr 16, 2025
31195ce
Improve query builders and add more tests
simo026q May 12, 2025
811866a
Make sort parameters reversable
simo026q May 12, 2025
91f98db
Update tests
simo026q May 12, 2025
e2ee888
Improve benchmarks
simo026q May 12, 2025
7b0bf2e
Merge pull request #9 from simo026q/1.2.0
simo026q May 12, 2025
10a0b31
Add line break in docs
Jan 13, 2025
bec9962
Update deprecation of Default and Name in SortParameterBuilder
Jan 13, 2025
c26b942
Improve options in SortBuilder
Apr 16, 2025
1b01c68
Fix ToFrozenDictionary not keeping the comparer
Apr 16, 2025
b9d64ea
Remove deprecated methods in SortParameterBuilder
Apr 16, 2025
5507f2b
Rewrite Sorter for improved testability and validation
Apr 16, 2025
d1e5a78
Improve query builders and add more tests
simo026q May 12, 2025
9f3aa15
Make sort parameters reversable
simo026q May 12, 2025
2fe5275
Update tests
simo026q May 12, 2025
ab075e4
Improve benchmarks
simo026q May 12, 2025
0c45151
Merge branch 'next' of https://github.com/simo026q/FluentSortingDotNe…
simo026q May 12, 2025
1d0b593
Add benchmarks to CI
simo026q May 12, 2025
34cdf4b
Fix benchmark project path in CI workflow
simo026q May 12, 2025
280818f
Fix benchmark run configuration in CI workflow
simo026q May 12, 2025
ff5c157
Update benchmark result path in CI workflow
simo026q May 12, 2025
ab79303
Update CI workflow permissions
simo026q May 12, 2025
12d990d
Create nuget.yml
simo026q May 13, 2025
084df70
Update benchmarks in README.md
simo026q May 13, 2025
c4ec3dd
Improve README.md examples
simo026q May 13, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .github/workflows/dotnet.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ on:
branches: [master]
pull_request:

permissions:
contents: read
issues: write
pull-requests: write

jobs:
build:

Expand All @@ -25,3 +30,21 @@ jobs:
run: dotnet build --no-restore /warnaserror
- name: Test
run: dotnet test --no-build --verbosity normal
- name: Run benchmarks
run: dotnet run --configuration Release --project tests/FluentSortingDotNet.Benchmarks
- name: Read benchmark results
id: read_benchmark
run: |
content=$(cat BenchmarkDotNet.Artifacts/results/*-report-github.md)
echo "content<<EOF" >> $GITHUB_OUTPUT
echo "$content" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
- name: Post benchmark comment
if: github.event_name == 'pull_request'
uses: peter-evans/create-or-update-comment@v4
with:
token: ${{ secrets.GITHUB_TOKEN }}
issue-number: ${{ github.event.pull_request.number }}
body: |
## Benchmark Results
${{ steps.read_benchmark.outputs.content }}
31 changes: 31 additions & 0 deletions .github/workflows/nuget.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
name: Publish to NuGet

on:
push:
tags:
- 'v*' # e.g., v1.0.0

jobs:
build-and-publish:
runs-on: windows-latest

steps:
- name: Checkout repository
uses: actions/checkout@v4

- name: Setup .NET SDK
uses: actions/setup-dotnet@v4
with:
dotnet-version: '9.0.x' # adjust based on your project

- name: Restore dependencies
run: dotnet restore

- name: Build project
run: dotnet build --configuration Release --no-restore

- name: Pack NuGet package
run: dotnet pack --configuration Release --no-build --output ./nupkg

- name: Push package to NuGet
run: dotnet nuget push ./nupkg/*.nupkg --source https://api.nuget.org/v3/index.json --api-key ${{ secrets.NUGET_API_KEY }}
12 changes: 4 additions & 8 deletions FluentSortingDotNet.sln
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,6 @@ VisualStudioVersion = 17.12.35506.116
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FluentSortingDotNet", "src\FluentSortingDotNet\FluentSortingDotNet.csproj", "{F156F26F-6B62-44C3-A53F-7703A9C3BFED}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FluentSortingDotNet.ConsoleTest", "tests\FluentSortingDotNet.ConsoleTest\FluentSortingDotNet.ConsoleTest.csproj", "{520AD80C-9C18-44CB-85AB-18A04C7D41D1}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{5CF6B4C7-248A-44EB-9B78-9DC5C7E004C4}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{D5F916DA-2A0B-4B72-854A-D19FDFF0C2EF}"
Expand All @@ -15,7 +13,7 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FluentSortingDotNet.Benchma
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FluentSortingDotNet.Testing", "tests\FluentSortingDotNet.Testing\FluentSortingDotNet.Testing.csproj", "{95003460-2CF1-4CC4-9089-194B8C570A34}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FluentSortingDotNet.UnitTests", "tests\FluentSortingDotNet.UnitTests\FluentSortingDotNet.UnitTests.csproj", "{3A17280E-7BA2-4219-8E76-F45CBAB12144}"
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FluentSortingDotNet.Tests", "tests\FluentSortingDotNet.Tests\FluentSortingDotNet.Tests.csproj", "{3A17280E-7BA2-4219-8E76-F45CBAB12144}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Expand All @@ -27,10 +25,6 @@ Global
{F156F26F-6B62-44C3-A53F-7703A9C3BFED}.Debug|Any CPU.Build.0 = Debug|Any CPU
{F156F26F-6B62-44C3-A53F-7703A9C3BFED}.Release|Any CPU.ActiveCfg = Release|Any CPU
{F156F26F-6B62-44C3-A53F-7703A9C3BFED}.Release|Any CPU.Build.0 = Release|Any CPU
{520AD80C-9C18-44CB-85AB-18A04C7D41D1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{520AD80C-9C18-44CB-85AB-18A04C7D41D1}.Debug|Any CPU.Build.0 = Debug|Any CPU
{520AD80C-9C18-44CB-85AB-18A04C7D41D1}.Release|Any CPU.ActiveCfg = Release|Any CPU
{520AD80C-9C18-44CB-85AB-18A04C7D41D1}.Release|Any CPU.Build.0 = Release|Any CPU
{6E024A6D-ED64-49C7-9F1C-54800B9292C0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{6E024A6D-ED64-49C7-9F1C-54800B9292C0}.Debug|Any CPU.Build.0 = Debug|Any CPU
{6E024A6D-ED64-49C7-9F1C-54800B9292C0}.Release|Any CPU.ActiveCfg = Release|Any CPU
Expand All @@ -49,9 +43,11 @@ Global
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{F156F26F-6B62-44C3-A53F-7703A9C3BFED} = {D5F916DA-2A0B-4B72-854A-D19FDFF0C2EF}
{520AD80C-9C18-44CB-85AB-18A04C7D41D1} = {5CF6B4C7-248A-44EB-9B78-9DC5C7E004C4}
{6E024A6D-ED64-49C7-9F1C-54800B9292C0} = {5CF6B4C7-248A-44EB-9B78-9DC5C7E004C4}
{95003460-2CF1-4CC4-9089-194B8C570A34} = {5CF6B4C7-248A-44EB-9B78-9DC5C7E004C4}
{3A17280E-7BA2-4219-8E76-F45CBAB12144} = {5CF6B4C7-248A-44EB-9B78-9DC5C7E004C4}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {E0C5B042-0A90-4794-8105-91387DFBCB8D}
EndGlobalSection
EndGlobal
47 changes: 28 additions & 19 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,17 +34,20 @@ public record Person(string Name, int Age);
```csharp
using FluentSortingDotNet;

public sealed class PersonSorter(ISortParameterParser parser) : Sorter<Person>(parser) // The Sorter class also have an empty constructor that uses the DefaultSortParameterParser
public sealed class PersonSorter : Sorter<Person>
{
protected override void Configure(SortBuilder<Person> builder)
{
// when no parameters are provided, sort by name descending
builder.ForParameter(p => p.Name).WithName("name").IsDefault(direction: SortDirection.Descending);
// When no parameters are provided, sort by name descending
builder.ForParameter(p => p.Name).IsDefault(direction: SortDirection.Descending);

builder.ForParameter(p => p.DateOfBirth).WithName("age");
builder.ForParameter(p => p.DateOfBirth).WithName("age").ReverseDirection();

// ignore case when sorting by name
// Ignore case when sorting by name
builder.IgnoreParameterCase();

// Ignore invalid parameters instead of throwing an exception when not validated with PersonSorter.Validate(string)
builder.IgnoreInvalidParameters();
}
}
```
Expand All @@ -54,27 +57,25 @@ public sealed class PersonSorter(ISortParameterParser parser) : Sorter<Person>(p
```csharp
using FluentSortingDotNet;

var sorter = new PersonSorter(DefaultSortParameterParser.Instance);

IQueryable<Person> peopleQuery = ...;
PersonSorter sorter = new();

SortResult result = sorter.Sort(ref peopleQuery, "name,-age");
SortContext sortContext = sorter.Validate("name,-age");

if (result.IsSuccess)
{
var orderedPeople = peopleQuery.ToList();
}
else
if (!sortContext.IsValid)
{
Console.WriteLine($"Invalid sort parameters: {string.Join(", ", result.InvalidSortParameters)}");
Console.WriteLine($"Invalid sort parameters: {string.Join(", ", sortContext.InvalidParameters)}");
return;
}

IQueryable<Person> peopleQuery = ...;

IQueryable<Person> sortedQuery = sorter.Sort(peopleQuery, sortContext);
```

### Dependency Injection

```csharp
services.AddSingleton<ISortParameterParser>(DefaultSortParameterParser.Instance);
services.AddSingleton<PersonSorter>();
services.AddSingleton<ISorter<Person>, PersonSorter>();
```

## Extensibility
Expand Down Expand Up @@ -163,10 +164,18 @@ It has a slightly worse performance (when using a sort query string) than callin
The performance is slightly better when sorting on the default sort parameters since the query is precompiled.
Both of the benchmarked query builders allocate a bit less memory since the expressions are reused.

![Query building benchmark results](tests/FluentSortingDotNet.Benchmarks/query-builder-1.0.0-rc.3.png "Query building benchmark results")
| Method | Mean | Error | StdDev | Ratio | RatioSD | Allocated | Alloc Ratio |
|--------- |---------:|--------:|--------:|------:|--------:|----------:|------------:|
| Default | 465.9 μs | 6.89 μs | 6.45 μs | 0.98 | 0.02 | 16.78 KB | 0.94 |
| Compiled | 470.9 μs | 6.06 μs | 5.67 μs | 0.99 | 0.02 | 16.67 KB | 0.94 |
| Linq | 474.9 μs | 6.39 μs | 5.98 μs | 1.00 | 0.02 | 17.82 KB | 1.00 |

#### Parsing

The parsing has no real-world impact on performance.

![Parsing benchmark results](tests/FluentSortingDotNet.Benchmarks/parser-1.0.0-rc.3.png "Parsing benchmark results")
| Method | Query | Mean | Error | StdDev | Allocated |
|----------- |----------------- |---------:|---------:|---------:|----------:|
| **ParseFirst** | **-a,b** | **16.58 ns** | **0.209 ns** | **0.196 ns** | **24 B** |
| **ParseFirst** | **a** | **16.94 ns** | **0.074 ns** | **0.061 ns** | **24 B** |
| **ParseFirst** | **a,b,-c,d,-e,-f,g** | **16.63 ns** | **0.153 ns** | **0.143 ns** | **24 B** |
6 changes: 5 additions & 1 deletion src/FluentSortingDotNet/FluentSortingDotNet.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
<Description>Apply sorting from a string (e.g. query string) with a FluentValidation-like API.</Description>
<PackageTags>sorting, fluent, query, string, linq, order, by</PackageTags>
</PropertyGroup>

<ItemGroup>
<None Include="..\..\LICENSE.txt">
<Pack>True</Pack>
Expand All @@ -34,4 +34,8 @@
<PackageReference Include="System.Memory" Version="4.6.0" />
</ItemGroup>

<ItemGroup>
<InternalsVisibleTo Include="FluentSortingDotNet.Tests" />
</ItemGroup>

</Project>
26 changes: 26 additions & 0 deletions src/FluentSortingDotNet/ISorter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
using FluentSortingDotNet.Queries;
using System;
using System.Linq;

namespace FluentSortingDotNet;

/// <summary>
/// Represents a sorter can create a sort query for a <see cref="IQueryable{T}"/>.
/// </summary>
/// <typeparam name="T">The type of items to sort.</typeparam>
public interface ISorter<T>
{
/// <summary>
/// Create a new instance of the <see cref="ISortQuery{T}"/> class with the specified <paramref name="sortContext"/>.
/// </summary>
/// <param name="sortContext">The <see cref="SortContext{T}"/> that contains the sort parameters.</param>
/// <returns>A new instance of the <see cref="ISortQuery{T}"/> class.</returns>
ISortQuery<T> CreateSortQuery(SortContext<T> sortContext);

/// <summary>
/// Validates the specified sort query and returns a <see cref="SortContext{T}"/> that can be used to sort a query.
/// </summary>
/// <param name="sortQuery">The sort query to validate.</param>
/// <returns>A new <see cref="SortContext{T}"/> that contains the valid and invalid parameters.</returns>
SortContext<T> Validate(ReadOnlySpan<char> sortQuery);
}
1 change: 1 addition & 0 deletions src/FluentSortingDotNet/Internal/SortableParameter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,5 @@ internal sealed class SortableParameter(LambdaExpression expression, string name
public LambdaExpression Expression { get; } = expression;
public string Name { get; set; } = name;
public SortDirection? DefaultDirection { get; set; }
public bool ShouldReverseDirection { get; set; }
}
73 changes: 35 additions & 38 deletions src/FluentSortingDotNet/Queries/DefaultSortQueryBuilder.cs
Original file line number Diff line number Diff line change
@@ -1,18 +1,17 @@
using System;
using FluentSortingDotNet.Internal;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Linq.Expressions;
using FluentSortingDotNet.Internal;

namespace FluentSortingDotNet.Queries;

/// <summary>
/// Represents a class that can build a sort query.
/// </summary>
/// <typeparam name="T">The type of items to sort.</typeparam>
public sealed class DefaultSortQueryBuilder<T> : ISortQueryBuilder<T>, ISortQuery<T>
public sealed class DefaultSortQueryBuilder<T> : ISortQueryBuilder<T>
{
private bool _built;
private readonly List<SortExpression> _sortExpressions = new();

/// <inheritdoc />
Expand All @@ -28,50 +27,48 @@ public ISortQueryBuilder<T> SortBy(LambdaExpression expression, SortDirection so
/// <inheritdoc />
public ISortQuery<T> Build()
{
if (IsEmpty)
throw new InvalidOperationException("No sorting expressions have been added.");

_built = true;
return this;
return IsEmpty
? throw new InvalidOperationException("No sorting expressions have been added.")
: new SortQuery(_sortExpressions);
}

/// <inheritdoc />
/// <exception cref="InvalidOperationException">Thrown when the query has not been built.</exception>
public IQueryable<T> Apply(IQueryable<T> query)
private sealed class SortExpression(LambdaExpression expression, SortDirection sortDirection)
{
if (!_built)
throw new InvalidOperationException("Cannot apply sorting to an unbuilt query.");
public LambdaExpression Expression { get; } = expression;
public SortDirection SortDirection { get; } = sortDirection;
}

IOrderedQueryable<T>? orderedQuery = null;
private sealed class SortQuery(List<SortExpression> sortExpressions) : ISortQuery<T>
{
private readonly List<SortExpression> _sortExpressions = sortExpressions;

foreach (SortExpression expression in _sortExpressions)
public IQueryable<T> Apply(IQueryable<T> query)
{
if (orderedQuery == null)
IOrderedQueryable<T>? orderedQuery = null;

foreach (SortExpression expression in _sortExpressions)
{
orderedQuery = expression.SortDirection switch
if (orderedQuery == null)
{
SortDirection.Ascending => query.OrderBy(expression.Expression),
SortDirection.Descending => query.OrderByDescending(expression.Expression),
_ => throw new ArgumentOutOfRangeException(nameof(expression.SortDirection))
};
}
else
{
orderedQuery = expression.SortDirection switch
orderedQuery = expression.SortDirection switch
{
SortDirection.Ascending => query.OrderBy(expression.Expression),
SortDirection.Descending => query.OrderByDescending(expression.Expression),
_ => throw new InvalidOperationException("Invalid sort direction.")
};
}
else
{
SortDirection.Ascending => orderedQuery.ThenBy(expression.Expression),
SortDirection.Descending => orderedQuery.ThenByDescending(expression.Expression),
_ => throw new ArgumentOutOfRangeException(nameof(expression.SortDirection))
};
orderedQuery = expression.SortDirection switch
{
SortDirection.Ascending => orderedQuery.ThenBy(expression.Expression),
SortDirection.Descending => orderedQuery.ThenByDescending(expression.Expression),
_ => throw new InvalidOperationException("Invalid sort direction.")
};
}
}
}

return orderedQuery ?? query;
}

private sealed class SortExpression(LambdaExpression expression, SortDirection sortDirection)
{
public LambdaExpression Expression { get; } = expression;
public SortDirection SortDirection { get; } = sortDirection;
return orderedQuery ?? query;
}
}
}
9 changes: 6 additions & 3 deletions src/FluentSortingDotNet/Queries/ExpressionSortQueryBuilder.cs
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
using System;
using FluentSortingDotNet.Internal;
using System;
using System.Linq;
using System.Linq.Expressions;
using System.Reflection;
using FluentSortingDotNet.Internal;

namespace FluentSortingDotNet.Queries;

Expand Down Expand Up @@ -39,9 +39,12 @@ Expression CreateMethodCallExpression(Func<SortDirection, MethodInfo> methodProv
public ISortQuery<T> Build()
{
if (_sortExpression == null)
{
throw new InvalidOperationException("No sorting expressions have been added.");
}

return new DelegateSortQuery(Expression.Lambda<Func<IQueryable<T>, IQueryable<T>>>(_sortExpression, QueryParameter).Compile());
var lambda = Expression.Lambda<Func<IQueryable<T>, IQueryable<T>>>(_sortExpression, QueryParameter).Compile();
return new DelegateSortQuery(lambda);
}

private static LambdaExpression ReplaceParameter(LambdaExpression original, ParameterExpression toReplace, ParameterExpression replacement)
Expand Down
Loading