diff --git a/All.sln.DotSettings b/All.sln.DotSettings index 9aeadf36333..4e7bf4fbf52 100644 --- a/All.sln.DotSettings +++ b/All.sln.DotSettings @@ -285,6 +285,7 @@ The .NET Foundation licenses this file to you under the MIT license. True True True + True True True True diff --git a/src/EFCore.Relational/Query/QuerySqlGenerator.cs b/src/EFCore.Relational/Query/QuerySqlGenerator.cs index 6eee8267c34..209a7b463ae 100644 --- a/src/EFCore.Relational/Query/QuerySqlGenerator.cs +++ b/src/EFCore.Relational/Query/QuerySqlGenerator.cs @@ -17,7 +17,6 @@ namespace Microsoft.EntityFrameworkCore.Query; /// public class QuerySqlGenerator : SqlExpressionVisitor { - private static readonly Dictionary OperatorMap = new() { { ExpressionType.Equal, " = " }, @@ -820,6 +819,42 @@ protected override Expression VisitIn(InExpression inExpression) return inExpression; } + /// + protected override Expression VisitAtTimeZone(AtTimeZoneExpression atTimeZoneExpression) + { + var requiresBrackets = RequiresParentheses(atTimeZoneExpression, atTimeZoneExpression.Operand); + + if (requiresBrackets) + { + _relationalCommandBuilder.Append("("); + } + + Visit(atTimeZoneExpression.Operand); + + if (requiresBrackets) + { + _relationalCommandBuilder.Append(")"); + } + + _relationalCommandBuilder.Append(" AT TIME ZONE "); + + requiresBrackets = RequiresParentheses(atTimeZoneExpression, atTimeZoneExpression.TimeZone); + + if (requiresBrackets) + { + _relationalCommandBuilder.Append("("); + } + + Visit(atTimeZoneExpression.TimeZone); + + if (requiresBrackets) + { + _relationalCommandBuilder.Append(")"); + } + + return atTimeZoneExpression; + } + /// /// Gets a SQL operator for a SQL binary operation. /// @@ -839,7 +874,7 @@ protected virtual bool RequiresParentheses(SqlExpression outerExpression, SqlExp { switch (innerExpression) { - case LikeExpression: + case AtTimeZoneExpression or LikeExpression: return true; case SqlUnaryExpression sqlUnaryExpression: diff --git a/src/EFCore.Relational/Query/SqlExpressionVisitor.cs b/src/EFCore.Relational/Query/SqlExpressionVisitor.cs index 340f02ff56c..d9b63051284 100644 --- a/src/EFCore.Relational/Query/SqlExpressionVisitor.cs +++ b/src/EFCore.Relational/Query/SqlExpressionVisitor.cs @@ -25,6 +25,9 @@ protected override Expression VisitExtension(Expression extensionExpression) return shapedQueryExpression.Update( Visit(shapedQueryExpression.QueryExpression), shapedQueryExpression.ShaperExpression); + case AtTimeZoneExpression atTimeZoneExpression: + return VisitAtTimeZone(atTimeZoneExpression); + case CaseExpression caseExpression: return VisitCase(caseExpression); @@ -116,6 +119,14 @@ protected override Expression VisitExtension(Expression extensionExpression) return base.VisitExtension(extensionExpression); } + + /// + /// Visits the children of the sql "at time zone" expression. + /// + /// The expression to visit. + /// The modified expression, if it or any subexpression was modified; otherwise, returns the original expression. + protected abstract Expression VisitAtTimeZone(AtTimeZoneExpression atTimeZoneExpression); + /// /// Visits the children of the case expression. /// @@ -278,7 +289,7 @@ protected override Expression VisitExtension(Expression extensionExpression) protected abstract Expression VisitSqlConstant(SqlConstantExpression sqlConstantExpression); /// - /// Visits the children of the sql fragent expression. + /// Visits the children of the sql fragment expression. /// /// The expression to visit. /// The modified expression, if it or any subexpression was modified; otherwise, returns the original expression. diff --git a/src/EFCore.Relational/Query/SqlExpressions/AtTimeZoneExpression.cs b/src/EFCore.Relational/Query/SqlExpressions/AtTimeZoneExpression.cs new file mode 100644 index 00000000000..d11c2f5694c --- /dev/null +++ b/src/EFCore.Relational/Query/SqlExpressions/AtTimeZoneExpression.cs @@ -0,0 +1,91 @@ +// 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.SqlExpressions; + +/// +/// +/// An expression that represents an AT TIME ZONE operation in a SQL tree. +/// +/// +/// This type is typically used by database providers (and other extensions). It is generally +/// not used in application code. +/// +/// +public class AtTimeZoneExpression : SqlExpression +{ + /// + /// Creates a new instance of the class. + /// + /// The operand on which to perform the time zone conversion. + /// The time zone to convert to. + /// The of the expression. + /// The associated with the expression. + public AtTimeZoneExpression( + SqlExpression operand, + SqlExpression timeZone, + Type type, + RelationalTypeMapping? typeMapping) + : base(type, typeMapping) + { + Operand = operand; + TimeZone = timeZone; + } + + /// + /// The left operand. + /// + public virtual SqlExpression Operand { get; } + + /// + /// The right operand. + /// + public virtual SqlExpression TimeZone { get; } + + /// + protected override Expression VisitChildren(ExpressionVisitor visitor) + { + var operand = (SqlExpression)visitor.Visit(Operand); + var timeZone = (SqlExpression)visitor.Visit(TimeZone); + + return Update(operand, timeZone); + } + + /// + /// 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. + /// This expression if no children changed, or an expression with the updated children. + public virtual AtTimeZoneExpression Update(SqlExpression operand, SqlExpression timeZone) + => operand != Operand || timeZone != TimeZone + ? new AtTimeZoneExpression(operand, timeZone, Type, TypeMapping) + : this; + + /// + protected override void Print(ExpressionPrinter expressionPrinter) + { + expressionPrinter.Visit(Operand); + + expressionPrinter.Append(" AT TIME ZONE "); + + expressionPrinter.Visit(TimeZone); + } + + /// + public override bool Equals(object? obj) + => obj != null + && (ReferenceEquals(this, obj) + || obj is AtTimeZoneExpression atTimeZoneExpression + && Equals(atTimeZoneExpression)); + + private bool Equals(AtTimeZoneExpression atTimeZoneExpression) + => base.Equals(atTimeZoneExpression) + && Operand.Equals(atTimeZoneExpression.Operand) + && TimeZone.Equals(atTimeZoneExpression.TimeZone); + + /// + public override int GetHashCode() + => HashCode.Combine(base.GetHashCode(), Operand, TimeZone); +} diff --git a/src/EFCore.Relational/Query/SqlNullabilityProcessor.cs b/src/EFCore.Relational/Query/SqlNullabilityProcessor.cs index 206454bba90..94bda84cbb3 100644 --- a/src/EFCore.Relational/Query/SqlNullabilityProcessor.cs +++ b/src/EFCore.Relational/Query/SqlNullabilityProcessor.cs @@ -348,6 +348,8 @@ protected virtual SelectExpression Visit(SelectExpression selectExpression) var nullValueColumnsCount = _nullValueColumns.Count; var result = sqlExpression switch { + AtTimeZoneExpression sqlAtTimeZoneExpression + => VisitAtTimeZone(sqlAtTimeZoneExpression, allowOptimizedExpansion, out nullable), CaseExpression caseExpression => VisitCase(caseExpression, allowOptimizedExpansion, out nullable), CollateExpression collateExpression @@ -404,6 +406,25 @@ protected virtual SqlExpression VisitCustomSqlExpression( => throw new InvalidOperationException( RelationalStrings.UnhandledExpressionInVisitor(sqlExpression, sqlExpression.GetType(), nameof(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. + /// + protected virtual SqlExpression VisitAtTimeZone( + AtTimeZoneExpression atTimeZoneExpression, + bool allowOptimizedExpansion, + out bool nullable) + { + var operand = Visit(atTimeZoneExpression.Operand, out var operandNullable); + var timeZone = Visit(atTimeZoneExpression.TimeZone, out var timeZoneNullable); + + nullable = operandNullable || timeZoneNullable; + + return atTimeZoneExpression.Update(operand, timeZone); + } + /// /// Visits a and computes its nullability. /// diff --git a/src/EFCore.SqlServer/Extensions/SqlServerDbFunctionsExtensions.cs b/src/EFCore.SqlServer/Extensions/SqlServerDbFunctionsExtensions.cs index d41be8cd35e..2126850ce02 100644 --- a/src/EFCore.SqlServer/Extensions/SqlServerDbFunctionsExtensions.cs +++ b/src/EFCore.SqlServer/Extensions/SqlServerDbFunctionsExtensions.cs @@ -1438,4 +1438,22 @@ public static bool IsNumeric( this DbFunctions _, string expression) => throw new InvalidOperationException(CoreStrings.FunctionOnClient(nameof(IsNumeric))); + + /// + /// Converts to the corresponding datetimeoffset in the target . + /// + /// + /// See Database functions, and + /// Accessing SQL Server and SQL Azure databases with EF Core + /// for more information and examples. + /// + /// The instance. + /// The value to convert to datetimeoffset. + /// The time zone to convert to. + /// The datetimeoffset resulting from the conversion. + public static DateTimeOffset AtTimeZone( + this DbFunctions _, + DateTime dateTime, + string timeZone) + => throw new InvalidOperationException(CoreStrings.FunctionOnClient(nameof(AtTimeZone))); } diff --git a/src/EFCore.SqlServer/Query/Internal/SearchConditionConvertingExpressionVisitor.cs b/src/EFCore.SqlServer/Query/Internal/SearchConditionConvertingExpressionVisitor.cs index da1fe7c7729..e2ca45f7adb 100644 --- a/src/EFCore.SqlServer/Query/Internal/SearchConditionConvertingExpressionVisitor.cs +++ b/src/EFCore.SqlServer/Query/Internal/SearchConditionConvertingExpressionVisitor.cs @@ -306,6 +306,23 @@ protected override Expression VisitSelect(SelectExpression 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 VisitAtTimeZone(AtTimeZoneExpression atTimeZoneExpression) + { + var parentSearchCondition = _isSearchCondition; + _isSearchCondition = false; + var operand = (SqlExpression)Visit(atTimeZoneExpression.Operand); + var timeZone = (SqlExpression)Visit(atTimeZoneExpression.TimeZone); + _isSearchCondition = parentSearchCondition; + + return atTimeZoneExpression.Update(operand, timeZone); + } + /// /// 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/SqlServerDateTimeMethodTranslator.cs b/src/EFCore.SqlServer/Query/Internal/SqlServerDateTimeMethodTranslator.cs index 8c9e008373c..f85970d8205 100644 --- a/src/EFCore.SqlServer/Query/Internal/SqlServerDateTimeMethodTranslator.cs +++ b/src/EFCore.SqlServer/Query/Internal/SqlServerDateTimeMethodTranslator.cs @@ -31,6 +31,12 @@ public class SqlServerDateTimeMethodTranslator : IMethodCallTranslator { typeof(DateTimeOffset).GetRequiredRuntimeMethod(nameof(DateTimeOffset.AddMilliseconds), typeof(double)), "millisecond" } }; + private static readonly MethodInfo _atTimeZoneMethodInfo = typeof(SqlServerDbFunctionsExtensions) + .GetRequiredRuntimeMethod(nameof(SqlServerDbFunctionsExtensions.AtTimeZone), typeof(DbFunctions), typeof(DateTime), typeof(string)); + + private static readonly MethodInfo _convertTimeBySystemTimeZoneIdMethodInfo = typeof(TimeZoneInfo) + .GetRequiredRuntimeMethod(nameof(TimeZoneInfo.ConvertTimeBySystemTimeZoneId), typeof(DateTimeOffset), typeof(string)); + private readonly ISqlExpressionFactory _sqlExpressionFactory; private readonly IRelationalTypeMappingSource _typeMappingSource; @@ -89,6 +95,42 @@ public SqlServerDateTimeMethodTranslator( instance.TypeMapping); } + if (method == _convertTimeBySystemTimeZoneIdMethodInfo) + { + var (operand, timeZone) = (arguments[0], arguments[1]); + + if (operand is SqlConstantExpression) + { + // Our constant representation for datetimeoffset is an untyped string literal, which the AT TIME ZONE expression does not + // accept. Type it explicitly. + operand = _sqlExpressionFactory.Convert( + operand, typeof(DateTimeOffset), _typeMappingSource.FindMapping(typeof(DateTimeOffset))); + } + + operand = _sqlExpressionFactory.ApplyDefaultTypeMapping(operand); + timeZone = _sqlExpressionFactory.ApplyDefaultTypeMapping(timeZone); + + return new AtTimeZoneExpression(operand, timeZone, typeof(DateTimeOffset), operand.TypeMapping); + } + + if (method == _atTimeZoneMethodInfo) + { + var (operand, timeZone) = (arguments[1], arguments[2]); + + if (operand is SqlConstantExpression) + { + // Our constant representation for datetime is an untyped string literal, which the AT TIME ZONE expression does not accept. + // Type it explicitly. + operand = _sqlExpressionFactory.Convert(operand, typeof(DateTime), _typeMappingSource.FindMapping(typeof(DateTime))); + } + + return new AtTimeZoneExpression( + _sqlExpressionFactory.ApplyDefaultTypeMapping(operand), + _sqlExpressionFactory.ApplyDefaultTypeMapping(timeZone), + typeof(DateTimeOffset), + _typeMappingSource.FindMapping(typeof(DateTimeOffset))); + } + return null; } } diff --git a/test/EFCore.Specification.Tests/Query/GearsOfWarQueryTestBase.cs b/test/EFCore.Specification.Tests/Query/GearsOfWarQueryTestBase.cs index fcfda639960..8fa1d79707e 100644 --- a/test/EFCore.Specification.Tests/Query/GearsOfWarQueryTestBase.cs +++ b/test/EFCore.Specification.Tests/Query/GearsOfWarQueryTestBase.cs @@ -8269,6 +8269,15 @@ public virtual Task Enum_matching_take_value_gets_different_type_mapping(bool as .Select(g => g.Rank & value)); } + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public virtual Task Where_TimeZoneInfo_ConvertTimeBySystemTimeZoneId_DateTimeOffset(bool async) + => AssertQuery( + async, + ss => ss.Set().Where( + m => TimeZoneInfo.ConvertTimeBySystemTimeZoneId(m.Timeline, "UTC") + == new DateTimeOffset(2, 3, 1, 13, 0, 0, TimeSpan.Zero))); + protected GearsOfWarContext CreateContext() => Fixture.CreateContext(); diff --git a/test/EFCore.SqlServer.FunctionalTests/Query/GearsOfWarQuerySqlServerTest.cs b/test/EFCore.SqlServer.FunctionalTests/Query/GearsOfWarQuerySqlServerTest.cs index 6a0159b700f..cabcd7a14b7 100644 --- a/test/EFCore.SqlServer.FunctionalTests/Query/GearsOfWarQuerySqlServerTest.cs +++ b/test/EFCore.SqlServer.FunctionalTests/Query/GearsOfWarQuerySqlServerTest.cs @@ -8248,6 +8248,78 @@ FROM [Gears] AS [g] ORDER BY [g].[Nickname]"); } + [ConditionalTheory] + [MemberData(nameof(IsAsyncData))] + public override async Task Where_TimeZoneInfo_ConvertTimeBySystemTimeZoneId_DateTimeOffset(bool async) + { + await base.Where_TimeZoneInfo_ConvertTimeBySystemTimeZoneId_DateTimeOffset(async); + + AssertSql( + @"SELECT [m].[Id], [m].[BriefingDocument], [m].[BriefingDocumentFileExtension], [m].[CodeName], [m].[Duration], [m].[Rating], [m].[Timeline] +FROM [Missions] AS [m] +WHERE ([m].[Timeline] AT TIME ZONE N'UTC') = '0002-03-01T13:00:00.0000000+00:00'"); + } + + [ConditionalFact] + public virtual async Task Where_AtTimeZone_constant() + { + using var context = CreateContext(); + + var missions = await context.Set() + .Where(m => m.Timeline == EF.Functions.AtTimeZone(new DateTime(10, 5, 3, 12, 0, 0), "UTC")) + .ToListAsync(); + + var mission = Assert.Single(missions); + Assert.Equal(3, Assert.Single(missions).Id); + + AssertSql( + @"SELECT [m].[Id], [m].[BriefingDocument], [m].[BriefingDocumentFileExtension], [m].[CodeName], [m].[Duration], [m].[Rating], [m].[Timeline] +FROM [Missions] AS [m] +WHERE [m].[Timeline] = (CAST('0010-05-03T12:00:00.0000000' AS datetime2) AT TIME ZONE N'UTC')"); + } + + [ConditionalFact] + public virtual async Task Where_AtTimeZone_parameter() + { + using var context = CreateContext(); + + var dateTime = new DateTime(10, 5, 3, 12, 0, 0); + var timeZone = "UTC"; + + var missions = await context.Set() + .Where(m => m.Timeline == EF.Functions.AtTimeZone(dateTime, timeZone)) + .ToListAsync(); + + var mission = Assert.Single(missions); + Assert.Equal(3, Assert.Single(missions).Id); + + AssertSql( + @"@__dateTime_1='0010-05-03T12:00:00.0000000' +@__timeZone_2='UTC' (Size = 4000) + +SELECT [m].[Id], [m].[BriefingDocument], [m].[BriefingDocumentFileExtension], [m].[CodeName], [m].[Duration], [m].[Rating], [m].[Timeline] +FROM [Missions] AS [m] +WHERE [m].[Timeline] = (@__dateTime_1 AT TIME ZONE @__timeZone_2)"); + } + + [ConditionalFact] + public virtual async Task Where_AtTimeZone_column() + { + using var context = CreateContext(); + + var missions = await context.Set() + .Where(ct => EF.Functions.AtTimeZone(ct.IssueDate, "UTC") == new DateTimeOffset(15, 3, 7, 0, 0, 0, TimeSpan.Zero)) + .ToListAsync(); + + var mission = Assert.Single(missions); + Assert.Equal(Guid.Parse("A7BE028A-0CF2-448F-AB55-CE8BC5D8CF69"), Assert.Single(missions).Id); + + AssertSql( + @"SELECT [t].[Id], [t].[GearNickName], [t].[GearSquadId], [t].[IssueDate], [t].[Note] +FROM [Tags] AS [t] +WHERE ([t].[IssueDate] AT TIME ZONE N'UTC') = '0015-03-07T00:00:00.0000000+00:00'"); + } + private void AssertSql(params string[] expected) => Fixture.TestSqlLoggerFactory.AssertBaseline(expected); } diff --git a/test/EFCore.Sqlite.FunctionalTests/Query/GearsOfWarQuerySqliteTest.cs b/test/EFCore.Sqlite.FunctionalTests/Query/GearsOfWarQuerySqliteTest.cs index 62a9931456f..0fcd9816b58 100644 --- a/test/EFCore.Sqlite.FunctionalTests/Query/GearsOfWarQuerySqliteTest.cs +++ b/test/EFCore.Sqlite.FunctionalTests/Query/GearsOfWarQuerySqliteTest.cs @@ -525,6 +525,9 @@ public override async Task Where_TimeOnly_subtract_TimeOnly(bool async) AssertSql(""); } + public override Task Where_TimeZoneInfo_ConvertTimeBySystemTimeZoneId_DateTimeOffset(bool async) + => AssertTranslationFailed(() => base.Where_TimeZoneInfo_ConvertTimeBySystemTimeZoneId_DateTimeOffset(async)); + private void AssertSql(params string[] expected) => Fixture.TestSqlLoggerFactory.AssertBaseline(expected); } diff --git a/test/EFCore.Sqlite.FunctionalTests/Query/TPTGearsOfWarQuerySqliteTest.cs b/test/EFCore.Sqlite.FunctionalTests/Query/TPTGearsOfWarQuerySqliteTest.cs index 3cb5f9473c5..b84c5d70362 100644 --- a/test/EFCore.Sqlite.FunctionalTests/Query/TPTGearsOfWarQuerySqliteTest.cs +++ b/test/EFCore.Sqlite.FunctionalTests/Query/TPTGearsOfWarQuerySqliteTest.cs @@ -54,6 +54,9 @@ public override Task DateTimeOffset_Contains_Less_than_Greater_than(bool async) public override Task DateTimeOffset_Date_returns_datetime(bool async) => AssertTranslationFailed(() => base.DateTimeOffset_Date_returns_datetime(async)); + public override Task Where_TimeZoneInfo_ConvertTimeBySystemTimeZoneId_DateTimeOffset(bool async) + => AssertTranslationFailed(() => base.Where_TimeZoneInfo_ConvertTimeBySystemTimeZoneId_DateTimeOffset(async)); + public override async Task Correlated_collections_inner_subquery_predicate_references_outer_qsre(bool async) => Assert.Equal( SqliteStrings.ApplyNotSupported,