From 7fb0d9dbc4b746818e906077d7acbd685a07cf3d Mon Sep 17 00:00:00 2001 From: Willy <63505597+willysoft@users.noreply.github.com> Date: Tue, 27 Jan 2026 15:36:13 +0800 Subject: [PATCH] feat(query): support multiple sort fields with comma separator - Allow sorting by multiple fields using comma-separated values (e.g., "Id,-Name") - Use OrderBy for first field and ThenBy for subsequent fields - Prefix "-" indicates descending order for each field - Update unit tests to verify multi-field sorting behavior - Add test query configuration and options for demo --- .../TestUserQueryConfiguration.cs | 15 +++++ .../Models/TestUserQueryOptions.cs | 18 ++++++ src/AutoQuery/Extensions/QueryExtensions.cs | 63 ++++++++++++------- .../Extensions/QueryExtensionsTests.cs | 45 ++++++++++++- 4 files changed, 117 insertions(+), 24 deletions(-) create mode 100644 sample/AutoQueryApiDemo/Configurations/TestUserQueryConfiguration.cs create mode 100644 sample/AutoQueryApiDemo/Models/TestUserQueryOptions.cs diff --git a/sample/AutoQueryApiDemo/Configurations/TestUserQueryConfiguration.cs b/sample/AutoQueryApiDemo/Configurations/TestUserQueryConfiguration.cs new file mode 100644 index 0000000..705bcc5 --- /dev/null +++ b/sample/AutoQueryApiDemo/Configurations/TestUserQueryConfiguration.cs @@ -0,0 +1,15 @@ +using AutoQuery; +using AutoQuery.Abstractions; +using AutoQuery.Extensions; +using AutoQueryApiDemo.Models; + +namespace AutoQueryApiDemo.Configurations; + +public class TestUserQueryConfiguration : IFilterQueryConfiguration +{ + public void Configure(FilterQueryBuilder builder) + { + builder.Property(q => q.FilterEmail, d => d.Email) + .HasEqual(); + } +} diff --git a/sample/AutoQueryApiDemo/Models/TestUserQueryOptions.cs b/sample/AutoQueryApiDemo/Models/TestUserQueryOptions.cs new file mode 100644 index 0000000..fb2f00b --- /dev/null +++ b/sample/AutoQueryApiDemo/Models/TestUserQueryOptions.cs @@ -0,0 +1,18 @@ +using AutoQuery.Abstractions; +using Microsoft.AspNetCore.Mvc; + +namespace AutoQueryApiDemo.Models; + +public class TestUserQueryOptions : IQueryPagedOptions +{ + [FromQuery(Name = "filter[email]")] + public string? FilterEmail { get; set; } + [FromQuery(Name = "fields")] + public string? Fields { get; set; } + [FromQuery(Name = "sort")] + public string? Sort { get; set; } + [FromQuery(Name = "page")] + public int? Page { get; set; } + [FromQuery(Name = "pageSize")] + public int? PageSize { get; set; } +} diff --git a/src/AutoQuery/Extensions/QueryExtensions.cs b/src/AutoQuery/Extensions/QueryExtensions.cs index 28a39fd..3991f21 100644 --- a/src/AutoQuery/Extensions/QueryExtensions.cs +++ b/src/AutoQuery/Extensions/QueryExtensions.cs @@ -96,29 +96,48 @@ public static IQueryable ApplySort(this IQueryable query, IQueryOptions if (string.IsNullOrWhiteSpace(queryOption.Sort)) return query; - var sort = queryOption.Sort; - var descending = sort.StartsWith("-"); - var sortBy = descending ? sort.Substring(1) : sort; - var cacheKey = $"{typeof(T).FullName}_{sortBy}"; - var propertyInfo = s_PropertyCache.GetOrAdd(cacheKey, t => typeof(T).GetProperty(sortBy, BindingFlags.IgnoreCase | BindingFlags.Public | BindingFlags.Instance)); - if (propertyInfo == null) - return query; + var sortFields = queryOption.Sort.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + var isFirstSort = true; + + foreach (var sort in sortFields) + { + if (string.IsNullOrWhiteSpace(sort)) + continue; + + var descending = sort.StartsWith("-"); + var sortBy = descending ? sort[1..] : sort; + var cacheKey = $"{typeof(T).FullName}_{sortBy}"; + var propertyInfo = s_PropertyCache.GetOrAdd(cacheKey, _ => typeof(T).GetProperty(sortBy, BindingFlags.IgnoreCase | BindingFlags.Public | BindingFlags.Instance)); + + if (propertyInfo == null) + continue; + + var parameter = Expression.Parameter(typeof(T), "entity"); + var property = Expression.Property(parameter, propertyInfo); + var delegateType = typeof(Func<,>).MakeGenericType(typeof(T), propertyInfo.PropertyType); + var lambda = Expression.Lambda(delegateType, property, parameter); - var parameter = Expression.Parameter(typeof(T), "entity"); - var property = Expression.Property(parameter, propertyInfo); - var delegateType = typeof(Func<,>).MakeGenericType(typeof(T), propertyInfo.PropertyType); - var lambda = Expression.Lambda(delegateType, property, parameter); - - string methodName = descending ? "OrderByDescending" : "OrderBy"; - var resultExpression = Expression.Call( - typeof(Queryable), - methodName, - [typeof(T), propertyInfo.PropertyType], - query.Expression, - lambda - ); - - return query.Provider.CreateQuery(resultExpression); + string methodName = (isFirstSort, descending) switch + { + (true, true) => "OrderByDescending", + (true, false) => "OrderBy", + (false, true) => "ThenByDescending", + (false, false) => "ThenBy" + }; + + var resultExpression = Expression.Call( + typeof(Queryable), + methodName, + [typeof(T), propertyInfo.PropertyType], + query.Expression, + lambda + ); + + query = query.Provider.CreateQuery(resultExpression); + isFirstSort = false; + } + + return query; } /// diff --git a/test/AutoQuery.Tests/Extensions/QueryExtensionsTests.cs b/test/AutoQuery.Tests/Extensions/QueryExtensionsTests.cs index 15519b1..b5f445a 100644 --- a/test/AutoQuery.Tests/Extensions/QueryExtensionsTests.cs +++ b/test/AutoQuery.Tests/Extensions/QueryExtensionsTests.cs @@ -79,7 +79,7 @@ public void ApplyQueryPagedResult_ShouldApplyFilterSelectorAndPaging(List data, TestQueryOptions queryOptions, string expectedFirstName) + public void ApplySort_ShouldSortData(List data, TestQueryOptions queryOptions, int expectedFirstId, string expectedFirstName) { // Arrange var queryableData = data.AsQueryable(); @@ -88,6 +88,7 @@ public void ApplySort_ShouldSortData(List data, TestQueryOptions query var result = queryableData.ApplySort(queryOptions); // Assert + Assert.Equal(expectedFirstId, result.First().Id); Assert.Equal(expectedFirstName, result.First().Name); } @@ -289,7 +290,8 @@ public IEnumerator GetEnumerator() new TestData { Id = 1, Name = "B" }, new TestData { Id = 2, Name = "A" } }, - new TestQueryOptions {Sort = "Name"}, + new TestQueryOptions { Sort = "Name" }, + 2, "A" }; yield return new object[] @@ -300,6 +302,7 @@ public IEnumerator GetEnumerator() new TestData { Id = 2, Name = "A" } }, new TestQueryOptions { Sort = "-Name" }, + 1, "B" }; yield return new object[] @@ -310,6 +313,7 @@ public IEnumerator GetEnumerator() new TestData { Id = 4, Name = "D" } }, new TestQueryOptions { Sort = "Name" }, + 3, "C" }; yield return new object[] @@ -320,8 +324,45 @@ public IEnumerator GetEnumerator() new TestData { Id = 6, Name = "F" } }, new TestQueryOptions { Sort = "-Name" }, + 6, "F" }; + yield return new object[] + { + new List + { + new TestData { Id = 2, Name = "A" }, + new TestData { Id = 1, Name = "A" }, + new TestData { Id = 3, Name = "B" } + }, + new TestQueryOptions { Sort = "Name,Id" }, + 1, + "A" + }; + yield return new object[] + { + new List + { + new TestData { Id = 1, Name = "A" }, + new TestData { Id = 2, Name = "A" }, + new TestData { Id = 3, Name = "B" } + }, + new TestQueryOptions { Sort = "Name,-Id" }, + 2, + "A" + }; + yield return new object[] + { + new List + { + new TestData { Id = 1, Name = "C" }, + new TestData { Id = 2, Name = "B" }, + new TestData { Id = 2, Name = "A" } + }, + new TestQueryOptions { Sort = "-Id,Name" }, + 2, + "A" + }; } IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();