From 929c189f0b9ee3cd316a806069ff2e18ac3baf11 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C4=B0lker=20amca?= Date: Mon, 3 Aug 2026 13:46:53 +0300 Subject: [PATCH 1/3] Handle ranges spanning midnight when translating TimeOnly.IsBetween - The translation always emitted "time >= start AND time < end", but TimeOnly.IsBetween treats a range whose start is after its end as wrapping around midnight and then matches times that are either after the start or before the end, so queries such as IsBetween(23:00, 01:00) matched no rows at all - Emit the OR form for constant bounds that wrap, and decide in SQL when the bounds are not constants - Add tests for wrapping and non-wrapping bounds, as constants and as parameters --- .../SqlServerTimeOnlyMethodTranslator.cs | 30 +++++++-- .../TimeOnlyTranslationsSqlServerTest.cs | 66 ++++++++++++++++++- 2 files changed, 86 insertions(+), 10 deletions(-) diff --git a/src/EFCore.SqlServer/Query/Internal/Translators/SqlServerTimeOnlyMethodTranslator.cs b/src/EFCore.SqlServer/Query/Internal/Translators/SqlServerTimeOnlyMethodTranslator.cs index 9e45428e27f..1866b41852a 100644 --- a/src/EFCore.SqlServer/Query/Internal/Translators/SqlServerTimeOnlyMethodTranslator.cs +++ b/src/EFCore.SqlServer/Query/Internal/Translators/SqlServerTimeOnlyMethodTranslator.cs @@ -76,13 +76,29 @@ public class SqlServerTimeOnlyMethodTranslator(ISqlExpressionFactory sqlExpressi var typeMapping = ExpressionExtensions.InferTypeMapping(instance, arguments[0], arguments[1]); instance = sqlExpressionFactory.ApplyTypeMapping(instance, typeMapping); - return sqlExpressionFactory.And( - sqlExpressionFactory.GreaterThanOrEqual( - instance, - sqlExpressionFactory.ApplyTypeMapping(arguments[0], typeMapping)), - sqlExpressionFactory.LessThan( - instance, - sqlExpressionFactory.ApplyTypeMapping(arguments[1], typeMapping))); + var start = sqlExpressionFactory.ApplyTypeMapping(arguments[0], typeMapping); + var end = sqlExpressionFactory.ApplyTypeMapping(arguments[1], typeMapping); + + var isAfterStart = sqlExpressionFactory.GreaterThanOrEqual(instance, start); + var isBeforeEnd = sqlExpressionFactory.LessThan(instance, end); + + // A range whose start is after its end wraps around midnight, and matches the times that are either after + // the start or before the end. + if (arguments is [SqlConstantExpression { Value: TimeOnly startValue }, SqlConstantExpression { Value: TimeOnly endValue }]) + { + return startValue > endValue + ? sqlExpressionFactory.OrElse(isAfterStart, isBeforeEnd) + : sqlExpressionFactory.AndAlso(isAfterStart, isBeforeEnd); + } + + // The bounds aren't known when translating, so both cases have to be handled in the SQL. + return sqlExpressionFactory.Case( + [ + new CaseWhenClause( + sqlExpressionFactory.LessThanOrEqual(start, end), + sqlExpressionFactory.AndAlso(isAfterStart, isBeforeEnd)) + ], + sqlExpressionFactory.OrElse(isAfterStart, isBeforeEnd)); } return null; diff --git a/test/EFCore.SqlServer.FunctionalTests/Query/Translations/Temporal/TimeOnlyTranslationsSqlServerTest.cs b/test/EFCore.SqlServer.FunctionalTests/Query/Translations/Temporal/TimeOnlyTranslationsSqlServerTest.cs index d34bf24a762..4f698bd3a12 100644 --- a/test/EFCore.SqlServer.FunctionalTests/Query/Translations/Temporal/TimeOnlyTranslationsSqlServerTest.cs +++ b/test/EFCore.SqlServer.FunctionalTests/Query/Translations/Temporal/TimeOnlyTranslationsSqlServerTest.cs @@ -123,18 +123,78 @@ public override async Task IsBetween() 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 [b].[TimeOnly] >= '14:00:00' AND [b].[TimeOnly] < '16:00:00' +"""); + } + + [Fact] + public virtual async Task IsBetween_spanning_midnight_with_parameters() + { + var start = new TimeOnly(15, 0, 0); + var end = new TimeOnly(14, 0, 0); + + await AssertQuery(ss => ss.Set().Where(b => b.TimeOnly.IsBetween(start, end))); + + AssertSql( + """ +@start='15:00' (DbType = Time) +@end='14:00' (DbType = Time) + 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 CASE - WHEN [b].[TimeOnly] >= '14:00:00' THEN CAST(1 AS bit) + WHEN @start <= @end THEN CASE + WHEN [b].[TimeOnly] >= @start AND [b].[TimeOnly] < @end THEN CAST(1 AS bit) + ELSE CAST(0 AS bit) + END + WHEN [b].[TimeOnly] >= @start OR [b].[TimeOnly] < @end THEN CAST(1 AS bit) ELSE CAST(0 AS bit) -END & CASE - WHEN [b].[TimeOnly] < '16:00:00' THEN CAST(1 AS bit) +END = CAST(1 AS bit) +"""); + } + + [Fact] + public virtual async Task IsBetween_with_parameters() + { + var start = new TimeOnly(14, 0, 0); + var end = new TimeOnly(16, 0, 0); + + await AssertQuery(ss => ss.Set().Where(b => b.TimeOnly.IsBetween(start, end))); + + AssertSql( + """ +@start='14:00' (DbType = Time) +@end='16:00' (DbType = Time) + +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 CASE + WHEN @start <= @end THEN CASE + WHEN [b].[TimeOnly] >= @start AND [b].[TimeOnly] < @end THEN CAST(1 AS bit) + ELSE CAST(0 AS bit) + END + WHEN [b].[TimeOnly] >= @start OR [b].[TimeOnly] < @end THEN CAST(1 AS bit) ELSE CAST(0 AS bit) END = CAST(1 AS bit) """); } + [Fact] + public virtual async Task IsBetween_spanning_midnight() + { + await AssertQuery( + ss => ss.Set().Where(b => b.TimeOnly.IsBetween(new TimeOnly(15, 0, 0), new TimeOnly(14, 0, 0)))); + + 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 [b].[TimeOnly] >= '15:00:00' OR [b].[TimeOnly] < '14:00:00' +"""); + } + public override async Task Subtract() { await AssertTranslationFailed(() => base.Subtract()); From bd21170b3c3d5df33c2a96e71e0b7663049d261c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C4=B0lker=20amca?= Date: Tue, 4 Aug 2026 00:50:19 +0300 Subject: [PATCH 2/3] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../SqlServerTimeOnlyMethodTranslator.cs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/EFCore.SqlServer/Query/Internal/Translators/SqlServerTimeOnlyMethodTranslator.cs b/src/EFCore.SqlServer/Query/Internal/Translators/SqlServerTimeOnlyMethodTranslator.cs index 1866b41852a..08f3a172360 100644 --- a/src/EFCore.SqlServer/Query/Internal/Translators/SqlServerTimeOnlyMethodTranslator.cs +++ b/src/EFCore.SqlServer/Query/Internal/Translators/SqlServerTimeOnlyMethodTranslator.cs @@ -92,13 +92,13 @@ public class SqlServerTimeOnlyMethodTranslator(ISqlExpressionFactory sqlExpressi } // The bounds aren't known when translating, so both cases have to be handled in the SQL. - return sqlExpressionFactory.Case( - [ - new CaseWhenClause( - sqlExpressionFactory.LessThanOrEqual(start, end), - sqlExpressionFactory.AndAlso(isAfterStart, isBeforeEnd)) - ], - sqlExpressionFactory.OrElse(isAfterStart, isBeforeEnd)); + return sqlExpressionFactory.OrElse( + sqlExpressionFactory.AndAlso( + sqlExpressionFactory.LessThanOrEqual(start, end), + sqlExpressionFactory.AndAlso(isAfterStart, isBeforeEnd)), + sqlExpressionFactory.AndAlso( + sqlExpressionFactory.GreaterThan(start, end), + sqlExpressionFactory.OrElse(isAfterStart, isBeforeEnd))); } return null; From 197c8d18cc295386946e16c90d0e3f19234d2101 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C4=B0lker=20amca?= Date: Tue, 18 Aug 2026 22:54:21 +0300 Subject: [PATCH 3/3] Use a discriminating range in the IsBetween tests spanning midnight - The seed data only contains the TimeOnly values 00:00:00, 00:00:00.0102004, 15:30:10 and 15:30:10.123456, so IsBetween(15:00, 14:00) matched every row and the tests would have passed even if the predicate matched everything - Use 16:00 to 12:00 instead, which returns the midnight rows and excludes the 15:30 ones, so both sides of the wrap are exercised - Update the expected SQL for the non-constant bounds --- .../TimeOnlyTranslationsSqlServerTest.cs | 30 +++++-------------- 1 file changed, 8 insertions(+), 22 deletions(-) diff --git a/test/EFCore.SqlServer.FunctionalTests/Query/Translations/Temporal/TimeOnlyTranslationsSqlServerTest.cs b/test/EFCore.SqlServer.FunctionalTests/Query/Translations/Temporal/TimeOnlyTranslationsSqlServerTest.cs index 4f698bd3a12..9d2b5359568 100644 --- a/test/EFCore.SqlServer.FunctionalTests/Query/Translations/Temporal/TimeOnlyTranslationsSqlServerTest.cs +++ b/test/EFCore.SqlServer.FunctionalTests/Query/Translations/Temporal/TimeOnlyTranslationsSqlServerTest.cs @@ -132,26 +132,19 @@ FROM [BasicTypesEntities] AS [b] [Fact] public virtual async Task IsBetween_spanning_midnight_with_parameters() { - var start = new TimeOnly(15, 0, 0); - var end = new TimeOnly(14, 0, 0); + var start = new TimeOnly(16, 0, 0); + var end = new TimeOnly(12, 0, 0); await AssertQuery(ss => ss.Set().Where(b => b.TimeOnly.IsBetween(start, end))); AssertSql( """ -@start='15:00' (DbType = Time) -@end='14:00' (DbType = Time) +@start='16:00' (DbType = Time) +@end='12:00' (DbType = Time) 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 CASE - WHEN @start <= @end THEN CASE - WHEN [b].[TimeOnly] >= @start AND [b].[TimeOnly] < @end THEN CAST(1 AS bit) - ELSE CAST(0 AS bit) - END - WHEN [b].[TimeOnly] >= @start OR [b].[TimeOnly] < @end THEN CAST(1 AS bit) - ELSE CAST(0 AS bit) -END = CAST(1 AS bit) +WHERE (@start <= @end AND [b].[TimeOnly] >= @start AND [b].[TimeOnly] < @end) OR (@start > @end AND ([b].[TimeOnly] >= @start OR [b].[TimeOnly] < @end)) """); } @@ -170,14 +163,7 @@ public virtual async Task IsBetween_with_parameters() 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 CASE - WHEN @start <= @end THEN CASE - WHEN [b].[TimeOnly] >= @start AND [b].[TimeOnly] < @end THEN CAST(1 AS bit) - ELSE CAST(0 AS bit) - END - WHEN [b].[TimeOnly] >= @start OR [b].[TimeOnly] < @end THEN CAST(1 AS bit) - ELSE CAST(0 AS bit) -END = CAST(1 AS bit) +WHERE (@start <= @end AND [b].[TimeOnly] >= @start AND [b].[TimeOnly] < @end) OR (@start > @end AND ([b].[TimeOnly] >= @start OR [b].[TimeOnly] < @end)) """); } @@ -185,13 +171,13 @@ ELSE CAST(0 AS bit) public virtual async Task IsBetween_spanning_midnight() { await AssertQuery( - ss => ss.Set().Where(b => b.TimeOnly.IsBetween(new TimeOnly(15, 0, 0), new TimeOnly(14, 0, 0)))); + ss => ss.Set().Where(b => b.TimeOnly.IsBetween(new TimeOnly(16, 0, 0), new TimeOnly(12, 0, 0)))); 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 [b].[TimeOnly] >= '15:00:00' OR [b].[TimeOnly] < '14:00:00' +WHERE [b].[TimeOnly] >= '16:00:00' OR [b].[TimeOnly] < '12:00:00' """); }