diff --git a/AutoMapper.AspNetCore.OData.EFCore/LinqExtensions.cs b/AutoMapper.AspNetCore.OData.EFCore/LinqExtensions.cs index 23f9873..47f64a5 100644 --- a/AutoMapper.AspNetCore.OData.EFCore/LinqExtensions.cs +++ b/AutoMapper.AspNetCore.OData.EFCore/LinqExtensions.cs @@ -123,10 +123,8 @@ public static Expression> ToFilterExpression(this ODataQueryOpt /// public static Expression, IQueryable>> GetQueryableExpression(this ODataQueryOptions options, ODataSettings oDataSettings = null) { - if (NoQueryableMethod(options, oDataSettings)) - return null; - - ParameterExpression param = Expression.Parameter(typeof(IQueryable), "q"); + if (NoQueryableMethod(options, oDataSettings)) return null; + var param = Expression.Parameter(typeof(IQueryable), "q"); return Expression.Lambda, IQueryable>> ( @@ -134,19 +132,18 @@ public static Expression, IQueryable>> GetQueryableExpress ); } - public static Expression GetOrderByMethod(this Expression expression, + private static Expression GetOrderByMethod(this Expression expression, ODataQueryOptions options, ODataSettings oDataSettings = null) { - if (NoQueryableMethod(options, oDataSettings)) - return null; - + if (NoQueryableMethod(options, oDataSettings)) return null; return expression.GetQueryableMethod ( options.Context, options.OrderBy?.OrderByClause, typeof(T), options.Skip?.Value, - GetPageSize() + GetPageSize(), + oDataSettings ); int? GetPageSize() @@ -166,8 +163,30 @@ public static Expression GetOrderByMethod(this Expression expression, } public static Expression GetQueryableMethod(this Expression expression, - ODataQueryContext context, OrderByClause orderByClause, Type type, int? skip, int? top) + ODataQueryContext context, OrderByClause orderByClause, Type type, int? skip, int? top, + ODataSettings? oDataSettings = null) { + if (oDataSettings?.AlwaysSortByPrimaryKey is true) + { + var orderBySettings = context.FindSortableProperties(type, OrderByDirection.Descending); + if (orderBySettings is null) return null; + + return orderByClause switch + { + null when skip is null && top is null => expression + .GetDefaultOrderByCall(orderBySettings), + null => expression + .GetDefaultOrderByCall(orderBySettings) + .GetSkipCall(skip) + .GetTakeCall(top), + _ => expression + .GetOrderByCall(orderByClause, context) + .GetDefaultThenByCall(orderBySettings) + .GetSkipCall(skip) + .GetTakeCall(top) + }; + } + if (orderByClause is null && skip is null && top is null) return null; @@ -189,22 +208,26 @@ public static Expression GetQueryableMethod(this Expression expression, .GetSkipCall(skip) .GetTakeCall(top); } - + private static bool NoQueryableMethod(ODataQueryOptions options, ODataSettings oDataSettings) => options.OrderBy is null - && options.Top is null - && options.Skip is null - && oDataSettings?.PageSize is null; - - + && options.Top is null + && options.Skip is null + && oDataSettings?.PageSize is null + && (oDataSettings is null || oDataSettings.AlwaysSortByPrimaryKey is false); + private static Expression GetDefaultThenByCall(this Expression expression, OrderBySetting settings) { return settings.ThenBy is null ? GetMethodCall() : GetMethodCall().GetDefaultThenByCall(settings.ThenBy); - Expression GetMethodCall() => - expression.GetOrderByCall(settings.Name, nameof(Queryable.ThenBy)); + Expression GetMethodCall() + { + return settings.Direction is OrderByDirection.Ascending + ? expression.GetOrderByCall(settings.Name, nameof(Queryable.ThenBy)) + : expression.GetOrderByCall(settings.Name, nameof(Queryable.ThenByDescending)); + } } private static Expression GetDefaultOrderByCall(this Expression expression, OrderBySetting settings) @@ -213,8 +236,12 @@ private static Expression GetDefaultOrderByCall(this Expression expression, Orde ? GetMethodCall() : GetMethodCall().GetDefaultThenByCall(settings.ThenBy); - Expression GetMethodCall() => - expression.GetOrderByCall(settings.Name, nameof(Queryable.OrderBy)); + Expression GetMethodCall() + { + return settings.Direction is OrderByDirection.Ascending + ? expression.GetOrderByCall(settings.Name, nameof(Queryable.OrderBy)) + : expression.GetOrderByCall(settings.Name, nameof(Queryable.OrderByDescending)); + } } private static Expression GetOrderByCall(this Expression expression, OrderByClause orderByClause, ODataQueryContext context) diff --git a/AutoMapper.AspNetCore.OData.EFCore/ODataQueryContextExtentions.cs b/AutoMapper.AspNetCore.OData.EFCore/ODataQueryContextExtentions.cs index a162416..f68d847 100644 --- a/AutoMapper.AspNetCore.OData.EFCore/ODataQueryContextExtentions.cs +++ b/AutoMapper.AspNetCore.OData.EFCore/ODataQueryContextExtentions.cs @@ -3,18 +3,20 @@ using System; using System.Collections.Generic; using System.Linq; +using Microsoft.OData.UriParser; namespace AutoMapper.AspNet.OData { internal static class ODataQueryContextExtentions { - public static OrderBySetting FindSortableProperties(this ODataQueryContext context, Type type) + public static OrderBySetting FindSortableProperties(this ODataQueryContext context, Type type, + OrderByDirection orderByDirection = OrderByDirection.Ascending) { context = context ?? throw new ArgumentNullException(nameof(context)); var entity = GetEntity(); return entity is not null - ? FindProperties(entity) + ? FindProperties(entity, orderByDirection) : throw new InvalidOperationException($"The type '{type.FullName}' has not been declared in the entity data model."); IEdmEntityType GetEntity() @@ -26,7 +28,7 @@ IEdmEntityType GetEntity() return null; } - static OrderBySetting FindProperties(IEdmEntityType entity) + static OrderBySetting FindProperties(IEdmEntityType entity, OrderByDirection orderByDirection) { var propertyNames = entity.Key().Any() switch { @@ -43,9 +45,10 @@ static OrderBySetting FindProperties(IEdmEntityType entity) if (settings.Name is null) { settings.Name = name; + settings.Direction = orderByDirection; return settings; } - settings.ThenBy = new() { Name = name }; + settings.ThenBy = new() { Name = name, Direction = orderByDirection }; return settings.ThenBy; }); return orderBySettings.Name is null ? null : orderBySettings; diff --git a/AutoMapper.AspNetCore.OData.EFCore/ODataSettings.cs b/AutoMapper.AspNetCore.OData.EFCore/ODataSettings.cs index 5e2dc35..b74111c 100644 --- a/AutoMapper.AspNetCore.OData.EFCore/ODataSettings.cs +++ b/AutoMapper.AspNetCore.OData.EFCore/ODataSettings.cs @@ -41,5 +41,22 @@ public class ODataSettings /// Default is true. /// public bool EnableConstantParameterization { get; set; } = true; + + /// + /// If sets to true, orderBy pk desc will always be present on main entity. + /// + /// + /// SELECT * + /// FROM "TEntitiy" AS "c" + /// ORDER BY "c"."Id" DESC + /// In case some orderBy was passed, additional thenBy pk will be applied + /// SELECT * + /// FROM "TEntitiy" AS "c" + /// ORDER BY "c"."Type" DESC, "c"."Id" DESC + /// + /// + /// Default is false. + /// + public bool AlwaysSortByPrimaryKey { get; set; } } } diff --git a/AutoMapper.AspNetCore.OData.EFCore/OrderBySetting.cs b/AutoMapper.AspNetCore.OData.EFCore/OrderBySetting.cs index 036a6ea..f821df6 100644 --- a/AutoMapper.AspNetCore.OData.EFCore/OrderBySetting.cs +++ b/AutoMapper.AspNetCore.OData.EFCore/OrderBySetting.cs @@ -1,8 +1,11 @@ -namespace AutoMapper.AspNet.OData +using Microsoft.OData.UriParser; + +namespace AutoMapper.AspNet.OData { internal class OrderBySetting { public string Name { get; set; } + public OrderByDirection Direction { get; set; } = OrderByDirection.Ascending; public OrderBySetting ThenBy { get; set; } } -} +} \ No newline at end of file diff --git a/AutoMapper.OData.EFCore.Tests/GetQueryTests.cs b/AutoMapper.OData.EFCore.Tests/GetQueryTests.cs index ad44a0e..2543794 100644 --- a/AutoMapper.OData.EFCore.Tests/GetQueryTests.cs +++ b/AutoMapper.OData.EFCore.Tests/GetQueryTests.cs @@ -265,6 +265,84 @@ void Test(ICollection collection) Assert.Equal("Two", collection.First().Name); } } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async void OpsTenantNoExpandNoFilterNoOrderByShouldApplyByPk(bool alwaysSortByPk) + { + // Arrange + const string query = "/opstenant"; + var querySettings = new QuerySettings + { + ODataSettings = new ODataSettings { AlwaysSortByPrimaryKey = alwaysSortByPk } + }; + + Test(Get(query, GetMandators(), querySettings: querySettings)); + Test(await GetAsync(query, GetMandators(), querySettings: querySettings)); + Test(await GetUsingCustomNameSpace(query, GetMandators(), querySettings: querySettings)); + return; + + void Test(ICollection collection) + { + var expected = collection + .Select(x => x.Identity) + .OrderByDescending(identity => identity) + .ToList(); + + if (alwaysSortByPk) + { + Assert.True(collection + .Select(x => x.Identity) + .SequenceEqual(expected)); + } + else + { + Assert.False(collection + .Select(x => x.Identity) + .SequenceEqual(expected)); + } + } + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task OpsTenantNoExpandNoFilterWithOrderByShouldApplyByPk(bool alwaysSortByPk) + { + const string query = "/opstenant?$orderby=Name desc"; + var querySettings = new QuerySettings + { + ODataSettings = new ODataSettings { AlwaysSortByPrimaryKey = alwaysSortByPk } + }; + + // Test multiple scenarios + Test(Get(query, GetMandators(), querySettings: querySettings)); + Test(await GetAsync(query, GetMandators(), querySettings: querySettings)); + Test(await GetUsingCustomNameSpace(query, GetMandators(), querySettings: querySettings)); + + return; + + void Test(ICollection collection) + { + // Check if the collection is correctly ordered by Name (desc) and then by Identity (asc) + var expected = collection + .OrderByDescending(x => x.Name) + .ThenByDescending(x => x.Identity) + .ToList(); + + if (alwaysSortByPk) + { + Assert.True(collection.SequenceEqual(expected), + "Collection is not ordered by Name (desc) and Identity (desc)."); + } + else + { + Assert.False(collection.SequenceEqual(expected), + "Collection is ordered by Name (desc) and Identity (desc)."); + } + } + } [Fact] public async void OpsTenantNoExpandFilterEqAndOrderBy() @@ -847,6 +925,33 @@ void Test(ICollection collection) Assert.Equal("Leeds", collection.Last().Builder.City.Name); } } + + private IQueryable GetMandators() + { + return new TMandator[] + { + new TMandator + { + Identity = Guid.Empty, // The first guide in order. + Name = "Two", // Duplicate name. + CreatedDate = new DateTime(2011, 12, 12) + }, + new TMandator + { + Identity = Guid.NewGuid(), + Name = "One", + CreatedDate = new DateTime(2012, 12, 12), + Buildings = new List() + }, + new TMandator + { + Identity = Guid.NewGuid(), + Name = "Two", + CreatedDate = new DateTime(2012, 12, 12), + Buildings = new List() + } + }.AsQueryable(); + } private IQueryable GetCategories() => new Category[]