From cc2ce3c38f8a644543eb90392700dd3db96f2e68 Mon Sep 17 00:00:00 2001 From: Ben O'Donovan Date: Tue, 13 Oct 2026 15:05:50 +0100 Subject: [PATCH 1/5] Translate grouping aggregates as window functions behind an opt-in - Add WindowFunctionExpression and translate aggregates projected alongside a grouping's elements, e.g. GroupBy(e => e.Dept).Select(g => new { Items = g.ToList(), Avg = g.Average(e => e.Salary) }), as OVER (PARTITION BY ) over the ungrouped rows, instead of grouping and joining the source back to itself - Opt in with UseWindowFunctionAggregates(), off by default and part of the compiled query cache key - Mark the whole new surface [Experimental(EF9001)] - Support Count/LongCount/Min/Max/Sum/Average applied directly to the grouping, up to six per projection; anything else keeps the existing GROUP BY translation - Let providers veto an aggregate through IsWindowFunctionSupported; SQLite vetoes its user-defined decimal aggregates - Keep nullability rewrites outside OVER, so SUM gets its COALESCE around the window function rather than inside it - Carry window values in their nullable form, so an aggregate over a partition of nulls materializes correctly - Share OVER clause generation between window functions and ROW_NUMBER Part of #12747 --- .../EFCore.Relational.baseline.json | 60 +++ .../RelationalDbContextOptionsBuilder.cs | 23 + .../RelationalOptionsExtension.cs | 34 ++ .../Query/QuerySqlGenerator.cs | 52 ++- ...elationalCompiledQueryCacheKeyGenerator.cs | 37 +- .../RelationalQueryCompilationContext.cs | 16 +- ...yableMethodTranslatingExpressionVisitor.cs | 235 +++++++++- .../WindowFunctionExpression.cs | 170 +++++++ .../Query/SqlNullabilityProcessor.cs | 99 +++- ...yableMethodTranslatingExpressionVisitor.cs | 13 + .../WindowFunctionQueryRelationalTestBase.cs | 430 ++++++++++++++++++ .../Query/WindowFunctionQuerySqlServerTest.cs | 209 +++++++++ .../Query/WindowFunctionQuerySqliteTest.cs | 214 +++++++++ 13 files changed, 1561 insertions(+), 31 deletions(-) create mode 100644 src/EFCore.Relational/Query/SqlExpressions/WindowFunctionExpression.cs create mode 100644 test/EFCore.Relational.Specification.Tests/Query/WindowFunctionQueryRelationalTestBase.cs create mode 100644 test/EFCore.SqlServer.FunctionalTests/Query/WindowFunctionQuerySqlServerTest.cs create mode 100644 test/EFCore.Sqlite.FunctionalTests/Query/WindowFunctionQuerySqliteTest.cs diff --git a/src/EFCore.Relational/EFCore.Relational.baseline.json b/src/EFCore.Relational/EFCore.Relational.baseline.json index 93c6cafd4c3..90156b81271 100644 --- a/src/EFCore.Relational/EFCore.Relational.baseline.json +++ b/src/EFCore.Relational/EFCore.Relational.baseline.json @@ -8992,6 +8992,10 @@ }, { "Member": "virtual System.Linq.Expressions.Expression VisitValues(Microsoft.EntityFrameworkCore.Query.SqlExpressions.ValuesExpression valuesExpression);" + }, + { + "Member": "virtual System.Linq.Expressions.Expression VisitWindowFunction(Microsoft.EntityFrameworkCore.Query.SqlExpressions.WindowFunctionExpression windowFunctionExpression);", + "Stage": "Experimental" } ], "Properties": [ @@ -9949,6 +9953,10 @@ { "Member": "RelationalCompiledQueryCacheKey(Microsoft.EntityFrameworkCore.Query.CompiledQueryCacheKeyGenerator.CompiledQueryCacheKey compiledQueryCacheKey, bool useRelationalNulls, Microsoft.EntityFrameworkCore.QuerySplittingBehavior? querySplittingBehavior, bool shouldBuffer);" }, + { + "Member": "RelationalCompiledQueryCacheKey(Microsoft.EntityFrameworkCore.Query.CompiledQueryCacheKeyGenerator.CompiledQueryCacheKey compiledQueryCacheKey, bool useRelationalNulls, Microsoft.EntityFrameworkCore.QuerySplittingBehavior? querySplittingBehavior, bool shouldBuffer, bool useWindowFunctionAggregates);", + "Stage": "Experimental" + }, { "Member": "RelationalCompiledQueryCacheKey();" }, @@ -10864,6 +10872,10 @@ { "Member": "virtual TBuilder UseRelationalNulls(bool useRelationalNulls = true);" }, + { + "Member": "virtual TBuilder UseWindowFunctionAggregates(bool useWindowFunctionAggregates = true);", + "Stage": "Experimental" + }, { "Member": "virtual TBuilder WithOption(System.Func setAction);" } @@ -13549,6 +13561,10 @@ }, { "Member": "virtual Microsoft.EntityFrameworkCore.Infrastructure.RelationalOptionsExtension WithUseRelationalNulls(bool useRelationalNulls);" + }, + { + "Member": "virtual Microsoft.EntityFrameworkCore.Infrastructure.RelationalOptionsExtension WithUseWindowFunctionAggregates(bool useWindowFunctionAggregates);", + "Stage": "Experimental" } ], "Properties": [ @@ -13596,6 +13612,10 @@ }, { "Member": "virtual bool UseRelationalNulls { get; }" + }, + { + "Member": "virtual bool UseWindowFunctionAggregates { get; }", + "Stage": "Experimental" } ] }, @@ -14399,6 +14419,10 @@ { "Member": "virtual bool IsValidSelectExpressionForExecuteUpdate(Microsoft.EntityFrameworkCore.Query.SqlExpressions.SelectExpression selectExpression, Microsoft.EntityFrameworkCore.Query.SqlExpressions.TableExpressionBase targetTable, out Microsoft.EntityFrameworkCore.Query.SqlExpressions.TableExpression? tableExpression);" }, + { + "Member": "virtual bool IsWindowFunctionSupported(Microsoft.EntityFrameworkCore.Query.SqlExpressions.SqlFunctionExpression function);", + "Stage": "Experimental" + }, { "Member": "virtual Microsoft.EntityFrameworkCore.Query.ShapedQueryExpression? TransformJsonQueryToTable(Microsoft.EntityFrameworkCore.Query.JsonQueryExpression jsonQueryExpression);" }, @@ -14479,6 +14503,10 @@ }, { "Member": "virtual Microsoft.EntityFrameworkCore.Query.SqlAliasManager SqlAliasManager { get; }" + }, + { + "Member": "virtual bool UseWindowFunctionAggregates { get; }", + "Stage": "Experimental" } ] }, @@ -17945,6 +17973,10 @@ }, { "Member": "virtual Microsoft.EntityFrameworkCore.Query.SqlExpressions.SqlExpression VisitSqlUnary(Microsoft.EntityFrameworkCore.Query.SqlExpressions.SqlUnaryExpression sqlUnaryExpression, bool allowOptimizedExpansion, out bool nullable);" + }, + { + "Member": "virtual Microsoft.EntityFrameworkCore.Query.SqlExpressions.SqlExpression VisitWindowFunction(Microsoft.EntityFrameworkCore.Query.SqlExpressions.WindowFunctionExpression windowFunctionExpression, bool allowOptimizedExpansion, out bool nullable);", + "Stage": "Experimental" } ], "Properties": [ @@ -19374,6 +19406,34 @@ "Member": "virtual Microsoft.EntityFrameworkCore.Metadata.Builders.ViewColumnBuilder HasColumnName(string? name);" } ] + }, + { + "Type": "class Microsoft.EntityFrameworkCore.Query.SqlExpressions.WindowFunctionExpression : Microsoft.EntityFrameworkCore.Query.SqlExpressions.SqlExpression", + "Stage": "Experimental", + "Methods": [ + { + "Member": "WindowFunctionExpression(Microsoft.EntityFrameworkCore.Query.SqlExpressions.SqlExpression function, System.Collections.Generic.IReadOnlyList? partitions, System.Collections.Generic.IReadOnlyList? orderings, Microsoft.EntityFrameworkCore.Storage.RelationalTypeMapping? typeMapping);", + "Stage": "Experimental" + }, + { + "Member": "virtual Microsoft.EntityFrameworkCore.Query.SqlExpressions.WindowFunctionExpression Update(Microsoft.EntityFrameworkCore.Query.SqlExpressions.SqlExpression function, System.Collections.Generic.IReadOnlyList partitions, System.Collections.Generic.IReadOnlyList orderings);", + "Stage": "Experimental" + } + ], + "Properties": [ + { + "Member": "virtual Microsoft.EntityFrameworkCore.Query.SqlExpressions.SqlExpression Function { get; }", + "Stage": "Experimental" + }, + { + "Member": "virtual System.Collections.Generic.IReadOnlyList Orderings { get; }", + "Stage": "Experimental" + }, + { + "Member": "virtual System.Collections.Generic.IReadOnlyList Partitions { get; }", + "Stage": "Experimental" + } + ] } ] } \ No newline at end of file diff --git a/src/EFCore.Relational/Infrastructure/RelationalDbContextOptionsBuilder.cs b/src/EFCore.Relational/Infrastructure/RelationalDbContextOptionsBuilder.cs index a3aac7672be..a462f4d10d5 100644 --- a/src/EFCore.Relational/Infrastructure/RelationalDbContextOptionsBuilder.cs +++ b/src/EFCore.Relational/Infrastructure/RelationalDbContextOptionsBuilder.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; namespace Microsoft.EntityFrameworkCore.Infrastructure; @@ -163,6 +164,28 @@ public virtual TBuilder ExecutionStrategy( public virtual TBuilder UseParameterizedCollectionMode(ParameterTranslationMode parameterizedCollectionMode) => WithOption(e => (TExtension)e.WithUseParameterizedCollectionMode(parameterizedCollectionMode)); + /// + /// Configures the context to translate aggregates which are projected alongside the elements of a grouping as SQL window + /// functions, e.g. GroupBy(e => e.Key).Select(g => new { Elements = g.ToList(), Sum = g.Sum(e => e.Value) }) + /// becomes SUM(Value) OVER (PARTITION BY Key) over the ungrouped rows. + /// + /// + /// + /// Without this option such a projection groups on the server and then joins the source back to itself to get the elements + /// back, reading everything twice. Projections which this option cannot translate keep that existing translation. + /// + /// + /// This is an experimental API used to gather feedback on + /// window function support; it may change or be removed in + /// a future release. + /// + /// + /// to enable the translation; to disable it. + /// The same builder instance so that multiple calls can be chained. + [Experimental(EFDiagnostics.ExperimentalApi)] + public virtual TBuilder UseWindowFunctionAggregates(bool useWindowFunctionAggregates = true) + => WithOption(e => (TExtension)e.WithUseWindowFunctionAggregates(useWindowFunctionAggregates)); + /// /// Sets an option by cloning the extension used to store the settings. This ensures the builder /// does not modify options that are already in use elsewhere. diff --git a/src/EFCore.Relational/Infrastructure/RelationalOptionsExtension.cs b/src/EFCore.Relational/Infrastructure/RelationalOptionsExtension.cs index 345d082a952..38a7b38345e 100644 --- a/src/EFCore.Relational/Infrastructure/RelationalOptionsExtension.cs +++ b/src/EFCore.Relational/Infrastructure/RelationalOptionsExtension.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Diagnostics.CodeAnalysis; using System.Text; namespace Microsoft.EntityFrameworkCore.Infrastructure; @@ -37,6 +38,7 @@ public abstract class RelationalOptionsExtension : IDbContextOptionsExtension private string? _migrationsHistoryTableSchema; private Func? _executionStrategyFactory; private ParameterTranslationMode? _parameterizedCollectionMode; + private bool _useWindowFunctionAggregates; /// /// Creates a new set of options with everything set to default values. @@ -65,6 +67,7 @@ protected RelationalOptionsExtension(RelationalOptionsExtension copyFrom) _migrationsHistoryTableSchema = copyFrom._migrationsHistoryTableSchema; _executionStrategyFactory = copyFrom._executionStrategyFactory; _parameterizedCollectionMode = copyFrom._parameterizedCollectionMode; + _useWindowFunctionAggregates = copyFrom._useWindowFunctionAggregates; } /// @@ -404,6 +407,32 @@ public virtual RelationalOptionsExtension WithUseParameterizedCollectionMode( return clone; } + /// + /// Whether aggregates projected alongside the elements of a grouping are translated as window functions. + /// + /// + /// See the window function issue for more information. + /// + [Experimental(EFDiagnostics.ExperimentalApi)] + public virtual bool UseWindowFunctionAggregates + => _useWindowFunctionAggregates; + + /// + /// Creates a new instance with all options the same as for this instance, but with the given option changed. + /// It is unusual to call this method directly. Instead use . + /// + /// The option to change. + /// A new instance with the option changed. + [Experimental(EFDiagnostics.ExperimentalApi)] + public virtual RelationalOptionsExtension WithUseWindowFunctionAggregates(bool useWindowFunctionAggregates) + { + var clone = Clone(); + + clone._useWindowFunctionAggregates = useWindowFunctionAggregates; + + return clone; + } + /// /// Finds an existing registered on the given options /// or throws if none has been registered. This is typically used to find some relational @@ -563,6 +592,11 @@ public override string LogFragment .Append(' '); } + if (Extension._useWindowFunctionAggregates) + { + builder.Append("UseWindowFunctionAggregates "); + } + _logFragment = builder.ToString(); } diff --git a/src/EFCore.Relational/Query/QuerySqlGenerator.cs b/src/EFCore.Relational/Query/QuerySqlGenerator.cs index b9200960c85..bee84ea8509 100644 --- a/src/EFCore.Relational/Query/QuerySqlGenerator.cs +++ b/src/EFCore.Relational/Query/QuerySqlGenerator.cs @@ -156,6 +156,9 @@ protected override Expression VisitExtension(Expression expression) UpdateExpression e => VisitUpdate(e), JsonScalarExpression e => VisitJsonScalar(e), ValuesExpression e => VisitValues(e), +#pragma warning disable EF9001 // Window function support is experimental + WindowFunctionExpression e => VisitWindowFunction(e), +#pragma warning restore EF9001 _ => throw new InvalidOperationException( RelationalStrings.UnhandledExpressionInVisitor(expression, expression.GetType(), nameof(QuerySqlGenerator))), @@ -1384,17 +1387,8 @@ protected virtual Expression VisitScalarSubquery(ScalarSubqueryExpression scalar /// The for which to generate SQL. protected virtual Expression VisitRowNumber(RowNumberExpression rowNumberExpression) { - _relationalCommandBuilder.Append("ROW_NUMBER() OVER("); - if (rowNumberExpression.Partitions.Any()) - { - _relationalCommandBuilder.Append("PARTITION BY "); - GenerateList(rowNumberExpression.Partitions, e => Visit(e)); - _relationalCommandBuilder.Append(" "); - } - - _relationalCommandBuilder.Append("ORDER BY "); - GenerateList(rowNumberExpression.Orderings, e => Visit(e)); - _relationalCommandBuilder.Append(")"); + _relationalCommandBuilder.Append("ROW_NUMBER()"); + GenerateOver(rowNumberExpression.Partitions, rowNumberExpression.Orderings); return rowNumberExpression; } @@ -1408,6 +1402,42 @@ private Expression VisitDenseRank(DenseRankExpression denseRankExpression) return denseRankExpression; } + /// + /// Generates SQL for a window function expression. + /// + /// The for which to generate SQL. + [Experimental(EFDiagnostics.ExperimentalApi)] + protected virtual Expression VisitWindowFunction(WindowFunctionExpression windowFunctionExpression) + { + Visit(windowFunctionExpression.Function); + GenerateOver(windowFunctionExpression.Partitions, windowFunctionExpression.Orderings); + + return windowFunctionExpression; + } + + private void GenerateOver(IReadOnlyList partitions, IReadOnlyList orderings) + { + _relationalCommandBuilder.Append(" OVER("); + if (partitions.Count > 0) + { + _relationalCommandBuilder.Append("PARTITION BY "); + GenerateList(partitions, e => Visit(e)); + + if (orderings.Count > 0) + { + _relationalCommandBuilder.Append(" "); + } + } + + if (orderings.Count > 0) + { + _relationalCommandBuilder.Append("ORDER BY "); + GenerateList(orderings, e => Visit(e)); + } + + _relationalCommandBuilder.Append(")"); + } + /// /// Generates SQL for a row value expression. /// diff --git a/src/EFCore.Relational/Query/RelationalCompiledQueryCacheKeyGenerator.cs b/src/EFCore.Relational/Query/RelationalCompiledQueryCacheKeyGenerator.cs index 34f1571ec13..8b4fc1fdfef 100644 --- a/src/EFCore.Relational/Query/RelationalCompiledQueryCacheKeyGenerator.cs +++ b/src/EFCore.Relational/Query/RelationalCompiledQueryCacheKeyGenerator.cs @@ -1,6 +1,8 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Diagnostics.CodeAnalysis; + namespace Microsoft.EntityFrameworkCore.Query; /// @@ -37,11 +39,14 @@ public override object GenerateCacheKey(Expression query, bool async) { var relationalOptions = RelationalOptionsExtension.Extract(RelationalDependencies.ContextOptions); +#pragma warning disable EF9001 // Window function support is experimental return new RelationalCompiledQueryCacheKey( base.GenerateCacheKeyCore(query, async), relationalOptions.UseRelationalNulls, relationalOptions.QuerySplittingBehavior, - shouldBuffer: ExecutionStrategy.Current?.RetriesOnFailure ?? Dependencies.IsRetryingExecutionStrategy); + shouldBuffer: ExecutionStrategy.Current?.RetriesOnFailure ?? Dependencies.IsRetryingExecutionStrategy, + relationalOptions.UseWindowFunctionAggregates); +#pragma warning restore EF9001 } /// @@ -60,6 +65,7 @@ public override object GenerateCacheKey(Expression query, bool async) private readonly bool _useRelationalNulls; private readonly QuerySplittingBehavior? _querySplittingBehavior; private readonly bool _shouldBuffer; + private readonly bool _useWindowFunctionAggregates; /// /// Initializes a new instance of the class. @@ -73,11 +79,35 @@ public RelationalCompiledQueryCacheKey( bool useRelationalNulls, QuerySplittingBehavior? querySplittingBehavior, bool shouldBuffer) +#pragma warning disable EF9001 // Window function support is experimental + : this(compiledQueryCacheKey, useRelationalNulls, querySplittingBehavior, shouldBuffer, useWindowFunctionAggregates: false) +#pragma warning restore EF9001 + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The non-relational cache key. + /// True to use relational null logic. + /// to use when loading related collections. + /// if the query should be buffered. + /// + /// if aggregates projected alongside the elements of a grouping are translated as window functions. + /// + [Experimental(EFDiagnostics.ExperimentalApi)] + public RelationalCompiledQueryCacheKey( + CompiledQueryCacheKey compiledQueryCacheKey, + bool useRelationalNulls, + QuerySplittingBehavior? querySplittingBehavior, + bool shouldBuffer, + bool useWindowFunctionAggregates) { _compiledQueryCacheKey = compiledQueryCacheKey; _useRelationalNulls = useRelationalNulls; _querySplittingBehavior = querySplittingBehavior; _shouldBuffer = shouldBuffer; + _useWindowFunctionAggregates = useWindowFunctionAggregates; } /// @@ -90,11 +120,12 @@ public bool Equals(RelationalCompiledQueryCacheKey other) => _compiledQueryCacheKey.Equals(other._compiledQueryCacheKey) && _useRelationalNulls == other._useRelationalNulls && _querySplittingBehavior == other._querySplittingBehavior - && _shouldBuffer == other._shouldBuffer; + && _shouldBuffer == other._shouldBuffer + && _useWindowFunctionAggregates == other._useWindowFunctionAggregates; /// public override int GetHashCode() => HashCode.Combine( - _compiledQueryCacheKey, _useRelationalNulls, _querySplittingBehavior, _shouldBuffer); + _compiledQueryCacheKey, _useRelationalNulls, _querySplittingBehavior, _shouldBuffer, _useWindowFunctionAggregates); } } diff --git a/src/EFCore.Relational/Query/RelationalQueryCompilationContext.cs b/src/EFCore.Relational/Query/RelationalQueryCompilationContext.cs index 682a0ce30c4..89986fdd0e1 100644 --- a/src/EFCore.Relational/Query/RelationalQueryCompilationContext.cs +++ b/src/EFCore.Relational/Query/RelationalQueryCompilationContext.cs @@ -46,7 +46,12 @@ public RelationalQueryCompilationContext( : base(dependencies, async, precompiling) { RelationalDependencies = relationalDependencies; - QuerySplittingBehavior = RelationalOptionsExtension.Extract(ContextOptions).QuerySplittingBehavior; + + var relationalOptions = RelationalOptionsExtension.Extract(ContextOptions); + QuerySplittingBehavior = relationalOptions.QuerySplittingBehavior; +#pragma warning disable EF9001 // Window function support is experimental + UseWindowFunctionAggregates = relationalOptions.UseWindowFunctionAggregates; +#pragma warning restore EF9001 SqlAliasManager = relationalDependencies.SqlAliasManagerFactory.Create(); } @@ -62,6 +67,15 @@ public RelationalQueryCompilationContext( /// public virtual QuerySplittingBehavior? QuerySplittingBehavior { get; internal set; } + /// + /// A value indicating whether aggregates projected alongside the elements of a grouping are translated as window functions. + /// + /// + /// See . + /// + [Experimental(EFDiagnostics.ExperimentalApi)] + public virtual bool UseWindowFunctionAggregates { get; } + /// /// A manager for SQL aliases, capable of generate uniquified table aliases. /// diff --git a/src/EFCore.Relational/Query/RelationalQueryableMethodTranslatingExpressionVisitor.cs b/src/EFCore.Relational/Query/RelationalQueryableMethodTranslatingExpressionVisitor.cs index 1154bf7ab89..1c99c09eaa1 100644 --- a/src/EFCore.Relational/Query/RelationalQueryableMethodTranslatingExpressionVisitor.cs +++ b/src/EFCore.Relational/Query/RelationalQueryableMethodTranslatingExpressionVisitor.cs @@ -12,6 +12,19 @@ public partial class RelationalQueryableMethodTranslatingExpressionVisitor : Que { private const string SqlQuerySingleColumnAlias = "Value"; + /// + /// The number of aggregates which can be projected alongside a grouping's elements as window functions. The window values ride + /// along with the element in a , whose last type argument is a nested tuple rather than a value of its + /// own; a projection with more aggregates than fit keeps the regular GROUP BY translation. + /// + private const int MaxWindowFunctionAggregates = 6; + + private static readonly Type[] ValueTupleTypes = + [ + typeof(ValueTuple<,>), typeof(ValueTuple<,,>), typeof(ValueTuple<,,,>), typeof(ValueTuple<,,,,>), + typeof(ValueTuple<,,,,,>), typeof(ValueTuple<,,,,,,>) + ]; + private readonly RelationalSqlTranslatingExpressionVisitor _sqlTranslator; private readonly SharedTypeEntityExpandingExpressionVisitor _sharedTypeEntityExpandingExpressionVisitor; private readonly RelationalProjectionBindingExpressionVisitor _projectionBindingExpressionVisitor; @@ -900,25 +913,84 @@ protected override ShapedQueryExpression TranslateExcept(ShapedQueryExpression s return null; } - var analyzer = new GroupingElementProjectionAnalyzer(groupByShaper); +#pragma warning disable EF9001 // Window function support is experimental + var analyzer = new GroupingElementProjectionAnalyzer(groupByShaper, _queryCompilationContext.UseWindowFunctionAggregates); +#pragma warning restore EF9001 if (!analyzer.Analyze(projection)) { return null; } + var elementType = analyzer.ElementSelector?.ReturnType ?? groupByShaper.ElementSelector.Type; + + // Aggregates projected alongside the elements become window functions over the very same rows: the GROUP BY is dropped just as + // it is for an elements-only projection, and each aggregate is projected as OVER (PARTITION BY ), which repeats + // it on every row of the partition. The element therefore becomes a tuple of the element proper and those window values, and + // the client selector reads each value back off the first element of the materialized grouping. +#pragma warning disable EF9001 // Window function support is experimental + var windowFunctions = Array.Empty(); + if (analyzer.Aggregates.Count > 0) + { + if (analyzer.Aggregates.Count > MaxWindowFunctionAggregates) + { + return null; + } + + var partitions = selectExpression.GroupBy.ToArray(); + windowFunctions = new WindowFunctionExpression[analyzer.Aggregates.Count]; + for (var i = 0; i < windowFunctions.Length; i++) + { + // SQL applies OVER to the aggregate itself, so only an aggregate which translates to a bare function call can carry a + // window. An aggregate whose translation wraps the function - Sum and Average over a float become a CAST around it - + // keeps the regular GROUP BY translation, as does one the provider can't evaluate over a window. + if (_sqlTranslator.Translate(analyzer.Aggregates[i]) is not SqlFunctionExpression aggregate + || !IsWindowFunctionSupported(aggregate)) + { + return null; + } + + windowFunctions[i] = new WindowFunctionExpression(aggregate, partitions, orderings: null, typeMapping: null); + } + + elementType = MakeWindowFunctionElementType(elementType, windowFunctions); + } +#pragma warning restore EF9001 + // Build the client-side selector before anything gets composed into the SelectExpression: composing mutates it, and there's no // way back to the correlated subquery translation afterwards. var groupingParameter = Expression.Parameter( - typeof(IGrouping<,>).MakeGenericType( - groupByShaper.KeySelector.Type, - analyzer.ElementSelector?.ReturnType ?? groupByShaper.ElementSelector.Type), - "g"); + typeof(IGrouping<,>).MakeGenericType(groupByShaper.KeySelector.Type, elementType), "g"); var resultSelector = Expression.Lambda(analyzer.Rewrite(projection, groupingParameter), groupingParameter); - var elementShaper = analyzer.ElementSelector is { } elementSelector - ? TranslateSelect(source.UpdateShaperExpression(groupByShaper.ElementSelector), elementSelector).ShaperExpression - : groupByShaper.ElementSelector; + Expression elementShaper; + if (windowFunctions.Length > 0) + { + var elementBody = analyzer.ElementSelector is { } windowElementSelector + ? RemapLambdaBody(source.UpdateShaperExpression(groupByShaper.ElementSelector), windowElementSelector) + : groupByShaper.ElementSelector; + + var typeArguments = elementType.GetGenericArguments(); + var arguments = new Expression[typeArguments.Length]; + arguments[0] = elementBody; + for (var i = 0; i < windowFunctions.Length; i++) + { + // The window values are carried in their nullable form: a projection is read back as nullable whatever the aggregate's + // own type says, and converting a null one to a non-nullable field would throw while materializing. + arguments[i + 1] = windowFunctions[i].Type == typeArguments[i + 1] + ? windowFunctions[i] + : Expression.Convert(windowFunctions[i], typeArguments[i + 1]); + } + + elementShaper = _projectionBindingExpressionVisitor.Translate( + selectExpression, Expression.New(elementType.GetConstructor(typeArguments)!, arguments)); + } + else + { + elementShaper = analyzer.ElementSelector is { } elementSelector + ? TranslateSelect(source.UpdateShaperExpression(groupByShaper.ElementSelector), elementSelector).ShaperExpression + : groupByShaper.ElementSelector; + } // With the GROUP BY gone, the groupings are assembled on the client from rows ordered by the grouping key, starting a new one // whenever the key changes. Comparing the key on the client would use CLR equality, which can disagree with the database's: under @@ -1032,6 +1104,37 @@ protected override ShapedQueryExpression TranslateExcept(ShapedQueryExpression s selectExpression, ReplacingExpressionVisitor.Replace(elementReadPlaceholder, element, remappedProjection))); } + /// + /// Checks whether the given aggregate function can be evaluated over a window, i.e. whether the database accepts an OVER + /// clause applied to it. + /// + /// + /// Providers override this to reject aggregates which they translate to functions of their own, e.g. user-defined aggregates + /// which the database doesn't allow as window functions. + /// + /// The translated aggregate function. + /// if the aggregate can be translated as a window function. + [Experimental(EFDiagnostics.ExperimentalApi)] + protected virtual bool IsWindowFunctionSupported(SqlFunctionExpression function) + => true; + + /// + /// Builds the type of a grouping element which carries window function values alongside the element itself. + /// +#pragma warning disable EF9001 // Window function support is experimental + private static Type MakeWindowFunctionElementType(Type elementType, IReadOnlyList windowFunctions) + { + var typeArguments = new Type[windowFunctions.Count + 1]; + typeArguments[0] = elementType; + for (var i = 0; i < windowFunctions.Count; i++) + { + typeArguments[i + 1] = windowFunctions[i].Type.MakeNullable(); + } + + return ValueTupleTypes[windowFunctions.Count - 1].MakeGenericType(typeArguments); + } +#pragma warning restore EF9001 + private Expression? TranslateGroupingKey(Expression expression) { switch (expression) @@ -1576,13 +1679,16 @@ protected override ShapedQueryExpression TranslateSelect(ShapedQueryExpression s /// splits them into the element projection (which gets composed into the grouping's element selector, server-side) and the rest /// of the projection (which gets applied on the client to each materialized grouping). /// - private sealed class GroupingElementProjectionAnalyzer(RelationalGroupByShaperExpression groupByShaper) : ExpressionVisitor + private sealed class GroupingElementProjectionAnalyzer( + RelationalGroupByShaperExpression groupByShaper, + bool useWindowFunctionAggregates) : ExpressionVisitor { private static readonly MethodInfo[] ElementEnumerationMethods = [EnumerableMethods.ToList, EnumerableMethods.ToArray, EnumerableMethods.AsEnumerable]; private List _enumerationMethods = []; private Expression? _elements; + private readonly List _aggregates = []; private ParameterExpression? _groupingParameter; private bool _unsupported; @@ -1591,6 +1697,12 @@ private sealed class GroupingElementProjectionAnalyzer(RelationalGroupByShaperEx /// public LambdaExpression? ElementSelector { get; private set; } + /// + /// The aggregates projected alongside the elements, in the order of the window values which carry them. + /// + public IReadOnlyList Aggregates + => _aggregates; + /// /// Checks whether the given projection over the grouping can be applied on the client, over materialized groupings. /// @@ -1600,7 +1712,14 @@ public bool Analyze(Expression projection) // Projections which don't enumerate the elements at all (aggregates, or just the key) are better off translated as usual, // on the server. - return !_unsupported && _elements != null; + if (_unsupported || _elements is null) + { + return false; + } + + // The elements are projected as the grouping itself rather than as a sequence of them, so there's no projection to carry + // the window values on: rewriting the grouping would change the type the query asked for. + return _aggregates.Count == 0 || !ReferenceEquals(_elements, groupByShaper); } /// @@ -1648,6 +1767,23 @@ public Expression Rewrite(Expression projection, ParameterExpression groupingPar return _groupingParameter is null ? expression : RewriteElements(); } + if (useWindowFunctionAggregates) + { + // The same aggregate projected more than once is carried by a single window value. + var aggregateIndex = _aggregates.FindIndex(a => ExpressionEqualityComparer.Instance.Equals(a, expression)); + if (aggregateIndex >= 0) + { + return _groupingParameter is null ? expression : RewriteAggregate(aggregateIndex); + } + + if (_groupingParameter is null && IsAggregateOverGrouping(expression)) + { + _aggregates.Add(expression); + + return expression; + } + } + switch (expression) { // Assembling the key and the elements into a result is all the client selector is allowed to do. Anything computed - @@ -1724,11 +1860,90 @@ private bool TryMatchElements( return true; } + /// + /// Checks whether the given expression is an aggregate applied directly to the grouping, e.g. g.Sum(e => e.Value). + /// + private bool IsAggregateOverGrouping(Expression expression) + { + if (expression is not MethodCallExpression { Object: null, Arguments.Count: > 0 } methodCall + || methodCall.Method.DeclaringType != typeof(Queryable)) + { + return false; + } + + var genericMethod = methodCall.Method.IsGenericMethod + ? methodCall.Method.GetGenericMethodDefinition() + : methodCall.Method; + + // Only the aggregates SQL evaluates the same way over a window as over a group. Notably absent is anything counting + // distinct values: COUNT(DISTINCT x) OVER (...) isn't supported by SQL Server. + if (genericMethod != QueryableMethods.CountWithoutPredicate + && genericMethod != QueryableMethods.CountWithPredicate + && genericMethod != QueryableMethods.LongCountWithoutPredicate + && genericMethod != QueryableMethods.LongCountWithPredicate + && genericMethod != QueryableMethods.MinWithoutSelector + && genericMethod != QueryableMethods.MinWithSelector + && genericMethod != QueryableMethods.MaxWithoutSelector + && genericMethod != QueryableMethods.MaxWithSelector + && !QueryableMethods.IsSumWithoutSelector(genericMethod) + && !QueryableMethods.IsSumWithSelector(genericMethod) + && !QueryableMethods.IsAverageWithoutSelector(genericMethod) + && !QueryableMethods.IsAverageWithSelector(genericMethod)) + { + return false; + } + + // The aggregate has to be over the grouping itself. Anything in between - a Where, a Distinct, a Select whose result is + // then aggregated - either changes the rows the aggregate sees or needs a window form SQL doesn't offer. + var source = methodCall.Arguments[0]; + if (source is MethodCallExpression + { + Object: null, Method.IsGenericMethod: true, Arguments: [var asQueryableSource] + } asQueryable + && asQueryable.Method.GetGenericMethodDefinition() == QueryableMethods.AsQueryable) + { + source = asQueryableSource; + } + + return ReferenceEquals(source, groupByShaper); + } + + /// + /// Rewrites an aggregate into a read of the window value carried by the grouping's elements. Every row of the partition + /// carries the same value, so the first materialized element has it. + /// + private Expression RewriteAggregate(int index) + { + var elementType = _groupingParameter!.Type.GetGenericArguments()[1]; + + Expression value = Expression.Field( + Expression.Call(EnumerableMethods.FirstWithoutPredicate.MakeGenericMethod(elementType), _groupingParameter), + "Item" + (index + 2)); + + return value.Type == _aggregates[index].Type + ? value + : Expression.Convert(value, _aggregates[index].Type); + } + private Expression RewriteElements() { var elementType = _groupingParameter!.Type.GetGenericArguments()[1]; var elements = (Expression)_groupingParameter; + if (_aggregates.Count > 0) + { + // The element carries the window values alongside the element proper; project the element proper back out of it. + var innerElementType = elementType.GetGenericArguments()[0]; + var tupleParameter = Expression.Parameter(elementType, "t"); + + elements = Expression.Call( + EnumerableMethods.Select.MakeGenericMethod(elementType, innerElementType), + elements, + Expression.Lambda(Expression.Field(tupleParameter, "Item1"), tupleParameter)); + + elementType = innerElementType; + } + // The elements are enumerated as a queryable (over the grouping) rather than as a plain sequence; keep that type, the // enumeration around it may well depend on it. if (_elements!.Type.IsGenericType && _elements.Type.GetGenericTypeDefinition() == typeof(IQueryable<>)) diff --git a/src/EFCore.Relational/Query/SqlExpressions/WindowFunctionExpression.cs b/src/EFCore.Relational/Query/SqlExpressions/WindowFunctionExpression.cs new file mode 100644 index 00000000000..7697c49fe38 --- /dev/null +++ b/src/EFCore.Relational/Query/SqlExpressions/WindowFunctionExpression.cs @@ -0,0 +1,170 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics.CodeAnalysis; + +namespace Microsoft.EntityFrameworkCore.Query.SqlExpressions; + +/// +/// +/// An expression that represents a window function - an aggregate (or other) function evaluated over a window of rows via an +/// OVER clause, e.g. AVG(Salary) OVER (PARTITION BY DepartmentName). +/// +/// +/// This type is typically used by database providers (and other extensions). It is generally +/// not used in application code. +/// +/// +[Experimental(EFDiagnostics.ExperimentalApi)] +public class WindowFunctionExpression : SqlExpression +{ + private static ConstructorInfo? _quotingConstructor; + + /// + /// Creates a new instance of the class. + /// + /// The function evaluated over the window, e.g. the AVG(Salary) of AVG(Salary) OVER (...). + /// A list of expressions to partition by. + /// A list of ordering expressions to order by inside the partition. + /// The associated with the expression. + public WindowFunctionExpression( + SqlExpression function, + IReadOnlyList? partitions, + IReadOnlyList? orderings, + RelationalTypeMapping? typeMapping) + : base(function.Type, typeMapping ?? function.TypeMapping) + { + Function = function; + Partitions = partitions ?? []; + Orderings = orderings ?? []; + } + + /// + /// The function which is evaluated over the window. + /// + public virtual SqlExpression Function { get; } + + /// + /// The list of expressions used in partitioning. + /// + public virtual IReadOnlyList Partitions { get; } + + /// + /// The list of ordering expressions used to order inside the given partition. + /// + public virtual IReadOnlyList Orderings { get; } + + /// + protected override Expression VisitChildren(ExpressionVisitor visitor) + { + var function = (SqlExpression)visitor.Visit(Function); + var changed = function != Function; + + var partitions = new List(); + foreach (var partition in Partitions) + { + var newPartition = (SqlExpression)visitor.Visit(partition); + changed |= newPartition != partition; + partitions.Add(newPartition); + } + + var orderings = new List(); + foreach (var ordering in Orderings) + { + var newOrdering = (OrderingExpression)visitor.Visit(ordering); + changed |= newOrdering != ordering; + orderings.Add(newOrdering); + } + + return changed + ? new WindowFunctionExpression(function, partitions, orderings, TypeMapping) + : this; + } + + /// + /// Creates a new expression that is like this one, but using the supplied children. If all of the children are the same, it will + /// return this expression. + /// + /// The property of the result. + /// The property of the result. + /// The property of the result. + /// This expression if no children changed, or an expression with the updated children. + public virtual WindowFunctionExpression Update( + SqlExpression function, + IReadOnlyList partitions, + IReadOnlyList orderings) + => function == Function + && Partitions.SequenceEqual(partitions) + && Orderings.SequenceEqual(orderings) + ? this + : new WindowFunctionExpression(function, partitions, orderings, TypeMapping); + + /// + public override Expression Quote() + => New( + _quotingConstructor ??= typeof(WindowFunctionExpression).GetConstructor( + [ + typeof(SqlExpression), typeof(IReadOnlyList), typeof(IReadOnlyList), + typeof(RelationalTypeMapping) + ])!, + Function.Quote(), + NewArrayInit(typeof(SqlExpression), initializers: Partitions.Select(p => p.Quote())), + NewArrayInit(typeof(OrderingExpression), initializers: Orderings.Select(o => o.Quote())), + RelationalExpressionQuotingUtilities.QuoteTypeMapping(TypeMapping)); + + /// + protected override void Print(ExpressionPrinter expressionPrinter) + { + expressionPrinter.Visit(Function); + expressionPrinter.Append(" OVER("); + if (Partitions.Any()) + { + expressionPrinter.Append("PARTITION BY "); + expressionPrinter.VisitCollection(Partitions); + if (Orderings.Any()) + { + expressionPrinter.Append(" "); + } + } + + if (Orderings.Any()) + { + expressionPrinter.Append("ORDER BY "); + expressionPrinter.VisitCollection(Orderings); + } + + expressionPrinter.Append(")"); + } + + /// + public override bool Equals(object? obj) + => obj != null + && (ReferenceEquals(this, obj) + || (obj is WindowFunctionExpression windowFunctionExpression + && Equals(windowFunctionExpression))); + + private bool Equals(WindowFunctionExpression windowFunctionExpression) + => base.Equals(windowFunctionExpression) + && Function.Equals(windowFunctionExpression.Function) + && Partitions.SequenceEqual(windowFunctionExpression.Partitions) + && Orderings.SequenceEqual(windowFunctionExpression.Orderings); + + /// + public override int GetHashCode() + { + var hash = new HashCode(); + hash.Add(base.GetHashCode()); + hash.Add(Function); + foreach (var partition in Partitions) + { + hash.Add(partition); + } + + foreach (var ordering in Orderings) + { + hash.Add(ordering); + } + + return hash.ToHashCode(); + } +} diff --git a/src/EFCore.Relational/Query/SqlNullabilityProcessor.cs b/src/EFCore.Relational/Query/SqlNullabilityProcessor.cs index d506e3e9c4d..f9a3c83e4b6 100644 --- a/src/EFCore.Relational/Query/SqlNullabilityProcessor.cs +++ b/src/EFCore.Relational/Query/SqlNullabilityProcessor.cs @@ -386,6 +386,10 @@ SqlUnaryExpression sqlUnaryExpression => VisitSqlUnary(sqlUnaryExpression, allowOptimizedExpansion, out nullable), JsonScalarExpression jsonScalarExpression => VisitJsonScalar(jsonScalarExpression, allowOptimizedExpansion, out nullable), +#pragma warning disable EF9001 // Window function support is experimental + WindowFunctionExpression windowFunctionExpression + => VisitWindowFunction(windowFunctionExpression, allowOptimizedExpansion, out nullable), +#pragma warning restore EF9001 _ => VisitCustomSqlExpression(sqlExpression, allowOptimizedExpansion, out nullable) }; @@ -1293,6 +1297,84 @@ private SqlExpression VisitDenseRank(DenseRankExpression denseRankExpression, ou : denseRankExpression; } + /// + /// Visits a and computes its nullability. + /// + /// A window function expression to visit. + /// A bool value indicating if optimized expansion which considers null value as false value is allowed. + /// A bool value indicating whether the sql expression is nullable. + /// An optimized sql expression. + [Experimental(EFDiagnostics.ExperimentalApi)] + protected virtual SqlExpression VisitWindowFunction( + WindowFunctionExpression windowFunctionExpression, + bool allowOptimizedExpansion, + out bool nullable) + { + // The window is what the function is evaluated over; it never makes a non-nullable function nullable (an aggregate always has + // at least the current row in its partition), nor a nullable one non-nullable. So the nullability is the function's own: + // COUNT(...) OVER (...) is never null, while AVG(x) OVER (...) is null for a partition in which every x is null. + // + // The aggregate's arguments are visited, but the aggregate node itself is not: SQL applies OVER to the aggregate, so nothing + // may come between the two, whereas a provider is free to rewrite an aggregate into an expression which wraps it. Any such + // wrapping has to go around the whole window function instead, as it does for SUM below. + SqlExpression function; + if (windowFunctionExpression.Function is SqlFunctionExpression sqlFunctionExpression) + { + var instance = sqlFunctionExpression.Instance is null ? null : Visit(sqlFunctionExpression.Instance, out _); + + List? visitedArguments = null; + if (sqlFunctionExpression.Arguments is not null) + { + visitedArguments = []; + foreach (var argument in sqlFunctionExpression.Arguments) + { + visitedArguments.Add(Visit(argument, out _)); + } + } + + function = sqlFunctionExpression.Update(instance, visitedArguments); + nullable = sqlFunctionExpression.IsNullable; + } + else + { + function = Visit(windowFunctionExpression.Function, allowOptimizedExpansion: false, out nullable); + } + + var changed = function != windowFunctionExpression.Function; + + var partitions = new List(); + foreach (var partition in windowFunctionExpression.Partitions) + { + var newPartition = Visit(partition, out _); + changed |= newPartition != partition; + partitions.Add(newPartition); + } + + var orderings = new List(); + foreach (var ordering in windowFunctionExpression.Orderings) + { + var newOrdering = ordering.Update(Visit(ordering.Expression, out _)); + changed |= newOrdering != ordering; + orderings.Add(newOrdering); + } + + var result = changed + ? windowFunctionExpression.Update(function, partitions, orderings) + : windowFunctionExpression; + + // SUM is null over a partition in which every value is null - the current row being in it doesn't help - whereas LINQ's Sum is + // zero. So it gets the same COALESCE as a grouped SUM, around the window function rather than inside it. + if (function is SqlFunctionExpression { IsBuiltIn: true } sumFunction + && string.Equals(sumFunction.Name, "SUM", StringComparison.OrdinalIgnoreCase)) + { + nullable = false; + + return CoalesceToZero(result); + } + + return result; + } + /// /// Visits a and computes its nullability. /// @@ -1640,12 +1722,7 @@ protected virtual SqlExpression VisitSqlFunction( { nullable = false; - // The zero must be of the function's CLR type, since the type mapping's value converter (if any) is applied to it. - var resultType = sqlFunctionExpression.Type.UnwrapNullableType(); - return _sqlExpressionFactory.Coalesce( - sqlFunctionExpression, - _sqlExpressionFactory.Constant(resultType.GetDefaultValue(), resultType, sqlFunctionExpression.TypeMapping), - sqlFunctionExpression.TypeMapping); + return CoalesceToZero(sqlFunctionExpression); } // if some of the {Instance,Arguments}PropagateNullability are true, use @@ -1654,6 +1731,16 @@ protected virtual SqlExpression VisitSqlFunction( return sqlFunctionExpression; } + private SqlExpression CoalesceToZero(SqlExpression sum) + { + // The zero must be of the function's CLR type, since the type mapping's value converter (if any) is applied to it. + var resultType = sum.Type.UnwrapNullableType(); + return _sqlExpressionFactory.Coalesce( + sum, + _sqlExpressionFactory.Constant(resultType.GetDefaultValue(), resultType, sum.TypeMapping), + sum.TypeMapping); + } + /// /// Visits a and computes its nullability. /// diff --git a/src/EFCore.Sqlite.Core/Query/Internal/SqliteQueryableMethodTranslatingExpressionVisitor.cs b/src/EFCore.Sqlite.Core/Query/Internal/SqliteQueryableMethodTranslatingExpressionVisitor.cs index 5908525f6a7..2ef68a47a46 100644 --- a/src/EFCore.Sqlite.Core/Query/Internal/SqliteQueryableMethodTranslatingExpressionVisitor.cs +++ b/src/EFCore.Sqlite.Core/Query/Internal/SqliteQueryableMethodTranslatingExpressionVisitor.cs @@ -769,6 +769,19 @@ protected override bool TrySerializeScalarToJson( return null; } +#pragma warning disable EF9001 // Window function support is experimental + /// + /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to + /// the same compatibility standards as public APIs. It may be changed or removed without notice in + /// any release. You should only use it directly in your code with extreme caution and knowing that + /// doing so can result in application failures when updating to a new Entity Framework Core release. + /// + protected override bool IsWindowFunctionSupported(SqlFunctionExpression function) + // The decimal aggregates are user-defined (see SqliteRelationalConnection), and SQLite only accepts an OVER clause on a + // user-defined aggregate registered as a window function. + => function.Name is not ("ef_sum" or "ef_avg" or "ef_min" or "ef_max"); +#pragma warning restore EF9001 + /// /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to /// the same compatibility standards as public APIs. It may be changed or removed without notice in diff --git a/test/EFCore.Relational.Specification.Tests/Query/WindowFunctionQueryRelationalTestBase.cs b/test/EFCore.Relational.Specification.Tests/Query/WindowFunctionQueryRelationalTestBase.cs new file mode 100644 index 00000000000..ccec7f218f7 --- /dev/null +++ b/test/EFCore.Relational.Specification.Tests/Query/WindowFunctionQueryRelationalTestBase.cs @@ -0,0 +1,430 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.EntityFrameworkCore.Query; + +/// +/// Tests for aggregates projected alongside the elements of a grouping, which are translated as window functions when +/// UseWindowFunctionAggregates is configured. See . +/// +public abstract class WindowFunctionQueryRelationalTestBase(NonSharedFixture fixture) + : NonSharedModelTestBase(fixture), IClassFixture +{ + protected override string NonSharedStoreName + => "WindowFunctionQueryTests"; + + protected TestSqlLoggerFactory TestSqlLoggerFactory + => (TestSqlLoggerFactory)ListLoggerFactory; + + protected void AssertSql(params string[] expected) + => TestSqlLoggerFactory.AssertBaseline(expected); + + /// + /// Enables the window function translation on the given options, using the provider's own options builder. + /// + protected abstract DbContextOptionsBuilder UseWindowFunctionAggregates(DbContextOptionsBuilder optionsBuilder); + + private Task> InitializeAsync(bool useWindowFunctionAggregates) + => InitializeNonSharedTest( + seed: c => c.SeedAsync(), + onConfiguring: o => + { + if (useWindowFunctionAggregates) + { + UseWindowFunctionAggregates(o); + } + }); + + [Fact] + public virtual async Task Elements_and_aggregate() + { + var contextFactory = await InitializeAsync(useWindowFunctionAggregates: true); + await using var context = contextFactory.CreateDbContext(); + + var results = await context.Employees + .GroupBy(e => e.DepartmentName) + .Select(g => new { Employees = g.ToList(), AverageSalary = g.Average(e => e.Salary) }) + .ToListAsync(); + + Assert.Collection( + results, + engineering => + { + Assert.Equal(["Alice", "Bob", "Cheng"], engineering.Employees.Select(e => e.Name).Order()); + Assert.Equal(200, engineering.AverageSalary); + }, + sales => + { + Assert.Equal(["Dara", "Emre"], sales.Employees.Select(e => e.Name).Order()); + Assert.Equal(75, sales.AverageSalary); + }); + } + + [Fact] + public virtual async Task Elements_and_multiple_aggregates() + { + var contextFactory = await InitializeAsync(useWindowFunctionAggregates: true); + await using var context = contextFactory.CreateDbContext(); + + var results = await context.Employees + .GroupBy(e => e.DepartmentName) + .Select( + g => new + { + g.Key, + Employees = g.ToList(), + Count = g.Count(), + Total = g.Sum(e => e.Salary), + Highest = g.Max(e => e.Salary) + }) + .ToListAsync(); + + Assert.Collection( + results, + engineering => + { + Assert.Equal("Engineering", engineering.Key); + Assert.Equal(3, engineering.Employees.Count); + Assert.Equal(3, engineering.Count); + Assert.Equal(600, engineering.Total); + Assert.Equal(300, engineering.Highest); + }, + sales => + { + Assert.Equal("Sales", sales.Key); + Assert.Equal(2, sales.Employees.Count); + Assert.Equal(2, sales.Count); + Assert.Equal(150, sales.Total); + Assert.Equal(100, sales.Highest); + }); + } + + [Fact] + public virtual async Task Projected_elements_and_aggregate() + { + var contextFactory = await InitializeAsync(useWindowFunctionAggregates: true); + await using var context = contextFactory.CreateDbContext(); + + var results = await context.Employees + .GroupBy(e => e.DepartmentName) + .Select(g => new { g.Key, Names = g.Select(e => e.Name).ToList(), Total = g.Sum(e => e.Salary) }) + .ToListAsync(); + + Assert.Collection( + results, + engineering => + { + Assert.Equal(["Alice", "Bob", "Cheng"], engineering.Names.Order()); + Assert.Equal(600, engineering.Total); + }, + sales => + { + Assert.Equal(["Dara", "Emre"], sales.Names.Order()); + Assert.Equal(150, sales.Total); + }); + } + + /// + /// An aggregate is null for a partition in which every value is, exactly as it is for a group in which every value is - the + /// window neither adds rows to the partition nor takes any away. + /// + [Fact] + public virtual async Task Nullable_aggregate_over_partition_without_values() + { + var contextFactory = await InitializeAsync(useWindowFunctionAggregates: true); + await using var context = contextFactory.CreateDbContext(); + + var results = await context.Employees + .GroupBy(e => e.DepartmentName) + .Select(g => new { g.Key, Employees = g.ToList(), AverageBonus = g.Average(e => e.Bonus) }) + .ToListAsync(); + + Assert.Collection( + results, + engineering => Assert.Null(engineering.AverageBonus), + sales => Assert.Equal(10, sales.AverageBonus)); + } + + /// + /// A sum is zero for a partition in which every value is null, exactly as it is for a group in which every value is: a partition + /// always holding the current row doesn't keep SUM from returning null over it. + /// + [Fact] + public virtual async Task Sum_over_partition_without_values() + { + var contextFactory = await InitializeAsync(useWindowFunctionAggregates: true); + await using var context = contextFactory.CreateDbContext(); + + var results = await context.Employees + .GroupBy(e => e.DepartmentName) + .Select(g => new { g.Key, Names = g.Select(e => e.Name).ToList(), TotalBonus = g.Sum(e => e.Bonus) }) + .ToListAsync(); + + Assert.Collection( + results, + engineering => Assert.Equal(0, engineering.TotalBonus), + sales => Assert.Equal(10, sales.TotalBonus)); + } + + /// + /// Aggregates over decimals, which some providers translate to functions of their own rather than to the built-in aggregates. + /// + [Fact] + public virtual async Task Decimal_aggregates() + { + var contextFactory = await InitializeAsync(useWindowFunctionAggregates: true); + await using var context = contextFactory.CreateDbContext(); + + var results = await context.Employees + .GroupBy(e => e.DepartmentName) + .Select( + g => new + { + g.Key, + Names = g.Select(e => e.Name).ToList(), + Total = g.Sum(e => (decimal)e.Salary), + Average = g.Average(e => (decimal)e.Salary), + Highest = g.Max(e => (decimal)e.Salary) + }) + .ToListAsync(); + + Assert.Collection( + results, + engineering => + { + Assert.Equal(600m, engineering.Total); + Assert.Equal(200m, engineering.Average); + Assert.Equal(300m, engineering.Highest); + }, + sales => + { + Assert.Equal(150m, sales.Total); + Assert.Equal(75m, sales.Average); + Assert.Equal(100m, sales.Highest); + }); + } + + /// + /// An aggregate projected more than once is computed once. + /// + [Fact] + public virtual async Task Repeated_aggregate() + { + var contextFactory = await InitializeAsync(useWindowFunctionAggregates: true); + await using var context = contextFactory.CreateDbContext(); + + var results = await context.Employees + .GroupBy(e => e.DepartmentName) + .Select( + g => new + { + g.Key, + Names = g.Select(e => e.Name).ToList(), + Total = g.Sum(e => e.Salary), + Summary = new { Total = g.Sum(e => e.Salary), Count = g.Count() } + }) + .ToListAsync(); + + Assert.Collection( + results, + engineering => + { + Assert.Equal(600, engineering.Total); + Assert.Equal(600, engineering.Summary.Total); + Assert.Equal(3, engineering.Summary.Count); + }, + sales => + { + Assert.Equal(150, sales.Total); + Assert.Equal(150, sales.Summary.Total); + Assert.Equal(2, sales.Summary.Count); + }); + } + + /// + /// Grouping by a constant is the workaround for a window over the whole result set rather than over a partition of it. + /// + [Fact] + public virtual async Task Constant_key() + { + var contextFactory = await InitializeAsync(useWindowFunctionAggregates: true); + await using var context = contextFactory.CreateDbContext(); + + var results = await context.Employees + .GroupBy(e => 1) + .Select(g => new { Employees = g.ToList(), AverageSalary = g.Average(e => e.Salary) }) + .ToListAsync(); + + var all = Assert.Single(results); + Assert.Equal(5, all.Employees.Count); + Assert.Equal(150, all.AverageSalary); + } + + /// + /// A filter applied before the grouping needs no duplicating: the window is evaluated over the rows the query already left. + /// + [Fact] + public virtual async Task Filter_before_grouping() + { + var contextFactory = await InitializeAsync(useWindowFunctionAggregates: true); + await using var context = contextFactory.CreateDbContext(); + + var results = await context.Employees + .Where(e => e.Salary > 50) + .GroupBy(e => e.DepartmentName) + .Select(g => new { g.Key, Employees = g.ToList(), AverageSalary = g.Average(e => e.Salary) }) + .ToListAsync(); + + Assert.Collection( + results, + engineering => + { + Assert.Equal(3, engineering.Employees.Count); + Assert.Equal(200, engineering.AverageSalary); + }, + sales => + { + Assert.Equal(1, sales.Employees.Count); + Assert.Equal(100, sales.AverageSalary); + }); + } + + /// + /// Without the opt-in, the same query as keeps the GROUP BY translation it has today. + /// + [Fact] + public virtual async Task Elements_and_aggregate_without_opt_in() + { + var contextFactory = await InitializeAsync(useWindowFunctionAggregates: false); + await using var context = contextFactory.CreateDbContext(); + + var results = await context.Employees + .GroupBy(e => e.DepartmentName) + .Select(g => new { Employees = g.ToList(), AverageSalary = g.Average(e => e.Salary) }) + .ToListAsync(); + + Assert.Collection( + results, + engineering => + { + Assert.Equal(["Alice", "Bob", "Cheng"], engineering.Employees.Select(e => e.Name).Order()); + Assert.Equal(200, engineering.AverageSalary); + }, + sales => + { + Assert.Equal(["Dara", "Emre"], sales.Employees.Select(e => e.Name).Order()); + Assert.Equal(75, sales.AverageSalary); + }); + } + + /// + /// An aggregate with no elements projected alongside it stays a plain GROUP BY: there are no rows to repeat the value on, and + /// collapsing them is exactly what the query asked for. + /// + [Fact] + public virtual async Task Aggregate_without_elements() + { + var contextFactory = await InitializeAsync(useWindowFunctionAggregates: true); + await using var context = contextFactory.CreateDbContext(); + + var results = await context.Employees + .GroupBy(e => e.DepartmentName) + .Select(g => new { g.Key, AverageSalary = g.Average(e => e.Salary) }) + .OrderBy(x => x.Key) + .ToListAsync(); + + Assert.Collection( + results, + engineering => Assert.Equal(200, engineering.AverageSalary), + sales => Assert.Equal(75, sales.AverageSalary)); + } + + /// + /// A projection of the elements alone is lifted as it is today, with no window function involved. + /// + [Fact] + public virtual async Task Elements_without_aggregate() + { + var contextFactory = await InitializeAsync(useWindowFunctionAggregates: true); + await using var context = contextFactory.CreateDbContext(); + + var results = await context.Employees + .GroupBy(e => e.DepartmentName) + .Select(g => new { g.Key, Employees = g.ToList() }) + .ToListAsync(); + + Assert.Collection( + results, + engineering => Assert.Equal(3, engineering.Employees.Count), + sales => Assert.Equal(2, sales.Employees.Count)); + } + + /// + /// COUNT(DISTINCT ...) OVER (...) isn't supported by SQL Server, so a distinct aggregate keeps the GROUP BY translation. + /// + [Fact] + public virtual async Task Distinct_aggregate_is_not_lifted() + { + var contextFactory = await InitializeAsync(useWindowFunctionAggregates: true); + await using var context = contextFactory.CreateDbContext(); + + var results = await context.Employees + .GroupBy(e => e.DepartmentName) + .Select(g => new { g.Key, Employees = g.ToList(), Salaries = g.Select(e => e.Salary).Distinct().Count() }) + .OrderBy(x => x.Key) + .ToListAsync(); + + Assert.Collection( + results, + engineering => Assert.Equal(3, engineering.Salaries), + sales => Assert.Equal(2, sales.Salaries)); + } + + /// + /// A filtered aggregate composes over rows other than the partition's, so it too keeps the GROUP BY translation. + /// + [Fact] + public virtual async Task Filtered_aggregate_is_not_lifted() + { + var contextFactory = await InitializeAsync(useWindowFunctionAggregates: true); + await using var context = contextFactory.CreateDbContext(); + + var results = await context.Employees + .GroupBy(e => e.DepartmentName) + .Select(g => new { g.Key, Employees = g.ToList(), HighEarners = g.Where(e => e.Salary > 100).Count() }) + .OrderBy(x => x.Key) + .ToListAsync(); + + Assert.Collection( + results, + engineering => Assert.Equal(2, engineering.HighEarners), + sales => Assert.Equal(0, sales.HighEarners)); + } + + protected class WindowFunctionContext(DbContextOptions options) : DbContext(options) + { + public DbSet Employees + => Set(); + + public Task SeedAsync() + { + AddRange( + new Employee { Name = "Alice", DepartmentName = "Engineering", Salary = 100 }, + new Employee { Name = "Bob", DepartmentName = "Engineering", Salary = 200 }, + new Employee { Name = "Cheng", DepartmentName = "Engineering", Salary = 300 }, + new Employee { Name = "Dara", DepartmentName = "Sales", Salary = 50, Bonus = 10 }, + new Employee { Name = "Emre", DepartmentName = "Sales", Salary = 100 }); + + return SaveChangesAsync(); + } + } + + protected class Employee + { + public int Id { get; set; } + public string Name { get; set; } = null!; + public string DepartmentName { get; set; } = null!; + public int Salary { get; set; } + public int? Bonus { get; set; } + } +} diff --git a/test/EFCore.SqlServer.FunctionalTests/Query/WindowFunctionQuerySqlServerTest.cs b/test/EFCore.SqlServer.FunctionalTests/Query/WindowFunctionQuerySqlServerTest.cs new file mode 100644 index 00000000000..e4ce066f50f --- /dev/null +++ b/test/EFCore.SqlServer.FunctionalTests/Query/WindowFunctionQuerySqlServerTest.cs @@ -0,0 +1,209 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.EntityFrameworkCore.Query; + +public class WindowFunctionQuerySqlServerTest(NonSharedFixture fixture) : WindowFunctionQueryRelationalTestBase(fixture) +{ + protected override ITestStoreFactory NonSharedTestStoreFactory + => SqlServerTestStoreFactory.Instance; + + protected override DbContextOptionsBuilder UseWindowFunctionAggregates(DbContextOptionsBuilder optionsBuilder) + { +#pragma warning disable EF9001 // UseWindowFunctionAggregates is experimental + new SqlServerDbContextOptionsBuilder(optionsBuilder).UseWindowFunctionAggregates(); +#pragma warning restore EF9001 + + return optionsBuilder; + } + + public override async Task Elements_and_aggregate() + { + await base.Elements_and_aggregate(); + + AssertSql( + """ +SELECT [e].[DepartmentName], [e].[Id], [e].[Bonus], [e].[Name], [e].[Salary], AVG(CAST([e].[Salary] AS float)) OVER(PARTITION BY [e].[DepartmentName]) +FROM [Employees] AS [e] +ORDER BY [e].[DepartmentName] +"""); + } + + public override async Task Elements_and_multiple_aggregates() + { + await base.Elements_and_multiple_aggregates(); + + AssertSql( + """ +SELECT [e].[DepartmentName], [e].[Id], [e].[Bonus], [e].[Name], [e].[Salary], COUNT(*) OVER(PARTITION BY [e].[DepartmentName]), ISNULL(SUM([e].[Salary]) OVER(PARTITION BY [e].[DepartmentName]), 0), MAX([e].[Salary]) OVER(PARTITION BY [e].[DepartmentName]) +FROM [Employees] AS [e] +ORDER BY [e].[DepartmentName] +"""); + } + + public override async Task Projected_elements_and_aggregate() + { + await base.Projected_elements_and_aggregate(); + + AssertSql( + """ +SELECT [e].[DepartmentName], [e].[Name], ISNULL(SUM([e].[Salary]) OVER(PARTITION BY [e].[DepartmentName]), 0) +FROM [Employees] AS [e] +ORDER BY [e].[DepartmentName] +"""); + } + + public override async Task Nullable_aggregate_over_partition_without_values() + { + await base.Nullable_aggregate_over_partition_without_values(); + + AssertSql( + """ +SELECT [e].[DepartmentName], [e].[Id], [e].[Bonus], [e].[Name], [e].[Salary], AVG(CAST([e].[Bonus] AS float)) OVER(PARTITION BY [e].[DepartmentName]) +FROM [Employees] AS [e] +ORDER BY [e].[DepartmentName] +"""); + } + + public override async Task Sum_over_partition_without_values() + { + await base.Sum_over_partition_without_values(); + + AssertSql( + """ +SELECT [e].[DepartmentName], [e].[Name], ISNULL(SUM([e].[Bonus]) OVER(PARTITION BY [e].[DepartmentName]), 0) +FROM [Employees] AS [e] +ORDER BY [e].[DepartmentName] +"""); + } + + public override async Task Decimal_aggregates() + { + await base.Decimal_aggregates(); + + AssertSql( + """ +SELECT [e].[DepartmentName], [e].[Name], ISNULL(SUM(CAST([e].[Salary] AS decimal(18,2))) OVER(PARTITION BY [e].[DepartmentName]), 0.0), AVG(CAST([e].[Salary] AS decimal(18,2))) OVER(PARTITION BY [e].[DepartmentName]), MAX(CAST([e].[Salary] AS decimal(18,2))) OVER(PARTITION BY [e].[DepartmentName]) +FROM [Employees] AS [e] +ORDER BY [e].[DepartmentName] +"""); + } + + public override async Task Repeated_aggregate() + { + await base.Repeated_aggregate(); + + AssertSql( + """ +SELECT [e].[DepartmentName], [e].[Name], ISNULL(SUM([e].[Salary]) OVER(PARTITION BY [e].[DepartmentName]), 0), COUNT(*) OVER(PARTITION BY [e].[DepartmentName]) +FROM [Employees] AS [e] +ORDER BY [e].[DepartmentName] +"""); + } + + public override async Task Constant_key() + { + await base.Constant_key(); + + AssertSql( + """ +SELECT [e0].[Key], [e0].[Id], [e0].[Bonus], [e0].[DepartmentName], [e0].[Name], [e0].[Salary], AVG(CAST([e0].[Salary] AS float)) OVER(PARTITION BY [e0].[Key]) +FROM ( + SELECT [e].[Id], [e].[Bonus], [e].[DepartmentName], [e].[Name], [e].[Salary], 1 AS [Key] + FROM [Employees] AS [e] +) AS [e0] +ORDER BY [e0].[Key] +"""); + } + + public override async Task Filter_before_grouping() + { + await base.Filter_before_grouping(); + + AssertSql( + """ +SELECT [e].[DepartmentName], [e].[Id], [e].[Bonus], [e].[Name], [e].[Salary], AVG(CAST([e].[Salary] AS float)) OVER(PARTITION BY [e].[DepartmentName]) +FROM [Employees] AS [e] +WHERE [e].[Salary] > 50 +ORDER BY [e].[DepartmentName] +"""); + } + + public override async Task Elements_and_aggregate_without_opt_in() + { + await base.Elements_and_aggregate_without_opt_in(); + + AssertSql( + """ +SELECT [e1].[DepartmentName], [e0].[Id], [e0].[Bonus], [e0].[DepartmentName], [e0].[Name], [e0].[Salary], [e1].[c] +FROM ( + SELECT AVG(CAST([e].[Salary] AS float)) AS [c], [e].[DepartmentName] + FROM [Employees] AS [e] + GROUP BY [e].[DepartmentName] +) AS [e1] +LEFT JOIN [Employees] AS [e0] ON [e1].[DepartmentName] = [e0].[DepartmentName] +ORDER BY [e1].[DepartmentName] +"""); + } + + public override async Task Aggregate_without_elements() + { + await base.Aggregate_without_elements(); + + AssertSql( + """ +SELECT [e].[DepartmentName] AS [Key], AVG(CAST([e].[Salary] AS float)) AS [AverageSalary] +FROM [Employees] AS [e] +GROUP BY [e].[DepartmentName] +ORDER BY [e].[DepartmentName] +"""); + } + + public override async Task Elements_without_aggregate() + { + await base.Elements_without_aggregate(); + + AssertSql( + """ +SELECT [e].[DepartmentName], [e].[Id], [e].[Bonus], [e].[Name], [e].[Salary] +FROM [Employees] AS [e] +ORDER BY [e].[DepartmentName] +"""); + } + + public override async Task Distinct_aggregate_is_not_lifted() + { + await base.Distinct_aggregate_is_not_lifted(); + + AssertSql( + """ +SELECT [e1].[DepartmentName], [e0].[Id], [e0].[Bonus], [e0].[DepartmentName], [e0].[Name], [e0].[Salary], [e1].[c] +FROM ( + SELECT [e].[DepartmentName], COUNT(DISTINCT ([e].[Salary])) AS [c] + FROM [Employees] AS [e] + GROUP BY [e].[DepartmentName] +) AS [e1] +LEFT JOIN [Employees] AS [e0] ON [e1].[DepartmentName] = [e0].[DepartmentName] +ORDER BY [e1].[DepartmentName] +"""); + } + + public override async Task Filtered_aggregate_is_not_lifted() + { + await base.Filtered_aggregate_is_not_lifted(); + + AssertSql( + """ +SELECT [e1].[DepartmentName], [e0].[Id], [e0].[Bonus], [e0].[DepartmentName], [e0].[Name], [e0].[Salary], [e1].[c] +FROM ( + SELECT [e].[DepartmentName], COUNT(CASE + WHEN [e].[Salary] > 100 THEN 1 + END) AS [c] + FROM [Employees] AS [e] + GROUP BY [e].[DepartmentName] +) AS [e1] +LEFT JOIN [Employees] AS [e0] ON [e1].[DepartmentName] = [e0].[DepartmentName] +ORDER BY [e1].[DepartmentName] +"""); + } +} diff --git a/test/EFCore.Sqlite.FunctionalTests/Query/WindowFunctionQuerySqliteTest.cs b/test/EFCore.Sqlite.FunctionalTests/Query/WindowFunctionQuerySqliteTest.cs new file mode 100644 index 00000000000..18f67469b96 --- /dev/null +++ b/test/EFCore.Sqlite.FunctionalTests/Query/WindowFunctionQuerySqliteTest.cs @@ -0,0 +1,214 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.EntityFrameworkCore.Query; + +public class WindowFunctionQuerySqliteTest(NonSharedFixture fixture) : WindowFunctionQueryRelationalTestBase(fixture) +{ + protected override ITestStoreFactory NonSharedTestStoreFactory + => SqliteTestStoreFactory.Instance; + + protected override DbContextOptionsBuilder UseWindowFunctionAggregates(DbContextOptionsBuilder optionsBuilder) + { +#pragma warning disable EF9001 // UseWindowFunctionAggregates is experimental + new SqliteDbContextOptionsBuilder(optionsBuilder).UseWindowFunctionAggregates(); +#pragma warning restore EF9001 + + return optionsBuilder; + } + + public override async Task Elements_and_aggregate() + { + await base.Elements_and_aggregate(); + + AssertSql( + """ +SELECT "e"."DepartmentName", "e"."Id", "e"."Bonus", "e"."Name", "e"."Salary", AVG(CAST("e"."Salary" AS REAL)) OVER(PARTITION BY "e"."DepartmentName") +FROM "Employees" AS "e" +ORDER BY "e"."DepartmentName" +"""); + } + + public override async Task Elements_and_multiple_aggregates() + { + await base.Elements_and_multiple_aggregates(); + + AssertSql( + """ +SELECT "e"."DepartmentName", "e"."Id", "e"."Bonus", "e"."Name", "e"."Salary", COUNT(*) OVER(PARTITION BY "e"."DepartmentName"), COALESCE(SUM("e"."Salary") OVER(PARTITION BY "e"."DepartmentName"), 0), MAX("e"."Salary") OVER(PARTITION BY "e"."DepartmentName") +FROM "Employees" AS "e" +ORDER BY "e"."DepartmentName" +"""); + } + + public override async Task Projected_elements_and_aggregate() + { + await base.Projected_elements_and_aggregate(); + + AssertSql( + """ +SELECT "e"."DepartmentName", "e"."Name", COALESCE(SUM("e"."Salary") OVER(PARTITION BY "e"."DepartmentName"), 0) +FROM "Employees" AS "e" +ORDER BY "e"."DepartmentName" +"""); + } + + public override async Task Nullable_aggregate_over_partition_without_values() + { + await base.Nullable_aggregate_over_partition_without_values(); + + AssertSql( + """ +SELECT "e"."DepartmentName", "e"."Id", "e"."Bonus", "e"."Name", "e"."Salary", AVG(CAST("e"."Bonus" AS REAL)) OVER(PARTITION BY "e"."DepartmentName") +FROM "Employees" AS "e" +ORDER BY "e"."DepartmentName" +"""); + } + + public override async Task Sum_over_partition_without_values() + { + await base.Sum_over_partition_without_values(); + + AssertSql( + """ +SELECT "e"."DepartmentName", "e"."Name", COALESCE(SUM("e"."Bonus") OVER(PARTITION BY "e"."DepartmentName"), 0) +FROM "Employees" AS "e" +ORDER BY "e"."DepartmentName" +"""); + } + + public override async Task Decimal_aggregates() + { + await base.Decimal_aggregates(); + + AssertSql( + """ +SELECT "e1"."DepartmentName", "e0"."Name", "e0"."Id", "e1"."c", "e1"."c0", "e1"."c1" +FROM ( + SELECT "e"."DepartmentName", COALESCE(ef_sum(CAST("e"."Salary" AS TEXT)), '0.0') AS "c", ef_avg(CAST("e"."Salary" AS TEXT)) AS "c0", ef_max(CAST("e"."Salary" AS TEXT)) AS "c1" + FROM "Employees" AS "e" + GROUP BY "e"."DepartmentName" +) AS "e1" +LEFT JOIN "Employees" AS "e0" ON "e1"."DepartmentName" = "e0"."DepartmentName" +ORDER BY "e1"."DepartmentName" +"""); + } + + public override async Task Repeated_aggregate() + { + await base.Repeated_aggregate(); + + AssertSql( + """ +SELECT "e"."DepartmentName", "e"."Name", COALESCE(SUM("e"."Salary") OVER(PARTITION BY "e"."DepartmentName"), 0), COUNT(*) OVER(PARTITION BY "e"."DepartmentName") +FROM "Employees" AS "e" +ORDER BY "e"."DepartmentName" +"""); + } + + public override async Task Constant_key() + { + await base.Constant_key(); + + AssertSql( + """ +SELECT "e0"."Key", "e0"."Id", "e0"."Bonus", "e0"."DepartmentName", "e0"."Name", "e0"."Salary", AVG(CAST("e0"."Salary" AS REAL)) OVER(PARTITION BY "e0"."Key") +FROM ( + SELECT "e"."Id", "e"."Bonus", "e"."DepartmentName", "e"."Name", "e"."Salary", 1 AS "Key" + FROM "Employees" AS "e" +) AS "e0" +ORDER BY "e0"."Key" +"""); + } + + public override async Task Filter_before_grouping() + { + await base.Filter_before_grouping(); + + AssertSql( + """ +SELECT "e"."DepartmentName", "e"."Id", "e"."Bonus", "e"."Name", "e"."Salary", AVG(CAST("e"."Salary" AS REAL)) OVER(PARTITION BY "e"."DepartmentName") +FROM "Employees" AS "e" +WHERE "e"."Salary" > 50 +ORDER BY "e"."DepartmentName" +"""); + } + + public override async Task Elements_and_aggregate_without_opt_in() + { + await base.Elements_and_aggregate_without_opt_in(); + + AssertSql( + """ +SELECT "e1"."DepartmentName", "e0"."Id", "e0"."Bonus", "e0"."DepartmentName", "e0"."Name", "e0"."Salary", "e1"."c" +FROM ( + SELECT AVG(CAST("e"."Salary" AS REAL)) AS "c", "e"."DepartmentName" + FROM "Employees" AS "e" + GROUP BY "e"."DepartmentName" +) AS "e1" +LEFT JOIN "Employees" AS "e0" ON "e1"."DepartmentName" = "e0"."DepartmentName" +ORDER BY "e1"."DepartmentName" +"""); + } + + public override async Task Aggregate_without_elements() + { + await base.Aggregate_without_elements(); + + AssertSql( + """ +SELECT "e"."DepartmentName" AS "Key", AVG(CAST("e"."Salary" AS REAL)) AS "AverageSalary" +FROM "Employees" AS "e" +GROUP BY "e"."DepartmentName" +ORDER BY "e"."DepartmentName" +"""); + } + + public override async Task Elements_without_aggregate() + { + await base.Elements_without_aggregate(); + + AssertSql( + """ +SELECT "e"."DepartmentName", "e"."Id", "e"."Bonus", "e"."Name", "e"."Salary" +FROM "Employees" AS "e" +ORDER BY "e"."DepartmentName" +"""); + } + + public override async Task Distinct_aggregate_is_not_lifted() + { + await base.Distinct_aggregate_is_not_lifted(); + + AssertSql( + """ +SELECT "e1"."DepartmentName", "e0"."Id", "e0"."Bonus", "e0"."DepartmentName", "e0"."Name", "e0"."Salary", "e1"."c" +FROM ( + SELECT "e"."DepartmentName", COUNT(DISTINCT ("e"."Salary")) AS "c" + FROM "Employees" AS "e" + GROUP BY "e"."DepartmentName" +) AS "e1" +LEFT JOIN "Employees" AS "e0" ON "e1"."DepartmentName" = "e0"."DepartmentName" +ORDER BY "e1"."DepartmentName" +"""); + } + + public override async Task Filtered_aggregate_is_not_lifted() + { + await base.Filtered_aggregate_is_not_lifted(); + + AssertSql( + """ +SELECT "e1"."DepartmentName", "e0"."Id", "e0"."Bonus", "e0"."DepartmentName", "e0"."Name", "e0"."Salary", "e1"."c" +FROM ( + SELECT "e"."DepartmentName", COUNT(CASE + WHEN "e"."Salary" > 100 THEN 1 + END) AS "c" + FROM "Employees" AS "e" + GROUP BY "e"."DepartmentName" +) AS "e1" +LEFT JOIN "Employees" AS "e0" ON "e1"."DepartmentName" = "e0"."DepartmentName" +ORDER BY "e1"."DepartmentName" +"""); + } +} From 8f93b8a8eff66394e600ad65c81154d52936e4d5 Mon Sep 17 00:00:00 2001 From: Ben O'Donovan Date: Tue, 13 Oct 2026 15:33:03 +0100 Subject: [PATCH 2/5] Suppress EF9001 in precompiled queries and cover LongCount windowing - Suppress EF9001 in generated precompiled-query files: a SQL tree quoted into the generated code constructs the experimental WindowFunctionExpression, which failed to compile - Add a precompiled-query test whose SQL tree is quoted rather than pregenerated, so the window function is constructed in generated code - Add a LongCount test, which SQL Server translates to COUNT_BIG rather than COUNT Part of #12747 --- .../Internal/PrecompiledQueryCodeGenerator.cs | 1 + ...AdHocPrecompiledQueryRelationalTestBase.cs | 65 +++++++++++++++++++ .../WindowFunctionQueryRelationalTestBase.cs | 30 +++++++++ .../AdHocPrecompiledQuerySqlServerTest.cs | 46 +++++++++++++ .../Query/WindowFunctionQuerySqlServerTest.cs | 12 ++++ .../Query/AdHocPrecompiledQuerySqliteTest.cs | 8 +++ .../Query/WindowFunctionQuerySqliteTest.cs | 12 ++++ 7 files changed, 174 insertions(+) diff --git a/src/EFCore.Design/Query/Internal/PrecompiledQueryCodeGenerator.cs b/src/EFCore.Design/Query/Internal/PrecompiledQueryCodeGenerator.cs index f986552c58f..a91e0efc59c 100644 --- a/src/EFCore.Design/Query/Internal/PrecompiledQueryCodeGenerator.cs +++ b/src/EFCore.Design/Query/Internal/PrecompiledQueryCodeGenerator.cs @@ -170,6 +170,7 @@ public virtual IReadOnlyList GeneratePrecompiledQueries( _code .AppendLine() .AppendLine("#pragma warning disable EF9100 // Precompiled query is experimental") + .AppendLine("#pragma warning disable EF9001 // The query may use experimental APIs, e.g. window functions") // Auto-generated code has the nullable context disabled, but the interceptor signatures contain nullable annotations .AppendLine("#nullable enable annotations") .AppendLine() diff --git a/test/EFCore.Relational.Specification.Tests/Query/AdHocPrecompiledQueryRelationalTestBase.cs b/test/EFCore.Relational.Specification.Tests/Query/AdHocPrecompiledQueryRelationalTestBase.cs index 367700e3d4d..3a8d86a4144 100644 --- a/test/EFCore.Relational.Specification.Tests/Query/AdHocPrecompiledQueryRelationalTestBase.cs +++ b/test/EFCore.Relational.Specification.Tests/Query/AdHocPrecompiledQueryRelationalTestBase.cs @@ -395,6 +395,71 @@ public class RatingConverter() : ValueConverter( v => new Rating(v)); } + #region Window functions + + [Fact] + public virtual async Task GroupBy_elements_and_aggregates_as_window_functions() + { + var contextFactory = await InitializeNonSharedTest( + onConfiguring: o => UseWindowFunctionAggregates(o)); + var options = contextFactory.GetOptions(); + + await Test( + """ +await using var context = new AdHocPrecompiledQueryRelationalTestBase.WindowFunctionContext(dbContextOptions); +context.Employees.AddRange( + new AdHocPrecompiledQueryRelationalTestBase.WindowFunctionEmployee { DepartmentName = "Engineering", Salary = 100 }, + new AdHocPrecompiledQueryRelationalTestBase.WindowFunctionEmployee { DepartmentName = "Engineering", Salary = 200 }, + new AdHocPrecompiledQueryRelationalTestBase.WindowFunctionEmployee { DepartmentName = "Sales", Salary = 50 }); +await context.SaveChangesAsync(); + +// With more nullable parameters than SQL is pregenerated for, the SQL tree itself is quoted into the generated code. +string? excluded1 = "Finance", excluded2 = "Legal", excluded3 = "Marketing", excluded4 = "Support"; +var departments = await context.Employees + .Where(e => e.DepartmentName != excluded1 && e.DepartmentName != excluded2 && e.DepartmentName != excluded3 && e.DepartmentName != excluded4) + .GroupBy(e => e.DepartmentName) + .Select(g => new { Employees = g.ToList(), Total = g.Sum(e => e.Salary), Count = g.LongCount() }) + .ToListAsync(); + +Assert.Collection( + departments.OrderBy(d => d.Total), + sales => + { + Assert.Single(sales.Employees); + Assert.Equal(50, sales.Total); + Assert.Equal(1L, sales.Count); + }, + engineering => + { + Assert.Equal(2, engineering.Employees.Count); + Assert.Equal(300, engineering.Total); + Assert.Equal(2L, engineering.Count); + }); +""", + typeof(WindowFunctionContext), + options, + interceptorCodeAsserter: code => Assert.Contains("new WindowFunctionExpression(", code)); + } + + /// + /// Enables the window function translation on the given options, using the provider's own options builder. + /// + protected abstract DbContextOptionsBuilder UseWindowFunctionAggregates(DbContextOptionsBuilder optionsBuilder); + + public class WindowFunctionContext(DbContextOptions options) : DbContext(options) + { + public DbSet Employees { get; set; } = null!; + } + + public class WindowFunctionEmployee + { + public int Id { get; set; } + public string DepartmentName { get; set; } = ""; + public int Salary { get; set; } + } + + #endregion + #region Invalid runtime constant name [Fact] diff --git a/test/EFCore.Relational.Specification.Tests/Query/WindowFunctionQueryRelationalTestBase.cs b/test/EFCore.Relational.Specification.Tests/Query/WindowFunctionQueryRelationalTestBase.cs index ccec7f218f7..4b948ca5df0 100644 --- a/test/EFCore.Relational.Specification.Tests/Query/WindowFunctionQueryRelationalTestBase.cs +++ b/test/EFCore.Relational.Specification.Tests/Query/WindowFunctionQueryRelationalTestBase.cs @@ -99,6 +99,36 @@ public virtual async Task Elements_and_multiple_aggregates() }); } + /// + /// LongCount, which providers may translate to a function of their own (COUNT_BIG on SQL Server) rather than to COUNT. + /// + [Fact] + public virtual async Task Elements_and_long_count() + { + var contextFactory = await InitializeAsync(useWindowFunctionAggregates: true); + await using var context = contextFactory.CreateDbContext(); + + var results = await context.Employees + .GroupBy(e => e.DepartmentName) + .Select(g => new { g.Key, Names = g.Select(e => e.Name).ToList(), Count = g.LongCount() }) + .ToListAsync(); + + Assert.Collection( + results, + engineering => + { + Assert.Equal("Engineering", engineering.Key); + Assert.Equal(["Alice", "Bob", "Cheng"], engineering.Names.Order()); + Assert.Equal(3L, engineering.Count); + }, + sales => + { + Assert.Equal("Sales", sales.Key); + Assert.Equal(["Dara", "Emre"], sales.Names.Order()); + Assert.Equal(2L, sales.Count); + }); + } + [Fact] public virtual async Task Projected_elements_and_aggregate() { diff --git a/test/EFCore.SqlServer.FunctionalTests/Query/AdHocPrecompiledQuerySqlServerTest.cs b/test/EFCore.SqlServer.FunctionalTests/Query/AdHocPrecompiledQuerySqlServerTest.cs index 85ceb6eaf31..408df481d54 100644 --- a/test/EFCore.SqlServer.FunctionalTests/Query/AdHocPrecompiledQuerySqlServerTest.cs +++ b/test/EFCore.SqlServer.FunctionalTests/Query/AdHocPrecompiledQuerySqlServerTest.cs @@ -11,6 +11,52 @@ public class AdHocPrecompiledQuerySqlServerTest(NonSharedFixture fixture, ITestO protected override bool AlwaysPrintGeneratedSources => false; + protected override DbContextOptionsBuilder UseWindowFunctionAggregates(DbContextOptionsBuilder optionsBuilder) + { +#pragma warning disable EF9001 // UseWindowFunctionAggregates is experimental + new SqlServerDbContextOptionsBuilder(optionsBuilder).UseWindowFunctionAggregates(); +#pragma warning restore EF9001 + return optionsBuilder; + } + + public override async Task GroupBy_elements_and_aggregates_as_window_functions() + { + await base.GroupBy_elements_and_aggregates_as_window_functions(); + + AssertSql( + """ +@p0='Engineering' (Nullable = false) (Size = 4000) +@p1='100' +@p2='Engineering' (Nullable = false) (Size = 4000) +@p3='200' +@p4='Sales' (Nullable = false) (Size = 4000) +@p5='50' + +SET IMPLICIT_TRANSACTIONS OFF; +SET NOCOUNT ON; +MERGE [Employees] USING ( +VALUES (@p0, @p1, 0), +(@p2, @p3, 1), +(@p4, @p5, 2)) AS i ([DepartmentName], [Salary], _Position) ON 1=0 +WHEN NOT MATCHED THEN +INSERT ([DepartmentName], [Salary]) +VALUES (i.[DepartmentName], i.[Salary]) +OUTPUT INSERTED.[Id], i._Position; +""", + // + """ +@excluded1='Finance' (Size = 4000) +@excluded2='Legal' (Size = 4000) +@excluded3='Marketing' (Size = 4000) +@excluded4='Support' (Size = 4000) + +SELECT [e].[DepartmentName], [e].[Id], [e].[Salary], ISNULL(SUM([e].[Salary]) OVER(PARTITION BY [e].[DepartmentName]), 0), COUNT_BIG(*) OVER(PARTITION BY [e].[DepartmentName]) +FROM [Employees] AS [e] +WHERE [e].[DepartmentName] <> @excluded1 AND [e].[DepartmentName] <> @excluded2 AND [e].[DepartmentName] <> @excluded3 AND [e].[DepartmentName] <> @excluded4 +ORDER BY [e].[DepartmentName] +"""); + } + public override async Task Index_no_evaluatability() { if (!SqlServerTestEnvironment.SupportsJsonPathExpressions) diff --git a/test/EFCore.SqlServer.FunctionalTests/Query/WindowFunctionQuerySqlServerTest.cs b/test/EFCore.SqlServer.FunctionalTests/Query/WindowFunctionQuerySqlServerTest.cs index e4ce066f50f..30ae7ade45a 100644 --- a/test/EFCore.SqlServer.FunctionalTests/Query/WindowFunctionQuerySqlServerTest.cs +++ b/test/EFCore.SqlServer.FunctionalTests/Query/WindowFunctionQuerySqlServerTest.cs @@ -41,6 +41,18 @@ ORDER BY [e].[DepartmentName] """); } + public override async Task Elements_and_long_count() + { + await base.Elements_and_long_count(); + + AssertSql( + """ +SELECT [e].[DepartmentName], [e].[Name], COUNT_BIG(*) OVER(PARTITION BY [e].[DepartmentName]) +FROM [Employees] AS [e] +ORDER BY [e].[DepartmentName] +"""); + } + public override async Task Projected_elements_and_aggregate() { await base.Projected_elements_and_aggregate(); diff --git a/test/EFCore.Sqlite.FunctionalTests/Query/AdHocPrecompiledQuerySqliteTest.cs b/test/EFCore.Sqlite.FunctionalTests/Query/AdHocPrecompiledQuerySqliteTest.cs index 90ca92501a0..5f5c0b0f8fd 100644 --- a/test/EFCore.Sqlite.FunctionalTests/Query/AdHocPrecompiledQuerySqliteTest.cs +++ b/test/EFCore.Sqlite.FunctionalTests/Query/AdHocPrecompiledQuerySqliteTest.cs @@ -9,6 +9,14 @@ public class AdHocPrecompiledQuerySqliteTest(NonSharedFixture fixture, ITestOutp protected override bool AlwaysPrintGeneratedSources => false; + protected override DbContextOptionsBuilder UseWindowFunctionAggregates(DbContextOptionsBuilder optionsBuilder) + { +#pragma warning disable EF9001 // UseWindowFunctionAggregates is experimental + new SqliteDbContextOptionsBuilder(optionsBuilder).UseWindowFunctionAggregates(); +#pragma warning restore EF9001 + return optionsBuilder; + } + protected override ITestStoreFactory NonSharedTestStoreFactory => SqliteTestStoreFactory.Instance; diff --git a/test/EFCore.Sqlite.FunctionalTests/Query/WindowFunctionQuerySqliteTest.cs b/test/EFCore.Sqlite.FunctionalTests/Query/WindowFunctionQuerySqliteTest.cs index 18f67469b96..dc90633191b 100644 --- a/test/EFCore.Sqlite.FunctionalTests/Query/WindowFunctionQuerySqliteTest.cs +++ b/test/EFCore.Sqlite.FunctionalTests/Query/WindowFunctionQuerySqliteTest.cs @@ -41,6 +41,18 @@ ORDER BY "e"."DepartmentName" """); } + public override async Task Elements_and_long_count() + { + await base.Elements_and_long_count(); + + AssertSql( + """ +SELECT "e"."DepartmentName", "e"."Name", COUNT(*) OVER(PARTITION BY "e"."DepartmentName") +FROM "Employees" AS "e" +ORDER BY "e"."DepartmentName" +"""); + } + public override async Task Projected_elements_and_aggregate() { await base.Projected_elements_and_aggregate(); From ef71215963e083b29da5c4e7ea6540f4f5c4065b Mon Sep 17 00:00:00 2001 From: Ben O'Donovan Date: Sat, 17 Oct 2026 10:55:30 +0100 Subject: [PATCH 3/5] Cover window aggregates under collation-equal keys and a wrapped aggregate - Add Elements_and_aggregate_with_keys_differing_in_case: groupings, and the window values they carry, follow the database's grouping, now that lifted projections identify their groups by DENSE_RANK() (#39241) - Add Elements_and_float_sum: an aggregate whose translation wraps the function (a float sum is a CAST around SUM on SQL Server) keeps the GROUP BY translation - Update window function baselines for the group rank column Part of #12747 --- .../WindowFunctionQueryRelationalTestBase.cs | 61 +++++++++++++++++++ .../AdHocPrecompiledQuerySqlServerTest.cs | 2 +- .../Query/WindowFunctionQuerySqlServerTest.cs | 51 ++++++++++++---- .../Query/WindowFunctionQuerySqliteTest.cs | 49 ++++++++++++--- 4 files changed, 141 insertions(+), 22 deletions(-) diff --git a/test/EFCore.Relational.Specification.Tests/Query/WindowFunctionQueryRelationalTestBase.cs b/test/EFCore.Relational.Specification.Tests/Query/WindowFunctionQueryRelationalTestBase.cs index 4b948ca5df0..35e44dc1649 100644 --- a/test/EFCore.Relational.Specification.Tests/Query/WindowFunctionQueryRelationalTestBase.cs +++ b/test/EFCore.Relational.Specification.Tests/Query/WindowFunctionQueryRelationalTestBase.cs @@ -19,6 +19,9 @@ protected TestSqlLoggerFactory TestSqlLoggerFactory protected void AssertSql(params string[] expected) => TestSqlLoggerFactory.AssertBaseline(expected); + protected void ClearLog() + => TestSqlLoggerFactory.Clear(); + /// /// Enables the window function translation on the given options, using the provider's own options builder. /// @@ -129,6 +132,64 @@ public virtual async Task Elements_and_long_count() }); } + /// + /// Keys which differ only in case are one group under a case-insensitive collation, and two under a case-sensitive one. Either + /// way the groupings, and the window values they carry, have to agree with the database's grouping rather than with CLR string + /// equality. + /// + [Fact] + public virtual async Task Elements_and_aggregate_with_keys_differing_in_case() + { + var contextFactory = await InitializeAsync(useWindowFunctionAggregates: true); + await using var context = contextFactory.CreateDbContext(); + context.Employees.Add(new Employee { Name = "Fen", DepartmentName = "engineering", Salary = 400 }); + await context.SaveChangesAsync(); + + var databaseGroups = await context.Employees + .GroupBy(e => e.DepartmentName) + .Select(g => g.Count()) + .ToListAsync(); + + ClearLog(); + + var results = await context.Employees + .GroupBy(e => e.DepartmentName) + .Select(g => new { Names = g.Select(e => e.Name).ToList(), Count = g.Count() }) + .ToListAsync(); + + Assert.Equal(databaseGroups.Order(), results.Select(r => r.Names.Count).Order()); + Assert.All(results, r => Assert.Equal(r.Names.Count, r.Count)); + } + + /// + /// An aggregate whose translation wraps the function rather than being one - a float sum is a CAST around SUM on SQL Server - can't + /// carry an OVER clause, so it keeps the GROUP BY translation. + /// + [Fact] + public virtual async Task Elements_and_float_sum() + { + var contextFactory = await InitializeAsync(useWindowFunctionAggregates: true); + await using var context = contextFactory.CreateDbContext(); + + var results = await context.Employees + .GroupBy(e => e.DepartmentName) + .Select(g => new { g.Key, Names = g.Select(e => e.Name).ToList(), Total = g.Sum(e => (float)e.Salary) }) + .ToListAsync(); + + Assert.Collection( + results.OrderBy(r => r.Key), + engineering => + { + Assert.Equal(["Alice", "Bob", "Cheng"], engineering.Names.Order()); + Assert.Equal(600f, engineering.Total); + }, + sales => + { + Assert.Equal(["Dara", "Emre"], sales.Names.Order()); + Assert.Equal(150f, sales.Total); + }); + } + [Fact] public virtual async Task Projected_elements_and_aggregate() { diff --git a/test/EFCore.SqlServer.FunctionalTests/Query/AdHocPrecompiledQuerySqlServerTest.cs b/test/EFCore.SqlServer.FunctionalTests/Query/AdHocPrecompiledQuerySqlServerTest.cs index 408df481d54..89311205b6b 100644 --- a/test/EFCore.SqlServer.FunctionalTests/Query/AdHocPrecompiledQuerySqlServerTest.cs +++ b/test/EFCore.SqlServer.FunctionalTests/Query/AdHocPrecompiledQuerySqlServerTest.cs @@ -50,7 +50,7 @@ WHEN NOT MATCHED THEN @excluded3='Marketing' (Size = 4000) @excluded4='Support' (Size = 4000) -SELECT [e].[DepartmentName], [e].[Id], [e].[Salary], ISNULL(SUM([e].[Salary]) OVER(PARTITION BY [e].[DepartmentName]), 0), COUNT_BIG(*) OVER(PARTITION BY [e].[DepartmentName]) +SELECT [e].[DepartmentName], DENSE_RANK() OVER(ORDER BY [e].[DepartmentName]), [e].[Id], [e].[Salary], ISNULL(SUM([e].[Salary]) OVER(PARTITION BY [e].[DepartmentName]), 0), COUNT_BIG(*) OVER(PARTITION BY [e].[DepartmentName]) FROM [Employees] AS [e] WHERE [e].[DepartmentName] <> @excluded1 AND [e].[DepartmentName] <> @excluded2 AND [e].[DepartmentName] <> @excluded3 AND [e].[DepartmentName] <> @excluded4 ORDER BY [e].[DepartmentName] diff --git a/test/EFCore.SqlServer.FunctionalTests/Query/WindowFunctionQuerySqlServerTest.cs b/test/EFCore.SqlServer.FunctionalTests/Query/WindowFunctionQuerySqlServerTest.cs index 30ae7ade45a..2bf0d92071b 100644 --- a/test/EFCore.SqlServer.FunctionalTests/Query/WindowFunctionQuerySqlServerTest.cs +++ b/test/EFCore.SqlServer.FunctionalTests/Query/WindowFunctionQuerySqlServerTest.cs @@ -23,7 +23,7 @@ public override async Task Elements_and_aggregate() AssertSql( """ -SELECT [e].[DepartmentName], [e].[Id], [e].[Bonus], [e].[Name], [e].[Salary], AVG(CAST([e].[Salary] AS float)) OVER(PARTITION BY [e].[DepartmentName]) +SELECT [e].[DepartmentName], DENSE_RANK() OVER(ORDER BY [e].[DepartmentName]), [e].[Id], [e].[Bonus], [e].[Name], [e].[Salary], AVG(CAST([e].[Salary] AS float)) OVER(PARTITION BY [e].[DepartmentName]) FROM [Employees] AS [e] ORDER BY [e].[DepartmentName] """); @@ -35,7 +35,7 @@ public override async Task Elements_and_multiple_aggregates() AssertSql( """ -SELECT [e].[DepartmentName], [e].[Id], [e].[Bonus], [e].[Name], [e].[Salary], COUNT(*) OVER(PARTITION BY [e].[DepartmentName]), ISNULL(SUM([e].[Salary]) OVER(PARTITION BY [e].[DepartmentName]), 0), MAX([e].[Salary]) OVER(PARTITION BY [e].[DepartmentName]) +SELECT [e].[DepartmentName], DENSE_RANK() OVER(ORDER BY [e].[DepartmentName]), [e].[Id], [e].[Bonus], [e].[Name], [e].[Salary], COUNT(*) OVER(PARTITION BY [e].[DepartmentName]), ISNULL(SUM([e].[Salary]) OVER(PARTITION BY [e].[DepartmentName]), 0), MAX([e].[Salary]) OVER(PARTITION BY [e].[DepartmentName]) FROM [Employees] AS [e] ORDER BY [e].[DepartmentName] """); @@ -47,19 +47,48 @@ public override async Task Elements_and_long_count() AssertSql( """ -SELECT [e].[DepartmentName], [e].[Name], COUNT_BIG(*) OVER(PARTITION BY [e].[DepartmentName]) +SELECT [e].[DepartmentName], DENSE_RANK() OVER(ORDER BY [e].[DepartmentName]), [e].[Name], COUNT_BIG(*) OVER(PARTITION BY [e].[DepartmentName]) FROM [Employees] AS [e] ORDER BY [e].[DepartmentName] """); } + public override async Task Elements_and_aggregate_with_keys_differing_in_case() + { + await base.Elements_and_aggregate_with_keys_differing_in_case(); + + AssertSql( + """ +SELECT [e].[DepartmentName], DENSE_RANK() OVER(ORDER BY [e].[DepartmentName]), [e].[Name], COUNT(*) OVER(PARTITION BY [e].[DepartmentName]) +FROM [Employees] AS [e] +ORDER BY [e].[DepartmentName] +"""); + } + + public override async Task Elements_and_float_sum() + { + await base.Elements_and_float_sum(); + + AssertSql( + """ +SELECT [e1].[DepartmentName], [e0].[Name], [e0].[Id], [e1].[c] +FROM ( + SELECT [e].[DepartmentName], CAST(ISNULL(SUM(CAST([e].[Salary] AS real)), 0.0E0) AS real) AS [c] + FROM [Employees] AS [e] + GROUP BY [e].[DepartmentName] +) AS [e1] +LEFT JOIN [Employees] AS [e0] ON [e1].[DepartmentName] = [e0].[DepartmentName] +ORDER BY [e1].[DepartmentName] +"""); + } + public override async Task Projected_elements_and_aggregate() { await base.Projected_elements_and_aggregate(); AssertSql( """ -SELECT [e].[DepartmentName], [e].[Name], ISNULL(SUM([e].[Salary]) OVER(PARTITION BY [e].[DepartmentName]), 0) +SELECT [e].[DepartmentName], DENSE_RANK() OVER(ORDER BY [e].[DepartmentName]), [e].[Name], ISNULL(SUM([e].[Salary]) OVER(PARTITION BY [e].[DepartmentName]), 0) FROM [Employees] AS [e] ORDER BY [e].[DepartmentName] """); @@ -71,7 +100,7 @@ public override async Task Nullable_aggregate_over_partition_without_values() AssertSql( """ -SELECT [e].[DepartmentName], [e].[Id], [e].[Bonus], [e].[Name], [e].[Salary], AVG(CAST([e].[Bonus] AS float)) OVER(PARTITION BY [e].[DepartmentName]) +SELECT [e].[DepartmentName], DENSE_RANK() OVER(ORDER BY [e].[DepartmentName]), [e].[Id], [e].[Bonus], [e].[Name], [e].[Salary], AVG(CAST([e].[Bonus] AS float)) OVER(PARTITION BY [e].[DepartmentName]) FROM [Employees] AS [e] ORDER BY [e].[DepartmentName] """); @@ -83,7 +112,7 @@ public override async Task Sum_over_partition_without_values() AssertSql( """ -SELECT [e].[DepartmentName], [e].[Name], ISNULL(SUM([e].[Bonus]) OVER(PARTITION BY [e].[DepartmentName]), 0) +SELECT [e].[DepartmentName], DENSE_RANK() OVER(ORDER BY [e].[DepartmentName]), [e].[Name], ISNULL(SUM([e].[Bonus]) OVER(PARTITION BY [e].[DepartmentName]), 0) FROM [Employees] AS [e] ORDER BY [e].[DepartmentName] """); @@ -95,7 +124,7 @@ public override async Task Decimal_aggregates() AssertSql( """ -SELECT [e].[DepartmentName], [e].[Name], ISNULL(SUM(CAST([e].[Salary] AS decimal(18,2))) OVER(PARTITION BY [e].[DepartmentName]), 0.0), AVG(CAST([e].[Salary] AS decimal(18,2))) OVER(PARTITION BY [e].[DepartmentName]), MAX(CAST([e].[Salary] AS decimal(18,2))) OVER(PARTITION BY [e].[DepartmentName]) +SELECT [e].[DepartmentName], DENSE_RANK() OVER(ORDER BY [e].[DepartmentName]), [e].[Name], ISNULL(SUM(CAST([e].[Salary] AS decimal(18,2))) OVER(PARTITION BY [e].[DepartmentName]), 0.0), AVG(CAST([e].[Salary] AS decimal(18,2))) OVER(PARTITION BY [e].[DepartmentName]), MAX(CAST([e].[Salary] AS decimal(18,2))) OVER(PARTITION BY [e].[DepartmentName]) FROM [Employees] AS [e] ORDER BY [e].[DepartmentName] """); @@ -107,7 +136,7 @@ public override async Task Repeated_aggregate() AssertSql( """ -SELECT [e].[DepartmentName], [e].[Name], ISNULL(SUM([e].[Salary]) OVER(PARTITION BY [e].[DepartmentName]), 0), COUNT(*) OVER(PARTITION BY [e].[DepartmentName]) +SELECT [e].[DepartmentName], DENSE_RANK() OVER(ORDER BY [e].[DepartmentName]), [e].[Name], ISNULL(SUM([e].[Salary]) OVER(PARTITION BY [e].[DepartmentName]), 0), COUNT(*) OVER(PARTITION BY [e].[DepartmentName]) FROM [Employees] AS [e] ORDER BY [e].[DepartmentName] """); @@ -119,7 +148,7 @@ public override async Task Constant_key() AssertSql( """ -SELECT [e0].[Key], [e0].[Id], [e0].[Bonus], [e0].[DepartmentName], [e0].[Name], [e0].[Salary], AVG(CAST([e0].[Salary] AS float)) OVER(PARTITION BY [e0].[Key]) +SELECT [e0].[Key], DENSE_RANK() OVER(ORDER BY [e0].[Key]), [e0].[Id], [e0].[Bonus], [e0].[DepartmentName], [e0].[Name], [e0].[Salary], AVG(CAST([e0].[Salary] AS float)) OVER(PARTITION BY [e0].[Key]) FROM ( SELECT [e].[Id], [e].[Bonus], [e].[DepartmentName], [e].[Name], [e].[Salary], 1 AS [Key] FROM [Employees] AS [e] @@ -134,7 +163,7 @@ public override async Task Filter_before_grouping() AssertSql( """ -SELECT [e].[DepartmentName], [e].[Id], [e].[Bonus], [e].[Name], [e].[Salary], AVG(CAST([e].[Salary] AS float)) OVER(PARTITION BY [e].[DepartmentName]) +SELECT [e].[DepartmentName], DENSE_RANK() OVER(ORDER BY [e].[DepartmentName]), [e].[Id], [e].[Bonus], [e].[Name], [e].[Salary], AVG(CAST([e].[Salary] AS float)) OVER(PARTITION BY [e].[DepartmentName]) FROM [Employees] AS [e] WHERE [e].[Salary] > 50 ORDER BY [e].[DepartmentName] @@ -177,7 +206,7 @@ public override async Task Elements_without_aggregate() AssertSql( """ -SELECT [e].[DepartmentName], [e].[Id], [e].[Bonus], [e].[Name], [e].[Salary] +SELECT [e].[DepartmentName], DENSE_RANK() OVER(ORDER BY [e].[DepartmentName]), [e].[Id], [e].[Bonus], [e].[Name], [e].[Salary] FROM [Employees] AS [e] ORDER BY [e].[DepartmentName] """); diff --git a/test/EFCore.Sqlite.FunctionalTests/Query/WindowFunctionQuerySqliteTest.cs b/test/EFCore.Sqlite.FunctionalTests/Query/WindowFunctionQuerySqliteTest.cs index dc90633191b..bb0f1399885 100644 --- a/test/EFCore.Sqlite.FunctionalTests/Query/WindowFunctionQuerySqliteTest.cs +++ b/test/EFCore.Sqlite.FunctionalTests/Query/WindowFunctionQuerySqliteTest.cs @@ -23,7 +23,7 @@ public override async Task Elements_and_aggregate() AssertSql( """ -SELECT "e"."DepartmentName", "e"."Id", "e"."Bonus", "e"."Name", "e"."Salary", AVG(CAST("e"."Salary" AS REAL)) OVER(PARTITION BY "e"."DepartmentName") +SELECT "e"."DepartmentName", DENSE_RANK() OVER(ORDER BY "e"."DepartmentName"), "e"."Id", "e"."Bonus", "e"."Name", "e"."Salary", AVG(CAST("e"."Salary" AS REAL)) OVER(PARTITION BY "e"."DepartmentName") FROM "Employees" AS "e" ORDER BY "e"."DepartmentName" """); @@ -35,7 +35,7 @@ public override async Task Elements_and_multiple_aggregates() AssertSql( """ -SELECT "e"."DepartmentName", "e"."Id", "e"."Bonus", "e"."Name", "e"."Salary", COUNT(*) OVER(PARTITION BY "e"."DepartmentName"), COALESCE(SUM("e"."Salary") OVER(PARTITION BY "e"."DepartmentName"), 0), MAX("e"."Salary") OVER(PARTITION BY "e"."DepartmentName") +SELECT "e"."DepartmentName", DENSE_RANK() OVER(ORDER BY "e"."DepartmentName"), "e"."Id", "e"."Bonus", "e"."Name", "e"."Salary", COUNT(*) OVER(PARTITION BY "e"."DepartmentName"), COALESCE(SUM("e"."Salary") OVER(PARTITION BY "e"."DepartmentName"), 0), MAX("e"."Salary") OVER(PARTITION BY "e"."DepartmentName") FROM "Employees" AS "e" ORDER BY "e"."DepartmentName" """); @@ -47,19 +47,48 @@ public override async Task Elements_and_long_count() AssertSql( """ -SELECT "e"."DepartmentName", "e"."Name", COUNT(*) OVER(PARTITION BY "e"."DepartmentName") +SELECT "e"."DepartmentName", DENSE_RANK() OVER(ORDER BY "e"."DepartmentName"), "e"."Name", COUNT(*) OVER(PARTITION BY "e"."DepartmentName") FROM "Employees" AS "e" ORDER BY "e"."DepartmentName" """); } + public override async Task Elements_and_aggregate_with_keys_differing_in_case() + { + await base.Elements_and_aggregate_with_keys_differing_in_case(); + + AssertSql( + """ +SELECT "e"."DepartmentName", DENSE_RANK() OVER(ORDER BY "e"."DepartmentName"), "e"."Name", COUNT(*) OVER(PARTITION BY "e"."DepartmentName") +FROM "Employees" AS "e" +ORDER BY "e"."DepartmentName" +"""); + } + + public override async Task Elements_and_float_sum() + { + await base.Elements_and_float_sum(); + + AssertSql( + """ +SELECT "e1"."DepartmentName", "e0"."Name", "e0"."Id", "e1"."c" +FROM ( + SELECT "e"."DepartmentName", COALESCE(SUM(CAST("e"."Salary" AS REAL)), 0.0) AS "c" + FROM "Employees" AS "e" + GROUP BY "e"."DepartmentName" +) AS "e1" +LEFT JOIN "Employees" AS "e0" ON "e1"."DepartmentName" = "e0"."DepartmentName" +ORDER BY "e1"."DepartmentName" +"""); + } + public override async Task Projected_elements_and_aggregate() { await base.Projected_elements_and_aggregate(); AssertSql( """ -SELECT "e"."DepartmentName", "e"."Name", COALESCE(SUM("e"."Salary") OVER(PARTITION BY "e"."DepartmentName"), 0) +SELECT "e"."DepartmentName", DENSE_RANK() OVER(ORDER BY "e"."DepartmentName"), "e"."Name", COALESCE(SUM("e"."Salary") OVER(PARTITION BY "e"."DepartmentName"), 0) FROM "Employees" AS "e" ORDER BY "e"."DepartmentName" """); @@ -71,7 +100,7 @@ public override async Task Nullable_aggregate_over_partition_without_values() AssertSql( """ -SELECT "e"."DepartmentName", "e"."Id", "e"."Bonus", "e"."Name", "e"."Salary", AVG(CAST("e"."Bonus" AS REAL)) OVER(PARTITION BY "e"."DepartmentName") +SELECT "e"."DepartmentName", DENSE_RANK() OVER(ORDER BY "e"."DepartmentName"), "e"."Id", "e"."Bonus", "e"."Name", "e"."Salary", AVG(CAST("e"."Bonus" AS REAL)) OVER(PARTITION BY "e"."DepartmentName") FROM "Employees" AS "e" ORDER BY "e"."DepartmentName" """); @@ -83,7 +112,7 @@ public override async Task Sum_over_partition_without_values() AssertSql( """ -SELECT "e"."DepartmentName", "e"."Name", COALESCE(SUM("e"."Bonus") OVER(PARTITION BY "e"."DepartmentName"), 0) +SELECT "e"."DepartmentName", DENSE_RANK() OVER(ORDER BY "e"."DepartmentName"), "e"."Name", COALESCE(SUM("e"."Bonus") OVER(PARTITION BY "e"."DepartmentName"), 0) FROM "Employees" AS "e" ORDER BY "e"."DepartmentName" """); @@ -112,7 +141,7 @@ public override async Task Repeated_aggregate() AssertSql( """ -SELECT "e"."DepartmentName", "e"."Name", COALESCE(SUM("e"."Salary") OVER(PARTITION BY "e"."DepartmentName"), 0), COUNT(*) OVER(PARTITION BY "e"."DepartmentName") +SELECT "e"."DepartmentName", DENSE_RANK() OVER(ORDER BY "e"."DepartmentName"), "e"."Name", COALESCE(SUM("e"."Salary") OVER(PARTITION BY "e"."DepartmentName"), 0), COUNT(*) OVER(PARTITION BY "e"."DepartmentName") FROM "Employees" AS "e" ORDER BY "e"."DepartmentName" """); @@ -124,7 +153,7 @@ public override async Task Constant_key() AssertSql( """ -SELECT "e0"."Key", "e0"."Id", "e0"."Bonus", "e0"."DepartmentName", "e0"."Name", "e0"."Salary", AVG(CAST("e0"."Salary" AS REAL)) OVER(PARTITION BY "e0"."Key") +SELECT "e0"."Key", DENSE_RANK() OVER(ORDER BY "e0"."Key"), "e0"."Id", "e0"."Bonus", "e0"."DepartmentName", "e0"."Name", "e0"."Salary", AVG(CAST("e0"."Salary" AS REAL)) OVER(PARTITION BY "e0"."Key") FROM ( SELECT "e"."Id", "e"."Bonus", "e"."DepartmentName", "e"."Name", "e"."Salary", 1 AS "Key" FROM "Employees" AS "e" @@ -139,7 +168,7 @@ public override async Task Filter_before_grouping() AssertSql( """ -SELECT "e"."DepartmentName", "e"."Id", "e"."Bonus", "e"."Name", "e"."Salary", AVG(CAST("e"."Salary" AS REAL)) OVER(PARTITION BY "e"."DepartmentName") +SELECT "e"."DepartmentName", DENSE_RANK() OVER(ORDER BY "e"."DepartmentName"), "e"."Id", "e"."Bonus", "e"."Name", "e"."Salary", AVG(CAST("e"."Salary" AS REAL)) OVER(PARTITION BY "e"."DepartmentName") FROM "Employees" AS "e" WHERE "e"."Salary" > 50 ORDER BY "e"."DepartmentName" @@ -182,7 +211,7 @@ public override async Task Elements_without_aggregate() AssertSql( """ -SELECT "e"."DepartmentName", "e"."Id", "e"."Bonus", "e"."Name", "e"."Salary" +SELECT "e"."DepartmentName", DENSE_RANK() OVER(ORDER BY "e"."DepartmentName"), "e"."Id", "e"."Bonus", "e"."Name", "e"."Salary" FROM "Employees" AS "e" ORDER BY "e"."DepartmentName" """); From c954d453f72193cb82e2aad67d9e822681f4de8b Mon Sep 17 00:00:00 2001 From: Ben O'Donovan Date: Tue, 13 Oct 2026 16:58:18 +0100 Subject: [PATCH 4/5] Make the distinct and filtered aggregate tests reach the window check - Drop the trailing OrderBy from Distinct_aggregate_is_not_lifted and Filtered_aggregate_is_not_lifted: as a final operator after the projection, it kept the lift from being attempted at all, so the tests passed whether or not the aggregate check rejected these shapes - Order the results on the client before asserting instead Part of #12747 --- .../Query/WindowFunctionQueryRelationalTestBase.cs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/test/EFCore.Relational.Specification.Tests/Query/WindowFunctionQueryRelationalTestBase.cs b/test/EFCore.Relational.Specification.Tests/Query/WindowFunctionQueryRelationalTestBase.cs index 35e44dc1649..44e4acc2d82 100644 --- a/test/EFCore.Relational.Specification.Tests/Query/WindowFunctionQueryRelationalTestBase.cs +++ b/test/EFCore.Relational.Specification.Tests/Query/WindowFunctionQueryRelationalTestBase.cs @@ -462,11 +462,10 @@ public virtual async Task Distinct_aggregate_is_not_lifted() var results = await context.Employees .GroupBy(e => e.DepartmentName) .Select(g => new { g.Key, Employees = g.ToList(), Salaries = g.Select(e => e.Salary).Distinct().Count() }) - .OrderBy(x => x.Key) .ToListAsync(); Assert.Collection( - results, + results.OrderBy(r => r.Key), engineering => Assert.Equal(3, engineering.Salaries), sales => Assert.Equal(2, sales.Salaries)); } @@ -483,11 +482,10 @@ public virtual async Task Filtered_aggregate_is_not_lifted() var results = await context.Employees .GroupBy(e => e.DepartmentName) .Select(g => new { g.Key, Employees = g.ToList(), HighEarners = g.Where(e => e.Salary > 100).Count() }) - .OrderBy(x => x.Key) .ToListAsync(); Assert.Collection( - results, + results.OrderBy(r => r.Key), engineering => Assert.Equal(2, engineering.HighEarners), sales => Assert.Equal(0, sales.HighEarners)); } From ceec5a2b28e79a4e525ee4300d2f031157e293d4 Mon Sep 17 00:00:00 2001 From: Ben O'Donovan Date: Tue, 13 Oct 2026 17:24:52 +0100 Subject: [PATCH 5/5] Cover predicate counts and the grouping projected alongside an aggregate - Add Elements_and_predicate_counts: Count and LongCount with a predicate are windowed as COUNT(CASE ...) OVER (...) (COUNT_BIG on SQL Server), with the grouping still carrying every row - Add Grouping_itself_and_aggregate_is_not_translated: projecting the grouping itself alongside an aggregate fails translation the same way with and without the opt-in Part of #12747 --- .../WindowFunctionQueryRelationalTestBase.cs | 59 +++++++++++++++++++ .../Query/WindowFunctionQuerySqlServerTest.cs | 23 ++++++++ .../Query/WindowFunctionQuerySqliteTest.cs | 23 ++++++++ 3 files changed, 105 insertions(+) diff --git a/test/EFCore.Relational.Specification.Tests/Query/WindowFunctionQueryRelationalTestBase.cs b/test/EFCore.Relational.Specification.Tests/Query/WindowFunctionQueryRelationalTestBase.cs index 44e4acc2d82..ce4e95f84fa 100644 --- a/test/EFCore.Relational.Specification.Tests/Query/WindowFunctionQueryRelationalTestBase.cs +++ b/test/EFCore.Relational.Specification.Tests/Query/WindowFunctionQueryRelationalTestBase.cs @@ -190,6 +190,44 @@ public virtual async Task Elements_and_float_sum() }); } + /// + /// Count and LongCount with a predicate: the predicate goes into the aggregate rather than filtering the rows, which the grouping + /// still carries all of. + /// + [Fact] + public virtual async Task Elements_and_predicate_counts() + { + var contextFactory = await InitializeAsync(useWindowFunctionAggregates: true); + await using var context = contextFactory.CreateDbContext(); + + var results = await context.Employees + .GroupBy(e => e.DepartmentName) + .Select( + g => new + { + g.Key, + Names = g.Select(e => e.Name).ToList(), + HighEarners = g.Count(e => e.Salary > 100), + LongHighEarners = g.LongCount(e => e.Salary > 100) + }) + .ToListAsync(); + + Assert.Collection( + results.OrderBy(r => r.Key), + engineering => + { + Assert.Equal(["Alice", "Bob", "Cheng"], engineering.Names.Order()); + Assert.Equal(2, engineering.HighEarners); + Assert.Equal(2L, engineering.LongHighEarners); + }, + sales => + { + Assert.Equal(["Dara", "Emre"], sales.Names.Order()); + Assert.Equal(0, sales.HighEarners); + Assert.Equal(0L, sales.LongHighEarners); + }); + } + [Fact] public virtual async Task Projected_elements_and_aggregate() { @@ -450,6 +488,27 @@ public virtual async Task Elements_without_aggregate() sales => Assert.Equal(2, sales.Employees.Count)); } + /// + /// The grouping projected as itself alongside an aggregate has no translation, with or without the opt-in: there are no + /// elements to carry window values on, and exposing the grouping would mean exposing the rows which carry them. + /// + [Theory, InlineData(false), InlineData(true)] + public virtual async Task Grouping_itself_and_aggregate_is_not_translated(bool useWindowFunctionAggregates) + { + var contextFactory = await InitializeAsync(useWindowFunctionAggregates); + await using var context = contextFactory.CreateDbContext(); + + var exception = await Assert.ThrowsAsync( + () => context.Employees + .GroupBy(e => e.DepartmentName) + .Select(g => new { Group = g, Count = g.Count() }) + .ToListAsync()); + + Assert.Contains( + CoreStrings.TranslationFailedWithDetails("", CoreStrings.QuerySelectContainsGrouping)[21..], + exception.Message); + } + /// /// COUNT(DISTINCT ...) OVER (...) isn't supported by SQL Server, so a distinct aggregate keeps the GROUP BY translation. /// diff --git a/test/EFCore.SqlServer.FunctionalTests/Query/WindowFunctionQuerySqlServerTest.cs b/test/EFCore.SqlServer.FunctionalTests/Query/WindowFunctionQuerySqlServerTest.cs index 2bf0d92071b..a0fe954c12b 100644 --- a/test/EFCore.SqlServer.FunctionalTests/Query/WindowFunctionQuerySqlServerTest.cs +++ b/test/EFCore.SqlServer.FunctionalTests/Query/WindowFunctionQuerySqlServerTest.cs @@ -82,6 +82,22 @@ ORDER BY [e1].[DepartmentName] """); } + public override async Task Elements_and_predicate_counts() + { + await base.Elements_and_predicate_counts(); + + AssertSql( + """ +SELECT [e].[DepartmentName], DENSE_RANK() OVER(ORDER BY [e].[DepartmentName]), [e].[Name], COUNT(CASE + WHEN [e].[Salary] > 100 THEN 1 +END) OVER(PARTITION BY [e].[DepartmentName]), COUNT_BIG(CASE + WHEN [e].[Salary] > 100 THEN 1 +END) OVER(PARTITION BY [e].[DepartmentName]) +FROM [Employees] AS [e] +ORDER BY [e].[DepartmentName] +"""); + } + public override async Task Projected_elements_and_aggregate() { await base.Projected_elements_and_aggregate(); @@ -212,6 +228,13 @@ ORDER BY [e].[DepartmentName] """); } + public override async Task Grouping_itself_and_aggregate_is_not_translated(bool useWindowFunctionAggregates) + { + await base.Grouping_itself_and_aggregate_is_not_translated(useWindowFunctionAggregates); + + AssertSql(); + } + public override async Task Distinct_aggregate_is_not_lifted() { await base.Distinct_aggregate_is_not_lifted(); diff --git a/test/EFCore.Sqlite.FunctionalTests/Query/WindowFunctionQuerySqliteTest.cs b/test/EFCore.Sqlite.FunctionalTests/Query/WindowFunctionQuerySqliteTest.cs index bb0f1399885..d3a97b5a747 100644 --- a/test/EFCore.Sqlite.FunctionalTests/Query/WindowFunctionQuerySqliteTest.cs +++ b/test/EFCore.Sqlite.FunctionalTests/Query/WindowFunctionQuerySqliteTest.cs @@ -82,6 +82,22 @@ ORDER BY "e1"."DepartmentName" """); } + public override async Task Elements_and_predicate_counts() + { + await base.Elements_and_predicate_counts(); + + AssertSql( + """ +SELECT "e"."DepartmentName", DENSE_RANK() OVER(ORDER BY "e"."DepartmentName"), "e"."Name", COUNT(CASE + WHEN "e"."Salary" > 100 THEN 1 +END) OVER(PARTITION BY "e"."DepartmentName"), COUNT(CASE + WHEN "e"."Salary" > 100 THEN 1 +END) OVER(PARTITION BY "e"."DepartmentName") +FROM "Employees" AS "e" +ORDER BY "e"."DepartmentName" +"""); + } + public override async Task Projected_elements_and_aggregate() { await base.Projected_elements_and_aggregate(); @@ -217,6 +233,13 @@ ORDER BY "e"."DepartmentName" """); } + public override async Task Grouping_itself_and_aggregate_is_not_translated(bool useWindowFunctionAggregates) + { + await base.Grouping_itself_and_aggregate_is_not_translated(useWindowFunctionAggregates); + + AssertSql(); + } + public override async Task Distinct_aggregate_is_not_lifted() { await base.Distinct_aggregate_is_not_lifted();