diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index f5dca21..adf6579 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -8,6 +8,11 @@ on: branches: [master] pull_request: +permissions: + contents: read + issues: write + pull-requests: write + jobs: build: @@ -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<> $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 }} diff --git a/.github/workflows/nuget.yml b/.github/workflows/nuget.yml new file mode 100644 index 0000000..58d025c --- /dev/null +++ b/.github/workflows/nuget.yml @@ -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 }} diff --git a/FluentSortingDotNet.sln b/FluentSortingDotNet.sln index 11b3dec..877c479 100644 --- a/FluentSortingDotNet.sln +++ b/FluentSortingDotNet.sln @@ -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}" @@ -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 @@ -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 @@ -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 diff --git a/README.md b/README.md index 903db10..db196de 100644 --- a/README.md +++ b/README.md @@ -34,17 +34,20 @@ public record Person(string Name, int Age); ```csharp using FluentSortingDotNet; -public sealed class PersonSorter(ISortParameterParser parser) : Sorter(parser) // The Sorter class also have an empty constructor that uses the DefaultSortParameterParser +public sealed class PersonSorter : Sorter { protected override void Configure(SortBuilder 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(); } } ``` @@ -54,27 +57,25 @@ public sealed class PersonSorter(ISortParameterParser parser) : Sorter(p ```csharp using FluentSortingDotNet; -var sorter = new PersonSorter(DefaultSortParameterParser.Instance); - -IQueryable 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 peopleQuery = ...; + +IQueryable sortedQuery = sorter.Sort(peopleQuery, sortContext); ``` ### Dependency Injection ```csharp -services.AddSingleton(DefaultSortParameterParser.Instance); -services.AddSingleton(); +services.AddSingleton, PersonSorter>(); ``` ## Extensibility @@ -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") \ No newline at end of file +| 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** | diff --git a/src/FluentSortingDotNet/FluentSortingDotNet.csproj b/src/FluentSortingDotNet/FluentSortingDotNet.csproj index 6b340b6..274a637 100644 --- a/src/FluentSortingDotNet/FluentSortingDotNet.csproj +++ b/src/FluentSortingDotNet/FluentSortingDotNet.csproj @@ -18,7 +18,7 @@ Apply sorting from a string (e.g. query string) with a FluentValidation-like API. sorting, fluent, query, string, linq, order, by - + True @@ -34,4 +34,8 @@ + + + + diff --git a/src/FluentSortingDotNet/ISorter.cs b/src/FluentSortingDotNet/ISorter.cs new file mode 100644 index 0000000..878d16c --- /dev/null +++ b/src/FluentSortingDotNet/ISorter.cs @@ -0,0 +1,26 @@ +using FluentSortingDotNet.Queries; +using System; +using System.Linq; + +namespace FluentSortingDotNet; + +/// +/// Represents a sorter can create a sort query for a . +/// +/// The type of items to sort. +public interface ISorter +{ + /// + /// Create a new instance of the class with the specified . + /// + /// The that contains the sort parameters. + /// A new instance of the class. + ISortQuery CreateSortQuery(SortContext sortContext); + + /// + /// Validates the specified sort query and returns a that can be used to sort a query. + /// + /// The sort query to validate. + /// A new that contains the valid and invalid parameters. + SortContext Validate(ReadOnlySpan sortQuery); +} \ No newline at end of file diff --git a/src/FluentSortingDotNet/Internal/SortableParameter.cs b/src/FluentSortingDotNet/Internal/SortableParameter.cs index 3eafca3..00710bc 100644 --- a/src/FluentSortingDotNet/Internal/SortableParameter.cs +++ b/src/FluentSortingDotNet/Internal/SortableParameter.cs @@ -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; } } \ No newline at end of file diff --git a/src/FluentSortingDotNet/Queries/DefaultSortQueryBuilder.cs b/src/FluentSortingDotNet/Queries/DefaultSortQueryBuilder.cs index e415bd4..55d45f4 100644 --- a/src/FluentSortingDotNet/Queries/DefaultSortQueryBuilder.cs +++ b/src/FluentSortingDotNet/Queries/DefaultSortQueryBuilder.cs @@ -1,8 +1,8 @@ -using System; +using FluentSortingDotNet.Internal; +using System; using System.Collections.Generic; using System.Linq; using System.Linq.Expressions; -using FluentSortingDotNet.Internal; namespace FluentSortingDotNet.Queries; @@ -10,9 +10,8 @@ namespace FluentSortingDotNet.Queries; /// Represents a class that can build a sort query. /// /// The type of items to sort. -public sealed class DefaultSortQueryBuilder : ISortQueryBuilder, ISortQuery +public sealed class DefaultSortQueryBuilder : ISortQueryBuilder { - private bool _built; private readonly List _sortExpressions = new(); /// @@ -28,50 +27,48 @@ public ISortQueryBuilder SortBy(LambdaExpression expression, SortDirection so /// public ISortQuery 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); } - /// - /// Thrown when the query has not been built. - public IQueryable Apply(IQueryable 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? orderedQuery = null; + private sealed class SortQuery(List sortExpressions) : ISortQuery + { + private readonly List _sortExpressions = sortExpressions; - foreach (SortExpression expression in _sortExpressions) + public IQueryable Apply(IQueryable query) { - if (orderedQuery == null) + IOrderedQueryable? 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; + } } } \ No newline at end of file diff --git a/src/FluentSortingDotNet/Queries/ExpressionSortQueryBuilder.cs b/src/FluentSortingDotNet/Queries/ExpressionSortQueryBuilder.cs index a8f71d8..a42e683 100644 --- a/src/FluentSortingDotNet/Queries/ExpressionSortQueryBuilder.cs +++ b/src/FluentSortingDotNet/Queries/ExpressionSortQueryBuilder.cs @@ -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; @@ -39,9 +39,12 @@ Expression CreateMethodCallExpression(Func methodProv public ISortQuery Build() { if (_sortExpression == null) + { throw new InvalidOperationException("No sorting expressions have been added."); + } - return new DelegateSortQuery(Expression.Lambda, IQueryable>>(_sortExpression, QueryParameter).Compile()); + var lambda = Expression.Lambda, IQueryable>>(_sortExpression, QueryParameter).Compile(); + return new DelegateSortQuery(lambda); } private static LambdaExpression ReplaceParameter(LambdaExpression original, ParameterExpression toReplace, ParameterExpression replacement) diff --git a/src/FluentSortingDotNet/SortBuilder.cs b/src/FluentSortingDotNet/SortBuilder.cs index d611cb2..d5a28f0 100644 --- a/src/FluentSortingDotNet/SortBuilder.cs +++ b/src/FluentSortingDotNet/SortBuilder.cs @@ -12,12 +12,14 @@ namespace FluentSortingDotNet; public sealed class SortBuilder { private readonly List _sortableParameters = new(); + private SorterOptions? _options; - internal SorterOptions? Options { get; private set; } + internal SorterOptions Options + => _options ??= SorterOptions.Default; internal SortBuilder(SorterOptions? options) { - Options = options; + _options = options; } /// @@ -26,11 +28,20 @@ internal SortBuilder(SorterOptions? options) /// The current builder instance. public SortBuilder IgnoreParameterCase() { - Options ??= new(); Options.ParameterNameComparer = StringComparer.OrdinalIgnoreCase; return this; } + /// + /// Ignores all invalid parameters when sorting instead of throwing an exception. + /// + /// The current builder instance. + public SortBuilder IgnoreInvalidParameters() + { + Options.IgnoreInvalidParameters = true; + return this; + } + /// /// Creates a new for the specified property. /// diff --git a/src/FluentSortingDotNet/SortContext.cs b/src/FluentSortingDotNet/SortContext.cs new file mode 100644 index 0000000..557e118 --- /dev/null +++ b/src/FluentSortingDotNet/SortContext.cs @@ -0,0 +1,38 @@ +using System; +using System.Collections.Generic; + +namespace FluentSortingDotNet; + +/// +/// Represents a context for sorting that contains valid and invalid sort parameters. +/// +/// The type of items to sort. +/// The valid sort parameters. +/// The invalid sort parameters. +public sealed class SortContext(IReadOnlyList? validParameters = null, IReadOnlyList? invalidParameters = null) +{ + /// + /// Represents an empty with no valid or invalid parameters. + /// + public static readonly SortContext Empty = new(); + + /// + /// Gets a value indicating whether the sort context is valid. + /// + public bool IsValid => InvalidParameters.Count == 0; + + /// + /// Gets a value indicating whether the sort context is empty. + /// + public bool IsEmpty => ValidParameters.Count == 0; + + /// + /// Gets the valid sort parameters. + /// + public IReadOnlyList ValidParameters { get; } = validParameters ?? Array.Empty(); + + /// + /// Gets the invalid sort parameters. + /// + public IReadOnlyList InvalidParameters { get; } = invalidParameters ?? Array.Empty(); +} \ No newline at end of file diff --git a/src/FluentSortingDotNet/SortParameterBuilder.cs b/src/FluentSortingDotNet/SortParameterBuilder.cs index 46d2bc6..a6075fe 100644 --- a/src/FluentSortingDotNet/SortParameterBuilder.cs +++ b/src/FluentSortingDotNet/SortParameterBuilder.cs @@ -1,5 +1,4 @@ using FluentSortingDotNet.Internal; -using System; namespace FluentSortingDotNet; @@ -15,18 +14,6 @@ internal SortParameterBuilder(SortableParameter parameter) _parameter = parameter; } - /// - /// Set the sort parameter as a default parameter when the sort query is empty. - /// - /// The sort direction of the default parameter. - /// The current builder instance. - [Obsolete("Use IsDefault instead.", error: false)] - public SortParameterBuilder Default(SortDirection sortDirection) - { - _parameter.DefaultDirection = sortDirection; - return this; - } - /// /// Set the sort parameter as a default parameter when the sort query is empty. /// @@ -39,25 +26,23 @@ public SortParameterBuilder IsDefault(SortDirection direction) } /// - /// Specifies a custom name of the parameter. + /// Set the name of the sort parameter. By default, the name is inferred from the property expression. /// /// The name of the parameter. /// The current builder instance. - [Obsolete("Use WithName instead.", error: false)] - public SortParameterBuilder Name(string name) + public SortParameterBuilder WithName(string name) { _parameter.Name = name; return this; } /// - /// Set the name of the sort parameter. By default, the name is inferred from the property expression. + /// Set the sort parameter to be reversed when sorting. /// - /// The name of the parameter. /// The current builder instance. - public SortParameterBuilder WithName(string name) + public SortParameterBuilder ReverseDirection() { - _parameter.Name = name; + _parameter.ShouldReverseDirection = true; return this; } } diff --git a/src/FluentSortingDotNet/SortResult.cs b/src/FluentSortingDotNet/SortResult.cs index bcb0e2a..7144139 100644 --- a/src/FluentSortingDotNet/SortResult.cs +++ b/src/FluentSortingDotNet/SortResult.cs @@ -1,4 +1,5 @@ using FluentSortingDotNet.Internal; +using System; using System.Collections.Generic; using System.Linq; @@ -7,6 +8,7 @@ namespace FluentSortingDotNet; /// /// Represents the result of a sort operation. /// +[Obsolete("This class is obsolete and will be removed in a future version. Use the new sorting API instead.", error: false)] public readonly struct SortResult { private static readonly IEnumerable EmptySortParameters = Enumerable.Empty(); diff --git a/src/FluentSortingDotNet/Sorter.cs b/src/FluentSortingDotNet/Sorter.cs index 6eb72cc..154e855 100644 --- a/src/FluentSortingDotNet/Sorter.cs +++ b/src/FluentSortingDotNet/Sorter.cs @@ -1,9 +1,10 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using FluentSortingDotNet.Internal; +using FluentSortingDotNet.Internal; using FluentSortingDotNet.Parsers; using FluentSortingDotNet.Queries; +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; #if NET8_0_OR_GREATER using System.Collections.Frozen; @@ -15,12 +16,13 @@ namespace FluentSortingDotNet; /// Represents a sorter that can sort a with a string based sort query. /// /// The type of items to sort. -public abstract class Sorter +public abstract class Sorter : ISorter { private readonly ISortParameterParser _parser; private readonly ISortQueryBuilderFactory _queryBuilderFactory; private readonly ISortQuery _defaultQuery; + private readonly SorterOptions _options; private readonly IDictionary _parameters; /// @@ -38,11 +40,11 @@ protected Sorter(ISortParameterParser parser, ISortQueryBuilderFactory sortQu var builder = new SortBuilder(options); Configure(builder); - options = builder.Options ?? SorterOptions.Default; + _options = builder.Options; List parameters = builder.Build(); - var parametersDictionary = new Dictionary(parameters.Count, options.ParameterNameComparer); + var parametersDictionary = new Dictionary(parameters.Count, _options.ParameterNameComparer); foreach (SortableParameter parameter in parameters) { @@ -67,13 +69,13 @@ protected Sorter(ISortParameterParser parser, ISortQueryBuilderFactory sortQu } #if NET8_0_OR_GREATER - _parameters = parametersDictionary.ToFrozenDictionary(); + _parameters = parametersDictionary.ToFrozenDictionary(_options.ParameterNameComparer); #else _parameters = parametersDictionary; #endif - _defaultQuery = defaultParameterSortQueryBuilder.IsEmpty - ? NullSortQuery.Instance + _defaultQuery = defaultParameterSortQueryBuilder.IsEmpty + ? NullSortQuery.Instance : defaultParameterSortQueryBuilder.Build(); static InvalidOperationException ParameterAlreadyExists(string name) @@ -115,11 +117,9 @@ protected Sorter(SorterOptions? options = null) : this(DefaultSortParameterParse /// /// The to sort. /// A that represents the result of the sorting operation. When is the refence of is updated with the sorted . + [Obsolete("This method is obsolete and will be removed in a future version. Use CreateSortQuery(SortContext) or extensions methods instead.", error: false)] public SortResult Sort(ref IQueryable query) - { - query = _defaultQuery.Apply(query); - return SortResult.Success(); - } + => Sort(ref query, ReadOnlySpan.Empty); /// /// Sorts the with the specified . If is or empty, the default sort parameters are used. @@ -127,6 +127,7 @@ public SortResult Sort(ref IQueryable query) /// The to sort. /// The string based sort query to use to sort the query or to use the default sort parameters. /// A that represents the result of the sorting operation. When is the refence of is updated with the sorted . + [Obsolete("This method is obsolete and will be removed in a future version. Use CreateSortQuery(SortContext) or extensions methods instead.", error: false)] public SortResult Sort(ref IQueryable query, string? sortQuery) { // when sortQuery is null, AsSpan() will return default(ReadOnlySpan) @@ -139,55 +140,84 @@ public SortResult Sort(ref IQueryable query, string? sortQuery) /// The to sort. /// The string based sort query to use to sort the query. /// A that represents the result of the sorting operation. When is the refence of is updated with the sorted . + [Obsolete("This method is obsolete and will be removed in a future version. Use CreateSortQuery(SortContext) or extensions methods instead.", error: false)] public SortResult Sort(ref IQueryable query, ReadOnlySpan sortQuerySpan) { - if (sortQuerySpan.IsEmpty) + var context = Validate(sortQuerySpan); + if (!_options.IgnoreInvalidParameters && !context.IsValid) { - return Sort(ref query); + return SortResult.Failure(context.InvalidParameters); } - ISortQueryBuilder queryBuilder = _queryBuilderFactory.Create(); + query = CreateSortQuery(context).Apply(query); + return SortResult.Success(); + } - while (_parser.TryGetNextParameter(ref sortQuerySpan, out ReadOnlySpan parameter)) + /// + /// The contains invalid parameters and is set to . + public ISortQuery CreateSortQuery(SortContext sortContext) + { + if (!_options.IgnoreInvalidParameters && !sortContext.IsValid) { - if (!_parser.TryParseParameter(parameter, out SortParameter sortParameter)) - { - return SortResult.Failure(GetInvalidParameters(parameter.ToString(), sortQuerySpan)); - } + throw new ArgumentException("The sort context contains invalid parameters.", nameof(sortContext)); + } + + if (sortContext.IsEmpty) + { + return _defaultQuery; + } + + ISortQueryBuilder queryBuilder = _queryBuilderFactory.Create(); + foreach (SortParameter sortParameter in sortContext.ValidParameters) + { if (!_parameters.TryGetValue(sortParameter.Name, out SortableParameter? sortableParameter)) { - return SortResult.Failure(GetInvalidParameters(sortParameter.Name, sortQuerySpan)); + Debug.Fail("This should never happen. The parameter should have been validated before."); + throw new InvalidOperationException($"The parameter '{sortParameter.Name}' is not valid."); } - queryBuilder.SortBy(sortableParameter.Expression, sortParameter.Direction); + queryBuilder.SortBy( + sortableParameter.Expression, + GetDirection(sortParameter.Direction, sortableParameter.ShouldReverseDirection)); } - if (queryBuilder.IsEmpty) + return queryBuilder.IsEmpty + ? _defaultQuery + : queryBuilder.Build(); + + static SortDirection GetDirection(SortDirection sortDirection, bool reverse) { - return Sort(ref query); + return reverse + ? sortDirection == SortDirection.Ascending ? SortDirection.Descending : SortDirection.Ascending + : sortDirection; } - - query = queryBuilder.Build().Apply(query); - return SortResult.Success(); } - private List GetInvalidParameters(string intialInvalidParameter, ReadOnlySpan sortQuerySpan) + /// + public SortContext Validate(ReadOnlySpan sortQuery) { - var invalidParameters = new List() { intialInvalidParameter }; + List validSortParameters = new(); + List? invalidSortParameters = null; - while (_parser.TryGetNextParameter(ref sortQuerySpan, out ReadOnlySpan parameter)) + while (_parser.TryGetNextParameter(ref sortQuery, out ReadOnlySpan parameter)) { if (!_parser.TryParseParameter(parameter, out SortParameter sortParameter)) { - invalidParameters.Add(parameter.ToString()); + invalidSortParameters ??= new(); + invalidSortParameters.Add(parameter.ToString()); } else if (!_parameters.ContainsKey(sortParameter.Name)) { - invalidParameters.Add(sortParameter.Name); + invalidSortParameters ??= new(); + invalidSortParameters.Add(sortParameter.Name); + } + else + { + validSortParameters.Add(sortParameter); } } - return invalidParameters; + return new SortContext(validSortParameters, invalidSortParameters); } } diff --git a/src/FluentSortingDotNet/SorterExtensions.cs b/src/FluentSortingDotNet/SorterExtensions.cs new file mode 100644 index 0000000..948f810 --- /dev/null +++ b/src/FluentSortingDotNet/SorterExtensions.cs @@ -0,0 +1,63 @@ +using System; +using System.Linq; + +namespace FluentSortingDotNet; + +/// +/// Extensions for the interface. +/// +public static class SorterExtensions +{ + /// + /// Sorts the specified query using the specified . + /// + /// The type of items to sort. + /// The sorter to use for sorting. + /// The query to sort. + /// The that contains the sort parameters. + /// A new query with the sorting applied. + public static IQueryable Sort(this ISorter sorter, IQueryable query, SortContext sortContext) + => sorter.CreateSortQuery(sortContext).Apply(query); + + /// + /// Sorts the specified query using the specified sort query. + /// + /// The type of items to sort. + /// The sorter to use for sorting. + /// The query to sort. + /// The sort query to use for sorting. + /// A new query with the sorting applied. + public static IQueryable Sort(this ISorter sorter, IQueryable query, ReadOnlySpan sortQuery) + => sorter.Sort(query, sorter.Validate(sortQuery)); + + /// + /// Sorts the specified query using the specified sort query. + /// + /// The type of items to sort. + /// The sorter to use for sorting. + /// The query to sort. + /// The sort query to use for sorting. + /// A new query with the sorting applied. + public static IQueryable Sort(this ISorter sorter, IQueryable query, string? sortQuery) + => sorter.Sort(query, sortQuery.AsSpan()); // when sortQuery is null, AsSpan() will return default(ReadOnlySpan) + + /// + /// Sorts the specified query using the default sort parameters. + /// + /// The type of items to sort. + /// The sorter to use for sorting. + /// The query to sort. + /// A new query with the default sorting applied. + public static IQueryable Sort(this ISorter sorter, IQueryable query) + => sorter.Sort(query, SortContext.Empty); + + /// + /// Validates the specified sort query and returns a that can be used to sort a query. + /// + /// The type of items to sort. + /// The sorter to use for validation. + /// The sort query to validate. + /// A new that contains the valid and invalid parameters. + public static SortContext Validate(this ISorter sorter, string? sortQuery) + => sorter.Validate(sortQuery.AsSpan()); // when sortQuery is null, AsSpan() will return default(ReadOnlySpan) +} diff --git a/src/FluentSortingDotNet/SorterOptions.cs b/src/FluentSortingDotNet/SorterOptions.cs index dbd0caa..7c8ee33 100644 --- a/src/FluentSortingDotNet/SorterOptions.cs +++ b/src/FluentSortingDotNet/SorterOptions.cs @@ -17,4 +17,9 @@ public sealed class SorterOptions /// Gets or sets the comparer to use to compare the parameter names. The default is . /// public IEqualityComparer ParameterNameComparer { get; set; } = StringComparer.Ordinal; -} + + /// + /// Gets or sets a value indicating whether to ignore invalid parameters. The default is . + /// + public bool IgnoreInvalidParameters { get; set; } = false; +} \ No newline at end of file diff --git a/tests/FluentSortingDotNet.Benchmarks/ParserBenchmarks.cs b/tests/FluentSortingDotNet.Benchmarks/ParserBenchmarks.cs index 44c04d4..20eefd1 100644 --- a/tests/FluentSortingDotNet.Benchmarks/ParserBenchmarks.cs +++ b/tests/FluentSortingDotNet.Benchmarks/ParserBenchmarks.cs @@ -2,13 +2,13 @@ using FluentSortingDotNet; using FluentSortingDotNet.Parsers; -[MemoryDiagnoser(false)] +[MemoryDiagnoser(false), MarkdownExporter] public class ParserBenchmarks { [Params("a", "-a,b", "a,b,-c,d,-e,-f,g")] - public string Query { get; set; } + public string Query { get; set; } = null!; - private readonly DefaultSortParameterParser DefaultParser = DefaultSortParameterParser.Instance; + private static readonly DefaultSortParameterParser DefaultParser = DefaultSortParameterParser.Instance; [Benchmark] public SortParameter ParseFirst() diff --git a/tests/FluentSortingDotNet.Benchmarks/Program.cs b/tests/FluentSortingDotNet.Benchmarks/Program.cs index 083bc04..e4d217f 100644 --- a/tests/FluentSortingDotNet.Benchmarks/Program.cs +++ b/tests/FluentSortingDotNet.Benchmarks/Program.cs @@ -1,4 +1,42 @@ using BenchmarkDotNet.Running; -//BenchmarkRunner.Run(); -BenchmarkRunner.Run(); \ No newline at end of file +var targets = GetBenchmarkTargets(args); + +if (targets.HasFlag(BenchmarkTargets.Parser)) +{ + BenchmarkRunner.Run(); +} + +if (targets.HasFlag(BenchmarkTargets.QueryBuilder)) +{ + BenchmarkRunner.Run(); +} + +static BenchmarkTargets GetBenchmarkTargets(string[] args) +{ + if (args.Length == 0) + { + return BenchmarkTargets.All; + } + + var targets = BenchmarkTargets.None; + + foreach (var arg in args) + { + if (Enum.TryParse(arg, ignoreCase: true, out var target)) + { + targets |= target; + } + } + + return targets; +} + +[Flags] +public enum BenchmarkTargets +{ + None = 0, + Parser = 1, + QueryBuilder = 2, + All = Parser | QueryBuilder +} \ No newline at end of file diff --git a/tests/FluentSortingDotNet.Benchmarks/Properties/launchSettings.json b/tests/FluentSortingDotNet.Benchmarks/Properties/launchSettings.json new file mode 100644 index 0000000..0d4ecfb --- /dev/null +++ b/tests/FluentSortingDotNet.Benchmarks/Properties/launchSettings.json @@ -0,0 +1,8 @@ +{ + "profiles": { + "FluentSortingDotNet.Benchmarks": { + "commandName": "Project", + "commandLineArgs": "All" + } + } +} \ No newline at end of file diff --git a/tests/FluentSortingDotNet.Benchmarks/QueryBuilderBenchmarks.cs b/tests/FluentSortingDotNet.Benchmarks/QueryBuilderBenchmarks.cs index b6f31a5..3c794e2 100644 --- a/tests/FluentSortingDotNet.Benchmarks/QueryBuilderBenchmarks.cs +++ b/tests/FluentSortingDotNet.Benchmarks/QueryBuilderBenchmarks.cs @@ -4,10 +4,10 @@ using FluentSortingDotNet.Testing; using System.Linq.Expressions; -[MemoryDiagnoser(false)] +[MemoryDiagnoser(false), MarkdownExporter] public class QueryBuilderBenchmarks { - public static readonly IQueryable People = Person.Faker.UseSeed(2024).Generate(10).AsQueryable(); + public static readonly IQueryable People = PersonGenerator.Instance.UseSeed(2024).Generate(10).AsQueryable(); public static readonly LambdaExpression NameExpression = (Expression>)(p => p.Name); public static readonly LambdaExpression AgeExpression = (Expression>)(p => p.Age); diff --git a/tests/FluentSortingDotNet.Benchmarks/parser-1.0.0-rc.3.png b/tests/FluentSortingDotNet.Benchmarks/parser-1.0.0-rc.3.png deleted file mode 100644 index 7fa01eb..0000000 Binary files a/tests/FluentSortingDotNet.Benchmarks/parser-1.0.0-rc.3.png and /dev/null differ diff --git a/tests/FluentSortingDotNet.Benchmarks/query-builder-1.0.0-rc.3.png b/tests/FluentSortingDotNet.Benchmarks/query-builder-1.0.0-rc.3.png deleted file mode 100644 index cb6fed5..0000000 Binary files a/tests/FluentSortingDotNet.Benchmarks/query-builder-1.0.0-rc.3.png and /dev/null differ diff --git a/tests/FluentSortingDotNet.ConsoleTest/FluentSortingDotNet.ConsoleTest.csproj b/tests/FluentSortingDotNet.ConsoleTest/FluentSortingDotNet.ConsoleTest.csproj deleted file mode 100644 index bd5ce65..0000000 --- a/tests/FluentSortingDotNet.ConsoleTest/FluentSortingDotNet.ConsoleTest.csproj +++ /dev/null @@ -1,15 +0,0 @@ - - - - Exe - net9.0 - enable - enable - - - - - - - - diff --git a/tests/FluentSortingDotNet.ConsoleTest/Program.cs b/tests/FluentSortingDotNet.ConsoleTest/Program.cs deleted file mode 100644 index 5ca0db5..0000000 --- a/tests/FluentSortingDotNet.ConsoleTest/Program.cs +++ /dev/null @@ -1,28 +0,0 @@ -using FluentSortingDotNet; -using FluentSortingDotNet.Testing; - -List people = Person.Faker.UseSeed(2024).Generate(10); -var sorter = new PersonSorter(); - -Console.WriteLine("Type 'exit' to quit."); - -string query; -while ((query = GetInput("Enter sort query: ")) != "exit") -{ - IQueryable queryable = people.AsQueryable(); - SortResult result = sorter.Sort(ref queryable, query); - if (result.IsSuccess) - { - Console.WriteLine(string.Join(", ", queryable)); - } - else - { - Console.WriteLine($"Invalid sort parameters: {string.Join(", ", result.InvalidSortParameters)}"); - } -} - -static string GetInput(string prompt) -{ - Console.Write(prompt); - return Console.ReadLine() ?? string.Empty; -} \ No newline at end of file diff --git a/tests/FluentSortingDotNet.Testing/FluentSortingDotNet.Testing.csproj b/tests/FluentSortingDotNet.Testing/FluentSortingDotNet.Testing.csproj index 4a4a349..641cc99 100644 --- a/tests/FluentSortingDotNet.Testing/FluentSortingDotNet.Testing.csproj +++ b/tests/FluentSortingDotNet.Testing/FluentSortingDotNet.Testing.csproj @@ -8,6 +8,7 @@ + diff --git a/tests/FluentSortingDotNet.Testing/Person.cs b/tests/FluentSortingDotNet.Testing/Person.cs index 5d0c154..387275e 100644 --- a/tests/FluentSortingDotNet.Testing/Person.cs +++ b/tests/FluentSortingDotNet.Testing/Person.cs @@ -1,15 +1,22 @@ -using Bogus; +using System.Diagnostics; namespace FluentSortingDotNet.Testing; +[DebuggerDisplay("{Name} ({Age})")] public sealed class Person { - public static readonly Faker Faker = new Faker() - .RuleFor(p => p.Name, f => f.Person.FullName) - .RuleFor(p => p.Age, f => f.Random.Int(18, 65)); + public Person() + { + } - public string Name { get; set; } = string.Empty; - public int Age { get; set; } + public Person(string name, DateTimeOffset dateOfBirth) + { + Name = name; + DateOfBirth = dateOfBirth; + } - public override string ToString() => $"{Name} ({Age})"; + public Guid Id { get; set; } = Guid.NewGuid(); + public string Name { get; set; } = string.Empty; + public DateTimeOffset DateOfBirth { get; set; } + public int Age => DateTimeOffset.UtcNow.Year - DateOfBirth.Year; } \ No newline at end of file diff --git a/tests/FluentSortingDotNet.Testing/PersonDbContext.cs b/tests/FluentSortingDotNet.Testing/PersonDbContext.cs new file mode 100644 index 0000000..cdf32ef --- /dev/null +++ b/tests/FluentSortingDotNet.Testing/PersonDbContext.cs @@ -0,0 +1,15 @@ +using Microsoft.EntityFrameworkCore; + +namespace FluentSortingDotNet.Testing; + +public sealed class PersonDbContext(DbContextOptions options) : DbContext(options) +{ + public DbSet People { get; set; } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity().HasKey(p => p.Id); + modelBuilder.Entity().Property(p => p.Name).IsRequired(); + modelBuilder.Entity().Property(p => p.DateOfBirth).IsRequired(); + } +} \ No newline at end of file diff --git a/tests/FluentSortingDotNet.Testing/PersonGenerator.cs b/tests/FluentSortingDotNet.Testing/PersonGenerator.cs new file mode 100644 index 0000000..be693a9 --- /dev/null +++ b/tests/FluentSortingDotNet.Testing/PersonGenerator.cs @@ -0,0 +1,14 @@ +using Bogus; + +namespace FluentSortingDotNet.Testing; + +public sealed class PersonGenerator : Faker +{ + public static readonly PersonGenerator Instance = new(); + + private PersonGenerator() + { + RuleFor(p => p.Name, f => f.Person.FullName); + RuleFor(p => p.DateOfBirth, f => f.Person.DateOfBirth); + } +} \ No newline at end of file diff --git a/tests/FluentSortingDotNet.Testing/PersonSorter.cs b/tests/FluentSortingDotNet.Testing/PersonSorter.cs index ec76fa8..b39ac42 100644 --- a/tests/FluentSortingDotNet.Testing/PersonSorter.cs +++ b/tests/FluentSortingDotNet.Testing/PersonSorter.cs @@ -3,16 +3,20 @@ namespace FluentSortingDotNet.Testing; -public sealed class PersonSorter(ISortParameterParser? parser = null, ISortQueryBuilderFactory? sortQueryBuilderBuilder = null, ISortQueryBuilder? defaultParameterSortQueryBuilder = null) - : Sorter(GetParser(parser), GetSortQueryBuilderFactory(sortQueryBuilderBuilder), GetDefaultParameterSortQueryBuilder(defaultParameterSortQueryBuilder)) +public sealed class PersonSorter( + ISortParameterParser? parser = null, + ISortQueryBuilderFactory? sortQueryBuilderFactory = null, + ISortQueryBuilder? defaultParameterSortQueryBuilder = null, + SorterOptions? options = null) + : Sorter( + parser ?? DefaultSortParameterParser.Instance, + sortQueryBuilderFactory ?? DefaultSortQueryBuilderFactory.Instance, + defaultParameterSortQueryBuilder ?? new ExpressionSortQueryBuilder(), + options) { protected override void Configure(SortBuilder builder) { - builder.ForParameter(p => p.Name, "name").IsDefault(SortDirection.Descending); - builder.ForParameter(p => p.Age, "age"); + builder.ForParameter(p => p.Name).IsDefault(SortDirection.Descending); + builder.ForParameter(p => p.DateOfBirth, "Age").ReverseDirection(); } - - private static ISortParameterParser GetParser(ISortParameterParser? parser) => parser ?? DefaultSortParameterParser.Instance; - private static ISortQueryBuilderFactory GetSortQueryBuilderFactory(ISortQueryBuilderFactory? sortQueryBuilderFactory) => sortQueryBuilderFactory ?? DefaultSortQueryBuilderFactory.Instance; - private static ISortQueryBuilder GetDefaultParameterSortQueryBuilder(ISortQueryBuilder? defaultParameterSortQueryBuilder) => defaultParameterSortQueryBuilder ?? new ExpressionSortQueryBuilder(); } \ No newline at end of file diff --git a/tests/FluentSortingDotNet.UnitTests/FluentSortingDotNet.UnitTests.csproj b/tests/FluentSortingDotNet.Tests/FluentSortingDotNet.Tests.csproj similarity index 91% rename from tests/FluentSortingDotNet.UnitTests/FluentSortingDotNet.UnitTests.csproj rename to tests/FluentSortingDotNet.Tests/FluentSortingDotNet.Tests.csproj index 0d9ca50..ca60abd 100644 --- a/tests/FluentSortingDotNet.UnitTests/FluentSortingDotNet.UnitTests.csproj +++ b/tests/FluentSortingDotNet.Tests/FluentSortingDotNet.Tests.csproj @@ -9,8 +9,9 @@ - + + diff --git a/tests/FluentSortingDotNet.UnitTests/Parser/DefaultSortParameterParserTests.cs b/tests/FluentSortingDotNet.Tests/Parsers/DefaultSortParameterParserTests.cs similarity index 98% rename from tests/FluentSortingDotNet.UnitTests/Parser/DefaultSortParameterParserTests.cs rename to tests/FluentSortingDotNet.Tests/Parsers/DefaultSortParameterParserTests.cs index e839961..ee0763a 100644 --- a/tests/FluentSortingDotNet.UnitTests/Parser/DefaultSortParameterParserTests.cs +++ b/tests/FluentSortingDotNet.Tests/Parsers/DefaultSortParameterParserTests.cs @@ -1,6 +1,6 @@ using FluentSortingDotNet.Parsers; -namespace FluentSortingDotNet.UnitTests.Parser; +namespace FluentSortingDotNet.Tests.Parsers; public class DefaultSortParameterParserTests { diff --git a/tests/FluentSortingDotNet.Tests/Queries/DefaultSortQueryBuilderTests.cs b/tests/FluentSortingDotNet.Tests/Queries/DefaultSortQueryBuilderTests.cs new file mode 100644 index 0000000..03a5406 --- /dev/null +++ b/tests/FluentSortingDotNet.Tests/Queries/DefaultSortQueryBuilderTests.cs @@ -0,0 +1,10 @@ +using FluentSortingDotNet.Queries; +using FluentSortingDotNet.Testing; + +namespace FluentSortingDotNet.Tests.Queries; + +public class DefaultSortQueryBuilderTests : SortQueryBuilderTests +{ + protected override ISortQueryBuilder CreateSortQueryBuilder() + => new DefaultSortQueryBuilder(); +} \ No newline at end of file diff --git a/tests/FluentSortingDotNet.Tests/Queries/DefaultSortQueryBuilder_SortQueryTests.cs b/tests/FluentSortingDotNet.Tests/Queries/DefaultSortQueryBuilder_SortQueryTests.cs new file mode 100644 index 0000000..e16b491 --- /dev/null +++ b/tests/FluentSortingDotNet.Tests/Queries/DefaultSortQueryBuilder_SortQueryTests.cs @@ -0,0 +1,10 @@ +using FluentSortingDotNet.Queries; +using FluentSortingDotNet.Testing; + +namespace FluentSortingDotNet.Tests.Queries; + +public class DefaultSortQueryBuilder_SortQueryTests : SortQueryBuilder_SortQueryTest +{ + protected override ISortQueryBuilder CreateSortQueryBuilder() + => new DefaultSortQueryBuilder(); +} \ No newline at end of file diff --git a/tests/FluentSortingDotNet.Tests/Queries/ExpressionSortQueryBuilderTests.cs b/tests/FluentSortingDotNet.Tests/Queries/ExpressionSortQueryBuilderTests.cs new file mode 100644 index 0000000..79234d0 --- /dev/null +++ b/tests/FluentSortingDotNet.Tests/Queries/ExpressionSortQueryBuilderTests.cs @@ -0,0 +1,10 @@ +using FluentSortingDotNet.Queries; +using FluentSortingDotNet.Testing; + +namespace FluentSortingDotNet.Tests.Queries; + +public class ExpressionSortQueryBuilderTests : SortQueryBuilderTests +{ + protected override ISortQueryBuilder CreateSortQueryBuilder() + => new ExpressionSortQueryBuilder(); +} \ No newline at end of file diff --git a/tests/FluentSortingDotNet.Tests/Queries/ExpressionSortQueryBuilder_SortQueryTests.cs b/tests/FluentSortingDotNet.Tests/Queries/ExpressionSortQueryBuilder_SortQueryTests.cs new file mode 100644 index 0000000..58d97cc --- /dev/null +++ b/tests/FluentSortingDotNet.Tests/Queries/ExpressionSortQueryBuilder_SortQueryTests.cs @@ -0,0 +1,10 @@ +using FluentSortingDotNet.Queries; +using FluentSortingDotNet.Testing; + +namespace FluentSortingDotNet.Tests.Queries; + +public class ExpressionSortQueryBuilder_SortQueryTests : SortQueryBuilder_SortQueryTest +{ + protected override ISortQueryBuilder CreateSortQueryBuilder() + => new ExpressionSortQueryBuilder(); +} \ No newline at end of file diff --git a/tests/FluentSortingDotNet.Tests/Queries/SortQueryBuilderTests.cs b/tests/FluentSortingDotNet.Tests/Queries/SortQueryBuilderTests.cs new file mode 100644 index 0000000..b37fdd8 --- /dev/null +++ b/tests/FluentSortingDotNet.Tests/Queries/SortQueryBuilderTests.cs @@ -0,0 +1,69 @@ +using FluentSortingDotNet.Queries; +using FluentSortingDotNet.Testing; +using System.Linq.Expressions; + +namespace FluentSortingDotNet.Tests.Queries; + +public abstract class SortQueryBuilderTests +{ + protected abstract ISortQueryBuilder CreateSortQueryBuilder(); + + [Fact] + public void IsEmpty_ReturnsTrue_WhenUnsorted() + { + // Arrange + var queryBuilder = CreateSortQueryBuilder(); + + // Act + bool result = queryBuilder.IsEmpty; + + // Assert + Assert.True(result); + } + + [Fact] + public void IsEmpty_ReturnFalse_WhenSorted() + { + // Arrange + var queryBuilder = CreateSortQueryBuilder(); + Expression> lambdaExpression = x => x.Name; + queryBuilder.SortBy(lambdaExpression, SortDirection.Ascending); + + // Act + bool result = queryBuilder.IsEmpty; + + // Assert + Assert.False(result); + } + + [Fact] + public void Build_ThrowsInvalidOperationException_WhenUnsorted() + { + // Arrange + var queryBuilder = CreateSortQueryBuilder(); + + // Act + Action act = () => queryBuilder.Build(); + + // Assert + Assert.True(queryBuilder.IsEmpty); + Assert.Throws(act); + } + + [Fact] + public void Build_ReturnsSortQuery_WhenSorted() + { + // Arrange + var queryBuilder = CreateSortQueryBuilder(); + Expression> lambdaExpression = x => x.Name; + queryBuilder.SortBy(lambdaExpression, SortDirection.Ascending); + + // Act + var result = queryBuilder.Build(); + + // Assert + Assert.False(queryBuilder.IsEmpty); + Assert.NotNull(result); + Assert.IsAssignableFrom>(result); + } +} \ No newline at end of file diff --git a/tests/FluentSortingDotNet.Tests/Queries/SortQueryBuilder_SortQueryTest.cs b/tests/FluentSortingDotNet.Tests/Queries/SortQueryBuilder_SortQueryTest.cs new file mode 100644 index 0000000..71c780d --- /dev/null +++ b/tests/FluentSortingDotNet.Tests/Queries/SortQueryBuilder_SortQueryTest.cs @@ -0,0 +1,97 @@ +using FluentSortingDotNet.Queries; +using FluentSortingDotNet.Testing; +using Microsoft.EntityFrameworkCore; +using System.Linq.Expressions; + +namespace FluentSortingDotNet.Tests.Queries; + +public abstract class SortQueryBuilder_SortQueryTest +{ + private readonly PersonDbContext _dbContext; + + public SortQueryBuilder_SortQueryTest() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + + _dbContext = new PersonDbContext(options); + } + + protected abstract ISortQueryBuilder CreateSortQueryBuilder(); + + [Theory] + [InlineData(1)] + [InlineData(3)] + [InlineData(5)] + [InlineData(10)] + public void Apply_ReturnsOrderedQuery_WhenSingleSortExpressionHaveBeenAdded(int count) + { + // Arrange + var queryBuilder = CreateSortQueryBuilder(); + Expression> lambdaExpression = x => x.Name; + queryBuilder.SortBy(lambdaExpression, SortDirection.Ascending); + + var people = PersonGenerator.Instance.UseSeed(2025).Generate(count); + + _dbContext.Database.EnsureDeleted(); + _dbContext.Database.EnsureCreated(); + _dbContext.People.AddRange(people); + _dbContext.SaveChanges(); + + var sortQuery = queryBuilder.Build(); + + // Act + var result = sortQuery.Apply(_dbContext.People.AsQueryable()).ToList(); + + // Assert + var expected = people.OrderBy(x => x.Name).ToList(); + + Assert.NotNull(result); + Assert.Equal(count, result.Count); + + for (int i = 0; i < count; i++) + { + Assert.Equal(expected[i].Name, result[i].Name); + } + } + + [Fact] + public void Apply_ReturnsOrderedQuery_WhenMultipleSortExpressionsHaveBeenAdded() + { + // Arrange + var queryBuilder = CreateSortQueryBuilder(); + Expression> lambdaExpression1 = x => x.Name; + Expression> lambdaExpression2 = x => x.DateOfBirth; + queryBuilder.SortBy(lambdaExpression1, SortDirection.Ascending); + queryBuilder.SortBy(lambdaExpression2, SortDirection.Descending); + + List people = [ + new Person("John", new DateTimeOffset(1990, 1, 1, 0, 0, 0, TimeSpan.Zero)), + new Person("John", new DateTimeOffset(1995, 1, 1, 0, 0, 0, TimeSpan.Zero)), + new Person("Alice", new DateTimeOffset(1992, 1, 1, 0, 0, 0, TimeSpan.Zero)), + ]; + + _dbContext.Database.EnsureDeleted(); + _dbContext.Database.EnsureCreated(); + _dbContext.People.AddRange(people); + _dbContext.SaveChanges(); + + var sortQuery = queryBuilder.Build(); + + // Act + var result = sortQuery.Apply(_dbContext.People.AsQueryable()).ToList(); + + // Assert + var expected = people.OrderBy(x => x.Name).ThenByDescending(x => x.DateOfBirth).ToList(); + + Assert.NotNull(result); + Assert.Equal(3, result.Count); + Assert.Equal(expected[0].Name, result[0].Name); + Assert.Equal(expected[0].DateOfBirth, result[0].DateOfBirth); + Assert.Equal(expected[1].Name, result[1].Name); + Assert.Equal(expected[1].DateOfBirth, result[1].DateOfBirth); + Assert.Equal(expected[2].Name, result[2].Name); + Assert.Equal(expected[2].DateOfBirth, result[2].DateOfBirth); + } +} \ No newline at end of file diff --git a/tests/FluentSortingDotNet.Tests/SorterTests.cs b/tests/FluentSortingDotNet.Tests/SorterTests.cs new file mode 100644 index 0000000..9348cf5 --- /dev/null +++ b/tests/FluentSortingDotNet.Tests/SorterTests.cs @@ -0,0 +1,251 @@ +using FluentSortingDotNet.Parsers; +using FluentSortingDotNet.Queries; +using FluentSortingDotNet.Testing; +using NSubstitute; +using System.Linq.Expressions; + +namespace FluentSortingDotNet.Tests; + +public class SorterTests +{ + private static SorterOptions CreateOptions(bool ignoreInvalidParameters = false, IEqualityComparer? parameterNameComparer = null) + => new() { IgnoreInvalidParameters = ignoreInvalidParameters, ParameterNameComparer = parameterNameComparer ?? StringComparer.Ordinal }; + + private static PersonSorter CreateSorter(ISortParameterParser? parser = null, ISortQueryBuilderFactory? sortQueryBuilderFactory = null, ISortQueryBuilder? defaultParameterSortQueryBuilder = null, SorterOptions? options = null) + => new(parser, sortQueryBuilderFactory, defaultParameterSortQueryBuilder, options); + + [Fact] + public void CreateSortQuery_ShouldThrowArgumentException_WhenSortContextIsInvalid() + { + // Arrange + SorterOptions options = CreateOptions(ignoreInvalidParameters: false); + Sorter sorter = CreateSorter(options: options); + SortContext sortContext = new(Array.Empty(), ["invalid_parameter"]); + + // Act + Action act = () => sorter.CreateSortQuery(sortContext); + + // Assert + Assert.Throws(act); + } + + [Fact] + public void CreateSortQuery_ShouldNotThrow_WhenIgnoreInvalidParametersIsTrue() + { + // Arrange + ISortQuery defaultSortQuery = Substitute.For>(); + ISortQueryBuilder sortQueryBuilder = Substitute.For>(); + sortQueryBuilder.IsEmpty.Returns(false); + sortQueryBuilder.Build().Returns(defaultSortQuery); + + SorterOptions options = CreateOptions(ignoreInvalidParameters: true); + + Sorter sorter = CreateSorter( + defaultParameterSortQueryBuilder: sortQueryBuilder, + options: options); + + SortContext sortContext = new(Array.Empty(), ["invalid_parameter"]); + + // Act + var sortQuery = sorter.CreateSortQuery(sortContext); + + // Assert + Assert.NotNull(sortQuery); + Assert.Same(defaultSortQuery, sortQuery); + } + + [Fact] + public void CreateSortQuery_ShouldReturnDefaultSortQuery_WhenSortContextIsEmpty() + { + // Arrange + ISortQuery defaultSortQuery = Substitute.For>(); + ISortQueryBuilder sortQueryBuilder = Substitute.For>(); + sortQueryBuilder.IsEmpty.Returns(false); + sortQueryBuilder.Build().Returns(defaultSortQuery); + + SorterOptions options = CreateOptions(ignoreInvalidParameters: false); + + Sorter sorter = CreateSorter( + defaultParameterSortQueryBuilder: sortQueryBuilder, + options: options); + + // Act + var sortQuery = sorter.CreateSortQuery(SortContext.Empty); + + // Assert + Assert.NotNull(sortQuery); + Assert.Same(defaultSortQuery, sortQuery); + } + + [Fact] + public void CreateSortQuery_ShouldReturnDefaultSortQuery_WhenQueryBuilderIsEmpty() + { + // Arrange + ISortQueryBuilder queryBuilder = Substitute.For>(); + queryBuilder.IsEmpty.Returns(true); + + ISortQueryBuilderFactory queryBuilderFactory = Substitute.For>(); + queryBuilderFactory.Create().Returns(queryBuilder); + + ISortQuery defaultQuery = Substitute.For>(); + ISortQueryBuilder defaultQueryBuilder = Substitute.For>(); + defaultQueryBuilder.IsEmpty.Returns(false); + defaultQueryBuilder.Build().Returns(defaultQuery); + + SorterOptions options = CreateOptions(parameterNameComparer: StringComparer.OrdinalIgnoreCase); + + Sorter sorter = CreateSorter( + sortQueryBuilderFactory: queryBuilderFactory, + defaultParameterSortQueryBuilder: defaultQueryBuilder, + options: options); + + SortContext sortContext = new([new SortParameter(nameof(Person.Name), SortDirection.Ascending)], Array.Empty()); + + // Act + var sortQuery = sorter.CreateSortQuery(sortContext); + + // Assert + Assert.NotNull(sortQuery); + Assert.Same(defaultQuery, sortQuery); + } + + [Fact] + public void CreateSortQuery_ShouldBuildQuery_WhenSortContextIsValid() + { + // Arrange + ISortQuery query = Substitute.For>(); + ISortQueryBuilder queryBuilder = Substitute.For>(); + queryBuilder.IsEmpty.Returns(false); + queryBuilder.Build().Returns(query); + + ISortQueryBuilderFactory queryBuilderFactory = Substitute.For>(); + queryBuilderFactory.Create().Returns(queryBuilder); + + SorterOptions options = CreateOptions(parameterNameComparer: StringComparer.OrdinalIgnoreCase); + + Sorter sorter = CreateSorter( + sortQueryBuilderFactory: queryBuilderFactory, + options: options); + + SortContext sortContext = new([new SortParameter(nameof(Person.Name), SortDirection.Ascending)], Array.Empty()); + + // Act + var sortQuery = sorter.CreateSortQuery(sortContext); + + // Assert + Assert.NotNull(sortQuery); + Assert.Same(query, sortQuery); + } + + [Fact] + public void CreateSortQuery_ShouldReverseSortDirection_WhenSortableParameterShouldReverseDirectionIsTrue() + { + // Arrange + ISortQuery query = Substitute.For>(); + ISortQueryBuilder queryBuilder = Substitute.For>(); + queryBuilder.IsEmpty.Returns(false); + queryBuilder.Build().Returns(query); + + ISortQueryBuilderFactory queryBuilderFactory = Substitute.For>(); + queryBuilderFactory.Create().Returns(queryBuilder); + + Sorter sorter = CreateSorter( + sortQueryBuilderFactory: queryBuilderFactory); + + SortContext sortContext = new([new SortParameter("Age", SortDirection.Descending)], Array.Empty()); + + // Act + var sortQuery = sorter.CreateSortQuery(sortContext); + + // Assert + Assert.NotNull(sortQuery); + Assert.Same(query, sortQuery); + queryBuilder.Received(1).SortBy(Arg.Is(x => x.ToString() == "p => p.DateOfBirth"), SortDirection.Ascending); + } + + [Fact] + public void Validate_ShouldReturnEmptyContext_WhenNoParametersProvided() + { + // Arrange + var query = "".AsSpan(); + Sorter sorter = CreateSorter(); + + // Act + var sortContext = sorter.Validate(query); + + // Assert + Assert.True(sortContext.IsEmpty); + Assert.True(sortContext.IsValid); + Assert.Empty(sortContext.InvalidParameters); + Assert.Empty(sortContext.ValidParameters); + } + + [Fact] + public void Validate_ShouldReturnInvalidContext_WhenUnparsableParametersProvided() + { + // Arrange + var query = "-"; + Sorter sorter = CreateSorter(); + + // Act + var sortContext = sorter.Validate(query.AsSpan()); + + // Assert + Assert.True(sortContext.IsEmpty); + Assert.False(sortContext.IsValid); + Assert.Single(sortContext.InvalidParameters); + Assert.Equal(query, sortContext.InvalidParameters[0]); + } + + [Fact] + public void Validate_ShouldReturnInvalidContext_WhenNonExistentParametersProvided() + { + // Arrange + var query = "invalid_parameter"; + Sorter sorter = CreateSorter(); + + // Act + var sortContext = sorter.Validate(query.AsSpan()); + + // Assert + Assert.True(sortContext.IsEmpty); + Assert.False(sortContext.IsValid); + Assert.Single(sortContext.InvalidParameters); + Assert.Equal(query, sortContext.InvalidParameters[0]); + } + + [Fact] + public void Validate_ShouldReturnValidContext_WhenValidParametersProvided() + { + // Arrange + var query = "Name,Age"; + Sorter sorter = CreateSorter(); + + // Act + var sortContext = sorter.Validate(query.AsSpan()); + + // Assert + Assert.False(sortContext.IsEmpty); + Assert.True(sortContext.IsValid); + Assert.Empty(sortContext.InvalidParameters); + Assert.Equal(2, sortContext.ValidParameters.Count); + } + + [Fact] + public void Validate_ShouldReturnValidContext_WhenValidParametersProvidedAndIgnoreCase() + { + // Arrange + var query = "nAmE,aGe"; + SorterOptions options = CreateOptions(parameterNameComparer: StringComparer.OrdinalIgnoreCase); + Sorter sorter = CreateSorter(options: options); + + // Act + var sortContext = sorter.Validate(query.AsSpan()); + + // Assert + Assert.False(sortContext.IsEmpty); + Assert.True(sortContext.IsValid); + Assert.Empty(sortContext.InvalidParameters); + Assert.Equal(2, sortContext.ValidParameters.Count); + } +} \ No newline at end of file diff --git a/tests/FluentSortingDotNet.UnitTests/Queries/DefaultSortQueryBuilderTests.cs b/tests/FluentSortingDotNet.UnitTests/Queries/DefaultSortQueryBuilderTests.cs deleted file mode 100644 index d8c3fc9..0000000 --- a/tests/FluentSortingDotNet.UnitTests/Queries/DefaultSortQueryBuilderTests.cs +++ /dev/null @@ -1,53 +0,0 @@ -using FluentSortingDotNet.Queries; -using FluentSortingDotNet.Testing; - -namespace FluentSortingDotNet.UnitTests.Queries; - -public class DefaultSortQueryBuilderTests -{ - [Fact] - public void Build_WhenNoSortExpressionsHaveBeenAdded_ThrowsInvalidOperationException() - { - // Arrange - var queryBuilder = new DefaultSortQueryBuilder(); - - // Act - Action act = () => queryBuilder.Build(); - - // Assert - Assert.Throws(act); - } - - [Fact] - public void Apply_WhenNotBuilt_ThrowsInvalidOperationException() - { - // Arrange - var query = Enumerable.Empty().AsQueryable(); - var queryBuilder = new DefaultSortQueryBuilder(); - - // Act - Action act = () => queryBuilder.Apply(query); - - // Assert - Assert.Throws(act); - } - - [Fact] - public void Apply_WhenSortExpressionsHaveBeenAdded_ReturnsOrderedQuery() - { - // Arrange - Person[] data = [new Person { Age = 18, Name = "Alice" }, new Person { Age = 25, Name = "Bob" }, new Person { Age = 30, Name = "Charlie" }]; - IQueryable query = data.AsQueryable(); - var queryBuilder = new DefaultSortQueryBuilder(); - queryBuilder.SortBy((Person p) => p.Name, SortDirection.Descending); - - // Act - var orderedQuery = queryBuilder.Build().Apply(query); - - // Assert - var result = orderedQuery.ToList(); - Assert.Equal("Charlie", result[0].Name); - Assert.Equal("Bob", result[1].Name); - Assert.Equal("Alice", result[2].Name); - } -} diff --git a/tests/FluentSortingDotNet.UnitTests/Queries/ExpressionSortQueryBuilderTests.cs b/tests/FluentSortingDotNet.UnitTests/Queries/ExpressionSortQueryBuilderTests.cs deleted file mode 100644 index 214b8d5..0000000 --- a/tests/FluentSortingDotNet.UnitTests/Queries/ExpressionSortQueryBuilderTests.cs +++ /dev/null @@ -1,40 +0,0 @@ -using FluentSortingDotNet.Queries; -using FluentSortingDotNet.Testing; - -namespace FluentSortingDotNet.UnitTests.Queries; - -public class ExpressionSortQueryBuilderTests -{ - [Fact] - public void Build_WhenNoSortExpressionsHaveBeenAdded_ThrowsInvalidOperationException() - { - // Arrange - var queryBuilder = new ExpressionSortQueryBuilder(); - - // Act - Action act = () => queryBuilder.Build(); - - // Assert - Assert.Throws(act); - } - - [Fact] - public void Apply_WhenSortExpressionsHaveBeenAdded_ReturnsOrderedQuery() - { - // Arrange - Person[] data = [new Person { Age = 18, Name = "Alice" }, new Person { Age = 25, Name = "Bob" }, new Person { Age = 30, Name = "Charlie" }]; - IQueryable query = data.AsQueryable(); - var queryBuilder = new ExpressionSortQueryBuilder(); - queryBuilder.SortBy((Person p) => p.Name, SortDirection.Descending); - var querySorter = queryBuilder.Build(); - - // Act - var orderedQuery = querySorter.Apply(query); - - // Assert - var result = orderedQuery.ToList(); - Assert.Equal("Charlie", result[0].Name); - Assert.Equal("Bob", result[1].Name); - Assert.Equal("Alice", result[2].Name); - } -} diff --git a/tests/FluentSortingDotNet.UnitTests/SorterTests.cs b/tests/FluentSortingDotNet.UnitTests/SorterTests.cs deleted file mode 100644 index 285bad9..0000000 --- a/tests/FluentSortingDotNet.UnitTests/SorterTests.cs +++ /dev/null @@ -1,84 +0,0 @@ -using FluentSortingDotNet.Testing; -using Microsoft.EntityFrameworkCore; - -namespace FluentSortingDotNet.UnitTests; - -public class SorterTests -{ - private sealed class PeopleContext(DbContextOptions options) : DbContext(options) - { - public DbSet People { get; set; } = null!; - - protected override void OnModelCreating(ModelBuilder modelBuilder) - { - modelBuilder.Entity().HasKey(p => p.Name); - } - } - - private readonly PeopleContext _dbContext; - private readonly List _people; - private readonly PersonSorter _sorter = new(); - - public SorterTests() - { - DbContextOptions options = new DbContextOptionsBuilder() - .UseInMemoryDatabase(nameof(SorterTests)) - .Options; - - _dbContext = new PeopleContext(options); - _people = Person.Faker.UseSeed(2024).Generate(10); - } - - [Fact] - public void Sort_WithValidSortParameter_SortsCorrectly() - { - // Arrange - _dbContext.Database.EnsureDeleted(); - _dbContext.AddRange(_people); - _dbContext.SaveChanges(); - - // Act - IQueryable queryable = _dbContext.People; - SortResult result = _sorter.Sort(ref queryable, "name"); - - // Assert - Assert.True(result.IsSuccess); - Assert.Equal(_people.OrderBy(p => p.Name).Select(p => p.Name), queryable.Select(p => p.Name)); - Assert.Empty(result.InvalidSortParameters); - } - - [Fact] - public void Sort_InvalidSortParameter_ReturnsInvalidSortParameters() - { - // Arrange - _dbContext.Database.EnsureDeleted(); - _dbContext.AddRange(_people); - _dbContext.SaveChanges(); - - // Act - IQueryable queryable = _dbContext.People; - SortResult result = _sorter.Sort(ref queryable, "invalid"); - - // Assert - Assert.False(result.IsSuccess); - Assert.Equal(["invalid"], result.InvalidSortParameters); - } - - [Fact] - public void Sort_WithEmptySortQuery_SortsWithDefaultSortParameters() - { - // Arrange - _dbContext.Database.EnsureDeleted(); - _dbContext.AddRange(_people); - _dbContext.SaveChanges(); - - // Act - IQueryable queryable = _dbContext.People; - SortResult result = _sorter.Sort(ref queryable, string.Empty); - - // Assert - Assert.True(result.IsSuccess); - Assert.Equal(_people.OrderByDescending(p => p.Name).Select(p => p.Name), queryable.Select(p => p.Name)); - Assert.Empty(result.InvalidSortParameters); - } -}