Skip to content

Commit

Permalink
Add AtTimeZoneExpression and some relevant translations (#26972)
Browse files Browse the repository at this point in the history
Closes #26199
Closes #26971
  • Loading branch information
roji committed May 24, 2022
1 parent 0849fd8 commit 176f301
Show file tree
Hide file tree
Showing 12 changed files with 486 additions and 2 deletions.
1 change: 1 addition & 0 deletions All.sln.DotSettings
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,7 @@ The .NET Foundation licenses this file to you under the MIT license.
<s:Boolean x:Key="/Default/UserDictionary/Words/=composability/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=composable/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=Constructible/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=datetimeoffset/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=doesnt/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=efcore/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=evaluatable/@EntryIndexedValue">True</s:Boolean>
Expand Down
38 changes: 37 additions & 1 deletion src/EFCore.Relational/Query/QuerySqlGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -895,6 +895,42 @@ protected override Expression VisitIn(InExpression inExpression)
return inExpression;
}

/// <inheritdoc />
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;
}

/// <summary>
/// Gets a SQL operator for a SQL binary operation.
/// </summary>
Expand All @@ -914,7 +950,7 @@ protected virtual bool RequiresParentheses(SqlExpression outerExpression, SqlExp
{
switch (innerExpression)
{
case LikeExpression:
case AtTimeZoneExpression or LikeExpression:
return true;

case SqlUnaryExpression sqlUnaryExpression:
Expand Down
4 changes: 4 additions & 0 deletions src/EFCore.Relational/Query/SqlExpressionFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ public SqlExpressionFactory(SqlExpressionFactoryDependencies dependencies)

return sqlExpression switch
{
AtTimeZoneExpression e => ApplyTypeMappingOnAtTimeZone(e, typeMapping),
CaseExpression e => ApplyTypeMappingOnCase(e, typeMapping),
CollateExpression e => ApplyTypeMappingOnCollate(e, typeMapping),
DistinctExpression e => ApplyTypeMappingOnDistinct(e, typeMapping),
Expand All @@ -69,6 +70,9 @@ public SqlExpressionFactory(SqlExpressionFactoryDependencies dependencies)
};
}

private SqlExpression ApplyTypeMappingOnAtTimeZone(AtTimeZoneExpression atTimeZoneExpression, RelationalTypeMapping? typeMapping)
=> new AtTimeZoneExpression(atTimeZoneExpression.Operand, atTimeZoneExpression.TimeZone, atTimeZoneExpression.Type, typeMapping);

private SqlExpression ApplyTypeMappingOnLike(LikeExpression likeExpression)
{
var inferredTypeMapping = (likeExpression.EscapeChar == null
Expand Down
13 changes: 12 additions & 1 deletion src/EFCore.Relational/Query/SqlExpressionVisitor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ protected override Expression VisitExtension(Expression extensionExpression)
case ShapedQueryExpression shapedQueryExpression:
return shapedQueryExpression.UpdateQueryExpression(Visit(shapedQueryExpression.QueryExpression));

case AtTimeZoneExpression atTimeZoneExpression:
return VisitAtTimeZone(atTimeZoneExpression);

case CaseExpression caseExpression:
return VisitCase(caseExpression);

Expand Down Expand Up @@ -115,6 +118,14 @@ protected override Expression VisitExtension(Expression extensionExpression)
return base.VisitExtension(extensionExpression);
}


/// <summary>
/// Visits the children of the sql "at time zone" expression.
/// </summary>
/// <param name="atTimeZoneExpression">The expression to visit.</param>
/// <returns>The modified expression, if it or any subexpression was modified; otherwise, returns the original expression.</returns>
protected abstract Expression VisitAtTimeZone(AtTimeZoneExpression atTimeZoneExpression);

/// <summary>
/// Visits the children of the case expression.
/// </summary>
Expand Down Expand Up @@ -277,7 +288,7 @@ protected override Expression VisitExtension(Expression extensionExpression)
protected abstract Expression VisitSqlConstant(SqlConstantExpression sqlConstantExpression);

/// <summary>
/// Visits the children of the sql fragent expression.
/// Visits the children of the sql fragment expression.
/// </summary>
/// <param name="sqlFragmentExpression">The expression to visit.</param>
/// <returns>The modified expression, if it or any subexpression was modified; otherwise, returns the original expression.</returns>
Expand Down
91 changes: 91 additions & 0 deletions src/EFCore.Relational/Query/SqlExpressions/AtTimeZoneExpression.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// <para>
/// An expression that represents an AT TIME ZONE operation in a SQL tree.
/// </para>
/// <para>
/// This type is typically used by database providers (and other extensions). It is generally
/// not used in application code.
/// </para>
/// </summary>
public class AtTimeZoneExpression : SqlExpression
{
/// <summary>
/// Creates a new instance of the <see cref="AtTimeZoneExpression" /> class.
/// </summary>
/// <param name="operand">The operand on which to perform the time zone conversion.</param>
/// <param name="timeZone">The time zone to convert to.</param>
/// <param name="type">The <see cref="Type" /> of the expression.</param>
/// <param name="typeMapping">The <see cref="RelationalTypeMapping" /> associated with the expression.</param>
public AtTimeZoneExpression(
SqlExpression operand,
SqlExpression timeZone,
Type type,
RelationalTypeMapping? typeMapping)
: base(type, typeMapping)
{
Operand = operand;
TimeZone = timeZone;
}

/// <summary>
/// The input operand on which to apply the time zone.
/// </summary>
public virtual SqlExpression Operand { get; }

/// <summary>
/// The time zone to be applied.
/// </summary>
public virtual SqlExpression TimeZone { get; }

/// <inheritdoc />
protected override Expression VisitChildren(ExpressionVisitor visitor)
{
var operand = (SqlExpression)visitor.Visit(Operand);
var timeZone = (SqlExpression)visitor.Visit(TimeZone);

return Update(operand, timeZone);
}

/// <summary>
/// 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.
/// </summary>
/// <param name="operand">The <see cref="Operand" /> property of the result.</param>
/// <param name="timeZone">The <see cref="TimeZone" /> property of the result.</param>
/// <returns>This expression if no children changed, or an expression with the updated children.</returns>
public virtual AtTimeZoneExpression Update(SqlExpression operand, SqlExpression timeZone)
=> operand != Operand || timeZone != TimeZone
? new AtTimeZoneExpression(operand, timeZone, Type, TypeMapping)
: this;

/// <inheritdoc />
protected override void Print(ExpressionPrinter expressionPrinter)
{
expressionPrinter.Visit(Operand);

expressionPrinter.Append(" AT TIME ZONE ");

expressionPrinter.Visit(TimeZone);
}

/// <inheritdoc />
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);

/// <inheritdoc />
public override int GetHashCode()
=> HashCode.Combine(base.GetHashCode(), Operand, TimeZone);
}
21 changes: 21 additions & 0 deletions src/EFCore.Relational/Query/SqlNullabilityProcessor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -357,6 +357,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
Expand Down Expand Up @@ -413,6 +415,25 @@ protected virtual SqlExpression VisitCustomSqlExpression(
=> throw new InvalidOperationException(
RelationalStrings.UnhandledExpressionInVisitor(sqlExpression, sqlExpression.GetType(), nameof(SqlNullabilityProcessor)));

/// <summary>
/// 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.
/// </summary>
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);
}

/// <summary>
/// Visits a <see cref="CaseExpression" /> and computes its nullability.
/// </summary>
Expand Down
46 changes: 46 additions & 0 deletions src/EFCore.SqlServer/Extensions/SqlServerDbFunctionsExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1438,4 +1438,50 @@ public static bool IsNumeric(
this DbFunctions _,
string expression)
=> throw new InvalidOperationException(CoreStrings.FunctionOnClient(nameof(IsNumeric)));

/// <summary>
/// Converts <paramref name="dateTime" /> to the corresponding <c>datetimeoffset</c> in the target <paramref name="timeZone" />.
/// Corresponds to the SQL Server's <c>AT TIME ZONE</c> construct.
/// </summary>
/// <remarks>
/// <para>
/// Note that the <see cref="DateTime.Kind" /> of <paramref name="dateTime" /> is not taken into account when performing the
/// conversion; the offset for the provided time zone is simply applied as-is.
/// </para>
/// <para>
/// See <see href="https://aka.ms/efcore-docs-database-functions">Database functions</see>, and
/// <see href="https://aka.ms/efcore-docs-sqlserver">Accessing SQL Server and SQL Azure databases with EF Core</see>
/// for more information and examples.
/// </para>
/// </remarks>
/// <param name="_">The <see cref="DbFunctions" /> instance.</param>
/// <param name="dateTime">The value to convert to <c>datetimeoffset</c>.</param>
/// <param name="timeZone">A valid SQL Server time zone ID.</param>
/// <returns>The <c>datetimeoffset</c> resulting from the conversion.</returns>
/// <seealso href="https://docs.microsoft.com/sql/t-sql/queries/at-time-zone-transact-sql">SQL Server documentation for <c>AT TIME ZONE</c>.</seealso>
public static DateTimeOffset AtTimeZone(
this DbFunctions _,
DateTime dateTime,
string timeZone)
=> throw new InvalidOperationException(CoreStrings.FunctionOnClient(nameof(AtTimeZone)));

/// <summary>
/// Converts <paramref name="dateTimeOffset" /> to the time zone specified by <paramref name="timeZone"/>.
/// Corresponds to the SQL Server's <c>AT TIME ZONE</c> construct.
/// </summary>
/// <remarks>
/// See <see href="https://aka.ms/efcore-docs-database-functions">Database functions</see>, and
/// <see href="https://aka.ms/efcore-docs-sqlserver">Accessing SQL Server and SQL Azure databases with EF Core</see>
/// for more information and examples.
/// </remarks>
/// <param name="_">The <see cref="DbFunctions" /> instance.</param>
/// <param name="dateTimeOffset">The value on which to perform the time zone conversion.</param>
/// <param name="timeZone">A valid SQL Server time zone ID.</param>
/// <returns>The <c>datetimeoffset</c> resulting from the conversion.</returns>
/// <seealso href="https://docs.microsoft.com/sql/t-sql/queries/at-time-zone-transact-sql">SQL Server documentation for <c>AT TIME ZONE</c>.</seealso>
public static DateTimeOffset AtTimeZone(
this DbFunctions _,
DateTimeOffset dateTimeOffset,
string timeZone)
=> throw new InvalidOperationException(CoreStrings.FunctionOnClient(nameof(AtTimeZone)));
}
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ public static IServiceCollection AddEntityFrameworkSqlServer(this IServiceCollec
.TryAdd<IMemberTranslatorProvider, SqlServerMemberTranslatorProvider>()
.TryAdd<IQuerySqlGeneratorFactory, SqlServerQuerySqlGeneratorFactory>()
.TryAdd<IRelationalSqlTranslatingExpressionVisitorFactory, SqlServerSqlTranslatingExpressionVisitorFactory>()
.TryAdd<ISqlExpressionFactory, SqlServerSqlExpressionFactory>()
.TryAdd<IRelationalParameterBasedSqlProcessorFactory, SqlServerParameterBasedSqlProcessorFactory>()
.TryAdd<INavigationExpansionExtensibilityHelper, SqlServerNavigationExpansionExtensibilityHelper>()
.TryAdd<IQueryableMethodTranslatingExpressionVisitorFactory, SqlServerQueryableMethodTranslatingExpressionVisitorFactory>()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,23 @@ protected override Expression VisitSelect(SelectExpression selectExpression)
: selectExpression;
}

