diff --git a/.gitignore b/.gitignore index c64dc0e4c07..37e4dd4ddc5 100644 --- a/.gitignore +++ b/.gitignore @@ -345,3 +345,7 @@ ASALocalRun/ # Local History for Visual Studio .localhistory/ full.targets.txt + + +# Language Server cache +*.lscache \ No newline at end of file diff --git a/src/EFCore.SqlServer/EFCore.SqlServer.baseline.json b/src/EFCore.SqlServer/EFCore.SqlServer.baseline.json index 8c014f27cad..8cde5490643 100644 --- a/src/EFCore.SqlServer/EFCore.SqlServer.baseline.json +++ b/src/EFCore.SqlServer/EFCore.SqlServer.baseline.json @@ -845,6 +845,30 @@ { "Member": "static System.DateTimeOffset Microsoft.EntityFrameworkCore.SqlServerDbFunctionsExtensions.DateTimeOffsetFromParts(this Microsoft.EntityFrameworkCore.DbFunctions _, int year, int month, int day, int hour, int minute, int second, int fractions, int hourOffset, int minuteOffset, int precision);" }, + { + "Member": "static System.DateTime Microsoft.EntityFrameworkCore.SqlServerDbFunctionsExtensions.DateTrunc(this Microsoft.EntityFrameworkCore.DbFunctions _, string datepart, System.DateTime date);" + }, + { + "Member": "static System.DateTime? Microsoft.EntityFrameworkCore.SqlServerDbFunctionsExtensions.DateTrunc(this Microsoft.EntityFrameworkCore.DbFunctions _, string datepart, System.DateTime? date);" + }, + { + "Member": "static System.DateTimeOffset Microsoft.EntityFrameworkCore.SqlServerDbFunctionsExtensions.DateTrunc(this Microsoft.EntityFrameworkCore.DbFunctions _, string datepart, System.DateTimeOffset dateTimeOffset);" + }, + { + "Member": "static System.DateTimeOffset? Microsoft.EntityFrameworkCore.SqlServerDbFunctionsExtensions.DateTrunc(this Microsoft.EntityFrameworkCore.DbFunctions _, string datepart, System.DateTimeOffset? dateTimeOffset);" + }, + { + "Member": "static System.DateOnly Microsoft.EntityFrameworkCore.SqlServerDbFunctionsExtensions.DateTrunc(this Microsoft.EntityFrameworkCore.DbFunctions _, string datepart, System.DateOnly date);" + }, + { + "Member": "static System.DateOnly? Microsoft.EntityFrameworkCore.SqlServerDbFunctionsExtensions.DateTrunc(this Microsoft.EntityFrameworkCore.DbFunctions _, string datepart, System.DateOnly? date);" + }, + { + "Member": "static System.TimeOnly Microsoft.EntityFrameworkCore.SqlServerDbFunctionsExtensions.DateTrunc(this Microsoft.EntityFrameworkCore.DbFunctions _, string datepart, System.TimeOnly time);" + }, + { + "Member": "static System.TimeOnly? Microsoft.EntityFrameworkCore.SqlServerDbFunctionsExtensions.DateTrunc(this Microsoft.EntityFrameworkCore.DbFunctions _, string datepart, System.TimeOnly? time);" + }, { "Member": "static bool Microsoft.EntityFrameworkCore.SqlServerDbFunctionsExtensions.FreeText(this Microsoft.EntityFrameworkCore.DbFunctions _, object propertyReference, string freeText, int languageTerm);" }, @@ -2630,7 +2654,7 @@ "Member": "static System.Linq.IQueryable> Microsoft.EntityFrameworkCore.SqlServerQueryableExtensions.FreeTextTable(this Microsoft.EntityFrameworkCore.DbSet source, string freeText, System.Linq.Expressions.Expression>? columnSelector = null, string? languageTerm = null, int? topN = null);" }, { - "Member": "static System.Linq.IQueryable> Microsoft.EntityFrameworkCore.SqlServerQueryableExtensions.VectorSearch(this Microsoft.EntityFrameworkCore.DbSet source, System.Linq.Expressions.Expression> vectorPropertySelector, TVector similarTo, string metric, int topN);", + "Member": "static System.Linq.IQueryable> Microsoft.EntityFrameworkCore.SqlServerQueryableExtensions.VectorSearch(this Microsoft.EntityFrameworkCore.DbSet source, System.Linq.Expressions.Expression> vectorPropertySelector, TVector similarTo, string metric);", "Stage": "Experimental" } ] diff --git a/src/EFCore.SqlServer/Extensions/SqlServerDbFunctionsExtensions.cs b/src/EFCore.SqlServer/Extensions/SqlServerDbFunctionsExtensions.cs index 54f11b9a170..d825c1b010e 100644 --- a/src/EFCore.SqlServer/Extensions/SqlServerDbFunctionsExtensions.cs +++ b/src/EFCore.SqlServer/Extensions/SqlServerDbFunctionsExtensions.cs @@ -2167,6 +2167,202 @@ public static DateTimeOffset AtTimeZone( string timeZone) => throw new InvalidOperationException(CoreStrings.FunctionOnClient(nameof(AtTimeZone))); + #region DateTrunc + + /// + /// Truncates the to the specified precision. + /// Corresponds to SQL Server's DATETRUNC(datepart, date). + /// + /// + /// See Database functions, and + /// Accessing SQL Server and Azure SQL databases with EF Core + /// for more information and examples. + /// + /// The instance. + /// + /// The datepart to truncate to. Can be one of: year, quarter, month, dayofyear, + /// day, week, iso_week, hour, minute, second, millisecond, + /// microsecond. + /// + /// The value to truncate. + /// The truncated value. + /// SQL Server documentation for DATETRUNC. + public static DateTime DateTrunc( + this DbFunctions _, + [NotParameterized] string datepart, + DateTime date) + => throw new InvalidOperationException(CoreStrings.FunctionOnClient(nameof(DateTrunc))); + + /// + /// Truncates the to the specified precision. + /// Corresponds to SQL Server's DATETRUNC(datepart, date). + /// + /// + /// See Database functions, and + /// Accessing SQL Server and Azure SQL databases with EF Core + /// for more information and examples. + /// + /// The instance. + /// + /// The datepart to truncate to. Can be one of: year, quarter, month, dayofyear, + /// day, week, iso_week, hour, minute, second, millisecond, + /// microsecond. + /// + /// The value to truncate. + /// The truncated value. + /// SQL Server documentation for DATETRUNC. + public static DateTime? DateTrunc( + this DbFunctions _, + [NotParameterized] string datepart, + DateTime? date) + => throw new InvalidOperationException(CoreStrings.FunctionOnClient(nameof(DateTrunc))); + + /// + /// Truncates the to the specified precision. + /// Corresponds to SQL Server's DATETRUNC(datepart, date). + /// + /// + /// See Database functions, and + /// Accessing SQL Server and Azure SQL databases with EF Core + /// for more information and examples. + /// + /// The instance. + /// + /// The datepart to truncate to. Can be one of: year, quarter, month, dayofyear, + /// day, week, iso_week, hour, minute, second, millisecond, + /// microsecond. + /// + /// The value to truncate. + /// The truncated value. + /// SQL Server documentation for DATETRUNC. + public static DateTimeOffset DateTrunc( + this DbFunctions _, + [NotParameterized] string datepart, + DateTimeOffset dateTimeOffset) + => throw new InvalidOperationException(CoreStrings.FunctionOnClient(nameof(DateTrunc))); + + /// + /// Truncates the to the specified precision. + /// Corresponds to SQL Server's DATETRUNC(datepart, date). + /// + /// + /// See Database functions, and + /// Accessing SQL Server and Azure SQL databases with EF Core + /// for more information and examples. + /// + /// The instance. + /// + /// The datepart to truncate to. Can be one of: year, quarter, month, dayofyear, + /// day, week, iso_week, hour, minute, second, millisecond, + /// microsecond. + /// + /// The value to truncate. + /// The truncated value. + /// SQL Server documentation for DATETRUNC. + public static DateTimeOffset? DateTrunc( + this DbFunctions _, + [NotParameterized] string datepart, + DateTimeOffset? dateTimeOffset) + => throw new InvalidOperationException(CoreStrings.FunctionOnClient(nameof(DateTrunc))); + + /// + /// Truncates the to the specified precision. + /// Corresponds to SQL Server's DATETRUNC(datepart, date). + /// + /// + /// See Database functions, and + /// Accessing SQL Server and Azure SQL databases with EF Core + /// for more information and examples. + /// + /// The instance. + /// + /// The datepart to truncate to. Can be one of: year, quarter, month, dayofyear, + /// day, week, iso_week, hour, minute, second, millisecond, + /// microsecond. + /// + /// The value to truncate. + /// The truncated value. + /// SQL Server documentation for DATETRUNC. + public static DateOnly DateTrunc( + this DbFunctions _, + [NotParameterized] string datepart, + DateOnly date) + => throw new InvalidOperationException(CoreStrings.FunctionOnClient(nameof(DateTrunc))); + + /// + /// Truncates the to the specified precision. + /// Corresponds to SQL Server's DATETRUNC(datepart, date). + /// + /// + /// See Database functions, and + /// Accessing SQL Server and Azure SQL databases with EF Core + /// for more information and examples. + /// + /// The instance. + /// + /// The datepart to truncate to. Can be one of: year, quarter, month, dayofyear, + /// day, week, iso_week, hour, minute, second, millisecond, + /// microsecond. + /// + /// The value to truncate. + /// The truncated value. + /// SQL Server documentation for DATETRUNC. + public static DateOnly? DateTrunc( + this DbFunctions _, + [NotParameterized] string datepart, + DateOnly? date) + => throw new InvalidOperationException(CoreStrings.FunctionOnClient(nameof(DateTrunc))); + + /// + /// Truncates the to the specified precision. + /// Corresponds to SQL Server's DATETRUNC(datepart, date). + /// + /// + /// See Database functions, and + /// Accessing SQL Server and Azure SQL databases with EF Core + /// for more information and examples. + /// + /// The instance. + /// + /// The datepart to truncate to. Can be one of: year, quarter, month, dayofyear, + /// day, week, iso_week, hour, minute, second, millisecond, + /// microsecond. + /// + /// The value to truncate. + /// The truncated value. + /// SQL Server documentation for DATETRUNC. + public static TimeOnly DateTrunc( + this DbFunctions _, + [NotParameterized] string datepart, + TimeOnly time) + => throw new InvalidOperationException(CoreStrings.FunctionOnClient(nameof(DateTrunc))); + + /// + /// Truncates the to the specified precision. + /// Corresponds to SQL Server's DATETRUNC(datepart, date). + /// + /// + /// See Database functions, and + /// Accessing SQL Server and Azure SQL databases with EF Core + /// for more information and examples. + /// + /// The instance. + /// + /// The datepart to truncate to. Can be one of: year, quarter, month, dayofyear, + /// day, week, iso_week, hour, minute, second, millisecond, + /// microsecond. + /// + /// The value to truncate. + /// The truncated value. + /// SQL Server documentation for DATETRUNC. + public static TimeOnly? DateTrunc( + this DbFunctions _, + [NotParameterized] string datepart, + TimeOnly? time) + => throw new InvalidOperationException(CoreStrings.FunctionOnClient(nameof(DateTrunc))); + + #endregion DateTrunc + /// /// Returns the starting position of the first occurrence of a pattern in a specified expression, or zero if the pattern is not found, on /// all valid text and character data types. diff --git a/src/EFCore.SqlServer/Properties/SqlServerStrings.Designer.cs b/src/EFCore.SqlServer/Properties/SqlServerStrings.Designer.cs index f8065d8582f..81d6cd17d86 100644 --- a/src/EFCore.SqlServer/Properties/SqlServerStrings.Designer.cs +++ b/src/EFCore.SqlServer/Properties/SqlServerStrings.Designer.cs @@ -59,6 +59,12 @@ public static string CompatibilityLevelTooLowForScalarCollections(object? compat GetString("CompatibilityLevelTooLowForScalarCollections", nameof(compatibilityLevel)), compatibilityLevel); + /// + /// 'DateTimeOffset.Offset' cannot be translated on its own; use 'DateTimeOffset.Offset.TotalMinutes' to get the offset in minutes. + /// + public static string DateTimeOffsetOffsetRequiresTotalMinutes + => GetString("DateTimeOffsetOffsetRequiresTotalMinutes"); + /// /// '{entityType1}.{property1}' and '{entityType2}.{property2}' are both mapped to column '{columnName}' in '{table}', but are configured with different identity increment values. /// @@ -261,6 +267,14 @@ public static string InvalidCollationName(object? collation) GetString("InvalidCollationName", nameof(collation)), collation); + /// + /// The datepart '{datepart}' is invalid for the {function} function; datepart values may only contain letters and underscores. + /// + public static string InvalidDatePart(object? datepart, object? function) + => string.Format( + GetString("InvalidDatePart", nameof(datepart), nameof(function)), + datepart, function); + /// /// The expression passed to the 'propertyReference' parameter of the 'FreeText' method is not a valid reference to a property. The expression must represent a reference to a full-text indexed property on the object referenced in the from clause: 'from e in context.Entities where EF.Functions.FreeText(e.SomeProperty, textToSearchFor) select e' /// @@ -471,6 +485,20 @@ public static string TemporalSetOperationOnMismatchedSources(object? entityType) GetString("TemporalSetOperationOnMismatchedSources", nameof(entityType)), entityType); + /// + /// SQL Server time zone offsets must be specified in whole minutes. The provided TimeSpan value contains sub-minute precision (seconds, milliseconds, or smaller), which is not supported. + /// + public static string TimeSpanOffsetPrecisionNotSupported + => GetString("TimeSpanOffsetPrecisionNotSupported"); + + /// + /// The provided time zone offset '{offset}' is outside the valid range for SQL Server. Time zone offsets must be between -14:00 and +14:00. + /// + public static string TimeSpanOffsetOutOfRange(object? offset) + => string.Format( + GetString("TimeSpanOffsetOutOfRange", nameof(offset)), + offset); + /// /// An exception has been raised that is likely due to a transient failure. Consider enabling transient error resiliency by adding 'EnableRetryOnFailure' to the 'UseSqlServer' call. /// diff --git a/src/EFCore.SqlServer/Properties/SqlServerStrings.resx b/src/EFCore.SqlServer/Properties/SqlServerStrings.resx index 765b0926c0d..2fc4c75cc8d 100644 --- a/src/EFCore.SqlServer/Properties/SqlServerStrings.resx +++ b/src/EFCore.SqlServer/Properties/SqlServerStrings.resx @@ -1,17 +1,17 @@  - @@ -132,6 +132,9 @@ EF Core's SQL Server compatibility level is set to {compatibilityLevel}; compatibility level 130 (SQL Server 2016) is the minimum for most forms of querying of JSON arrays. + + 'DateTimeOffset.Offset' cannot be translated on its own; use 'DateTimeOffset.Offset.TotalMinutes' to get the offset in minutes. + '{entityType1}.{property1}' and '{entityType2}.{property2}' are both mapped to column '{columnName}' in '{table}', but are configured with different identity increment values. @@ -210,6 +213,9 @@ Collation name '{collation}' is invalid; collation names may only contain alphanumeric characters and underscores. + + The datepart '{datepart}' is invalid for the {function} function; datepart values may only contain letters and underscores. + The expression passed to the 'propertyReference' parameter of the 'FreeText' method is not a valid reference to a property. The expression must represent a reference to a full-text indexed property on the object referenced in the from clause: 'from e in context.Entities where EF.Functions.FreeText(e.SomeProperty, textToSearchFor) select e' @@ -390,6 +396,12 @@ Set operation can't be applied on entity '{entityType}' because temporal operations on both arguments don't match. + + SQL Server time zone offsets must be specified in whole minutes. The provided TimeSpan value contains sub-minute precision (seconds, milliseconds, or smaller), which is not supported. + + + The provided time zone offset '{offset}' is outside the valid range for SQL Server. Time zone offsets must be between -14:00 and +14:00. + An exception has been raised that is likely due to a transient failure. Consider enabling transient error resiliency by adding 'EnableRetryOnFailure' to the 'UseSqlServer' call. diff --git a/src/EFCore.SqlServer/Query/Internal/SqlServerSqlTranslatingExpressionVisitor.cs b/src/EFCore.SqlServer/Query/Internal/SqlServerSqlTranslatingExpressionVisitor.cs index 7ed12888445..9c2ecb165be 100644 --- a/src/EFCore.SqlServer/Query/Internal/SqlServerSqlTranslatingExpressionVisitor.cs +++ b/src/EFCore.SqlServer/Query/Internal/SqlServerSqlTranslatingExpressionVisitor.cs @@ -5,6 +5,7 @@ using System.Text; using Microsoft.EntityFrameworkCore.Query.SqlExpressions; using Microsoft.EntityFrameworkCore.SqlServer.Infrastructure.Internal; +using Microsoft.EntityFrameworkCore.SqlServer.Internal; using ExpressionExtensions = Microsoft.EntityFrameworkCore.Query.ExpressionExtensions; namespace Microsoft.EntityFrameworkCore.SqlServer.Query.Internal; @@ -48,9 +49,128 @@ public class SqlServerSqlTranslatingExpressionVisitor( private static readonly MethodInfo EscapeLikePatternParameterMethod = typeof(SqlServerSqlTranslatingExpressionVisitor).GetTypeInfo().GetDeclaredMethod(nameof(ConstructLikePatternParameter))!; + private static readonly MethodInfo TimeSpanToSwitchOffsetMethod = + typeof(SqlServerSqlTranslatingExpressionVisitor).GetTypeInfo().GetDeclaredMethod(nameof(ConstructSwitchOffsetParameter))!; + private const char LikeEscapeChar = '\\'; private const string LikeEscapeString = "\\"; + /// + /// 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 VisitMember(MemberExpression memberExpression) + { + switch (memberExpression) + { + // Translate DateTimeOffset.Offset.TotalMinutes to DATEPART(tz, ), which returns the offset + // in whole minutes. We cannot translate DateTimeOffset.Offset alone since DATEPART(tz) returns an int (minutes), + // not a TimeSpan. + case + { + Member: { Name: nameof(TimeSpan.TotalMinutes), DeclaringType: var totalMinutesType }, + Expression: MemberExpression + { + Member: { Name: nameof(DateTimeOffset.Offset), DeclaringType: var offsetType }, + Expression: { } dateTimeOffsetExpression + } + } when totalMinutesType == typeof(TimeSpan) && offsetType == typeof(DateTimeOffset): + { + if (Visit(dateTimeOffsetExpression) is not SqlExpression translatedInstance) + { + return QueryCompilationContext.NotTranslatedExpression; + } + + return _sqlExpressionFactory.Convert( + _sqlExpressionFactory.Function( + "DATEPART", + arguments: [_sqlExpressionFactory.Fragment("tz"), translatedInstance], + nullable: true, + argumentsPropagateNullability: Statics.FalseTrue, + typeof(int)), + typeof(double)); + } + + // For standalone DateTimeOffset.Offset (not followed by TotalMinutes), provide a helpful error message. + case { Member: { Name: nameof(DateTimeOffset.Offset), DeclaringType: var offsetType } } + when offsetType == typeof(DateTimeOffset): + { + AddTranslationErrorDetails(SqlServerStrings.DateTimeOffsetOffsetRequiresTotalMinutes); + return QueryCompilationContext.NotTranslatedExpression; + } + + default: + return base.VisitMember(memberExpression); + } + } + + /// + /// 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 VisitNew(NewExpression newExpression) + { + if (base.VisitNew(newExpression) is var translation + && translation != QueryCompilationContext.NotTranslatedExpression) + { + return translation; + } + + // Translate new DateTimeOffset(DateTime) and new DateTimeOffset(DateTime, TimeSpan) to + // TODATETIMEOFFSET(datetime, offset). + switch (newExpression) + { + case { Constructor.DeclaringType: var type, Arguments: [var dateTimeArg, var offsetArg] } + when type == typeof(DateTimeOffset) + && dateTimeArg.Type == typeof(DateTime) + && offsetArg.Type == typeof(TimeSpan): + { + if (Visit(dateTimeArg) is not SqlExpression translatedDateTime + || Visit(offsetArg) is not SqlExpression translatedOffset) + { + return QueryCompilationContext.NotTranslatedExpression; + } + + var offsetExpression = TranslateTimeSpanToOffsetExpression(translatedOffset); + if (offsetExpression is null) + { + return QueryCompilationContext.NotTranslatedExpression; + } + + return _sqlExpressionFactory.Function( + "TODATETIMEOFFSET", + [translatedDateTime, offsetExpression], + nullable: true, + argumentsPropagateNullability: Statics.TrueArrays[2], + typeof(DateTimeOffset)); + } + + case { Constructor.DeclaringType: var type, Arguments: [var dateTimeArg] } + when type == typeof(DateTimeOffset) && dateTimeArg.Type == typeof(DateTime): + { + if (Visit(dateTimeArg) is not SqlExpression translatedDateTime) + { + return QueryCompilationContext.NotTranslatedExpression; + } + + var varcharTypeMapping = _typeMappingSource.FindMapping("varchar"); + return _sqlExpressionFactory.Function( + "TODATETIMEOFFSET", + [translatedDateTime, _sqlExpressionFactory.Constant("+00:00", varcharTypeMapping)], + nullable: true, + argumentsPropagateNullability: Statics.TrueArrays[2], + typeof(DateTimeOffset)); + } + + default: + return QueryCompilationContext.NotTranslatedExpression; + } + } + /// /// 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 @@ -355,6 +475,34 @@ protected override Expression VisitMethodCall(MethodCallExpression methodCallExp _sqlExpressionFactory.Constant(1)); } + // https://learn.microsoft.com/dotnet/api/system.datetimeoffset.tooffset + case nameof(DateTimeOffset.ToOffset) + when declaringType == typeof(DateTimeOffset) + && @object is not null + && arguments is [{ Type: var argType } offsetArgument] + && argType == typeof(TimeSpan): + { + if (Visit(@object) is not SqlExpression translatedInstance + || Visit(offsetArgument) is not SqlExpression translatedOffset) + { + return QueryCompilationContext.NotTranslatedExpression; + } + + var offsetExpression = TranslateTimeSpanToOffsetExpression(translatedOffset); + if (offsetExpression is null) + { + return QueryCompilationContext.NotTranslatedExpression; + } + + return _sqlExpressionFactory.Function( + "SWITCHOFFSET", + [translatedInstance, offsetExpression], + nullable: true, + argumentsPropagateNullability: Statics.TrueArrays[2], + typeof(DateTimeOffset), + translatedInstance.TypeMapping); + } + default: return QueryCompilationContext.NotTranslatedExpression; } @@ -549,6 +697,43 @@ SqlExpression CharIndexGreaterThanZero() } } + /// + /// Translates a TimeSpan SQL expression (constant or parameter) to a varchar offset string expression (e.g. '+01:30'). + /// SQL Server offset functions (SWITCHOFFSET, TODATETIMEOFFSET) expect varchar, but .NET uses TimeSpan. + /// + private SqlExpression? TranslateTimeSpanToOffsetExpression(SqlExpression translatedOffset) + { + var varcharTypeMapping = _typeMappingSource.FindMapping("varchar"); + + return translatedOffset switch + { + SqlConstantExpression { Value: TimeSpan timeSpan } + => _sqlExpressionFactory.Constant(TimeSpanToOffsetString(timeSpan), varcharTypeMapping), + + SqlParameterExpression offsetParameter => CreateRuntimeParameter(offsetParameter, varcharTypeMapping!), + + _ => null + }; + + SqlParameterExpression CreateRuntimeParameter( + SqlParameterExpression offsetParameter, + RelationalTypeMapping varcharTypeMapping) + { + var lambda = Expression.Lambda( + Expression.Call( + TimeSpanToSwitchOffsetMethod, + QueryCompilationContext.QueryContextParameter, + Expression.Constant(offsetParameter.Name)), + QueryCompilationContext.QueryContextParameter); + + var runtimeParameter = + _queryCompilationContext.RegisterRuntimeParameter( + $"{offsetParameter.Name}_offset", lambda); + + return new SqlParameterExpression(runtimeParameter.Name!, runtimeParameter.Type, varcharTypeMapping); + } + } + /// /// 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 @@ -595,6 +780,44 @@ SqlExpression CharIndexGreaterThanZero() _ => throw new UnreachableException() }; + /// + /// 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. + /// + [EntityFrameworkInternal] + public static string? ConstructSwitchOffsetParameter( + QueryContext queryContext, + string baseParameterName) + => queryContext.Parameters[baseParameterName] switch + { + null => null, + TimeSpan timeSpan => TimeSpanToOffsetString(timeSpan), + _ => throw new UnreachableException() + }; + + private static string TimeSpanToOffsetString(TimeSpan timeSpan) + { + if (timeSpan.Ticks % TimeSpan.TicksPerMinute != 0) + { + throw new InvalidOperationException(SqlServerStrings.TimeSpanOffsetPrecisionNotSupported); + } + + var totalMinutes = timeSpan.Ticks / TimeSpan.TicksPerMinute; + if (totalMinutes is < -14 * 60 or > 14 * 60) + { + throw new InvalidOperationException(SqlServerStrings.TimeSpanOffsetOutOfRange(timeSpan)); + } + + var sign = totalMinutes >= 0 ? "+" : "-"; + var absoluteTotalMinutes = Math.Abs(totalMinutes); + var hours = absoluteTotalMinutes / 60; + var minutes = absoluteTotalMinutes % 60; + + return $"{sign}{hours:D2}:{minutes:D2}"; + } + /// /// 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/Translators/SqlServerDateTimeMemberTranslator.cs b/src/EFCore.SqlServer/Query/Internal/Translators/SqlServerDateTimeMemberTranslator.cs index 99ee44946a6..da0413e86e0 100644 --- a/src/EFCore.SqlServer/Query/Internal/Translators/SqlServerDateTimeMemberTranslator.cs +++ b/src/EFCore.SqlServer/Query/Internal/Translators/SqlServerDateTimeMemberTranslator.cs @@ -68,6 +68,61 @@ public class SqlServerDateTimeMemberTranslator( argumentsPropagateNullability: Statics.FalseTrue, returnType), + nameof(DateTimeOffset.DateTime) + when declaringType == typeof(DateTimeOffset) + && instance!.TypeMapping is { StoreTypeNameBase: "datetimeoffset" } + => sqlExpressionFactory.Function( + "CONVERT", + [sqlExpressionFactory.Fragment("datetime2"), instance!], + nullable: true, + argumentsPropagateNullability: Statics.FalseTrue, + returnType, + typeMappingSource.FindMapping(typeof(DateTime))), + + nameof(DateTimeOffset.UtcDateTime) + when declaringType == typeof(DateTimeOffset) + && instance!.TypeMapping is { StoreTypeNameBase: "datetimeoffset" } + => sqlExpressionFactory.Function( + "CONVERT", + [ + sqlExpressionFactory.Fragment("datetime2"), + new AtTimeZoneExpression( + instance!, + sqlExpressionFactory.ApplyTypeMapping( + sqlExpressionFactory.Constant("UTC"), + typeMappingSource.FindMapping("varchar")), + typeof(DateTimeOffset), + instance!.TypeMapping) + ], + nullable: true, + argumentsPropagateNullability: Statics.FalseTrue, + returnType, + typeMappingSource.FindMapping(typeof(DateTime))), + + nameof(DateTimeOffset.LocalDateTime) + when declaringType == typeof(DateTimeOffset) + && instance!.TypeMapping is { StoreTypeNameBase: "datetimeoffset" } + => sqlExpressionFactory.Function( + "CONVERT", + [ + sqlExpressionFactory.Fragment("datetime2"), + new AtTimeZoneExpression( + instance!, + sqlExpressionFactory.Function( + "CURRENT_TIMEZONE_ID", + arguments: [], + nullable: false, + argumentsPropagateNullability: [], + typeof(string), + typeMappingSource.FindMapping("varchar")), + typeof(DateTimeOffset), + instance!.TypeMapping) + ], + nullable: true, + argumentsPropagateNullability: Statics.FalseTrue, + returnType, + typeMappingSource.FindMapping(typeof(DateTime))), + nameof(DateTime.Now) when declaringType == typeof(DateTime) => sqlExpressionFactory.Function( diff --git a/src/EFCore.SqlServer/Query/Internal/Translators/SqlServerDateTimeMethodTranslator.cs b/src/EFCore.SqlServer/Query/Internal/Translators/SqlServerDateTimeMethodTranslator.cs index 3c446344ae1..a8e4335fc72 100644 --- a/src/EFCore.SqlServer/Query/Internal/Translators/SqlServerDateTimeMethodTranslator.cs +++ b/src/EFCore.SqlServer/Query/Internal/Translators/SqlServerDateTimeMethodTranslator.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using Microsoft.EntityFrameworkCore.Query.SqlExpressions; +using Microsoft.EntityFrameworkCore.SqlServer.Internal; // ReSharper disable once CheckNamespace namespace Microsoft.EntityFrameworkCore.SqlServer.Query.Internal; @@ -142,6 +143,27 @@ public class SqlServerDateTimeMethodTranslator( resultTypeMapping); } + if (declaringType == typeof(SqlServerDbFunctionsExtensions) + && method.Name == nameof(SqlServerDbFunctionsExtensions.DateTrunc) + && arguments is [_, SqlConstantExpression { Value: string datePartValue }, var dateValue]) + { + foreach (var c in datePartValue) + { + if (!char.IsLetter(c) && c != '_') + { + throw new InvalidOperationException(SqlServerStrings.InvalidDatePart(datePartValue, "DATETRUNC")); + } + } + + return sqlExpressionFactory.Function( + "DATETRUNC", + [sqlExpressionFactory.Fragment(datePartValue), dateValue], + nullable: true, + argumentsPropagateNullability: [false, true], + method.ReturnType.UnwrapNullableType(), + dateValue.TypeMapping); + } + return null; } } diff --git a/test/EFCore.Cosmos.FunctionalTests/Query/Translations/Temporal/DateTimeOffsetTranslationsCosmosTest.cs b/test/EFCore.Cosmos.FunctionalTests/Query/Translations/Temporal/DateTimeOffsetTranslationsCosmosTest.cs index 5a4194d17da..c0a34901265 100644 --- a/test/EFCore.Cosmos.FunctionalTests/Query/Translations/Temporal/DateTimeOffsetTranslationsCosmosTest.cs +++ b/test/EFCore.Cosmos.FunctionalTests/Query/Translations/Temporal/DateTimeOffsetTranslationsCosmosTest.cs @@ -181,6 +181,30 @@ FROM root c """); } + public override async Task DateTime() + { + // Cosmos client evaluation. Issue #17246. + await AssertTranslationFailed(() => base.DateTime()); + + AssertSql(); + } + + public override async Task UtcDateTime() + { + // Cosmos client evaluation. Issue #17246. + await AssertTranslationFailed(() => base.UtcDateTime()); + + AssertSql(); + } + + public override async Task LocalDateTime() + { + // Cosmos client evaluation. Issue #17246. + await AssertTranslationFailed(() => base.LocalDateTime()); + + AssertSql(); + } + public override async Task AddYears() { // Our persisted representation of DateTimeOffset (xxx+00:00) isn't supported by Cosmos (should be xxxZ). #35310 @@ -271,6 +295,15 @@ public override Task ToUnixTimeMilliseconds() public override Task ToUnixTimeSecond() => AssertTranslationFailed(() => base.ToUnixTimeSecond()); + public override Task ToOffset() + => AssertTranslationFailed(() => base.ToOffset()); + + public override Task Ctor_DateTime() + => AssertTranslationFailed(() => base.Ctor_DateTime()); + + public override Task Ctor_DateTime_TimeSpan() + => AssertTranslationFailed(() => base.Ctor_DateTime_TimeSpan()); + public override async Task Milliseconds_parameter_and_constant() { await base.Milliseconds_parameter_and_constant(); diff --git a/test/EFCore.InMemory.FunctionalTests/Query/Translations/Temporal/DateTimeOffsetTranslationsInMemoryTest.cs b/test/EFCore.InMemory.FunctionalTests/Query/Translations/Temporal/DateTimeOffsetTranslationsInMemoryTest.cs index 25b6eeaef53..dc902d357e9 100644 --- a/test/EFCore.InMemory.FunctionalTests/Query/Translations/Temporal/DateTimeOffsetTranslationsInMemoryTest.cs +++ b/test/EFCore.InMemory.FunctionalTests/Query/Translations/Temporal/DateTimeOffsetTranslationsInMemoryTest.cs @@ -4,4 +4,10 @@ namespace Microsoft.EntityFrameworkCore.Query.Translations.Temporal; public class DateTimeOffsetTranslationsInMemoryTest(BasicTypesQueryInMemoryFixture fixture) - : DateTimeOffsetTranslationsTestBase(fixture); + : DateTimeOffsetTranslationsTestBase(fixture) +{ + // new DateTimeOffset(DateTime) with Unspecified kind uses the local timezone offset in .NET, which can overflow + // for dates near year boundaries (e.g., year 0001 with a negative UTC offset). Databases treat this as UTC. + public override Task Ctor_DateTime() + => Task.CompletedTask; +} diff --git a/test/EFCore.Specification.Tests/Query/Translations/Temporal/DateTimeOffsetTranslationsTestBase.cs b/test/EFCore.Specification.Tests/Query/Translations/Temporal/DateTimeOffsetTranslationsTestBase.cs index 47ab0f14aec..450b6ad267d 100644 --- a/test/EFCore.Specification.Tests/Query/Translations/Temporal/DateTimeOffsetTranslationsTestBase.cs +++ b/test/EFCore.Specification.Tests/Query/Translations/Temporal/DateTimeOffsetTranslationsTestBase.cs @@ -64,6 +64,23 @@ public virtual Task Nanosecond() public virtual Task TimeOfDay() => AssertQueryScalar(ss => ss.Set().Select(b => b.DateTimeOffset.TimeOfDay)); + [ConditionalFact] + public virtual Task DateTime() + => AssertQuery( + ss => ss.Set().Where(b => b.DateTimeOffset.DateTime == new DateTime(1998, 5, 4, 15, 30, 10))); + + [ConditionalFact] + public virtual Task UtcDateTime() + => AssertQuery( + ss => ss.Set().Where(b => b.DateTimeOffset.UtcDateTime == new DateTime(1998, 5, 4, 15, 30, 10))); + + [ConditionalFact] + public virtual Task LocalDateTime() + // Note: DateTimeOffset.LocalDateTime depends on the machine's local time zone, and the client and server may be in different + // time zones. Use a comparison far from any timezone boundary so the same rows match regardless of timezone. + => AssertQuery( + ss => ss.Set().Where(b => b.DateTimeOffset.LocalDateTime > new DateTime(1999, 1, 1))); + [ConditionalFact] public virtual Task AddYears() => AssertQueryScalar(ss => ss.Set().Select(b => b.DateTimeOffset.AddYears(1))); @@ -110,6 +127,29 @@ public virtual Task ToUnixTimeSecond() .Where(b => b.DateTimeOffset.ToUnixTimeSeconds() == unixEpochSeconds)); } + [ConditionalFact] + public virtual Task ToOffset() + => AssertQuery( + ss => ss.Set() + .Where(b => b.DateTimeOffset.ToOffset(new TimeSpan(2, 0, 0)) == new DateTimeOffset(1998, 5, 4, 17, 30, 10, new TimeSpan(2, 0, 0)))); + + // new DateTimeOffset(DateTime) with Unspecified kind: databases don't have DateTimeKind, so this is always treated + // as UTC (+00:00). The expected query explicitly uses TimeSpan.Zero to match. + [ConditionalFact] + public virtual Task Ctor_DateTime() + => AssertQuery( + ss => ss.Set() + .Where(b => new DateTimeOffset(b.DateTime) == new DateTimeOffset(1998, 5, 4, 15, 30, 10, TimeSpan.Zero)), + ss => ss.Set() + .Where(b => new DateTimeOffset(b.DateTime, TimeSpan.Zero) == new DateTimeOffset(1998, 5, 4, 15, 30, 10, TimeSpan.Zero))); + + [ConditionalFact] + public virtual Task Ctor_DateTime_TimeSpan() + => AssertQuery( + ss => ss.Set() + .Where(b => b.DateTime.Year > 1) + .Where(b => new DateTimeOffset(b.DateTime, new TimeSpan(2, 0, 0)) == new DateTimeOffset(1998, 5, 4, 15, 30, 10, new TimeSpan(2, 0, 0)))); + [ConditionalFact] public virtual Task Milliseconds_parameter_and_constant() { diff --git a/test/EFCore.SqlServer.FunctionalTests/Query/Translations/Temporal/DateOnlyTranslationsSqlServerTest.cs b/test/EFCore.SqlServer.FunctionalTests/Query/Translations/Temporal/DateOnlyTranslationsSqlServerTest.cs index ea0dbd133f5..475b5144fea 100644 --- a/test/EFCore.SqlServer.FunctionalTests/Query/Translations/Temporal/DateOnlyTranslationsSqlServerTest.cs +++ b/test/EFCore.SqlServer.FunctionalTests/Query/Translations/Temporal/DateOnlyTranslationsSqlServerTest.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.BasicTypesModel; + namespace Microsoft.EntityFrameworkCore.Query.Translations.Temporal; public class DateOnlyTranslationsSqlServerTest : DateOnlyTranslationsTestBase @@ -217,6 +219,34 @@ public override async Task ToDateTime_with_complex_TimeOnly() AssertSql(); } + [ConditionalFact, SqlServerCondition(SqlServerCondition.SupportsFunctions2022)] + public virtual async Task DateTrunc_year() + { + await AssertQueryScalar( + actualQuery: ss => ss.Set().Select(b => EF.Functions.DateTrunc("year", b.DateOnly)), + expectedQuery: ss => ss.Set().Select(b => new DateOnly(b.DateOnly.Year, 1, 1))); + + AssertSql( + """ +SELECT DATETRUNC(year, [b].[DateOnly]) +FROM [BasicTypesEntities] AS [b] +"""); + } + + [ConditionalFact, SqlServerCondition(SqlServerCondition.SupportsFunctions2022)] + public virtual async Task DateTrunc_month() + { + await AssertQueryScalar( + actualQuery: ss => ss.Set().Select(b => EF.Functions.DateTrunc("month", b.DateOnly)), + expectedQuery: ss => ss.Set().Select(b => new DateOnly(b.DateOnly.Year, b.DateOnly.Month, 1))); + + AssertSql( + """ +SELECT DATETRUNC(month, [b].[DateOnly]) +FROM [BasicTypesEntities] AS [b] +"""); + } + [ConditionalFact] public virtual void Check_all_tests_overridden() => TestHelpers.AssertAllMethodsOverridden(GetType()); diff --git a/test/EFCore.SqlServer.FunctionalTests/Query/Translations/Temporal/DateTimeOffsetTranslationsSqlServerTest.cs b/test/EFCore.SqlServer.FunctionalTests/Query/Translations/Temporal/DateTimeOffsetTranslationsSqlServerTest.cs index 17cbcfb86c8..a8aef389f4b 100644 --- a/test/EFCore.SqlServer.FunctionalTests/Query/Translations/Temporal/DateTimeOffsetTranslationsSqlServerTest.cs +++ b/test/EFCore.SqlServer.FunctionalTests/Query/Translations/Temporal/DateTimeOffsetTranslationsSqlServerTest.cs @@ -183,6 +183,43 @@ FROM [BasicTypesEntities] AS [b] """); } + public override async Task DateTime() + { + await base.DateTime(); + + AssertSql( + """ +SELECT [b].[Id], [b].[Bool], [b].[Byte], [b].[ByteArray], [b].[DateOnly], [b].[DateTime], [b].[DateTimeOffset], [b].[Decimal], [b].[Double], [b].[Enum], [b].[FlagsEnum], [b].[Float], [b].[Guid], [b].[Int], [b].[Long], [b].[Short], [b].[String], [b].[TimeOnly], [b].[TimeSpan] +FROM [BasicTypesEntities] AS [b] +WHERE CONVERT(datetime2, [b].[DateTimeOffset]) = '1998-05-04T15:30:10.0000000' +"""); + } + + public override async Task UtcDateTime() + { + await base.UtcDateTime(); + + AssertSql( + """ +SELECT [b].[Id], [b].[Bool], [b].[Byte], [b].[ByteArray], [b].[DateOnly], [b].[DateTime], [b].[DateTimeOffset], [b].[Decimal], [b].[Double], [b].[Enum], [b].[FlagsEnum], [b].[Float], [b].[Guid], [b].[Int], [b].[Long], [b].[Short], [b].[String], [b].[TimeOnly], [b].[TimeSpan] +FROM [BasicTypesEntities] AS [b] +WHERE CONVERT(datetime2, [b].[DateTimeOffset] AT TIME ZONE 'UTC') = '1998-05-04T15:30:10.0000000' +"""); + } + + [SqlServerCondition(SqlServerCondition.SupportsFunctions2022)] + public override async Task LocalDateTime() + { + await base.LocalDateTime(); + + AssertSql( + """ +SELECT [b].[Id], [b].[Bool], [b].[Byte], [b].[ByteArray], [b].[DateOnly], [b].[DateTime], [b].[DateTimeOffset], [b].[Decimal], [b].[Double], [b].[Enum], [b].[FlagsEnum], [b].[Float], [b].[Guid], [b].[Int], [b].[Long], [b].[Short], [b].[String], [b].[TimeOnly], [b].[TimeSpan] +FROM [BasicTypesEntities] AS [b] +WHERE CONVERT(datetime2, [b].[DateTimeOffset] AT TIME ZONE CURRENT_TIMEZONE_ID()) > '1999-01-01T00:00:00.0000000' +"""); + } + public override async Task AddYears() { await base.AddYears(); @@ -288,6 +325,19 @@ WHERE DATEDIFF_BIG(second, '1970-01-01T00:00:00.0000000+00:00', [b].[DateTimeOff """); } + [ConditionalFact] + public virtual async Task Offset_TotalMinutes() + { + await AssertQuery(ss => ss.Set().Where(b => b.DateTimeOffset.Offset.TotalMinutes == 90)); + + AssertSql( + """ +SELECT [b].[Id], [b].[Bool], [b].[Byte], [b].[ByteArray], [b].[DateOnly], [b].[DateTime], [b].[DateTimeOffset], [b].[Decimal], [b].[Double], [b].[Enum], [b].[FlagsEnum], [b].[Float], [b].[Guid], [b].[Int], [b].[Long], [b].[Short], [b].[String], [b].[TimeOnly], [b].[TimeSpan] +FROM [BasicTypesEntities] AS [b] +WHERE CAST(DATEPART(tz, [b].[DateTimeOffset]) AS float) = 90.0E0 +"""); + } + public override async Task Milliseconds_parameter_and_constant() { await base.Milliseconds_parameter_and_constant(); @@ -300,6 +350,78 @@ FROM [BasicTypesEntities] AS [b] """); } + public override async Task ToOffset() + { + await base.ToOffset(); + + AssertSql( + """ +SELECT [b].[Id], [b].[Bool], [b].[Byte], [b].[ByteArray], [b].[DateOnly], [b].[DateTime], [b].[DateTimeOffset], [b].[Decimal], [b].[Double], [b].[Enum], [b].[FlagsEnum], [b].[Float], [b].[Guid], [b].[Int], [b].[Long], [b].[Short], [b].[String], [b].[TimeOnly], [b].[TimeSpan] +FROM [BasicTypesEntities] AS [b] +WHERE SWITCHOFFSET([b].[DateTimeOffset], '+02:00') = '1998-05-04T17:30:10.0000000+02:00' +"""); + } + + [ConditionalFact] + public virtual async Task ToOffset_parameter() + { + var offset = new TimeSpan(2, 0, 0); + + await AssertQueryScalar(ss => ss.Set().Select(b => b.DateTimeOffset.ToOffset(offset))); + + AssertSql( + """ +@offset_offset='+02:00' (Size = 8000) (DbType = AnsiString) + +SELECT SWITCHOFFSET([b].[DateTimeOffset], @offset_offset) +FROM [BasicTypesEntities] AS [b] +"""); + } + + public override async Task Ctor_DateTime() + { + await base.Ctor_DateTime(); + + AssertSql( + """ +SELECT [b].[Id], [b].[Bool], [b].[Byte], [b].[ByteArray], [b].[DateOnly], [b].[DateTime], [b].[DateTimeOffset], [b].[Decimal], [b].[Double], [b].[Enum], [b].[FlagsEnum], [b].[Float], [b].[Guid], [b].[Int], [b].[Long], [b].[Short], [b].[String], [b].[TimeOnly], [b].[TimeSpan] +FROM [BasicTypesEntities] AS [b] +WHERE TODATETIMEOFFSET([b].[DateTime], '+00:00') = '1998-05-04T15:30:10.0000000+00:00' +"""); + } + + public override async Task Ctor_DateTime_TimeSpan() + { + await base.Ctor_DateTime_TimeSpan(); + + AssertSql( + """ +SELECT [b].[Id], [b].[Bool], [b].[Byte], [b].[ByteArray], [b].[DateOnly], [b].[DateTime], [b].[DateTimeOffset], [b].[Decimal], [b].[Double], [b].[Enum], [b].[FlagsEnum], [b].[Float], [b].[Guid], [b].[Int], [b].[Long], [b].[Short], [b].[String], [b].[TimeOnly], [b].[TimeSpan] +FROM [BasicTypesEntities] AS [b] +WHERE DATEPART(year, [b].[DateTime]) > 1 AND TODATETIMEOFFSET([b].[DateTime], '+02:00') = '1998-05-04T15:30:10.0000000+02:00' +"""); + } + + [ConditionalFact] + public virtual async Task Ctor_DateTime_TimeSpan_parameter() + { + var offset = new TimeSpan(2, 0, 0); + + await AssertQuery( + ss => ss.Set() + .Where(b => b.DateTime.Year > 1) + .Where(b => new DateTimeOffset(b.DateTime, offset) == new DateTimeOffset(1998, 5, 4, 15, 30, 10, new TimeSpan(2, 0, 0)))); + + AssertSql( + """ +@offset_offset='+02:00' (Size = 8000) (DbType = AnsiString) + +SELECT [b].[Id], [b].[Bool], [b].[Byte], [b].[ByteArray], [b].[DateOnly], [b].[DateTime], [b].[DateTimeOffset], [b].[Decimal], [b].[Double], [b].[Enum], [b].[FlagsEnum], [b].[Float], [b].[Guid], [b].[Int], [b].[Long], [b].[Short], [b].[String], [b].[TimeOnly], [b].[TimeSpan] +FROM [BasicTypesEntities] AS [b] +WHERE DATEPART(year, [b].[DateTime]) > 1 AND TODATETIMEOFFSET([b].[DateTime], @offset_offset) = '1998-05-04T15:30:10.0000000+02:00' +"""); + } + [ConditionalFact] public virtual async Task Now_has_proper_type_mapping_for_constant_comparison() { @@ -328,6 +450,36 @@ WHERE CAST(SYSUTCDATETIME() AS datetimeoffset) > '2025-01-01T00:00:00.0000000+00 """); } + [ConditionalFact, SqlServerCondition(SqlServerCondition.SupportsFunctions2022)] + public virtual async Task DateTrunc_day() + { + await AssertQueryScalar( + actualQuery: ss => ss.Set().Select(b => EF.Functions.DateTrunc("day", b.DateTimeOffset)), + expectedQuery: ss => ss.Set().Select(b => new DateTimeOffset(b.DateTimeOffset.Date, b.DateTimeOffset.Offset))); + + AssertSql( + """ +SELECT DATETRUNC(day, [b].[DateTimeOffset]) +FROM [BasicTypesEntities] AS [b] +"""); + } + + [ConditionalFact, SqlServerCondition(SqlServerCondition.SupportsFunctions2022)] + public virtual async Task DateTrunc_hour() + { + await AssertQueryScalar( + actualQuery: ss => ss.Set().Select(b => EF.Functions.DateTrunc("hour", b.DateTimeOffset)), + expectedQuery: ss => ss.Set().Select( + b => new DateTimeOffset(b.DateTimeOffset.Year, b.DateTimeOffset.Month, b.DateTimeOffset.Day, + b.DateTimeOffset.Hour, 0, 0, b.DateTimeOffset.Offset))); + + AssertSql( + """ +SELECT DATETRUNC(hour, [b].[DateTimeOffset]) +FROM [BasicTypesEntities] AS [b] +"""); + } + [ConditionalFact] public virtual void Check_all_tests_overridden() => TestHelpers.AssertAllMethodsOverridden(GetType()); diff --git a/test/EFCore.SqlServer.FunctionalTests/Query/Translations/Temporal/DateTimeTranslationsSqlServerTest.cs b/test/EFCore.SqlServer.FunctionalTests/Query/Translations/Temporal/DateTimeTranslationsSqlServerTest.cs index d8d1a3e4433..060f21aa0ff 100644 --- a/test/EFCore.SqlServer.FunctionalTests/Query/Translations/Temporal/DateTimeTranslationsSqlServerTest.cs +++ b/test/EFCore.SqlServer.FunctionalTests/Query/Translations/Temporal/DateTimeTranslationsSqlServerTest.cs @@ -271,6 +271,35 @@ WHERE GETUTCDATE() > '2025-01-01T00:00:00.000' """); } + [ConditionalFact, SqlServerCondition(SqlServerCondition.SupportsFunctions2022)] + public virtual async Task DateTrunc_day() + { + await AssertQueryScalar( + actualQuery: ss => ss.Set().Select(b => EF.Functions.DateTrunc("day", b.DateTime)), + expectedQuery: ss => ss.Set().Select(b => b.DateTime.Date)); + + AssertSql( + """ +SELECT DATETRUNC(day, [b].[DateTime]) +FROM [BasicTypesEntities] AS [b] +"""); + } + + [ConditionalFact, SqlServerCondition(SqlServerCondition.SupportsFunctions2022)] + public virtual async Task DateTrunc_hour() + { + await AssertQueryScalar( + actualQuery: ss => ss.Set().Select(b => EF.Functions.DateTrunc("hour", b.DateTime)), + expectedQuery: ss => ss.Set().Select( + b => new DateTime(b.DateTime.Year, b.DateTime.Month, b.DateTime.Day, b.DateTime.Hour, 0, 0))); + + AssertSql( + """ +SELECT DATETRUNC(hour, [b].[DateTime]) +FROM [BasicTypesEntities] AS [b] +"""); + } + [ConditionalFact] public virtual void Check_all_tests_overridden() => TestHelpers.AssertAllMethodsOverridden(GetType()); diff --git a/test/EFCore.SqlServer.FunctionalTests/Query/Translations/Temporal/TimeOnlyTranslationsSqlServerTest.cs b/test/EFCore.SqlServer.FunctionalTests/Query/Translations/Temporal/TimeOnlyTranslationsSqlServerTest.cs index a3b76ef442e..292be557c2f 100644 --- a/test/EFCore.SqlServer.FunctionalTests/Query/Translations/Temporal/TimeOnlyTranslationsSqlServerTest.cs +++ b/test/EFCore.SqlServer.FunctionalTests/Query/Translations/Temporal/TimeOnlyTranslationsSqlServerTest.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.BasicTypesModel; + namespace Microsoft.EntityFrameworkCore.Query.Translations.Temporal; public class TimeOnlyTranslationsSqlServerTest : TimeOnlyTranslationsTestBase @@ -216,6 +218,34 @@ ORDER BY CAST([b].[TimeSpan] AS time) """); } + [ConditionalFact, SqlServerCondition(SqlServerCondition.SupportsFunctions2022)] + public virtual async Task DateTrunc_hour() + { + await AssertQueryScalar( + actualQuery: ss => ss.Set().Select(b => EF.Functions.DateTrunc("hour", b.TimeOnly)), + expectedQuery: ss => ss.Set().Select(b => new TimeOnly(b.TimeOnly.Hour, 0))); + + AssertSql( + """ +SELECT DATETRUNC(hour, [b].[TimeOnly]) +FROM [BasicTypesEntities] AS [b] +"""); + } + + [ConditionalFact, SqlServerCondition(SqlServerCondition.SupportsFunctions2022)] + public virtual async Task DateTrunc_minute() + { + await AssertQueryScalar( + actualQuery: ss => ss.Set().Select(b => EF.Functions.DateTrunc("minute", b.TimeOnly)), + expectedQuery: ss => ss.Set().Select(b => new TimeOnly(b.TimeOnly.Hour, b.TimeOnly.Minute))); + + AssertSql( + """ +SELECT DATETRUNC(minute, [b].[TimeOnly]) +FROM [BasicTypesEntities] AS [b] +"""); + } + [ConditionalFact] public virtual void Check_all_tests_overridden() => TestHelpers.AssertAllMethodsOverridden(GetType()); diff --git a/test/EFCore.Sqlite.FunctionalTests/Query/Translations/Temporal/DateTimeOffsetTranslationsSqliteTest.cs b/test/EFCore.Sqlite.FunctionalTests/Query/Translations/Temporal/DateTimeOffsetTranslationsSqliteTest.cs index f3393859de8..34e58d45d3d 100644 --- a/test/EFCore.Sqlite.FunctionalTests/Query/Translations/Temporal/DateTimeOffsetTranslationsSqliteTest.cs +++ b/test/EFCore.Sqlite.FunctionalTests/Query/Translations/Temporal/DateTimeOffsetTranslationsSqliteTest.cs @@ -114,6 +114,27 @@ public override async Task TimeOfDay() """); } + public override async Task DateTime() + { + await AssertTranslationFailed(() => base.DateTime()); + + AssertSql(); + } + + public override async Task UtcDateTime() + { + await AssertTranslationFailed(() => base.UtcDateTime()); + + AssertSql(); + } + + public override async Task LocalDateTime() + { + await AssertTranslationFailed(() => base.LocalDateTime()); + + AssertSql(); + } + public override async Task AddYears() { await base.AddYears(); @@ -197,6 +218,15 @@ public override Task ToUnixTimeMilliseconds() public override Task ToUnixTimeSecond() => AssertTranslationFailed(() => base.ToUnixTimeSecond()); + public override Task ToOffset() + => AssertTranslationFailed(() => base.ToOffset()); + + public override Task Ctor_DateTime() + => AssertTranslationFailed(() => base.Ctor_DateTime()); + + public override Task Ctor_DateTime_TimeSpan() + => AssertTranslationFailed(() => base.Ctor_DateTime_TimeSpan()); + public override async Task Milliseconds_parameter_and_constant() { await base.Milliseconds_parameter_and_constant();