diff --git a/src/EFCore.Relational/Query/Internal/QueryableAggregateMethodTranslator.cs b/src/EFCore.Relational/Query/Internal/QueryableAggregateMethodTranslator.cs index 1d1e9223508..2aa27685733 100644 --- a/src/EFCore.Relational/Query/Internal/QueryableAggregateMethodTranslator.cs +++ b/src/EFCore.Relational/Query/Internal/QueryableAggregateMethodTranslator.cs @@ -74,6 +74,8 @@ public QueryableAggregateMethodTranslator(ISqlExpressionFactory sqlExpressionFac averageSqlExpression.Type, averageSqlExpression.TypeMapping); + // Count/LongCount are special since if the argument is a star fragment, it needs to be transformed to any non-null constant + // when a predicate is applied. case nameof(Queryable.Count) when methodInfo == QueryableMethods.CountWithoutPredicate || methodInfo == QueryableMethods.CountWithPredicate: diff --git a/src/EFCore.SqlServer/Extensions/SqlServerDbFunctionsExtensions.cs b/src/EFCore.SqlServer/Extensions/SqlServerDbFunctionsExtensions.cs index f4de9db02ab..3a74c14b1d6 100644 --- a/src/EFCore.SqlServer/Extensions/SqlServerDbFunctionsExtensions.cs +++ b/src/EFCore.SqlServer/Extensions/SqlServerDbFunctionsExtensions.cs @@ -1078,7 +1078,7 @@ public static int DateDiffWeek( /// /// Validate if the given string is a valid date. - /// Corresponds to the SQL Server's ISDATE('date'). + /// Corresponds to SQL Server's ISDATE('date'). /// /// /// See Database functions, and @@ -1096,7 +1096,7 @@ public static bool IsDate( /// /// Initializes a new instance of the structure to the specified year, month, day, hour, minute, second, /// and millisecond. - /// Corresponds to the SQL Server's DATETIMEFROMPARTS(year, month, day, hour, minute, second, millisecond). + /// Corresponds to SQL Server's DATETIMEFROMPARTS(year, month, day, hour, minute, second, millisecond). /// /// /// See Database functions, and @@ -1128,7 +1128,7 @@ public static DateTime DateTimeFromParts( /// /// Initializes a new instance of the structure to the specified year, month, day. - /// Corresponds to the SQL Server's DATEFROMPARTS(year, month, day). + /// Corresponds to SQL Server's DATEFROMPARTS(year, month, day). /// /// /// See Database functions, and @@ -1150,7 +1150,7 @@ public static DateTime DateFromParts( /// /// Initializes a new instance of the structure to the specified year, month, day, hour, minute, second, /// fractions, and precision. - /// Corresponds to the SQL Server's DATETIME2FROMPARTS(year, month, day, hour, minute, seconds, fractions, precision). + /// Corresponds to SQL Server's DATETIME2FROMPARTS(year, month, day, hour, minute, seconds, fractions, precision). /// /// /// See Database functions, and @@ -1185,7 +1185,7 @@ public static DateTime DateTime2FromParts( /// /// Initializes a new instance of the structure to the specified year, month, day, hour, minute, /// second, fractions, hourOffset, minuteOffset and precision. - /// Corresponds to the SQL Server's + /// Corresponds to SQL Server's /// /// DATETIMEOFFSETFROMPARTS(year, month, day, hour, minute, seconds, fractions, hour_offset, /// minute_offset, precision) @@ -1228,7 +1228,7 @@ public static DateTimeOffset DateTimeOffsetFromParts( /// /// Initializes a new instance of the structure to the specified year, month, day, hour and minute. - /// Corresponds to the SQL Server's SMALLDATETIMEFROMPARTS(year, month, day, hour, minute). + /// Corresponds to SQL Server's SMALLDATETIMEFROMPARTS(year, month, day, hour, minute). /// /// /// See Database functions, and @@ -1253,7 +1253,7 @@ public static DateTime SmallDateTimeFromParts( /// /// Initializes a new instance of the structure to the specified hour, minute, second, fractions, and - /// precision. Corresponds to the SQL Server's TIMEFROMPARTS(hour, minute, seconds, fractions, precision). + /// precision. Corresponds to SQL Server's TIMEFROMPARTS(hour, minute, seconds, fractions, precision). /// /// /// See Database functions, and @@ -1424,7 +1424,7 @@ public static TimeSpan TimeFromParts( /// /// Validate if the given string is a valid numeric. - /// Corresponds to the SQL Server's ISNUMERIC(expression). + /// Corresponds to the SQL Server ISNUMERIC(expression). /// /// /// See Database functions, and @@ -1441,7 +1441,7 @@ public static bool IsNumeric( /// /// Converts to the corresponding datetimeoffset in the target . - /// Corresponds to the SQL Server's AT TIME ZONE construct. + /// Corresponds to the SQL Server AT TIME ZONE construct. /// /// /// @@ -1467,7 +1467,7 @@ public static DateTimeOffset AtTimeZone( /// /// Converts to the time zone specified by . - /// Corresponds to the SQL Server's AT TIME ZONE construct. + /// Corresponds to the SQL Server AT TIME ZONE construct. /// /// /// See Database functions, and @@ -1484,4 +1484,300 @@ public static DateTimeOffset AtTimeZone( DateTimeOffset dateTimeOffset, string timeZone) => throw new InvalidOperationException(CoreStrings.FunctionOnClient(nameof(AtTimeZone))); + + #region Sample standard deviation + + /// + /// Returns the sample standard deviation of all values in the specified expression. + /// Corresponds to SQL Server's STDEV. + /// + /// The instance. + /// The values. + /// The computed sample standard deviation. + public static double? StandardDeviationSample(this DbFunctions _, IEnumerable values) + => throw new InvalidOperationException(CoreStrings.FunctionOnClient(nameof(StandardDeviationSample))); + + /// + /// Returns the sample standard deviation of all values in the specified expression. + /// Corresponds to SQL Server's STDEV. + /// + /// The instance. + /// The values. + /// The computed sample standard deviation. + public static double? StandardDeviationSample(this DbFunctions _, IEnumerable values) + => throw new InvalidOperationException(CoreStrings.FunctionOnClient(nameof(StandardDeviationSample))); + + /// + /// Returns the sample standard deviation of all values in the specified expression. + /// Corresponds to SQL Server's STDEV. + /// + /// The instance. + /// The values. + /// The computed sample standard deviation. + public static double? StandardDeviationSample(this DbFunctions _, IEnumerable values) + => throw new InvalidOperationException(CoreStrings.FunctionOnClient(nameof(StandardDeviationSample))); + + /// + /// Returns the sample standard deviation of all values in the specified expression. + /// Corresponds to SQL Server's STDEV. + /// + /// The instance. + /// The values. + /// The computed sample standard deviation. + public static double? StandardDeviationSample(this DbFunctions _, IEnumerable values) + => throw new InvalidOperationException(CoreStrings.FunctionOnClient(nameof(StandardDeviationSample))); + + /// + /// Returns the sample standard deviation of all values in the specified expression. + /// Corresponds to SQL Server's STDEV. + /// + /// The instance. + /// The values. + /// The computed sample standard deviation. + public static double? StandardDeviationSample(this DbFunctions _, IEnumerable values) + => throw new InvalidOperationException(CoreStrings.FunctionOnClient(nameof(StandardDeviationSample))); + + /// + /// Returns the sample standard deviation of all values in the specified expression. + /// Corresponds to SQL Server's STDEV. + /// + /// The instance. + /// The values. + /// The computed sample standard deviation. + public static double? StandardDeviationSample(this DbFunctions _, IEnumerable values) + => throw new InvalidOperationException(CoreStrings.FunctionOnClient(nameof(StandardDeviationSample))); + + /// + /// Returns the sample standard deviation of all values in the specified expression. + /// Corresponds to SQL Server's STDEV. + /// + /// The instance. + /// The values. + /// The computed sample standard deviation. + public static double? StandardDeviationSample(this DbFunctions _, IEnumerable values) + => throw new InvalidOperationException(CoreStrings.FunctionOnClient(nameof(StandardDeviationSample))); + + #endregion Sample standard deviation + + #region Population standard deviation + + /// + /// Returns the population standard deviation of all values in the specified expression. + /// Corresponds to SQL Server's STDEVP. + /// + /// The instance. + /// The values. + /// The computed population standard deviation. + public static double? StandardDeviationPopulation(this DbFunctions _, IEnumerable values) + => throw new InvalidOperationException(CoreStrings.FunctionOnClient(nameof(StandardDeviationPopulation))); + + /// + /// Returns the population standard deviation of all values in the specified expression. + /// Corresponds to SQL Server's STDEVP. + /// + /// The instance. + /// The values. + /// The computed population standard deviation. + public static double? StandardDeviationPopulation(this DbFunctions _, IEnumerable values) + => throw new InvalidOperationException(CoreStrings.FunctionOnClient(nameof(StandardDeviationPopulation))); + + /// + /// Returns the population standard deviation of all values in the specified expression. + /// Corresponds to SQL Server's STDEVP. + /// + /// The instance. + /// The values. + /// The computed population standard deviation. + public static double? StandardDeviationPopulation(this DbFunctions _, IEnumerable values) + => throw new InvalidOperationException(CoreStrings.FunctionOnClient(nameof(StandardDeviationPopulation))); + + /// + /// Returns the population standard deviation of all values in the specified expression. + /// Corresponds to SQL Server's STDEVP. + /// + /// The instance. + /// The values. + /// The computed population standard deviation. + public static double? StandardDeviationPopulation(this DbFunctions _, IEnumerable values) + => throw new InvalidOperationException(CoreStrings.FunctionOnClient(nameof(StandardDeviationPopulation))); + + /// + /// Returns the population standard deviation of all values in the specified expression. + /// Corresponds to SQL Server's STDEVP. + /// + /// The instance. + /// The values. + /// The computed population standard deviation. + public static double? StandardDeviationPopulation(this DbFunctions _, IEnumerable values) + => throw new InvalidOperationException(CoreStrings.FunctionOnClient(nameof(StandardDeviationPopulation))); + + /// + /// Returns the population standard deviation of all values in the specified expression. + /// Corresponds to SQL Server's STDEVP. + /// + /// The instance. + /// The values. + /// The computed population standard deviation. + public static double? StandardDeviationPopulation(this DbFunctions _, IEnumerable values) + => throw new InvalidOperationException(CoreStrings.FunctionOnClient(nameof(StandardDeviationPopulation))); + + /// + /// Returns the population standard deviation of all values in the specified expression. + /// Corresponds to SQL Server's STDEVP. + /// + /// The instance. + /// The values. + /// The computed population standard deviation. + public static double? StandardDeviationPopulation(this DbFunctions _, IEnumerable values) + => throw new InvalidOperationException(CoreStrings.FunctionOnClient(nameof(StandardDeviationPopulation))); + + #endregion Population standard deviation + + #region Sample variance + + /// + /// Returns the sample variance of all values in the specified expression. + /// Corresponds to SQL Server's VAR. + /// + /// The instance. + /// The values. + /// The computed sample variance. + public static double? VarianceSample(this DbFunctions _, IEnumerable values) + => throw new InvalidOperationException(CoreStrings.FunctionOnClient(nameof(VarianceSample))); + + /// + /// Returns the sample variance of all values in the specified expression. + /// Corresponds to SQL Server's VAR. + /// + /// The instance. + /// The values. + /// The computed sample variance. + public static double? VarianceSample(this DbFunctions _, IEnumerable values) + => throw new InvalidOperationException(CoreStrings.FunctionOnClient(nameof(VarianceSample))); + + /// + /// Returns the sample variance of all values in the specified expression. + /// Corresponds to SQL Server's VAR. + /// + /// The instance. + /// The values. + /// The computed sample variance. + public static double? VarianceSample(this DbFunctions _, IEnumerable values) + => throw new InvalidOperationException(CoreStrings.FunctionOnClient(nameof(VarianceSample))); + + /// + /// Returns the sample variance of all values in the specified expression. + /// Corresponds to SQL Server's VAR. + /// + /// The instance. + /// The values. + /// The computed sample variance. + public static double? VarianceSample(this DbFunctions _, IEnumerable values) + => throw new InvalidOperationException(CoreStrings.FunctionOnClient(nameof(VarianceSample))); + + /// + /// Returns the sample variance of all values in the specified expression. + /// Corresponds to SQL Server's VAR. + /// + /// The instance. + /// The values. + /// The computed sample variance. + public static double? VarianceSample(this DbFunctions _, IEnumerable values) + => throw new InvalidOperationException(CoreStrings.FunctionOnClient(nameof(VarianceSample))); + + /// + /// Returns the sample variance of all values in the specified expression. + /// Corresponds to SQL Server's VAR. + /// + /// The instance. + /// The values. + /// The computed sample variance. + public static double? VarianceSample(this DbFunctions _, IEnumerable values) + => throw new InvalidOperationException(CoreStrings.FunctionOnClient(nameof(VarianceSample))); + + /// + /// Returns the sample variance of all values in the specified expression. + /// Corresponds to SQL Server's VAR. + /// + /// The instance. + /// The values. + /// The computed sample variance. + public static double? VarianceSample(this DbFunctions _, IEnumerable values) + => throw new InvalidOperationException(CoreStrings.FunctionOnClient(nameof(VarianceSample))); + + #endregion Sample variance + + #region Population variance + + /// + /// Returns the population variance of all values in the specified expression. + /// Corresponds to SQL Server's VARP. + /// + /// The instance. + /// The values. + /// The computed population variance. + public static double? VariancePopulation(this DbFunctions _, IEnumerable values) + => throw new InvalidOperationException(CoreStrings.FunctionOnClient(nameof(VariancePopulation))); + + /// + /// Returns the population variance of all values in the specified expression. + /// Corresponds to SQL Server's VARP. + /// + /// The instance. + /// The values. + /// The computed population variance. + public static double? VariancePopulation(this DbFunctions _, IEnumerable values) + => throw new InvalidOperationException(CoreStrings.FunctionOnClient(nameof(VariancePopulation))); + + /// + /// Returns the population variance of all values in the specified expression. + /// Corresponds to SQL Server's VARP. + /// + /// The instance. + /// The values. + /// The computed population variance. + public static double? VariancePopulation(this DbFunctions _, IEnumerable values) + => throw new InvalidOperationException(CoreStrings.FunctionOnClient(nameof(VariancePopulation))); + + /// + /// Returns the population variance of all values in the specified expression. + /// Corresponds to SQL Server's VARP. + /// + /// The instance. + /// The values. + /// The computed population variance. + public static double? VariancePopulation(this DbFunctions _, IEnumerable values) + => throw new InvalidOperationException(CoreStrings.FunctionOnClient(nameof(VariancePopulation))); + + /// + /// Returns the population variance of all values in the specified expression. + /// Corresponds to SQL Server's VARP. + /// + /// The instance. + /// The values. + /// The computed population variance. + public static double? VariancePopulation(this DbFunctions _, IEnumerable values) + => throw new InvalidOperationException(CoreStrings.FunctionOnClient(nameof(VariancePopulation))); + + /// + /// Returns the population variance of all values in the specified expression. + /// Corresponds to SQL Server's VARP. + /// + /// The instance. + /// The values. + /// The computed population variance. + public static double? VariancePopulation(this DbFunctions _, IEnumerable values) + => throw new InvalidOperationException(CoreStrings.FunctionOnClient(nameof(VariancePopulation))); + + /// + /// Returns the population variance of all values in the specified expression. + /// Corresponds to SQL Server's VARP. + /// + /// The instance. + /// The values. + /// The computed population variance. + public static double? VariancePopulation(this DbFunctions _, IEnumerable values) + => throw new InvalidOperationException(CoreStrings.FunctionOnClient(nameof(VariancePopulation))); + + #endregion Population variance } diff --git a/src/EFCore.SqlServer/Query/Internal/SqlServerAggregateMethodCallTranslatorProvider.cs b/src/EFCore.SqlServer/Query/Internal/SqlServerAggregateMethodCallTranslatorProvider.cs index ba39f384e0f..9973b859434 100644 --- a/src/EFCore.SqlServer/Query/Internal/SqlServerAggregateMethodCallTranslatorProvider.cs +++ b/src/EFCore.SqlServer/Query/Internal/SqlServerAggregateMethodCallTranslatorProvider.cs @@ -21,10 +21,14 @@ public SqlServerAggregateMethodCallTranslatorProvider(RelationalAggregateMethodC : base(dependencies) { var sqlExpressionFactory = dependencies.SqlExpressionFactory; + var typeMappingSource = dependencies.RelationalTypeMappingSource; + AddTranslators( new IAggregateMethodCallTranslator[] { - new SqlServerLongCountMethodTranslator(sqlExpressionFactory) + new SqlServerLongCountMethodTranslator(sqlExpressionFactory), + new SqlServerStatisticsAggregateMethodTranslator(sqlExpressionFactory, typeMappingSource), + new SqlServerStringAggregateMethodTranslator(sqlExpressionFactory, typeMappingSource) }); } } diff --git a/src/EFCore.SqlServer/Query/Internal/SqlServerExpression.cs b/src/EFCore.SqlServer/Query/Internal/SqlServerExpression.cs new file mode 100644 index 00000000000..bab683eaf7d --- /dev/null +++ b/src/EFCore.SqlServer/Query/Internal/SqlServerExpression.cs @@ -0,0 +1,104 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.EntityFrameworkCore.Query.SqlExpressions; + +namespace Microsoft.EntityFrameworkCore.SqlServer.Query.Internal; + +/// +/// 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. +/// +public static class SqlServerExpression +{ + /// + /// 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. + /// + public static SqlFunctionExpression AggregateFunction( + ISqlExpressionFactory sqlExpressionFactory, + string name, + IEnumerable arguments, + EnumerableExpression enumerableExpression, + int enumerableArgumentIndex, + bool nullable, + IEnumerable argumentsPropagateNullability, + Type returnType, + RelationalTypeMapping? typeMapping = null) + => new( + name, + ProcessAggregateFunctionArguments(sqlExpressionFactory, arguments, enumerableExpression, enumerableArgumentIndex), + nullable, + argumentsPropagateNullability, + returnType, + typeMapping); + + /// + /// 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. + /// + public static SqlFunctionExpression AggregateFunctionWithOrdering( + ISqlExpressionFactory sqlExpressionFactory, + string name, + IEnumerable arguments, + EnumerableExpression enumerableExpression, + int enumerableArgumentIndex, + bool nullable, + IEnumerable argumentsPropagateNullability, + Type returnType, + RelationalTypeMapping? typeMapping = null) + => enumerableExpression.Orderings.Count == 0 + ? AggregateFunction(sqlExpressionFactory, name, arguments, enumerableExpression, enumerableArgumentIndex, nullable, argumentsPropagateNullability, returnType, typeMapping) + : new SqlServerSqlFunctionExpression( + name, + ProcessAggregateFunctionArguments(sqlExpressionFactory, arguments, enumerableExpression, enumerableArgumentIndex), + enumerableExpression.Orderings, + nullable, + argumentsPropagateNullability, + returnType, + typeMapping); + + private static IReadOnlyList ProcessAggregateFunctionArguments( + ISqlExpressionFactory sqlExpressionFactory, + IEnumerable arguments, + EnumerableExpression enumerableExpression, + int enumerableArgumentIndex) + { + var argIndex = 0; + var typeMappedArguments = new List(); + + foreach (var argument in arguments) + { + var modifiedArgument = sqlExpressionFactory.ApplyDefaultTypeMapping(argument); + + if (argIndex == enumerableArgumentIndex) + { + // This is the argument representing the enumerable inputs to be aggregated. + // Wrap it with a CASE/WHEN for the predicate and with DISTINCT, if necessary. + if (enumerableExpression.Predicate != null) + { + modifiedArgument = sqlExpressionFactory.Case( + new List { new(enumerableExpression.Predicate, modifiedArgument) }, + elseResult: null); + } + + if (enumerableExpression.IsDistinct) + { + modifiedArgument = new DistinctExpression(modifiedArgument); + } + } + + typeMappedArguments.Add(modifiedArgument); + + argIndex++; + } + + return typeMappedArguments; + } +} diff --git a/src/EFCore.SqlServer/Query/Internal/SqlServerParameterBasedSqlProcessor.cs b/src/EFCore.SqlServer/Query/Internal/SqlServerParameterBasedSqlProcessor.cs index 01a3d27c7c8..5194c71233b 100644 --- a/src/EFCore.SqlServer/Query/Internal/SqlServerParameterBasedSqlProcessor.cs +++ b/src/EFCore.SqlServer/Query/Internal/SqlServerParameterBasedSqlProcessor.cs @@ -46,4 +46,14 @@ public override Expression Optimize( return new SearchConditionConvertingExpressionVisitor(Dependencies.SqlExpressionFactory).Visit(optimizedQueryExpression); } + + /// + protected override Expression ProcessSqlNullability( + Expression selectExpression, IReadOnlyDictionary parametersValues, out bool canCache) + { + Check.NotNull(selectExpression, nameof(selectExpression)); + Check.NotNull(parametersValues, nameof(parametersValues)); + + return new SqlServerSqlNullabilityProcessor(Dependencies, UseRelationalNulls).Process(selectExpression, parametersValues, out canCache); + } } diff --git a/src/EFCore.SqlServer/Query/Internal/SqlServerQuerySqlGenerator.cs b/src/EFCore.SqlServer/Query/Internal/SqlServerQuerySqlGenerator.cs index 9a2fb269e95..d593e7e83e4 100644 --- a/src/EFCore.SqlServer/Query/Internal/SqlServerQuerySqlGenerator.cs +++ b/src/EFCore.SqlServer/Query/Internal/SqlServerQuerySqlGenerator.cs @@ -95,6 +95,37 @@ protected override void GenerateLimitOffset(SelectExpression selectExpression) } } + /// + /// 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 Expression VisitSqlFunction(SqlFunctionExpression sqlFunctionExpression) + { + base.VisitSqlFunction(sqlFunctionExpression); + + if (sqlFunctionExpression is SqlServerSqlFunctionExpression sqlServerFunctionExpression + && sqlServerFunctionExpression.AggregateOrderings.Count > 0) + { + Sql.Append(" WITHIN GROUP (ORDER BY "); + + for (var i = 0; i < sqlServerFunctionExpression.AggregateOrderings.Count; i++) + { + if (i > 0) + { + Sql.Append(", "); + } + + Visit(sqlServerFunctionExpression.AggregateOrderings[i]); + } + + Sql.Append(")"); + } + + return sqlFunctionExpression; + } + /// /// 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/src/EFCore.SqlServer/Query/Internal/SqlServerSqlExpressionFactory.cs b/src/EFCore.SqlServer/Query/Internal/SqlServerSqlExpressionFactory.cs index 3de428587f3..42b334385f8 100644 --- a/src/EFCore.SqlServer/Query/Internal/SqlServerSqlExpressionFactory.cs +++ b/src/EFCore.SqlServer/Query/Internal/SqlServerSqlExpressionFactory.cs @@ -14,7 +14,7 @@ namespace Microsoft.EntityFrameworkCore.SqlServer.Query.Internal; /// public class SqlServerSqlExpressionFactory : SqlExpressionFactory { - private IRelationalTypeMappingSource _typeMappingSource; + private readonly IRelationalTypeMappingSource _typeMappingSource; /// /// This is an internal API that supports the Entity Framework Core infrastructure and not subject to diff --git a/src/EFCore.SqlServer/Query/Internal/SqlServerSqlFunctionExpression.cs b/src/EFCore.SqlServer/Query/Internal/SqlServerSqlFunctionExpression.cs new file mode 100644 index 00000000000..ab0a8bec3ac --- /dev/null +++ b/src/EFCore.SqlServer/Query/Internal/SqlServerSqlFunctionExpression.cs @@ -0,0 +1,165 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.EntityFrameworkCore.Query.SqlExpressions; + +namespace Microsoft.EntityFrameworkCore.SqlServer.Query.Internal; + +/// +/// 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. +/// +public class SqlServerSqlFunctionExpression : SqlFunctionExpression, IEquatable +{ + /// + /// 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. + /// + public SqlServerSqlFunctionExpression( + string functionName, + IEnumerable arguments, + IReadOnlyList aggregateOrderings, + bool nullable, + IEnumerable argumentsPropagateNullability, + Type type, + RelationalTypeMapping? typeMapping) + : base(functionName, arguments, nullable, argumentsPropagateNullability, type, typeMapping) + => AggregateOrderings = aggregateOrderings; + + /// + /// 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. + /// + public virtual IReadOnlyList AggregateOrderings { get; } + + /// + /// 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 Expression VisitChildren(ExpressionVisitor visitor) + { + var visitedBase = (SqlFunctionExpression)base.VisitChildren(visitor); + + OrderingExpression[]? visitedAggregateOrderings = null; + + for (var i = 0; i < AggregateOrderings.Count; i++) + { + var visitedOrdering = (OrderingExpression)visitor.Visit(AggregateOrderings[i]); + if (visitedOrdering != AggregateOrderings[i] && visitedAggregateOrderings is null) + { + visitedAggregateOrderings = new OrderingExpression[AggregateOrderings.Count]; + + for (var j = 0; j < visitedAggregateOrderings.Length; j++) + { + visitedAggregateOrderings[j] = AggregateOrderings[j]; + } + } + + if (visitedAggregateOrderings is not null) + { + visitedAggregateOrderings[i] = visitedOrdering; + } + } + + return visitedBase != this || visitedAggregateOrderings is not null + ? new SqlServerSqlFunctionExpression( + Name, + visitedBase.Arguments!, + visitedAggregateOrderings ?? AggregateOrderings, + IsNullable, + ArgumentsPropagateNullability!, + Type, + TypeMapping) + : this; + } + + /// + /// 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. + /// + public override SqlServerSqlFunctionExpression ApplyTypeMapping(RelationalTypeMapping? typeMapping) + => new( + Name, + Arguments!, + AggregateOrderings, + IsNullable, + ArgumentsPropagateNullability!, + Type, + typeMapping ?? TypeMapping); + + /// + /// 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. + /// + public override SqlFunctionExpression Update(SqlExpression? instance, IReadOnlyList? arguments) + { + Check.DebugAssert(arguments is not null, "arguments is not null"); + Check.DebugAssert(instance is null, "instance not supported on SqlServerFunctionExpression"); + + return arguments.SequenceEqual(Arguments!) + ? this + : new SqlServerSqlFunctionExpression( + Name, arguments, AggregateOrderings, IsNullable, ArgumentsPropagateNullability!, Type, TypeMapping); + } + + /// + /// 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. + /// + public virtual SqlFunctionExpression UpdateAggregateOrderings(IReadOnlyList aggregateOrderings) + => aggregateOrderings.SequenceEqual(AggregateOrderings) + ? this + : new SqlServerSqlFunctionExpression( + Name, Arguments!, aggregateOrderings, IsNullable, ArgumentsPropagateNullability!, Type, TypeMapping); + + /// + protected override void Print(ExpressionPrinter expressionPrinter) + { + base.Print(expressionPrinter); + + if (AggregateOrderings.Count > 0) + { + expressionPrinter.Append(" WITHIN GROUP (ORDER BY "); + expressionPrinter.VisitCollection(AggregateOrderings); + expressionPrinter.Append(")"); + } + } + + /// + public override bool Equals(object? obj) + => obj is SqlServerSqlFunctionExpression sqlServerFunctionExpression && Equals(sqlServerFunctionExpression); + + /// + public virtual bool Equals(SqlServerSqlFunctionExpression? other) + => ReferenceEquals(this, other) + || base.Equals(other) && AggregateOrderings.SequenceEqual(other.AggregateOrderings); + + /// + public override int GetHashCode() + { + var hash = new HashCode(); + + hash.Add(base.GetHashCode()); + + foreach (var orderingExpression in AggregateOrderings) + { + hash.Add(orderingExpression); + } + + return hash.ToHashCode(); + } +} diff --git a/src/EFCore.SqlServer/Query/Internal/SqlServerSqlNullabilityProcessor.cs b/src/EFCore.SqlServer/Query/Internal/SqlServerSqlNullabilityProcessor.cs new file mode 100644 index 00000000000..2e78de986bb --- /dev/null +++ b/src/EFCore.SqlServer/Query/Internal/SqlServerSqlNullabilityProcessor.cs @@ -0,0 +1,75 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.EntityFrameworkCore.Query.SqlExpressions; + +namespace Microsoft.EntityFrameworkCore.SqlServer.Query.Internal; + +/// +/// 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. +/// +public class SqlServerSqlNullabilityProcessor : SqlNullabilityProcessor +{ + /// + /// 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. + /// + public SqlServerSqlNullabilityProcessor( + RelationalParameterBasedSqlProcessorDependencies dependencies, + bool useRelationalNulls) + : base(dependencies, useRelationalNulls) + { + } + + /// + /// 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 SqlExpression VisitSqlFunction( + SqlFunctionExpression sqlFunctionExpression, + bool allowOptimizedExpansion, + out bool nullable) + { + var visitedBase = base.VisitSqlFunction(sqlFunctionExpression, allowOptimizedExpansion, out nullable); + + if (visitedBase is SqlServerSqlFunctionExpression sqlServerSqlFunctionExpression) + { + var aggregateOrderings = sqlServerSqlFunctionExpression.AggregateOrderings; + OrderingExpression[]? visitedAggregateOrderings = null; + + for (var i = 0; i < aggregateOrderings.Count; i++) + { + var ordering = aggregateOrderings[i]; + var visitedOrdering = ordering.Update(Visit(ordering.Expression, out _)); + if (visitedOrdering != aggregateOrderings[i] && visitedAggregateOrderings is null) + { + visitedAggregateOrderings = new OrderingExpression[aggregateOrderings.Count]; + + for (var j = 0; j < visitedAggregateOrderings.Length; j++) + { + visitedAggregateOrderings[j] = aggregateOrderings[j]; + } + } + + if (visitedAggregateOrderings is not null) + { + visitedAggregateOrderings[i] = visitedOrdering; + } + } + + if (visitedAggregateOrderings is not null) + { + return sqlServerSqlFunctionExpression.UpdateAggregateOrderings(visitedAggregateOrderings); + } + } + + return visitedBase; + } +} diff --git a/src/EFCore.SqlServer/Query/Internal/SqlServerStatisticsAggregateMethodTranslator.cs b/src/EFCore.SqlServer/Query/Internal/SqlServerStatisticsAggregateMethodTranslator.cs new file mode 100644 index 00000000000..8487e541c52 --- /dev/null +++ b/src/EFCore.SqlServer/Query/Internal/SqlServerStatisticsAggregateMethodTranslator.cs @@ -0,0 +1,76 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.EntityFrameworkCore.Query.SqlExpressions; + +namespace Microsoft.EntityFrameworkCore.SqlServer.Query.Internal; + +/// +/// 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. +/// +public class SqlServerStatisticsAggregateMethodTranslator : IAggregateMethodCallTranslator +{ + private readonly ISqlExpressionFactory _sqlExpressionFactory; + private readonly RelationalTypeMapping _doubleTypeMapping; + + /// + /// 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. + /// + public SqlServerStatisticsAggregateMethodTranslator( + ISqlExpressionFactory sqlExpressionFactory, + IRelationalTypeMappingSource typeMappingSource) + { + _sqlExpressionFactory = sqlExpressionFactory; + _doubleTypeMapping = typeMappingSource.FindMapping(typeof(double))!; + } + + /// + /// 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. + /// + public virtual SqlExpression? Translate( + MethodInfo method, EnumerableExpression source, IReadOnlyList arguments, + IDiagnosticsLogger logger) + { + // Docs: https://docs.microsoft.com/sql/t-sql/functions/aggregate-functions-transact-sql + + if (method.DeclaringType != typeof(SqlServerDbFunctionsExtensions) + || source.Selector is not SqlExpression sqlExpression) + { + return null; + } + + var functionName = method.Name switch + { + nameof(SqlServerDbFunctionsExtensions.StandardDeviationSample) => "STDEV", + nameof(SqlServerDbFunctionsExtensions.StandardDeviationPopulation) => "STDEVP", + nameof(SqlServerDbFunctionsExtensions.VarianceSample) => "VAR", + nameof(SqlServerDbFunctionsExtensions.VariancePopulation) => "VARP", + _ => null + }; + + if (functionName is null) + { + return null; + } + + return SqlServerExpression.AggregateFunction( + _sqlExpressionFactory, + functionName, + new[] { sqlExpression }, + source, + enumerableArgumentIndex: 0, + nullable: true, + argumentsPropagateNullability: new[] { false }, + typeof(double), + _doubleTypeMapping); + } +} diff --git a/src/EFCore.SqlServer/Query/Internal/SqlServerStringAggregateMethodTranslator.cs b/src/EFCore.SqlServer/Query/Internal/SqlServerStringAggregateMethodTranslator.cs new file mode 100644 index 00000000000..d4ef723a318 --- /dev/null +++ b/src/EFCore.SqlServer/Query/Internal/SqlServerStringAggregateMethodTranslator.cs @@ -0,0 +1,110 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.EntityFrameworkCore.Query.SqlExpressions; + +namespace Microsoft.EntityFrameworkCore.SqlServer.Query.Internal; + +/// +/// 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. +/// +public class SqlServerStringAggregateMethodTranslator : IAggregateMethodCallTranslator +{ + private static readonly MethodInfo StringConcatMethod + = typeof(string).GetRuntimeMethod(nameof(string.Concat), new[] { typeof(IEnumerable) })!; + + private static readonly MethodInfo StringJoinMethod + = typeof(string).GetRuntimeMethod(nameof(string.Join), new[] { typeof(string), typeof(IEnumerable) })!; + + private readonly ISqlExpressionFactory _sqlExpressionFactory; + private readonly IRelationalTypeMappingSource _typeMappingSource; + + /// + /// 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. + /// + public SqlServerStringAggregateMethodTranslator( + ISqlExpressionFactory sqlExpressionFactory, + IRelationalTypeMappingSource typeMappingSource) + { + _sqlExpressionFactory = sqlExpressionFactory; + _typeMappingSource = typeMappingSource; + } + + /// + /// 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. + /// + public virtual SqlExpression? Translate( + MethodInfo method, EnumerableExpression source, IReadOnlyList arguments, + IDiagnosticsLogger logger) + { + // Docs: https://docs.microsoft.com/sql/t-sql/functions/string-agg-transact-sql + + if (source.Selector is not SqlExpression sqlExpression + || (method != StringJoinMethod && method != StringConcatMethod)) + { + return null; + } + + // STRING_AGG enlarges the return type size (e.g. for input VARCHAR(5), it returns VARCHAR(8000)). + // See https://docs.microsoft.com/sql/t-sql/functions/string-agg-transact-sql#return-types + var resultTypeMapping = sqlExpression.TypeMapping; + if (resultTypeMapping?.Size != null) + { + if (resultTypeMapping.IsUnicode && resultTypeMapping.Size < 8000) + { + resultTypeMapping = _typeMappingSource.FindMapping( + typeof(string), + resultTypeMapping.StoreTypeNameBase, + unicode: true, + size: 8000); + } + else if (!resultTypeMapping.IsUnicode && resultTypeMapping.Size < 4000) + { + resultTypeMapping = _typeMappingSource.FindMapping( + typeof(string), + resultTypeMapping.StoreTypeNameBase, + unicode: false, + size: 4000); + } + } + + // STRING_AGG filters out nulls, but string.Join treats them as empty strings; coalesce unless we know we're aggregating over + // a non-nullable column. + if (sqlExpression is not ColumnExpression { IsNullable: false }) + { + sqlExpression = _sqlExpressionFactory.Coalesce( + sqlExpression, + _sqlExpressionFactory.Constant(string.Empty, typeof(string))); + } + + // STRING_AGG returns null when there are no rows (or non-null values), but string.Join returns an empty string. + return + _sqlExpressionFactory.Coalesce( + SqlServerExpression.AggregateFunctionWithOrdering( + _sqlExpressionFactory, + "STRING_AGG", + new[] + { + sqlExpression, + _sqlExpressionFactory.ApplyTypeMapping( + method == StringJoinMethod ? arguments[0] : _sqlExpressionFactory.Constant(string.Empty, typeof(string)), + sqlExpression.TypeMapping) + }, + source, + enumerableArgumentIndex: 0, + nullable: true, + argumentsPropagateNullability: new[] { false, true }, + typeof(string)), + _sqlExpressionFactory.Constant(string.Empty, typeof(string)), + resultTypeMapping); + } +} diff --git a/src/EFCore.Sqlite.Core/Query/Internal/SqliteAggregateMethodCallTranslatorProvider.cs b/src/EFCore.Sqlite.Core/Query/Internal/SqliteAggregateMethodCallTranslatorProvider.cs index a836f543a5b..c67a66cc171 100644 --- a/src/EFCore.Sqlite.Core/Query/Internal/SqliteAggregateMethodCallTranslatorProvider.cs +++ b/src/EFCore.Sqlite.Core/Query/Internal/SqliteAggregateMethodCallTranslatorProvider.cs @@ -25,7 +25,8 @@ public SqliteAggregateMethodCallTranslatorProvider(RelationalAggregateMethodCall AddTranslators( new IAggregateMethodCallTranslator[] { - new SqliteQueryableAggregateMethodTranslator(sqlExpressionFactory) + new SqliteQueryableAggregateMethodTranslator(sqlExpressionFactory), + new SqliteStringAggregateMethodTranslator(sqlExpressionFactory) }); } } diff --git a/src/EFCore.Sqlite.Core/Query/Internal/SqliteStringAggregateMethodTranslator.cs b/src/EFCore.Sqlite.Core/Query/Internal/SqliteStringAggregateMethodTranslator.cs new file mode 100644 index 00000000000..f5a467880f6 --- /dev/null +++ b/src/EFCore.Sqlite.Core/Query/Internal/SqliteStringAggregateMethodTranslator.cs @@ -0,0 +1,99 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.EntityFrameworkCore.Query.SqlExpressions; + +namespace Microsoft.EntityFrameworkCore.Sqlite.Query.Internal; + +/// +/// 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. +/// +public class SqliteStringAggregateMethodTranslator : IAggregateMethodCallTranslator +{ + private static readonly MethodInfo StringConcatMethod + = typeof(string).GetRuntimeMethod(nameof(string.Concat), new[] { typeof(IEnumerable) })!; + + private static readonly MethodInfo StringJoinMethod + = typeof(string).GetRuntimeMethod(nameof(string.Join), new[] { typeof(string), typeof(IEnumerable) })!; + + private readonly ISqlExpressionFactory _sqlExpressionFactory; + + /// + /// 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. + /// + public SqliteStringAggregateMethodTranslator(ISqlExpressionFactory sqlExpressionFactory) + => _sqlExpressionFactory = sqlExpressionFactory; + + /// + /// 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. + /// + public virtual SqlExpression? Translate( + MethodInfo method, EnumerableExpression source, IReadOnlyList arguments, + IDiagnosticsLogger logger) + { + // Docs: https://sqlite.org/lang_aggfunc.html#group_concat + + if (source.Selector is not SqlExpression sqlExpression + || (method != StringJoinMethod && method != StringConcatMethod)) + { + return null; + } + + // SQLite does not support input ordering on aggregate methods. Since ordering matters very much for translating, if the user + // specified an ordering we refuse to translate (but to error than to ignore in this case). + if (source.Orderings.Count > 0) + { + return null; + } + + if (sqlExpression is not ColumnExpression { IsNullable: false }) + { + sqlExpression = _sqlExpressionFactory.Coalesce( + sqlExpression, + _sqlExpressionFactory.Constant(string.Empty, typeof(string))); + } + + if (source.Predicate != null) + { + if (sqlExpression is SqlFragmentExpression) + { + sqlExpression = _sqlExpressionFactory.Constant(1); + } + + sqlExpression = _sqlExpressionFactory.Case( + new List { new(source.Predicate, sqlExpression) }, + elseResult: null); + } + + if (source.IsDistinct) + { + sqlExpression = new DistinctExpression(sqlExpression); + } + + // group_concat returns null when there are no rows (or non-null values), but string.Join returns an empty string. + return _sqlExpressionFactory.Coalesce( + _sqlExpressionFactory.Function( + "group_concat", + new[] + { + sqlExpression, + _sqlExpressionFactory.ApplyTypeMapping( + method == StringJoinMethod ? arguments[0] : _sqlExpressionFactory.Constant(string.Empty, typeof(string)), + sqlExpression.TypeMapping) + }, + nullable: true, + argumentsPropagateNullability: new[] { false, true }, + typeof(string)), + _sqlExpressionFactory.Constant(string.Empty, typeof(string)), + sqlExpression.TypeMapping); + } +} diff --git a/test/EFCore.Specification.Tests/Query/NorthwindFunctionsQueryTestBase.cs b/test/EFCore.Specification.Tests/Query/NorthwindFunctionsQueryTestBase.cs index d2da16bccf9..a2a97cd9ebf 100644 --- a/test/EFCore.Specification.Tests/Query/NorthwindFunctionsQueryTestBase.cs +++ b/test/EFCore.Specification.Tests/Query/NorthwindFunctionsQueryTestBase.cs @@ -161,6 +161,111 @@ public virtual Task String_Contains_MethodCall(bool async) ss => ss.Set().Where(c => c.ContactName.Contains(LocalMethod1())), entryCount: 19); + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public virtual Task String_Join_over_non_nullable_column(bool async) + => AssertQuery( + async, + ss => ss.Set() + .GroupBy(c => c.City) + .Select(g => new + { + City = g.Key, + Customers = string.Join("|", g.Select(e => e.CustomerID)) + }), + elementSorter: x => x.City, + elementAsserter: (e, a) => + { + Assert.Equal(e.City, a.City); + + // Ordering inside the string isn't specified server-side, split and reorder + Assert.Equal( + e.Customers.Split("|").OrderBy(id => id).ToArray(), + a.Customers.Split("|").OrderBy(id => id).ToArray()); + }); + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public virtual Task String_Join_with_predicate(bool async) + => AssertQuery( + async, + ss => ss.Set() + .GroupBy(c => c.City) + .Select(g => new + { + City = g.Key, + Customers = string.Join("|", g.Where(e => e.ContactName.Length > 10).Select(e => e.CustomerID)) + }), + elementSorter: x => x.City, + elementAsserter: (e, a) => + { + Assert.Equal(e.City, a.City); + + // Ordering inside the string isn't specified server-side, split and reorder + Assert.Equal( + e.Customers.Split("|").OrderBy(id => id).ToArray(), + a.Customers.Split("|").OrderBy(id => id).ToArray()); + }); + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public virtual Task String_Join_with_ordering(bool async) + => AssertQuery( + async, + ss => ss.Set() + .GroupBy(c => c.City) + .Select(g => new + { + City = g.Key, + Customers = string.Join("|", g.OrderByDescending(e => e.CustomerID).Select(e => e.CustomerID)) + }), + elementSorter: x => x.City); + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public virtual Task String_Join_over_nullable_column(bool async) + => AssertQuery( + async, + ss => ss.Set() + .GroupBy(c => c.City) + .Select(g => new + { + City = g.Key, + Regions = string.Join("|", g.Select(e => e.Region)) + }), + elementSorter: x => x.City, + elementAsserter: (e, a) => + { + Assert.Equal(e.City, a.City); + + // Ordering inside the string isn't specified server-side, split and reorder + Assert.Equal( + e.Regions.Split("|").OrderBy(id => id).ToArray(), + a.Regions.Split("|").OrderBy(id => id).ToArray()); + }); + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public virtual Task String_Concat(bool async) + => AssertQuery( + async, + ss => ss.Set() + .GroupBy(c => c.City) + .Select(g => new + { + City = g.Key, + Customers = string.Concat(g.Select(e => e.CustomerID)) + }), + elementSorter: x => x.City, + elementAsserter: (e, a) => + { + Assert.Equal(e.City, a.City); + + // The best we can do for Concat without server-side ordering is sort the characters (concatenating without ordering + // and without a delimiter is somewhat dubious anyway). + Assert.Equal(e.Customers.OrderBy(c => c).ToArray(), a.Customers.OrderBy(c => c).ToArray()); + }); + [ConditionalTheory] [MemberData(nameof(IsAsyncData))] public virtual async Task String_Compare_simple_zero(bool async) diff --git a/test/EFCore.SqlServer.FunctionalTests/Query/NorthwindFunctionsQuerySqlServerTest.cs b/test/EFCore.SqlServer.FunctionalTests/Query/NorthwindFunctionsQuerySqlServerTest.cs index 8b58746c66d..9148b0e193f 100644 --- a/test/EFCore.SqlServer.FunctionalTests/Query/NorthwindFunctionsQuerySqlServerTest.cs +++ b/test/EFCore.SqlServer.FunctionalTests/Query/NorthwindFunctionsQuerySqlServerTest.cs @@ -238,6 +238,63 @@ FROM [Customers] AS [c] WHERE [c].[ContactName] LIKE N'%M%'"); } + [SqlServerCondition(SqlServerCondition.Version2017)] + public override async Task String_Join_over_non_nullable_column(bool async) + { + await base.String_Join_over_non_nullable_column(async); + + AssertSql( + @"SELECT [c].[City], COALESCE(STRING_AGG([c].[CustomerID], N'|'), N'') AS [Customers] +FROM [Customers] AS [c] +GROUP BY [c].[City]"); + } + + [SqlServerCondition(SqlServerCondition.Version2017)] + public override async Task String_Join_over_nullable_column(bool async) + { + await base.String_Join_over_nullable_column(async); + + AssertSql( + @"SELECT [c].[City], COALESCE(STRING_AGG(COALESCE([c].[Region], N''), N'|'), N'') AS [Regions] +FROM [Customers] AS [c] +GROUP BY [c].[City]"); + } + + [SqlServerCondition(SqlServerCondition.Version2017)] + public override async Task String_Join_with_predicate(bool async) + { + await base.String_Join_with_predicate(async); + + AssertSql( + @"SELECT [c].[City], COALESCE(STRING_AGG(CASE + WHEN CAST(LEN([c].[ContactName]) AS int) > 10 THEN [c].[CustomerID] +END, N'|'), N'') AS [Customers] +FROM [Customers] AS [c] +GROUP BY [c].[City]"); + } + + [SqlServerCondition(SqlServerCondition.Version2017)] + public override async Task String_Join_with_ordering(bool async) + { + await base.String_Join_with_ordering(async); + + AssertSql( + @"SELECT [c].[City], COALESCE(STRING_AGG([c].[CustomerID], N'|') WITHIN GROUP (ORDER BY [c].[CustomerID] DESC), N'') AS [Customers] +FROM [Customers] AS [c] +GROUP BY [c].[City]"); + } + + [SqlServerCondition(SqlServerCondition.Version2017)] + public override async Task String_Concat(bool async) + { + await base.String_Concat(async); + + AssertSql( + @"SELECT [c].[City], COALESCE(STRING_AGG([c].[CustomerID], N''), N'') AS [Customers] +FROM [Customers] AS [c] +GROUP BY [c].[City]"); + } + public override async Task String_Compare_simple_zero(bool async) { await base.String_Compare_simple_zero(async); @@ -2009,6 +2066,65 @@ public override Task Regex_IsMatch_MethodCall_constant_input(bool async) public override Task Datetime_subtraction_TotalDays(bool async) => AssertTranslationFailed(() => base.Datetime_subtraction_TotalDays(async)); + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public virtual async Task StandardDeviation(bool async) + { + await using var ctx = CreateContext(); + + var query = ctx.Set() + .GroupBy(od => od.ProductID) + .Select(g => new + { + ProductID = g.Key, + SampleStandardDeviation = EF.Functions.StandardDeviationSample(g.Select(od => od.UnitPrice)), + PopulationStandardDeviation = EF.Functions.StandardDeviationPopulation(g.Select(od => od.UnitPrice)) + }); + + var results = async + ? await query.ToListAsync() + : query.ToList(); + + var product9 = results.Single(r => r.ProductID == 9); + Assert.Equal(8.675943752699023, product9.SampleStandardDeviation.Value, 5); + Assert.Equal(7.759999999999856, product9.PopulationStandardDeviation.Value, 5); + + AssertSql( + @"SELECT [o].[ProductID], STDEV([o].[UnitPrice]) AS [SampleStandardDeviation], STDEVP([o].[UnitPrice]) AS [PopulationStandardDeviation] +FROM [Order Details] AS [o] +GROUP BY [o].[ProductID]"); + } + + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public virtual async Task Variance(bool async) + { + await using var ctx = CreateContext(); + + var query = ctx.Set() + .GroupBy(od => od.ProductID) + .Select( + g => new + { + ProductID = g.Key, + SampleStandardDeviation = EF.Functions.VarianceSample(g.Select(od => od.UnitPrice)), + PopulationStandardDeviation = EF.Functions.VariancePopulation(g.Select(od => od.UnitPrice)) + }); + + var results = async + ? await query.ToListAsync() + : query.ToList(); + + var product9 = results.Single(r => r.ProductID == 9); + Assert.Equal(75.2719999999972, product9.SampleStandardDeviation.Value, 5); + Assert.Equal(60.217599999997766, product9.PopulationStandardDeviation.Value, 5); + + AssertSql( + @"SELECT [o].[ProductID], VAR([o].[UnitPrice]) AS [SampleStandardDeviation], VARP([o].[UnitPrice]) AS [PopulationStandardDeviation] +FROM [Order Details] AS [o] +GROUP BY [o].[ProductID]"); + } + private void AssertSql(params string[] expected) => Fixture.TestSqlLoggerFactory.AssertBaseline(expected); diff --git a/test/EFCore.SqlServer.FunctionalTests/TestUtilities/SqlServerCondition.cs b/test/EFCore.SqlServer.FunctionalTests/TestUtilities/SqlServerCondition.cs index 5466a13c8bd..8a1a1a11513 100644 --- a/test/EFCore.SqlServer.FunctionalTests/TestUtilities/SqlServerCondition.cs +++ b/test/EFCore.SqlServer.FunctionalTests/TestUtilities/SqlServerCondition.cs @@ -15,5 +15,7 @@ public enum SqlServerCondition SupportsFullTextSearch = 1 << 6, SupportsOnlineIndexes = 1 << 7, SupportsTemporalTablesCascadeDelete = 1 << 8, - SupportsUtf8 = 1 << 9 + SupportsUtf8 = 1 << 9, + Version2019 = 1 << 10, + Version2017 = 1 << 11 } diff --git a/test/EFCore.SqlServer.FunctionalTests/TestUtilities/SqlServerConditionAttribute.cs b/test/EFCore.SqlServer.FunctionalTests/TestUtilities/SqlServerConditionAttribute.cs index d6d9a9882ad..81084da2942 100644 --- a/test/EFCore.SqlServer.FunctionalTests/TestUtilities/SqlServerConditionAttribute.cs +++ b/test/EFCore.SqlServer.FunctionalTests/TestUtilities/SqlServerConditionAttribute.cs @@ -72,6 +72,16 @@ public ValueTask IsMetAsync() isMet &= TestEnvironment.IsUtf8Supported; } + if (Conditions.HasFlag(SqlServerCondition.Version2019)) + { + isMet &= TestEnvironment.SqlServerMajorVersion >= 15; + } + + if (Conditions.HasFlag(SqlServerCondition.Version2017)) + { + isMet &= TestEnvironment.SqlServerMajorVersion >= 14; + } + return new ValueTask(isMet); } diff --git a/test/EFCore.SqlServer.FunctionalTests/TestUtilities/TestEnvironment.cs b/test/EFCore.SqlServer.FunctionalTests/TestUtilities/TestEnvironment.cs index 64d6392f9bf..bc276e97f5b 100644 --- a/test/EFCore.SqlServer.FunctionalTests/TestUtilities/TestEnvironment.cs +++ b/test/EFCore.SqlServer.FunctionalTests/TestUtilities/TestEnvironment.cs @@ -265,6 +265,9 @@ public static bool IsUtf8Supported } } + public static byte SqlServerMajorVersion + => GetProductMajorVersion(); + public static string ElasticPoolName { get; } = Config["ElasticPoolName"]; public static bool? GetFlag(string key) diff --git a/test/EFCore.Sqlite.FunctionalTests/Query/NorthwindFunctionsQuerySqliteTest.cs b/test/EFCore.Sqlite.FunctionalTests/Query/NorthwindFunctionsQuerySqliteTest.cs index 3e79290721e..0185d15146a 100644 --- a/test/EFCore.Sqlite.FunctionalTests/Query/NorthwindFunctionsQuerySqliteTest.cs +++ b/test/EFCore.Sqlite.FunctionalTests/Query/NorthwindFunctionsQuerySqliteTest.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 Microsoft.EntityFrameworkCore.TestModels.Northwind; + namespace Microsoft.EntityFrameworkCore.Query; public class NorthwindFunctionsQuerySqliteTest : NorthwindFunctionsQueryRelationalTestBase< @@ -324,6 +326,64 @@ public override async Task String_Contains_MethodCall(bool async) WHERE 'M' = '' OR instr(""c"".""ContactName"", 'M') > 0"); } + public override async Task String_Join_over_non_nullable_column(bool async) + { + await base.String_Join_over_non_nullable_column(async); + + AssertSql( + @"SELECT ""c"".""City"", COALESCE(group_concat(""c"".""CustomerID"", '|'), '') AS ""Customers"" +FROM ""Customers"" AS ""c"" +GROUP BY ""c"".""City"""); + } + + public override async Task String_Join_over_nullable_column(bool async) + { + await base.String_Join_over_nullable_column(async); + + AssertSql( + @"SELECT ""c"".""City"", COALESCE(group_concat(COALESCE(""c"".""Region"", ''), '|'), '') AS ""Regions"" +FROM ""Customers"" AS ""c"" +GROUP BY ""c"".""City"""); + } + + public override async Task String_Join_with_predicate(bool async) + { + await base.String_Join_with_predicate(async); + + AssertSql( + @"SELECT ""c"".""City"", COALESCE(group_concat(CASE + WHEN length(""c"".""ContactName"") > 10 THEN ""c"".""CustomerID"" +END, '|'), '') AS ""Customers"" +FROM ""Customers"" AS ""c"" +GROUP BY ""c"".""City"""); + } + + public override async Task String_Join_with_ordering(bool async) + { + // SQLite does not support input ordering on aggregate methods; the below does client evaluation. + await base.String_Join_with_ordering(async); + + AssertSql( + @"SELECT ""t"".""City"", ""c0"".""CustomerID"" +FROM ( + SELECT ""c"".""City"" + FROM ""Customers"" AS ""c"" + GROUP BY ""c"".""City"" +) AS ""t"" +LEFT JOIN ""Customers"" AS ""c0"" ON ""t"".""City"" = ""c0"".""City"" +ORDER BY ""t"".""City"", ""c0"".""CustomerID"" DESC"); + } + + public override async Task String_Concat(bool async) + { + await base.String_Concat(async); + + AssertSql( + @"SELECT ""c"".""City"", COALESCE(group_concat(""c"".""CustomerID"", ''), '') AS ""Customers"" +FROM ""Customers"" AS ""c"" +GROUP BY ""c"".""City"""); + } + public override async Task IsNullOrWhiteSpace_in_predicate(bool async) { await base.IsNullOrWhiteSpace_in_predicate(async);