/// <summary>
/// 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.
/// </summary>
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);
}

/// <summary>
/// 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,14 @@ public class SqlServerDateTimeMethodTranslator : IMethodCallTranslator
{ typeof(DateTimeOffset).GetRuntimeMethod(nameof(DateTimeOffset.AddMilliseconds), new[] { typeof(double) })!, "millisecond" }
};

private static readonly MethodInfo AtTimeZoneDateTimeOffsetMethodInfo = typeof(SqlServerDbFunctionsExtensions)
.GetRuntimeMethod(
nameof(SqlServerDbFunctionsExtensions.AtTimeZone), new[] { typeof(DbFunctions), typeof(DateTimeOffset), typeof(string) })!;

private static readonly MethodInfo AtTimeZoneDateTimeMethodInfo = typeof(SqlServerDbFunctionsExtensions)
.GetRuntimeMethod(
nameof(SqlServerDbFunctionsExtensions.AtTimeZone), new[] { typeof(DbFunctions), typeof(DateTime), typeof(string) })!;

private readonly ISqlExpressionFactory _sqlExpressionFactory;
private readonly IRelationalTypeMappingSource _typeMappingSource;

Expand Down Expand Up @@ -89,6 +97,48 @@ public SqlServerDateTimeMethodTranslator(
instance.TypeMapping);
}

