Skip to content

Commit 9048f63

Browse files
committed
MinBy MaxBy support
fixes #25566
1 parent 347ab47 commit 9048f63

13 files changed

+1073
-16
lines changed

src/EFCore/Extensions/EntityFrameworkQueryableExtensions.cs

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1036,6 +1036,100 @@ public static Task<TResult> MaxAsync<TSource, TResult>(
10361036

10371037
#endregion
10381038

1039+
#region MinBy
1040+
1041+
/// <summary>
1042+
/// Asynchronously returns the minimum value in a generic <see cref="IQueryable{T}"/> according to a specified key selector function.
1043+
/// </summary>
1044+
/// <remarks>
1045+
/// <para>
1046+
/// Multiple active operations on the same context instance are not supported. Use <see langword="await" /> to ensure
1047+
/// that any asynchronous operations have completed before calling another method on this context.
1048+
/// See <see href="https://aka.ms/efcore-docs-threading">Avoiding DbContext threading issues</see> for more information and examples.
1049+
/// </para>
1050+
/// <para>
1051+
/// See <see href="https://aka.ms/efcore-docs-async-linq">Querying data with EF Core</see> for more information and examples.
1052+
/// </para>
1053+
/// </remarks>
1054+
/// <typeparam name="TSource">The type of the elements of <paramref name="source" />.</typeparam>
1055+
/// <typeparam name="TKey">
1056+
/// The type of the value returned by the function represented by <paramref name="keySelector" />.
1057+
/// </typeparam>
1058+
/// <param name="source">A sequence of values to determine the minimum value of.</param>
1059+
/// <param name="keySelector">A function to extract the key for each element.</param>
1060+
/// <param name="cancellationToken">A <see cref="CancellationToken" /> to observe while waiting for the task to complete.</param>
1061+
/// <returns>
1062+
/// A task that represents the asynchronous operation.
1063+
/// The task contains the value with the minimum key in the sequence.
1064+
/// </returns>
1065+
/// <exception cref="ArgumentNullException">
1066+
/// <paramref name="source" /> or <paramref name="keySelector" /> is <see langword="null" />.
1067+
/// </exception>
1068+
/// <exception cref="InvalidOperationException"><paramref name="source" /> contains no elements.</exception>
1069+
/// <exception cref="OperationCanceledException">If the <see cref="CancellationToken" /> is canceled.</exception>
1070+
public static Task<TSource> MinByAsync<TSource, TKey>(
1071+
this IQueryable<TSource> source,
1072+
Expression<Func<TSource, TKey>> keySelector,
1073+
CancellationToken cancellationToken = default)
1074+
{
1075+
Check.NotNull(keySelector);
1076+
1077+
return ExecuteAsync<TSource, Task<TSource>>(
1078+
method: new Func<IQueryable<TSource>, Expression<Func<TSource, TKey>>, TSource?>(Queryable.MinBy).Method,
1079+
source,
1080+
keySelector,
1081+
cancellationToken);
1082+
}
1083+
1084+
#endregion
1085+
1086+
#region MaxBy
1087+
1088+
/// <summary>
1089+
/// Asynchronously returns the maximum value in a generic <see cref="IQueryable{T}"/> according to a specified key selector function.
1090+
/// </summary>
1091+
/// <remarks>
1092+
/// <para>
1093+
/// Multiple active operations on the same context instance are not supported. Use <see langword="await" /> to ensure
1094+
/// that any asynchronous operations have completed before calling another method on this context.
1095+
/// See <see href="https://aka.ms/efcore-docs-threading">Avoiding DbContext threading issues</see> for more information and examples.
1096+
/// </para>
1097+
/// <para>
1098+
/// See <see href="https://aka.ms/efcore-docs-async-linq">Querying data with EF Core</see> for more information and examples.
1099+
/// </para>
1100+
/// </remarks>
1101+
/// <typeparam name="TSource">The type of the elements of <paramref name="source" />.</typeparam>
1102+
/// <typeparam name="TKey">
1103+
/// The type of the value returned by the function represented by <paramref name="keySelector" />.
1104+
/// </typeparam>
1105+
/// <param name="source">A sequence of values to determine the maximum value of.</param>
1106+
/// <param name="keySelector">A function to extract the key for each element.</param>
1107+
/// <param name="cancellationToken">A <see cref="CancellationToken" /> to observe while waiting for the task to complete.</param>
1108+
/// <returns>
1109+
/// A task that represents the asynchronous operation.
1110+
/// The task contains the value with the maximum key in the sequence.
1111+
/// </returns>
1112+
/// <exception cref="ArgumentNullException">
1113+
/// <paramref name="source" /> or <paramref name="keySelector" /> is <see langword="null" />.
1114+
/// </exception>
1115+
/// <exception cref="InvalidOperationException"><paramref name="source" /> contains no elements.</exception>
1116+
/// <exception cref="OperationCanceledException">If the <see cref="CancellationToken" /> is canceled.</exception>
1117+
public static Task<TSource> MaxByAsync<TSource, TKey>(
1118+
this IQueryable<TSource> source,
1119+
Expression<Func<TSource, TKey>> keySelector,
1120+
CancellationToken cancellationToken = default)
1121+
{
1122+
Check.NotNull(keySelector);
1123+
1124+
return ExecuteAsync<TSource, Task<TSource>>(
1125+
method: new Func<IQueryable<TSource>, Expression<Func<TSource, TKey>>, TSource?>(Queryable.MaxBy).Method,
1126+
source,
1127+
keySelector,
1128+
cancellationToken);
1129+
}
1130+
1131+
#endregion
1132+
10391133
#region Sum
10401134

10411135
/// <summary>

src/EFCore/Query/Internal/NavigationExpandingExpressionVisitor.ExpressionVisitors.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1001,7 +1001,7 @@ private sealed class ReducingExpressionVisitor : ExpressionVisitor
10011001

10021002
if (navigationExpansionExpression.CardinalityReducingGenericMethodInfo != null)
10031003
{
1004-
var arguments = new List<Expression> { result };
1004+
var arguments = new List<Expression>(navigationExpansionExpression.CardinalityReducingMethodArguments.Count + 1) { result };
10051005
arguments.AddRange(navigationExpansionExpression.CardinalityReducingMethodArguments.Select(x => Visit(x)));
10061006

10071007
result = Expression.Call(

src/EFCore/Query/Internal/QueryableMethodNormalizingExpressionVisitor.cs

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,7 @@ or nameof(EntityFrameworkQueryableExtensions.NotQuiteInclude)
229229
{
230230
visitedMethodCall = TryNormalizeOrderAndOrderDescending(visitedMethodCall);
231231
visitedMethodCall = TryFlattenGroupJoinSelectMany(visitedMethodCall);
232+
visitedMethodCall = TryNormalizeMaxByMinBy(visitedMethodCall);
232233

233234
return visitedMethodCall;
234235
}
@@ -758,6 +759,68 @@ when selectManyMethod.GetGenericMethodDefinition() == QueryableMethods.SelectMan
758759
return methodCallExpression;
759760
}
760761

762+
private MethodCallExpression TryNormalizeMaxByMinBy(MethodCallExpression methodCallExpression)
763+
{
764+
/*
765+
MinBy(x => x.Prop) --> OrderBy(x => x.Prop).First/FirstOrDefault()
766+
MaxBy(x => x.Prop) --> OrderByDescending(x => x.Prop).First/FirstOrDefault()
767+
768+
MaxBy/MinBy(x => new { x.Prop, x.Prop2 }) --> OrderBy/Descending(x => x.Prop).ThenBy/Descending(x.Prop2).First/OrDefault()
769+
*/
770+
771+
var genericMethod = methodCallExpression.Method.GetGenericMethodDefinition();
772+
if (genericMethod == QueryableMethods.MinBy
773+
|| genericMethod == QueryableMethods.MaxBy)
774+
{
775+
var sourceType = methodCallExpression.Method.GetGenericArguments()[0];
776+
777+
var keySelector = methodCallExpression.Arguments[1].UnwrapLambdaFromQuote();
778+
779+
var firstMethod = sourceType.IsNullableValueType()
780+
? QueryableMethods.FirstOrDefaultWithoutPredicate
781+
: QueryableMethods.FirstWithoutPredicate;
782+
783+
var orderingMethod = genericMethod == QueryableMethods.MinBy
784+
? QueryableMethods.OrderBy
785+
: QueryableMethods.OrderByDescending;
786+
787+
Expression source;
788+
789+
if (keySelector.Body is NewExpression newExpression && newExpression.Arguments.Count > 0)
790+
{
791+
source = Expression.Call(
792+
orderingMethod.MakeGenericMethod(sourceType, newExpression.Arguments[0].Type),
793+
methodCallExpression.Arguments[0],
794+
Expression.Quote(Expression.Lambda(newExpression.Arguments[0], keySelector.Parameters[0])));
795+
796+
var thenByMethod = genericMethod == QueryableMethods.MinBy
797+
? QueryableMethods.ThenBy
798+
: QueryableMethods.ThenByDescending;
799+
800+
for (var i = 1; i < newExpression.Arguments.Count; i++)
801+
{
802+
source = Expression.Call(
803+
thenByMethod.MakeGenericMethod(sourceType, newExpression.Arguments[i].Type),
804+
source,
805+
Expression.Quote(Expression.Lambda(newExpression.Arguments[i], keySelector.Parameters[0])));
806+
}
807+
}
808+
else
809+
{
810+
source = Expression.Call(
811+
orderingMethod.MakeGenericMethod(sourceType, keySelector.ReturnType),
812+
methodCallExpression.Arguments[0],
813+
Expression.Quote(keySelector));
814+
}
815+
816+
return Expression.Call(
817+
firstMethod.MakeGenericMethod(sourceType),
818+
source);
819+
}
820+
821+
return methodCallExpression;
822+
}
823+
761824
private sealed class GroupJoinConvertingExpressionVisitor : ExpressionVisitor
762825
{
763826
protected override Expression VisitMethodCall(MethodCallExpression methodCallExpression)

src/EFCore/Query/QueryableMethods.cs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,20 @@ public static class QueryableMethods
270270
/// </summary>
271271
public static MethodInfo OrderDescending { get; }
272272

273+
/// <summary>
274+
/// The <see cref="MethodInfo" /> for
275+
/// <see
276+
/// cref="Queryable.MaxBy{TSource, TKey}(IQueryable{TSource}, Expression{Func{TSource, TKey}})"/>
277+
/// </summary>
278+
public static MethodInfo MaxBy { get; }
279+
280+
/// <summary>
281+
/// The <see cref="MethodInfo" /> for
282+
/// <see
283+
/// cref="Queryable.MinBy{TSource, TKey}(IQueryable{TSource}, Expression{Func{TSource, TKey}})"/>
284+
/// </summary>
285+
public static MethodInfo MinBy { get; }
286+
273287
/// <summary>
274288
/// The <see cref="MethodInfo" /> for <see cref="Queryable.Reverse{TSource}" />
275289
/// </summary>
@@ -724,6 +738,22 @@ static QueryableMethods()
724738
typeof(IQueryable<>).MakeGenericType(types[0])
725739
]);
726740

741+
MaxBy = GetMethod(
742+
nameof(Queryable.MaxBy), 2,
743+
types =>
744+
[
745+
typeof(IQueryable<>).MakeGenericType(types[0]),
746+
typeof(Expression<>).MakeGenericType(typeof(Func<,>).MakeGenericType(types[0], types[1])),
747+
]);
748+
749+
MinBy = GetMethod(
750+
nameof(Queryable.MinBy), 2,
751+
types =>
752+
[
753+
typeof(IQueryable<>).MakeGenericType(types[0]),
754+
typeof(Expression<>).MakeGenericType(typeof(Func<,>).MakeGenericType(types[0], types[1])),
755+
]);
756+
727757
Reverse = GetMethod(nameof(Queryable.Reverse), 1, types => [typeof(IQueryable<>).MakeGenericType(types[0])]);
728758

729759
RightJoin = GetMethod(

src/Shared/EnumerableMethods.cs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,10 @@ internal static class EnumerableMethods
118118

119119
public static MethodInfo OrderByDescending { get; }
120120

121+
public static MethodInfo MaxBy { get; }
122+
123+
public static MethodInfo MinBy { get; }
124+
121125
//public static MethodInfo OrderByDescendingWithComparer { get; }
122126

123127
//public static MethodInfo Prepend { get; }
@@ -458,6 +462,16 @@ static EnumerableMethods()
458462
nameof(Enumerable.OrderByDescending), 2,
459463
types => [typeof(IEnumerable<>).MakeGenericType(types[0]), typeof(Func<,>).MakeGenericType(types[0], types[1])]);
460464

465+
MaxBy = GetMethod(
466+
nameof(Enumerable.MaxBy), 2,
467+
types =>
468+
[typeof(IEnumerable<>).MakeGenericType(types[0]), typeof(Func<,>).MakeGenericType(types[0], types[1])]);
469+
470+
MinBy = GetMethod(
471+
nameof(Enumerable.MinBy), 2,
472+
types =>
473+
[typeof(IEnumerable<>).MakeGenericType(types[0]), typeof(Func<,>).MakeGenericType(types[0], types[1])]);
474+
461475
Reverse = GetMethod(nameof(Enumerable.Reverse), 1, types => [typeof(IEnumerable<>).MakeGenericType(types[0])]);
462476

463477
Select = GetMethod(

test/EFCore.InMemory.FunctionalTests/Query/NorthwindAggregateOperatorsQueryInMemoryTest.cs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,16 @@ public override async Task Min_no_data_subquery(bool async)
2222
"Sequence contains no elements",
2323
(await Assert.ThrowsAsync<InvalidOperationException>(() => base.Min_no_data_subquery(async))).Message);
2424

25+
public override async Task MaxBy_no_data_subquery(bool async)
26+
=> Assert.Equal(
27+
"Sequence contains no elements",
28+
(await Assert.ThrowsAsync<InvalidOperationException>(() => base.MaxBy_no_data_subquery(async))).Message);
29+
30+
public override async Task MinBy_no_data_subquery(bool async)
31+
=> Assert.Equal(
32+
"Sequence contains no elements",
33+
(await Assert.ThrowsAsync<InvalidOperationException>(() => base.MinBy_no_data_subquery(async))).Message);
34+
2535
public override async Task Average_on_nav_subquery_in_projection(bool async)
2636
=> Assert.Equal(
2737
"Sequence contains no elements",

0 commit comments

Comments
 (0)