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,