if (method == AtTimeZoneDateTimeOffsetMethodInfo || method == AtTimeZoneDateTimeMethodInfo)
{
var (operand, timeZone) = (arguments[1], arguments[2]);

RelationalTypeMapping? resultTypeMapping = null;

// The AT TIME ZONE construct bubbles up the precision of its operand, so when invoked over datetime2(2) it returns a
// datetimeoffset(2). So if the operand has a type mapping, bubble it up accordingly, otherwise allow the result type mapping
// to be inferred.
if (operand.TypeMapping is { } operandTypeMapping)
{
switch (operandTypeMapping.StoreTypeNameBase)
{
case "datetimeoffset":
resultTypeMapping = operandTypeMapping;
break;
case "datetime" or "datetime2" or "smalldatetime":
resultTypeMapping = _typeMappingSource.FindMapping(
typeof(DateTimeOffset), "datetimeoffset", precision: operandTypeMapping.Precision);
break;
default:
Check.DebugAssert(
false,
$"Unknown operand type mapping '{operandTypeMapping.StoreTypeNameBase}' when translating EF.Functions.AtTimeZone");
break;
}
}

if (operand is SqlConstantExpression)
{
// Our constant representation for datetime/datetimeoffset is an untyped string literal, which the AT TIME ZONE expression
// does not accept. Type it explicitly.
operand = _sqlExpressionFactory.Convert(operand, operand.Type);
}

return new AtTimeZoneExpression(
operand,
_sqlExpressionFactory.ApplyTypeMapping(timeZone, _typeMappingSource.FindMapping("varchar")),
typeof(DateTimeOffset),
resultTypeMapping);
}

return null;
}
}
Loading

0 comments on commit 176f301

Please sign in to comment.