From 3259a9c9ce67c5de6ba42b0dd52210755d5ee735 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20B=C3=B8ye=20Christensen?= Date: Mon, 13 Jan 2025 09:52:03 +0100 Subject: [PATCH 1/9] Update deprecation of Default and Name in SortParameterBuilder --- .../SortParameterBuilder.cs | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/src/FluentSortingDotNet/SortParameterBuilder.cs b/src/FluentSortingDotNet/SortParameterBuilder.cs index 46d2bc6..30a9d92 100644 --- a/src/FluentSortingDotNet/SortParameterBuilder.cs +++ b/src/FluentSortingDotNet/SortParameterBuilder.cs @@ -20,12 +20,9 @@ internal SortParameterBuilder(SortableParameter parameter) /// /// 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; - } + [Obsolete("Use IsDefault instead. This method will be removed in the next major or minor version.", error: true)] + public SortParameterBuilder Default(SortDirection sortDirection) + => IsDefault(sortDirection); /// /// Set the sort parameter as a default parameter when the sort query is empty. @@ -43,12 +40,9 @@ public SortParameterBuilder IsDefault(SortDirection direction) /// /// The name of the parameter. /// The current builder instance. - [Obsolete("Use WithName instead.", error: false)] - public SortParameterBuilder Name(string name) - { - _parameter.Name = name; - return this; - } + [Obsolete("Use WithName instead. This method will be removed in the next major or minor version.", error: true)] + public SortParameterBuilder Name(string name) + => WithName(name); /// /// Set the name of the sort parameter. By default, the name is inferred from the property expression. From c99e1c129a29f3790f7b3e832b90d0082b8e24e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20B=C3=B8ye=20Christensen?= Date: Wed, 16 Apr 2025 14:13:57 +0200 Subject: [PATCH 2/9] Improve options in SortBuilder --- src/FluentSortingDotNet/SortBuilder.cs | 6 ++++-- src/FluentSortingDotNet/Sorter.cs | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/FluentSortingDotNet/SortBuilder.cs b/src/FluentSortingDotNet/SortBuilder.cs index d611cb2..1812d2b 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; } /// diff --git a/src/FluentSortingDotNet/Sorter.cs b/src/FluentSortingDotNet/Sorter.cs index 6eb72cc..1755225 100644 --- a/src/FluentSortingDotNet/Sorter.cs +++ b/src/FluentSortingDotNet/Sorter.cs @@ -38,7 +38,7 @@ 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(); From 005ffca4b2fac011efe23003ffb30b8cad1ef04b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20B=C3=B8ye=20Christensen?= Date: Wed, 16 Apr 2025 16:39:18 +0200 Subject: [PATCH 3/9] Fix ToFrozenDictionary not keeping the comparer --- src/FluentSortingDotNet/Sorter.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/FluentSortingDotNet/Sorter.cs b/src/FluentSortingDotNet/Sorter.cs index 1755225..f2cbc0f 100644 --- a/src/FluentSortingDotNet/Sorter.cs +++ b/src/FluentSortingDotNet/Sorter.cs @@ -67,7 +67,7 @@ protected Sorter(ISortParameterParser parser, ISortQueryBuilderFactory sortQu } #if NET8_0_OR_GREATER - _parameters = parametersDictionary.ToFrozenDictionary(); + _parameters = parametersDictionary.ToFrozenDictionary(_options.ParameterNameComparer); #else _parameters = parametersDictionary; #endif From be6e0a9e6ecabf269176a2c2581afb7064ea3d95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20B=C3=B8ye=20Christensen?= Date: Wed, 16 Apr 2025 16:39:43 +0200 Subject: [PATCH 4/9] Remove deprecated methods in SortParameterBuilder --- .../SortParameterBuilder.cs | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/src/FluentSortingDotNet/SortParameterBuilder.cs b/src/FluentSortingDotNet/SortParameterBuilder.cs index 30a9d92..02936ef 100644 --- a/src/FluentSortingDotNet/SortParameterBuilder.cs +++ b/src/FluentSortingDotNet/SortParameterBuilder.cs @@ -1,5 +1,4 @@ using FluentSortingDotNet.Internal; -using System; namespace FluentSortingDotNet; @@ -15,15 +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. This method will be removed in the next major or minor version.", error: true)] - public SortParameterBuilder Default(SortDirection sortDirection) - => IsDefault(sortDirection); - /// /// Set the sort parameter as a default parameter when the sort query is empty. /// @@ -35,15 +25,6 @@ public SortParameterBuilder IsDefault(SortDirection direction) return this; } - /// - /// Specifies a custom name of the parameter. - /// - /// The name of the parameter. - /// The current builder instance. - [Obsolete("Use WithName instead. This method will be removed in the next major or minor version.", error: true)] - public SortParameterBuilder Name(string name) - => WithName(name); - /// /// Set the name of the sort parameter. By default, the name is inferred from the property expression. /// From 80d05ad5e7a8908fc9863e72a0ed2d02be0f781b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20B=C3=B8ye=20Christensen?= Date: Wed, 16 Apr 2025 17:00:40 +0200 Subject: [PATCH 5/9] Rewrite Sorter for improved testability and validation --- README.md | 32 +-- src/FluentSortingDotNet/ISorter.cs | 26 +++ src/FluentSortingDotNet/SortBuilder.cs | 11 +- src/FluentSortingDotNet/SortContext.cs | 38 ++++ src/FluentSortingDotNet/SortResult.cs | 2 + src/FluentSortingDotNet/Sorter.cs | 81 ++++--- src/FluentSortingDotNet/SorterExtensions.cs | 63 ++++++ src/FluentSortingDotNet/SorterOptions.cs | 7 +- .../PersonSorter.cs | 4 +- .../FluentSortingDotNet.UnitTests.csproj | 1 + .../SorterTests.cs | 208 ++++++++++++++---- 11 files changed, 380 insertions(+), 93 deletions(-) create mode 100644 src/FluentSortingDotNet/ISorter.cs create mode 100644 src/FluentSortingDotNet/SortContext.cs create mode 100644 src/FluentSortingDotNet/SorterExtensions.cs diff --git a/README.md b/README.md index 84a54ec..271f383 100644 --- a/README.md +++ b/README.md @@ -34,18 +34,21 @@ public record Person(string Name, int Age); ```csharp using FluentSortingDotNet; -// The Sorter class also have an empty constructor that uses the DefaultSortParameterParser +// The Sorter class also have a overload with an empty constructor that uses the DefaultSortParameterParser public sealed class PersonSorter(ISortParameterParser parser) : Sorter(parser) { 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"); - // 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(); } } ``` @@ -55,27 +58,26 @@ public sealed class PersonSorter(ISortParameterParser parser) : Sorter(p ```csharp using FluentSortingDotNet; -var sorter = new PersonSorter(DefaultSortParameterParser.Instance); - -IQueryable peopleQuery = ...; +PersonSorter sorter = new(DefaultSortParameterParser.Instance); -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 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/SortBuilder.cs b/src/FluentSortingDotNet/SortBuilder.cs index 1812d2b..d5a28f0 100644 --- a/src/FluentSortingDotNet/SortBuilder.cs +++ b/src/FluentSortingDotNet/SortBuilder.cs @@ -28,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/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 f2cbc0f..73f7ae0 100644 --- a/src/FluentSortingDotNet/Sorter.cs +++ b/src/FluentSortingDotNet/Sorter.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.Linq; using FluentSortingDotNet.Internal; using FluentSortingDotNet.Parsers; @@ -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; + _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) { @@ -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 . - public SortResult Sort(ref IQueryable query) - { - query = _defaultQuery.Apply(query); - return SortResult.Success(); - } + [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) + => 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,75 @@ 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); } - if (queryBuilder.IsEmpty) - { - return Sort(ref query); - } - - query = queryBuilder.Build().Apply(query); - return SortResult.Success(); + return queryBuilder.IsEmpty + ? _defaultQuery + : queryBuilder.Build(); } - 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.Testing/PersonSorter.cs b/tests/FluentSortingDotNet.Testing/PersonSorter.cs index ec76fa8..81d8505 100644 --- a/tests/FluentSortingDotNet.Testing/PersonSorter.cs +++ b/tests/FluentSortingDotNet.Testing/PersonSorter.cs @@ -3,8 +3,8 @@ 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(GetParser(parser), GetSortQueryBuilderFactory(sortQueryBuilderFactory), GetDefaultParameterSortQueryBuilder(defaultParameterSortQueryBuilder), options) { protected override void Configure(SortBuilder builder) { diff --git a/tests/FluentSortingDotNet.UnitTests/FluentSortingDotNet.UnitTests.csproj b/tests/FluentSortingDotNet.UnitTests/FluentSortingDotNet.UnitTests.csproj index 0d9ca50..fbb5c76 100644 --- a/tests/FluentSortingDotNet.UnitTests/FluentSortingDotNet.UnitTests.csproj +++ b/tests/FluentSortingDotNet.UnitTests/FluentSortingDotNet.UnitTests.csproj @@ -11,6 +11,7 @@ + diff --git a/tests/FluentSortingDotNet.UnitTests/SorterTests.cs b/tests/FluentSortingDotNet.UnitTests/SorterTests.cs index 285bad9..ed326d8 100644 --- a/tests/FluentSortingDotNet.UnitTests/SorterTests.cs +++ b/tests/FluentSortingDotNet.UnitTests/SorterTests.cs @@ -1,84 +1,204 @@ -using FluentSortingDotNet.Testing; -using Microsoft.EntityFrameworkCore; +using FluentSortingDotNet.Parsers; +using FluentSortingDotNet.Queries; +using FluentSortingDotNet.Testing; +using NSubstitute; namespace FluentSortingDotNet.UnitTests; public class SorterTests { - private sealed class PeopleContext(DbContextOptions options) : DbContext(options) + 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() { - public DbSet People { get; set; } = null!; + // Arrange + SorterOptions options = CreateOptions(ignoreInvalidParameters: false); + Sorter sorter = CreateSorter(options: options); + SortContext sortContext = new(Array.Empty(), ["invalid_parameter"]); - protected override void OnModelCreating(ModelBuilder modelBuilder) - { - modelBuilder.Entity().HasKey(p => p.Name); - } + // Act + Action act = () => sorter.CreateSortQuery(sortContext); + + // Assert + Assert.Throws(act); } - private readonly PeopleContext _dbContext; - private readonly List _people; - private readonly PersonSorter _sorter = new(); + [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); + } - public SorterTests() + [Fact] + public void CreateSortQuery_ShouldReturnDefaultSortQuery_WhenSortContextIsEmpty() { - DbContextOptions options = new DbContextOptionsBuilder() - .UseInMemoryDatabase(nameof(SorterTests)) - .Options; + // Arrange + ISortQuery defaultSortQuery = Substitute.For>(); + ISortQueryBuilder sortQueryBuilder = Substitute.For>(); + sortQueryBuilder.IsEmpty.Returns(false); + sortQueryBuilder.Build().Returns(defaultSortQuery); + + SorterOptions options = CreateOptions(ignoreInvalidParameters: false); - _dbContext = new PeopleContext(options); - _people = Person.Faker.UseSeed(2024).Generate(10); + 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 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); } [Fact] - public void Sort_WithValidSortParameter_SortsCorrectly() + public void Validate_ShouldReturnInvalidContext_WhenUnparsableParametersProvided() { // Arrange - _dbContext.Database.EnsureDeleted(); - _dbContext.AddRange(_people); - _dbContext.SaveChanges(); + var query = "-"; + Sorter sorter = CreateSorter(); // Act - IQueryable queryable = _dbContext.People; - SortResult result = _sorter.Sort(ref queryable, "name"); + var sortContext = sorter.Validate(query.AsSpan()); // 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); + Assert.True(sortContext.IsEmpty); + Assert.False(sortContext.IsValid); + Assert.Single(sortContext.InvalidParameters); + Assert.Equal(query, sortContext.InvalidParameters[0]); } [Fact] - public void Sort_InvalidSortParameter_ReturnsInvalidSortParameters() + public void Validate_ShouldReturnInvalidContext_WhenNonExistentParametersProvided() { // Arrange - _dbContext.Database.EnsureDeleted(); - _dbContext.AddRange(_people); - _dbContext.SaveChanges(); + var query = "invalid_parameter"; + Sorter sorter = CreateSorter(); // Act - IQueryable queryable = _dbContext.People; - SortResult result = _sorter.Sort(ref queryable, "invalid"); + var sortContext = sorter.Validate(query.AsSpan()); // Assert - Assert.False(result.IsSuccess); - Assert.Equal(["invalid"], result.InvalidSortParameters); + Assert.True(sortContext.IsEmpty); + Assert.False(sortContext.IsValid); + Assert.Single(sortContext.InvalidParameters); + Assert.Equal(query, sortContext.InvalidParameters[0]); } [Fact] - public void Sort_WithEmptySortQuery_SortsWithDefaultSortParameters() + public void Validate_ShouldReturnValidContext_WhenValidParametersProvided() { // Arrange - _dbContext.Database.EnsureDeleted(); - _dbContext.AddRange(_people); - _dbContext.SaveChanges(); + var query = "name,age"; + Sorter sorter = CreateSorter(); // Act - IQueryable queryable = _dbContext.People; - SortResult result = _sorter.Sort(ref queryable, string.Empty); + var sortContext = sorter.Validate(query.AsSpan()); // 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); + Assert.False(sortContext.IsEmpty); + Assert.True(sortContext.IsValid); + Assert.Empty(sortContext.InvalidParameters); + Assert.Equal(2, sortContext.ValidParameters.Count); } -} +} \ No newline at end of file From 31195cee8358592b5b0a7e8a5b59888cc85750d0 Mon Sep 17 00:00:00 2001 From: Simon Christensen Date: Mon, 12 May 2025 18:36:04 +0200 Subject: [PATCH 6/9] Improve query builders and add more tests --- .../Queries/DefaultSortQueryBuilder.cs | 73 +++++++------- .../Queries/ExpressionSortQueryBuilder.cs | 9 +- .../QueryBuilderBenchmarks.cs | 2 +- .../FluentSortingDotNet.Testing.csproj | 1 + tests/FluentSortingDotNet.Testing/Person.cs | 21 ++-- .../PersonDbContext.cs | 15 +++ .../PersonGenerator.cs | 14 +++ .../PersonSorter.cs | 20 ++-- ...sproj => FluentSortingDotNet.Tests.csproj} | 2 +- .../DefaultSortParameterParserTests.cs | 2 +- .../Queries/DefaultSortQueryBuilderTests.cs | 53 +--------- .../DefaultSortQueryBuilder_SortQueryTests.cs | 10 ++ .../ExpressionSortQueryBuilderTests.cs | 40 +------- ...pressionSortQueryBuilder_SortQueryTests.cs | 10 ++ .../Queries/SortQueryBuilderTests.cs | 69 +++++++++++++ .../Queries/SortQueryBuilder_SortQueryTest.cs | 97 +++++++++++++++++++ .../SorterTests.cs | 2 +- 17 files changed, 297 insertions(+), 143 deletions(-) create mode 100644 tests/FluentSortingDotNet.Testing/PersonDbContext.cs create mode 100644 tests/FluentSortingDotNet.Testing/PersonGenerator.cs rename tests/FluentSortingDotNet.UnitTests/{FluentSortingDotNet.UnitTests.csproj => FluentSortingDotNet.Tests.csproj} (97%) rename tests/FluentSortingDotNet.UnitTests/{Parser => Parsers}/DefaultSortParameterParserTests.cs (98%) create mode 100644 tests/FluentSortingDotNet.UnitTests/Queries/DefaultSortQueryBuilder_SortQueryTests.cs create mode 100644 tests/FluentSortingDotNet.UnitTests/Queries/ExpressionSortQueryBuilder_SortQueryTests.cs create mode 100644 tests/FluentSortingDotNet.UnitTests/Queries/SortQueryBuilderTests.cs create mode 100644 tests/FluentSortingDotNet.UnitTests/Queries/SortQueryBuilder_SortQueryTest.cs 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/tests/FluentSortingDotNet.Benchmarks/QueryBuilderBenchmarks.cs b/tests/FluentSortingDotNet.Benchmarks/QueryBuilderBenchmarks.cs index b6f31a5..5d42a27 100644 --- a/tests/FluentSortingDotNet.Benchmarks/QueryBuilderBenchmarks.cs +++ b/tests/FluentSortingDotNet.Benchmarks/QueryBuilderBenchmarks.cs @@ -7,7 +7,7 @@ [MemoryDiagnoser(false)] 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.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 81d8505..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? sortQueryBuilderFactory = null, ISortQueryBuilder? defaultParameterSortQueryBuilder = null, SorterOptions? options = null) - : Sorter(GetParser(parser), GetSortQueryBuilderFactory(sortQueryBuilderFactory), GetDefaultParameterSortQueryBuilder(defaultParameterSortQueryBuilder), options) +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.UnitTests/FluentSortingDotNet.Tests.csproj similarity index 97% rename from tests/FluentSortingDotNet.UnitTests/FluentSortingDotNet.UnitTests.csproj rename to tests/FluentSortingDotNet.UnitTests/FluentSortingDotNet.Tests.csproj index fbb5c76..ca60abd 100644 --- a/tests/FluentSortingDotNet.UnitTests/FluentSortingDotNet.UnitTests.csproj +++ b/tests/FluentSortingDotNet.UnitTests/FluentSortingDotNet.Tests.csproj @@ -9,7 +9,7 @@ - + diff --git a/tests/FluentSortingDotNet.UnitTests/Parser/DefaultSortParameterParserTests.cs b/tests/FluentSortingDotNet.UnitTests/Parsers/DefaultSortParameterParserTests.cs similarity index 98% rename from tests/FluentSortingDotNet.UnitTests/Parser/DefaultSortParameterParserTests.cs rename to tests/FluentSortingDotNet.UnitTests/Parsers/DefaultSortParameterParserTests.cs index e839961..ee0763a 100644 --- a/tests/FluentSortingDotNet.UnitTests/Parser/DefaultSortParameterParserTests.cs +++ b/tests/FluentSortingDotNet.UnitTests/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.UnitTests/Queries/DefaultSortQueryBuilderTests.cs b/tests/FluentSortingDotNet.UnitTests/Queries/DefaultSortQueryBuilderTests.cs index d8c3fc9..03a5406 100644 --- a/tests/FluentSortingDotNet.UnitTests/Queries/DefaultSortQueryBuilderTests.cs +++ b/tests/FluentSortingDotNet.UnitTests/Queries/DefaultSortQueryBuilderTests.cs @@ -1,53 +1,10 @@ using FluentSortingDotNet.Queries; using FluentSortingDotNet.Testing; -namespace FluentSortingDotNet.UnitTests.Queries; +namespace FluentSortingDotNet.Tests.Queries; -public class DefaultSortQueryBuilderTests +public class DefaultSortQueryBuilderTests : SortQueryBuilderTests { - [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); - } -} + protected override ISortQueryBuilder CreateSortQueryBuilder() + => new DefaultSortQueryBuilder(); +} \ No newline at end of file diff --git a/tests/FluentSortingDotNet.UnitTests/Queries/DefaultSortQueryBuilder_SortQueryTests.cs b/tests/FluentSortingDotNet.UnitTests/Queries/DefaultSortQueryBuilder_SortQueryTests.cs new file mode 100644 index 0000000..e16b491 --- /dev/null +++ b/tests/FluentSortingDotNet.UnitTests/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.UnitTests/Queries/ExpressionSortQueryBuilderTests.cs b/tests/FluentSortingDotNet.UnitTests/Queries/ExpressionSortQueryBuilderTests.cs index 214b8d5..79234d0 100644 --- a/tests/FluentSortingDotNet.UnitTests/Queries/ExpressionSortQueryBuilderTests.cs +++ b/tests/FluentSortingDotNet.UnitTests/Queries/ExpressionSortQueryBuilderTests.cs @@ -1,40 +1,10 @@ using FluentSortingDotNet.Queries; using FluentSortingDotNet.Testing; -namespace FluentSortingDotNet.UnitTests.Queries; +namespace FluentSortingDotNet.Tests.Queries; -public class ExpressionSortQueryBuilderTests +public class ExpressionSortQueryBuilderTests : SortQueryBuilderTests { - [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); - } -} + protected override ISortQueryBuilder CreateSortQueryBuilder() + => new ExpressionSortQueryBuilder(); +} \ No newline at end of file diff --git a/tests/FluentSortingDotNet.UnitTests/Queries/ExpressionSortQueryBuilder_SortQueryTests.cs b/tests/FluentSortingDotNet.UnitTests/Queries/ExpressionSortQueryBuilder_SortQueryTests.cs new file mode 100644 index 0000000..58d97cc --- /dev/null +++ b/tests/FluentSortingDotNet.UnitTests/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.UnitTests/Queries/SortQueryBuilderTests.cs b/tests/FluentSortingDotNet.UnitTests/Queries/SortQueryBuilderTests.cs new file mode 100644 index 0000000..b37fdd8 --- /dev/null +++ b/tests/FluentSortingDotNet.UnitTests/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.UnitTests/Queries/SortQueryBuilder_SortQueryTest.cs b/tests/FluentSortingDotNet.UnitTests/Queries/SortQueryBuilder_SortQueryTest.cs new file mode 100644 index 0000000..71c780d --- /dev/null +++ b/tests/FluentSortingDotNet.UnitTests/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.UnitTests/SorterTests.cs b/tests/FluentSortingDotNet.UnitTests/SorterTests.cs index ed326d8..db144f0 100644 --- a/tests/FluentSortingDotNet.UnitTests/SorterTests.cs +++ b/tests/FluentSortingDotNet.UnitTests/SorterTests.cs @@ -3,7 +3,7 @@ using FluentSortingDotNet.Testing; using NSubstitute; -namespace FluentSortingDotNet.UnitTests; +namespace FluentSortingDotNet.Tests; public class SorterTests { From 811866a97cb96f94d1083a1819dabd9b0d7dfd6d Mon Sep 17 00:00:00 2001 From: Simon Christensen Date: Mon, 12 May 2025 18:38:20 +0200 Subject: [PATCH 7/9] Make sort parameters reversable --- .../Internal/SortableParameter.cs | 1 + .../SortParameterBuilder.cs | 10 +++++++ src/FluentSortingDotNet/Sorter.cs | 29 ++++++++++++------- 3 files changed, 30 insertions(+), 10 deletions(-) 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/SortParameterBuilder.cs b/src/FluentSortingDotNet/SortParameterBuilder.cs index 02936ef..a6075fe 100644 --- a/src/FluentSortingDotNet/SortParameterBuilder.cs +++ b/src/FluentSortingDotNet/SortParameterBuilder.cs @@ -35,4 +35,14 @@ public SortParameterBuilder WithName(string name) _parameter.Name = name; return this; } + + /// + /// Set the sort parameter to be reversed when sorting. + /// + /// The current builder instance. + public SortParameterBuilder ReverseDirection() + { + _parameter.ShouldReverseDirection = true; + return this; + } } diff --git a/src/FluentSortingDotNet/Sorter.cs b/src/FluentSortingDotNet/Sorter.cs index 73f7ae0..154e855 100644 --- a/src/FluentSortingDotNet/Sorter.cs +++ b/src/FluentSortingDotNet/Sorter.cs @@ -1,10 +1,10 @@ -using System; +using FluentSortingDotNet.Internal; +using FluentSortingDotNet.Parsers; +using FluentSortingDotNet.Queries; +using System; using System.Collections.Generic; using System.Diagnostics; using System.Linq; -using FluentSortingDotNet.Internal; -using FluentSortingDotNet.Parsers; -using FluentSortingDotNet.Queries; #if NET8_0_OR_GREATER using System.Collections.Frozen; @@ -74,8 +74,8 @@ protected Sorter(ISortParameterParser parser, ISortQueryBuilderFactory sortQu _parameters = parametersDictionary; #endif - _defaultQuery = defaultParameterSortQueryBuilder.IsEmpty - ? NullSortQuery.Instance + _defaultQuery = defaultParameterSortQueryBuilder.IsEmpty + ? NullSortQuery.Instance : defaultParameterSortQueryBuilder.Build(); static InvalidOperationException ParameterAlreadyExists(string name) @@ -118,7 +118,7 @@ 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) + public SortResult Sort(ref IQueryable query) => Sort(ref query, ReadOnlySpan.Empty); /// @@ -177,12 +177,21 @@ public ISortQuery CreateSortQuery(SortContext sortContext) 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)); } - return queryBuilder.IsEmpty - ? _defaultQuery + return queryBuilder.IsEmpty + ? _defaultQuery : queryBuilder.Build(); + + static SortDirection GetDirection(SortDirection sortDirection, bool reverse) + { + return reverse + ? sortDirection == SortDirection.Ascending ? SortDirection.Descending : SortDirection.Ascending + : sortDirection; + } } /// From 91f98dbcebd8366ab2fc12e5f459c309498e1a27 Mon Sep 17 00:00:00 2001 From: Simon Christensen Date: Mon, 12 May 2025 18:38:25 +0200 Subject: [PATCH 8/9] Update tests --- FluentSortingDotNet.sln | 12 ++--- .../FluentSortingDotNet.csproj | 6 ++- .../ParserBenchmarks.cs | 2 +- .../FluentSortingDotNet.ConsoleTest.csproj | 15 ------ .../Program.cs | 28 ---------- .../FluentSortingDotNet.Tests.csproj | 0 .../DefaultSortParameterParserTests.cs | 0 .../Queries/DefaultSortQueryBuilderTests.cs | 0 .../DefaultSortQueryBuilder_SortQueryTests.cs | 0 .../ExpressionSortQueryBuilderTests.cs | 0 ...pressionSortQueryBuilder_SortQueryTests.cs | 0 .../Queries/SortQueryBuilderTests.cs | 0 .../Queries/SortQueryBuilder_SortQueryTest.cs | 0 .../SorterTests.cs | 53 +++++++++++++++++-- 14 files changed, 60 insertions(+), 56 deletions(-) delete mode 100644 tests/FluentSortingDotNet.ConsoleTest/FluentSortingDotNet.ConsoleTest.csproj delete mode 100644 tests/FluentSortingDotNet.ConsoleTest/Program.cs rename tests/{FluentSortingDotNet.UnitTests => FluentSortingDotNet.Tests}/FluentSortingDotNet.Tests.csproj (100%) rename tests/{FluentSortingDotNet.UnitTests => FluentSortingDotNet.Tests}/Parsers/DefaultSortParameterParserTests.cs (100%) rename tests/{FluentSortingDotNet.UnitTests => FluentSortingDotNet.Tests}/Queries/DefaultSortQueryBuilderTests.cs (100%) rename tests/{FluentSortingDotNet.UnitTests => FluentSortingDotNet.Tests}/Queries/DefaultSortQueryBuilder_SortQueryTests.cs (100%) rename tests/{FluentSortingDotNet.UnitTests => FluentSortingDotNet.Tests}/Queries/ExpressionSortQueryBuilderTests.cs (100%) rename tests/{FluentSortingDotNet.UnitTests => FluentSortingDotNet.Tests}/Queries/ExpressionSortQueryBuilder_SortQueryTests.cs (100%) rename tests/{FluentSortingDotNet.UnitTests => FluentSortingDotNet.Tests}/Queries/SortQueryBuilderTests.cs (100%) rename tests/{FluentSortingDotNet.UnitTests => FluentSortingDotNet.Tests}/Queries/SortQueryBuilder_SortQueryTest.cs (100%) rename tests/{FluentSortingDotNet.UnitTests => FluentSortingDotNet.Tests}/SorterTests.cs (79%) 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/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/tests/FluentSortingDotNet.Benchmarks/ParserBenchmarks.cs b/tests/FluentSortingDotNet.Benchmarks/ParserBenchmarks.cs index 44c04d4..0df3bb8 100644 --- a/tests/FluentSortingDotNet.Benchmarks/ParserBenchmarks.cs +++ b/tests/FluentSortingDotNet.Benchmarks/ParserBenchmarks.cs @@ -8,7 +8,7 @@ public class ParserBenchmarks [Params("a", "-a,b", "a,b,-c,d,-e,-f,g")] public string Query { get; set; } - private readonly DefaultSortParameterParser DefaultParser = DefaultSortParameterParser.Instance; + private static readonly DefaultSortParameterParser DefaultParser = DefaultSortParameterParser.Instance; [Benchmark] public SortParameter ParseFirst() 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.UnitTests/FluentSortingDotNet.Tests.csproj b/tests/FluentSortingDotNet.Tests/FluentSortingDotNet.Tests.csproj similarity index 100% rename from tests/FluentSortingDotNet.UnitTests/FluentSortingDotNet.Tests.csproj rename to tests/FluentSortingDotNet.Tests/FluentSortingDotNet.Tests.csproj diff --git a/tests/FluentSortingDotNet.UnitTests/Parsers/DefaultSortParameterParserTests.cs b/tests/FluentSortingDotNet.Tests/Parsers/DefaultSortParameterParserTests.cs similarity index 100% rename from tests/FluentSortingDotNet.UnitTests/Parsers/DefaultSortParameterParserTests.cs rename to tests/FluentSortingDotNet.Tests/Parsers/DefaultSortParameterParserTests.cs diff --git a/tests/FluentSortingDotNet.UnitTests/Queries/DefaultSortQueryBuilderTests.cs b/tests/FluentSortingDotNet.Tests/Queries/DefaultSortQueryBuilderTests.cs similarity index 100% rename from tests/FluentSortingDotNet.UnitTests/Queries/DefaultSortQueryBuilderTests.cs rename to tests/FluentSortingDotNet.Tests/Queries/DefaultSortQueryBuilderTests.cs diff --git a/tests/FluentSortingDotNet.UnitTests/Queries/DefaultSortQueryBuilder_SortQueryTests.cs b/tests/FluentSortingDotNet.Tests/Queries/DefaultSortQueryBuilder_SortQueryTests.cs similarity index 100% rename from tests/FluentSortingDotNet.UnitTests/Queries/DefaultSortQueryBuilder_SortQueryTests.cs rename to tests/FluentSortingDotNet.Tests/Queries/DefaultSortQueryBuilder_SortQueryTests.cs diff --git a/tests/FluentSortingDotNet.UnitTests/Queries/ExpressionSortQueryBuilderTests.cs b/tests/FluentSortingDotNet.Tests/Queries/ExpressionSortQueryBuilderTests.cs similarity index 100% rename from tests/FluentSortingDotNet.UnitTests/Queries/ExpressionSortQueryBuilderTests.cs rename to tests/FluentSortingDotNet.Tests/Queries/ExpressionSortQueryBuilderTests.cs diff --git a/tests/FluentSortingDotNet.UnitTests/Queries/ExpressionSortQueryBuilder_SortQueryTests.cs b/tests/FluentSortingDotNet.Tests/Queries/ExpressionSortQueryBuilder_SortQueryTests.cs similarity index 100% rename from tests/FluentSortingDotNet.UnitTests/Queries/ExpressionSortQueryBuilder_SortQueryTests.cs rename to tests/FluentSortingDotNet.Tests/Queries/ExpressionSortQueryBuilder_SortQueryTests.cs diff --git a/tests/FluentSortingDotNet.UnitTests/Queries/SortQueryBuilderTests.cs b/tests/FluentSortingDotNet.Tests/Queries/SortQueryBuilderTests.cs similarity index 100% rename from tests/FluentSortingDotNet.UnitTests/Queries/SortQueryBuilderTests.cs rename to tests/FluentSortingDotNet.Tests/Queries/SortQueryBuilderTests.cs diff --git a/tests/FluentSortingDotNet.UnitTests/Queries/SortQueryBuilder_SortQueryTest.cs b/tests/FluentSortingDotNet.Tests/Queries/SortQueryBuilder_SortQueryTest.cs similarity index 100% rename from tests/FluentSortingDotNet.UnitTests/Queries/SortQueryBuilder_SortQueryTest.cs rename to tests/FluentSortingDotNet.Tests/Queries/SortQueryBuilder_SortQueryTest.cs diff --git a/tests/FluentSortingDotNet.UnitTests/SorterTests.cs b/tests/FluentSortingDotNet.Tests/SorterTests.cs similarity index 79% rename from tests/FluentSortingDotNet.UnitTests/SorterTests.cs rename to tests/FluentSortingDotNet.Tests/SorterTests.cs index db144f0..9348cf5 100644 --- a/tests/FluentSortingDotNet.UnitTests/SorterTests.cs +++ b/tests/FluentSortingDotNet.Tests/SorterTests.cs @@ -2,6 +2,7 @@ using FluentSortingDotNet.Queries; using FluentSortingDotNet.Testing; using NSubstitute; +using System.Linq.Expressions; namespace FluentSortingDotNet.Tests; @@ -127,15 +128,41 @@ public void CreateSortQuery_ShouldBuildQuery_WhenSortContextIsValid() 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() { @@ -149,6 +176,8 @@ public void Validate_ShouldReturnEmptyContext_WhenNoParametersProvided() // Assert Assert.True(sortContext.IsEmpty); Assert.True(sortContext.IsValid); + Assert.Empty(sortContext.InvalidParameters); + Assert.Empty(sortContext.ValidParameters); } [Fact] @@ -189,7 +218,7 @@ public void Validate_ShouldReturnInvalidContext_WhenNonExistentParametersProvide public void Validate_ShouldReturnValidContext_WhenValidParametersProvided() { // Arrange - var query = "name,age"; + var query = "Name,Age"; Sorter sorter = CreateSorter(); // Act @@ -201,4 +230,22 @@ public void Validate_ShouldReturnValidContext_WhenValidParametersProvided() 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 From e2ee888baea9ebaac2ff8567245d02e32814c558 Mon Sep 17 00:00:00 2001 From: Simon Christensen Date: Mon, 12 May 2025 19:06:16 +0200 Subject: [PATCH 9/9] Improve benchmarks --- README.md | 4 +- .../ParserBenchmarks.cs | 4 +- .../FluentSortingDotNet.Benchmarks/Program.cs | 42 +++++++++++++++++- .../Properties/launchSettings.json | 8 ++++ .../QueryBuilderBenchmarks.cs | 2 +- .../parser-1.0.0-rc.3.png | Bin 20284 -> 0 bytes .../query-builder-1.0.0-rc.3.png | Bin 21831 -> 0 bytes 7 files changed, 53 insertions(+), 7 deletions(-) create mode 100644 tests/FluentSortingDotNet.Benchmarks/Properties/launchSettings.json delete mode 100644 tests/FluentSortingDotNet.Benchmarks/parser-1.0.0-rc.3.png delete mode 100644 tests/FluentSortingDotNet.Benchmarks/query-builder-1.0.0-rc.3.png diff --git a/README.md b/README.md index 271f383..0f0d507 100644 --- a/README.md +++ b/README.md @@ -166,10 +166,10 @@ 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") +**TODO: Insert benchmark results table** #### 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 +**TODO: Insert benchmark results table** \ No newline at end of file diff --git a/tests/FluentSortingDotNet.Benchmarks/ParserBenchmarks.cs b/tests/FluentSortingDotNet.Benchmarks/ParserBenchmarks.cs index 0df3bb8..20eefd1 100644 --- a/tests/FluentSortingDotNet.Benchmarks/ParserBenchmarks.cs +++ b/tests/FluentSortingDotNet.Benchmarks/ParserBenchmarks.cs @@ -2,11 +2,11 @@ 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 static readonly DefaultSortParameterParser DefaultParser = DefaultSortParameterParser.Instance; 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 5d42a27..3c794e2 100644 --- a/tests/FluentSortingDotNet.Benchmarks/QueryBuilderBenchmarks.cs +++ b/tests/FluentSortingDotNet.Benchmarks/QueryBuilderBenchmarks.cs @@ -4,7 +4,7 @@ using FluentSortingDotNet.Testing; using System.Linq.Expressions; -[MemoryDiagnoser(false)] +[MemoryDiagnoser(false), MarkdownExporter] public class QueryBuilderBenchmarks { public static readonly IQueryable People = PersonGenerator.Instance.UseSeed(2024).Generate(10).AsQueryable(); 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 7fa01eb175ccdf1c069e06e7ecf3896c3f0edf38..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 20284 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 UBYw{UHr|Q3T!1>fIPaVAFRRf@2><{9 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 cb6fed5a454fd4608489474b971afcfa003e8361..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 21831 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