From 8af512d36d765548b72a323e8178311d9d63a743 Mon Sep 17 00:00:00 2001 From: Vadym Buhaiov Date: Wed, 25 Feb 2026 01:52:26 -0500 Subject: [PATCH 1/2] Implement SQLite TimeSpan translation (fixes #18844) --- .../SqliteMemberTranslatorProvider.cs | 3 +- .../SqliteMethodCallTranslatorProvider.cs | 3 +- .../Internal/SqliteSqlExpressionFactory.cs | 29 ++++ .../SqliteSqlTranslatingExpressionVisitor.cs | 162 ++++++++++++++++-- ...qliteQueryableAggregateMethodTranslator.cs | 30 +++- .../SqliteTimeSpanMemberTranslator.cs | 95 ++++++++++ .../SqliteTimeSpanMethodTranslator.cs | 111 ++++++++++++ .../Internal/SqliteRelationalConnection.cs | 25 +++ .../DateTimeTranslationsCosmosTest.cs | 4 +- .../Temporal/DateTimeTranslationsTestBase.cs | 2 +- .../DateTimeTranslationsSqlServerTest.cs | 4 +- .../DateTimeTranslationsSqliteTest.cs | 15 +- .../TimeSpanTranslationsSqliteTest.cs | 71 +++++--- 13 files changed, 509 insertions(+), 45 deletions(-) create mode 100644 src/EFCore.Sqlite.Core/Query/Internal/Translators/SqliteTimeSpanMemberTranslator.cs create mode 100644 src/EFCore.Sqlite.Core/Query/Internal/Translators/SqliteTimeSpanMethodTranslator.cs diff --git a/src/EFCore.Sqlite.Core/Query/Internal/SqliteMemberTranslatorProvider.cs b/src/EFCore.Sqlite.Core/Query/Internal/SqliteMemberTranslatorProvider.cs index 25adce48e5f..3c1825dabfc 100644 --- a/src/EFCore.Sqlite.Core/Query/Internal/SqliteMemberTranslatorProvider.cs +++ b/src/EFCore.Sqlite.Core/Query/Internal/SqliteMemberTranslatorProvider.cs @@ -26,7 +26,8 @@ public SqliteMemberTranslatorProvider(RelationalMemberTranslatorProviderDependen [ new SqliteDateTimeMemberTranslator(sqlExpressionFactory), new SqliteStringLengthTranslator(sqlExpressionFactory), - new SqliteDateOnlyMemberTranslator(sqlExpressionFactory) + new SqliteDateOnlyMemberTranslator(sqlExpressionFactory), + new SqliteTimeSpanMemberTranslator(sqlExpressionFactory) ]); } } diff --git a/src/EFCore.Sqlite.Core/Query/Internal/SqliteMethodCallTranslatorProvider.cs b/src/EFCore.Sqlite.Core/Query/Internal/SqliteMethodCallTranslatorProvider.cs index 970873af563..1f07a8cc4a0 100644 --- a/src/EFCore.Sqlite.Core/Query/Internal/SqliteMethodCallTranslatorProvider.cs +++ b/src/EFCore.Sqlite.Core/Query/Internal/SqliteMethodCallTranslatorProvider.cs @@ -35,7 +35,8 @@ public SqliteMethodCallTranslatorProvider(RelationalMethodCallTranslatorProvider new SqliteRandomTranslator(sqlExpressionFactory), new SqliteRegexMethodTranslator(sqlExpressionFactory), new SqliteStringMethodTranslator(sqlExpressionFactory), - new SqliteSubstrMethodTranslator(sqlExpressionFactory) + new SqliteSubstrMethodTranslator(sqlExpressionFactory), + new SqliteTimeSpanMethodTranslator(sqlExpressionFactory) ]); } } diff --git a/src/EFCore.Sqlite.Core/Query/Internal/SqliteSqlExpressionFactory.cs b/src/EFCore.Sqlite.Core/Query/Internal/SqliteSqlExpressionFactory.cs index 91ddd2b8793..271d7c0e622 100644 --- a/src/EFCore.Sqlite.Core/Query/Internal/SqliteSqlExpressionFactory.cs +++ b/src/EFCore.Sqlite.Core/Query/Internal/SqliteSqlExpressionFactory.cs @@ -105,6 +105,35 @@ public virtual SqlExpression Date( typeMapping); } + /// + /// 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. + /// + public virtual SqlExpression EfDays(SqlExpression timeSpanExpression) + => Function( + "ef_days", + [timeSpanExpression], + nullable: true, + argumentsPropagateNullability: [true], + typeof(double)); + + /// + /// 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. + /// + public virtual SqlExpression EfTimespan(SqlExpression daysExpression) + => Function( + "ef_timespan", + [daysExpression], + nullable: true, + argumentsPropagateNullability: [true], + typeof(TimeSpan), + Dependencies.TypeMappingSource.FindMapping(typeof(TimeSpan), Dependencies.Model)); + /// /// 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.Sqlite.Core/Query/Internal/SqliteSqlTranslatingExpressionVisitor.cs b/src/EFCore.Sqlite.Core/Query/Internal/SqliteSqlTranslatingExpressionVisitor.cs index c0d7e051604..e2dc9a07c75 100644 --- a/src/EFCore.Sqlite.Core/Query/Internal/SqliteSqlTranslatingExpressionVisitor.cs +++ b/src/EFCore.Sqlite.Core/Query/Internal/SqliteSqlTranslatingExpressionVisitor.cs @@ -49,44 +49,37 @@ private static readonly IReadOnlyDictionary { typeof(TimeOnly), - typeof(TimeSpan), typeof(ulong) }, [ExpressionType.GreaterThan] = new HashSet { typeof(DateTimeOffset), - typeof(TimeSpan), typeof(ulong) }, [ExpressionType.GreaterThanOrEqual] = new HashSet { typeof(DateTimeOffset), - typeof(TimeSpan), typeof(ulong) }, [ExpressionType.LessThan] = new HashSet { typeof(DateTimeOffset), - typeof(TimeSpan), typeof(ulong) }, [ExpressionType.LessThanOrEqual] = new HashSet { typeof(DateTimeOffset), - typeof(TimeSpan), typeof(ulong) }, [ExpressionType.Modulo] = new HashSet { typeof(ulong) }, [ExpressionType.Multiply] = new HashSet { typeof(TimeOnly), - typeof(TimeSpan), typeof(ulong) }, [ExpressionType.Subtract] = new HashSet @@ -94,8 +87,7 @@ private static readonly IReadOnlyDictionary QueryCompilationContext.NotTranslatedExpression, + var t when t == typeof(TimeSpan) + && _sqlExpressionFactory is SqliteSqlExpressionFactory sqliteFactory + => sqliteFactory.EfTimespan(Dependencies.SqlExpressionFactory.Negate(sqliteFactory.EfDays(sqlUnary.Operand))), + _ => translation }; } @@ -191,6 +187,11 @@ when ModuloFunctions.TryGetValue(GetProviderType(sqlBinary.Left), out var functi case { } when AttemptDecimalArithmetic(sqlBinary): return DoDecimalArithmetics(translation, sqlBinary.OperatorType, sqlBinary.Left, sqlBinary.Right); + case { } when _sqlExpressionFactory is SqliteSqlExpressionFactory sqliteFactory + && TryTranslateTimeSpanDateTimeBinary(sqlBinary, sqliteFactory, out var timeSpanDateTimeResult) + && timeSpanDateTimeResult != null: + return timeSpanDateTimeResult; + case { } when RestrictedBinaryExpressions.TryGetValue(sqlBinary.OperatorType, out var restrictedTypes) && (restrictedTypes.Contains(GetProviderType(sqlBinary.Left)) @@ -204,6 +205,145 @@ when RestrictedBinaryExpressions.TryGetValue(sqlBinary.OperatorType, out var res return translation; } + private static bool TryTranslateTimeSpanDateTimeBinary( + SqlBinaryExpression sqlBinary, + SqliteSqlExpressionFactory sqliteFactory, + out SqlExpression? result) + { + result = null; + var leftType = GetProviderType(sqlBinary.Left); + var rightType = GetProviderType(sqlBinary.Right); + var operatorType = sqlBinary.OperatorType; + + if (leftType == typeof(TimeSpan) && rightType == typeof(TimeSpan)) + { + var leftDays = sqliteFactory.EfDays(sqlBinary.Left); + var rightDays = sqliteFactory.EfDays(sqlBinary.Right); + switch (operatorType) + { + case ExpressionType.Add: + result = sqliteFactory.EfTimespan( + sqliteFactory.Add(leftDays, rightDays)); + return true; + case ExpressionType.Subtract: + result = sqliteFactory.EfTimespan( + sqliteFactory.Subtract(leftDays, rightDays)); + return true; + case ExpressionType.Divide: + result = sqliteFactory.Divide(leftDays, rightDays); + return true; + case ExpressionType.GreaterThan: + result = sqliteFactory.GreaterThan(leftDays, rightDays); + return true; + case ExpressionType.GreaterThanOrEqual: + result = sqliteFactory.GreaterThanOrEqual(leftDays, rightDays); + return true; + case ExpressionType.LessThan: + result = sqliteFactory.LessThan(leftDays, rightDays); + return true; + case ExpressionType.LessThanOrEqual: + result = sqliteFactory.LessThanOrEqual(leftDays, rightDays); + return true; + } + } + + if (operatorType == ExpressionType.Divide && leftType == typeof(TimeSpan) && (rightType == typeof(double) || rightType == typeof(float))) + { + result = sqliteFactory.EfTimespan( + sqliteFactory.Divide(sqliteFactory.EfDays(sqlBinary.Left), sqlBinary.Right)); + return true; + } + + if (operatorType == ExpressionType.Multiply) + { + if ((leftType == typeof(double) || leftType == typeof(float)) && rightType == typeof(TimeSpan)) + { + result = sqliteFactory.EfTimespan( + sqliteFactory.Multiply(sqlBinary.Left, sqliteFactory.EfDays(sqlBinary.Right))); + return true; + } + if (leftType == typeof(TimeSpan) && (rightType == typeof(double) || rightType == typeof(float))) + { + result = sqliteFactory.EfTimespan( + sqliteFactory.Multiply(sqliteFactory.EfDays(sqlBinary.Left), sqlBinary.Right)); + return true; + } + } + + if (leftType == typeof(DateTime) && rightType == typeof(TimeSpan)) + { + if (operatorType == ExpressionType.Add) + { + var juliandayLeft = sqliteFactory.Function( + "julianday", + [sqlBinary.Left], + nullable: true, + argumentsPropagateNullability: Statics.TrueArrays[1], + typeof(double)); + var sumDays = sqliteFactory.Add(juliandayLeft, sqliteFactory.EfDays(sqlBinary.Right)); + result = MakeDateTimeFromJulianDay(sqliteFactory, sumDays); + return true; + } + if (operatorType == ExpressionType.Subtract) + { + var juliandayLeft = sqliteFactory.Function( + "julianday", + [sqlBinary.Left], + nullable: true, + argumentsPropagateNullability: Statics.TrueArrays[1], + typeof(double)); + var diffDays = sqliteFactory.Subtract(juliandayLeft, sqliteFactory.EfDays(sqlBinary.Right)); + result = MakeDateTimeFromJulianDay(sqliteFactory, diffDays); + return true; + } + } + + if (leftType == typeof(DateTime) && rightType == typeof(DateTime) && operatorType == ExpressionType.Subtract) + { + var juliandayLeft = sqliteFactory.Function( + "julianday", + [sqlBinary.Left], + nullable: true, + argumentsPropagateNullability: Statics.TrueArrays[1], + typeof(double)); + var juliandayRight = sqliteFactory.Function( + "julianday", + [sqlBinary.Right], + nullable: true, + argumentsPropagateNullability: Statics.TrueArrays[1], + typeof(double)); + result = sqliteFactory.EfTimespan(sqliteFactory.Subtract(juliandayLeft, juliandayRight)); + return true; + } + + return false; + } + + private static SqlExpression MakeDateTimeFromJulianDay(SqliteSqlExpressionFactory sqliteFactory, SqlExpression julianDayExpression) + { + var strftimeResult = sqliteFactory.Strftime( + typeof(DateTime), + "%Y-%m-%d %H:%M:%f", + julianDayExpression); + return sqliteFactory.Function( + "rtrim", + [ + sqliteFactory.Function( + "rtrim", + [ + strftimeResult, + sqliteFactory.Constant("0") + ], + nullable: true, + argumentsPropagateNullability: Statics.TrueFalse, + typeof(DateTime)), + sqliteFactory.Constant(".") + ], + nullable: true, + argumentsPropagateNullability: Statics.TrueFalse, + typeof(DateTime)); + } + /// protected override Expression VisitMethodCall(MethodCallExpression methodCallExpression) { diff --git a/src/EFCore.Sqlite.Core/Query/Internal/Translators/SqliteQueryableAggregateMethodTranslator.cs b/src/EFCore.Sqlite.Core/Query/Internal/Translators/SqliteQueryableAggregateMethodTranslator.cs index af1e816e87c..03a17a2d9a0 100644 --- a/src/EFCore.Sqlite.Core/Query/Internal/Translators/SqliteQueryableAggregateMethodTranslator.cs +++ b/src/EFCore.Sqlite.Core/Query/Internal/Translators/SqliteQueryableAggregateMethodTranslator.cs @@ -59,13 +59,26 @@ public class SqliteQueryableAggregateMethodTranslator(ISqlExpressionFactory sqlE && source.Selector is SqlExpression maxSqlExpression: var maxArgumentType = GetProviderType(maxSqlExpression); if (maxArgumentType == typeof(DateTimeOffset) - || maxArgumentType == typeof(TimeSpan) || maxArgumentType == typeof(ulong)) { throw new NotSupportedException( SqliteStrings.AggregateOperationNotSupported(nameof(Queryable.Max), maxArgumentType.ShortDisplayName())); } + if (maxArgumentType == typeof(TimeSpan) + && _sqlExpressionFactory is SqliteSqlExpressionFactory maxSqliteFactory) + { + maxSqlExpression = CombineTerms(source, maxSqlExpression); + var maxDaysExpression = maxSqliteFactory.EfDays(maxSqlExpression); + var maxAggregate = _sqlExpressionFactory.Function( + "max", + [maxDaysExpression], + nullable: true, + argumentsPropagateNullability: Statics.FalseArrays[1], + typeof(double)); + return maxSqliteFactory.EfTimespan(maxAggregate); + } + if (maxArgumentType == typeof(decimal)) { maxSqlExpression = CombineTerms(source, maxSqlExpression); @@ -86,13 +99,26 @@ public class SqliteQueryableAggregateMethodTranslator(ISqlExpressionFactory sqlE && source.Selector is SqlExpression minSqlExpression: var minArgumentType = GetProviderType(minSqlExpression); if (minArgumentType == typeof(DateTimeOffset) - || minArgumentType == typeof(TimeSpan) || minArgumentType == typeof(ulong)) { throw new NotSupportedException( SqliteStrings.AggregateOperationNotSupported(nameof(Queryable.Min), minArgumentType.ShortDisplayName())); } + if (minArgumentType == typeof(TimeSpan) + && _sqlExpressionFactory is SqliteSqlExpressionFactory minSqliteFactory) + { + minSqlExpression = CombineTerms(source, minSqlExpression); + var minDaysExpression = minSqliteFactory.EfDays(minSqlExpression); + var minAggregate = _sqlExpressionFactory.Function( + "min", + [minDaysExpression], + nullable: true, + argumentsPropagateNullability: Statics.FalseArrays[1], + typeof(double)); + return minSqliteFactory.EfTimespan(minAggregate); + } + if (minArgumentType == typeof(decimal)) { minSqlExpression = CombineTerms(source, minSqlExpression); diff --git a/src/EFCore.Sqlite.Core/Query/Internal/Translators/SqliteTimeSpanMemberTranslator.cs b/src/EFCore.Sqlite.Core/Query/Internal/Translators/SqliteTimeSpanMemberTranslator.cs new file mode 100644 index 00000000000..0ac60e7b904 --- /dev/null +++ b/src/EFCore.Sqlite.Core/Query/Internal/Translators/SqliteTimeSpanMemberTranslator.cs @@ -0,0 +1,95 @@ +// 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.Query.SqlExpressions; + +// ReSharper disable once CheckNamespace +namespace Microsoft.EntityFrameworkCore.Sqlite.Query.Internal; + +/// +/// 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. +/// +public class SqliteTimeSpanMemberTranslator(SqliteSqlExpressionFactory sqlExpressionFactory) : IMemberTranslator +{ + /// + /// 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. + /// + public virtual SqlExpression? Translate( + SqlExpression? instance, + MemberInfo member, + Type returnType, + IDiagnosticsLogger logger) + { + if (member.DeclaringType != typeof(TimeSpan) || instance is null) + { + return null; + } + + var daysExpression = sqlExpressionFactory.EfDays(instance); + + return member.Name switch + { + nameof(TimeSpan.Days) + => sqlExpressionFactory.Convert(daysExpression, typeof(int)), + nameof(TimeSpan.Hours) + => sqlExpressionFactory.Convert( + sqlExpressionFactory.Modulo( + sqlExpressionFactory.Multiply(daysExpression, sqlExpressionFactory.Constant(24.0)), + sqlExpressionFactory.Constant(24.0)), + returnType), + nameof(TimeSpan.Minutes) + => sqlExpressionFactory.Convert( + sqlExpressionFactory.Modulo( + sqlExpressionFactory.Multiply(daysExpression, sqlExpressionFactory.Constant(1440.0)), + sqlExpressionFactory.Constant(60.0)), + returnType), + nameof(TimeSpan.Seconds) + => sqlExpressionFactory.Convert( + sqlExpressionFactory.Modulo( + sqlExpressionFactory.Multiply(daysExpression, sqlExpressionFactory.Constant(86400.0)), + sqlExpressionFactory.Constant(60.0)), + returnType), + nameof(TimeSpan.Milliseconds) + => sqlExpressionFactory.Convert( + sqlExpressionFactory.Modulo( + sqlExpressionFactory.Multiply(daysExpression, sqlExpressionFactory.Constant(86400000.0)), + sqlExpressionFactory.Constant(1000.0)), + returnType), + nameof(TimeSpan.Microseconds) + => sqlExpressionFactory.Convert( + sqlExpressionFactory.Modulo( + sqlExpressionFactory.Multiply(daysExpression, sqlExpressionFactory.Constant(86400000000.0)), + sqlExpressionFactory.Constant(1000.0)), + returnType), + nameof(TimeSpan.Nanoseconds) + => sqlExpressionFactory.Convert( + sqlExpressionFactory.Modulo( + sqlExpressionFactory.Multiply(daysExpression, sqlExpressionFactory.Constant(86400000000000.0)), + sqlExpressionFactory.Constant(1000.0)), + returnType), + nameof(TimeSpan.Ticks) + => sqlExpressionFactory.Convert( + sqlExpressionFactory.Multiply( + daysExpression, + sqlExpressionFactory.Constant((double)TimeSpan.TicksPerDay)), + typeof(long)), + nameof(TimeSpan.TotalDays) + => daysExpression, + nameof(TimeSpan.TotalHours) + => sqlExpressionFactory.Multiply(daysExpression, sqlExpressionFactory.Constant(24.0)), + nameof(TimeSpan.TotalMinutes) + => sqlExpressionFactory.Multiply(daysExpression, sqlExpressionFactory.Constant(1440.0)), + nameof(TimeSpan.TotalSeconds) + => sqlExpressionFactory.Multiply(daysExpression, sqlExpressionFactory.Constant(86400.0)), + nameof(TimeSpan.TotalMilliseconds) + => sqlExpressionFactory.Multiply(daysExpression, sqlExpressionFactory.Constant(86400000.0)), + _ => null + }; + } +} diff --git a/src/EFCore.Sqlite.Core/Query/Internal/Translators/SqliteTimeSpanMethodTranslator.cs b/src/EFCore.Sqlite.Core/Query/Internal/Translators/SqliteTimeSpanMethodTranslator.cs new file mode 100644 index 00000000000..17b086325f2 --- /dev/null +++ b/src/EFCore.Sqlite.Core/Query/Internal/Translators/SqliteTimeSpanMethodTranslator.cs @@ -0,0 +1,111 @@ +// 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.Query.SqlExpressions; + +// ReSharper disable once CheckNamespace +namespace Microsoft.EntityFrameworkCore.Sqlite.Query.Internal; + +/// +/// 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. +/// +public class SqliteTimeSpanMethodTranslator(SqliteSqlExpressionFactory sqlExpressionFactory) : IMethodCallTranslator +{ + private static readonly MethodInfo DurationMethod + = typeof(TimeSpan).GetRuntimeMethod(nameof(TimeSpan.Duration), Type.EmptyTypes)!; + + private static readonly MethodInfo FromDaysMethod + = typeof(TimeSpan).GetRuntimeMethod(nameof(TimeSpan.FromDays), [typeof(double)])!; + + private static readonly MethodInfo FromHoursMethod + = typeof(TimeSpan).GetRuntimeMethod(nameof(TimeSpan.FromHours), [typeof(double)])!; + + private static readonly MethodInfo FromMinutesMethod + = typeof(TimeSpan).GetRuntimeMethod(nameof(TimeSpan.FromMinutes), [typeof(double)])!; + + private static readonly MethodInfo FromSecondsMethod + = typeof(TimeSpan).GetRuntimeMethod(nameof(TimeSpan.FromSeconds), [typeof(double)])!; + + private static readonly MethodInfo FromMillisecondsMethod + = typeof(TimeSpan).GetRuntimeMethod(nameof(TimeSpan.FromMilliseconds), [typeof(double)])!; + + private static readonly MethodInfo FromTicksMethod + = typeof(TimeSpan).GetRuntimeMethod(nameof(TimeSpan.FromTicks), [typeof(long)])!; + + /// + /// 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. + /// + public virtual SqlExpression? Translate( + SqlExpression? instance, + MethodInfo method, + IReadOnlyList arguments, + IDiagnosticsLogger logger) + { + if (method.DeclaringType != typeof(TimeSpan)) + { + return null; + } + + if (DurationMethod.Equals(method) && instance != null) + { + var daysExpression = sqlExpressionFactory.EfDays(instance); + var absExpression = sqlExpressionFactory.Function( + "abs", + [daysExpression], + nullable: true, + argumentsPropagateNullability: Statics.TrueArrays[1], + typeof(double)); + return sqlExpressionFactory.EfTimespan(absExpression); + } + + if (instance != null) + { + return null; + } + + if (FromDaysMethod.Equals(method) && arguments.Count == 1) + { + return sqlExpressionFactory.EfTimespan(arguments[0]); + } + + if (FromHoursMethod.Equals(method) && arguments.Count == 1) + { + return sqlExpressionFactory.EfTimespan( + sqlExpressionFactory.Divide(arguments[0], sqlExpressionFactory.Constant(24.0))); + } + + if (FromMinutesMethod.Equals(method) && arguments.Count == 1) + { + return sqlExpressionFactory.EfTimespan( + sqlExpressionFactory.Divide(arguments[0], sqlExpressionFactory.Constant(1440.0))); + } + + if (FromSecondsMethod.Equals(method) && arguments.Count == 1) + { + return sqlExpressionFactory.EfTimespan( + sqlExpressionFactory.Divide(arguments[0], sqlExpressionFactory.Constant(86400.0))); + } + + if (FromMillisecondsMethod.Equals(method) && arguments.Count == 1) + { + return sqlExpressionFactory.EfTimespan( + sqlExpressionFactory.Divide(arguments[0], sqlExpressionFactory.Constant(86400000.0))); + } + + if (FromTicksMethod.Equals(method) && arguments.Count == 1) + { + return sqlExpressionFactory.EfTimespan( + sqlExpressionFactory.Divide( + sqlExpressionFactory.Convert(arguments[0], typeof(double)), + sqlExpressionFactory.Constant(864000000000.0))); + } + + return null; + } +} diff --git a/src/EFCore.Sqlite.Core/Storage/Internal/SqliteRelationalConnection.cs b/src/EFCore.Sqlite.Core/Storage/Internal/SqliteRelationalConnection.cs index 942c1eda177..2c33a4f7421 100644 --- a/src/EFCore.Sqlite.Core/Storage/Internal/SqliteRelationalConnection.cs +++ b/src/EFCore.Sqlite.Core/Storage/Internal/SqliteRelationalConnection.cs @@ -190,6 +190,31 @@ private void InitializeDbConnection(DbConnection connection) (x, y) => decimal.Compare( decimal.Parse(x, NumberStyles.Number, CultureInfo.InvariantCulture), decimal.Parse(y, NumberStyles.Number, CultureInfo.InvariantCulture))); + + sqliteConnection.CreateFunction( + "ef_days", + value => value == null ? null : value.Value.TotalDays, + isDeterministic: true); + + sqliteConnection.CreateFunction( + "ef_days", + value => value == null ? null : TimeSpan.Parse(value).TotalDays, + isDeterministic: true); + + sqliteConnection.CreateFunction( + "ef_timespan", + value => + { + if (value == null) + { + return null; + } + + var totalDays = value.Value; + var ticks = (long)Math.Round(totalDays * TimeSpan.TicksPerDay); + return new TimeSpan(ticks); + }, + isDeterministic: true); } else { diff --git a/test/EFCore.Cosmos.FunctionalTests/Query/Translations/Temporal/DateTimeTranslationsCosmosTest.cs b/test/EFCore.Cosmos.FunctionalTests/Query/Translations/Temporal/DateTimeTranslationsCosmosTest.cs index f5fa9c5a1cd..e18d055bd78 100644 --- a/test/EFCore.Cosmos.FunctionalTests/Query/Translations/Temporal/DateTimeTranslationsCosmosTest.cs +++ b/test/EFCore.Cosmos.FunctionalTests/Query/Translations/Temporal/DateTimeTranslationsCosmosTest.cs @@ -162,10 +162,10 @@ public override async Task TimeOfDay() AssertSql(); } - public override async Task subtract_and_TotalDays() + public override async Task Subtract_and_TotalDays() { // Cosmos client evaluation. Issue #17246. - await AssertTranslationFailed(() => base.subtract_and_TotalDays()); + await AssertTranslationFailed(() => base.Subtract_and_TotalDays()); AssertSql(); } diff --git a/test/EFCore.Specification.Tests/Query/Translations/Temporal/DateTimeTranslationsTestBase.cs b/test/EFCore.Specification.Tests/Query/Translations/Temporal/DateTimeTranslationsTestBase.cs index f5b7d32b375..39f05804fcb 100644 --- a/test/EFCore.Specification.Tests/Query/Translations/Temporal/DateTimeTranslationsTestBase.cs +++ b/test/EFCore.Specification.Tests/Query/Translations/Temporal/DateTimeTranslationsTestBase.cs @@ -79,7 +79,7 @@ public virtual Task TimeOfDay() => AssertQuery(ss => ss.Set().Where(o => o.DateTime.TimeOfDay == TimeSpan.Zero)); [ConditionalFact] - public virtual Task subtract_and_TotalDays() + public virtual Task Subtract_and_TotalDays() { var date = new DateTime(1997, 1, 1); diff --git a/test/EFCore.SqlServer.FunctionalTests/Query/Translations/Temporal/DateTimeTranslationsSqlServerTest.cs b/test/EFCore.SqlServer.FunctionalTests/Query/Translations/Temporal/DateTimeTranslationsSqlServerTest.cs index d8d1a3e4433..b89e29ebba1 100644 --- a/test/EFCore.SqlServer.FunctionalTests/Query/Translations/Temporal/DateTimeTranslationsSqlServerTest.cs +++ b/test/EFCore.SqlServer.FunctionalTests/Query/Translations/Temporal/DateTimeTranslationsSqlServerTest.cs @@ -188,8 +188,8 @@ WHERE CONVERT(time, [b].[DateTime]) = '00:00:00' """); } - public override Task subtract_and_TotalDays() - => AssertTranslationFailed(() => base.subtract_and_TotalDays()); + public override Task Subtract_and_TotalDays() + => AssertTranslationFailed(() => base.Subtract_and_TotalDays()); public override async Task Parse_with_constant() { diff --git a/test/EFCore.Sqlite.FunctionalTests/Query/Translations/Temporal/DateTimeTranslationsSqliteTest.cs b/test/EFCore.Sqlite.FunctionalTests/Query/Translations/Temporal/DateTimeTranslationsSqliteTest.cs index a7970c2d4f2..5c9f10858cf 100644 --- a/test/EFCore.Sqlite.FunctionalTests/Query/Translations/Temporal/DateTimeTranslationsSqliteTest.cs +++ b/test/EFCore.Sqlite.FunctionalTests/Query/Translations/Temporal/DateTimeTranslationsSqliteTest.cs @@ -186,8 +186,19 @@ WHERE rtrim(rtrim(strftime('%H:%M:%f', "b"."DateTime"), '0'), '.') = '00:00:00' """); } - public override Task subtract_and_TotalDays() - => AssertTranslationFailed(() => base.subtract_and_TotalDays()); + public override async Task Subtract_and_TotalDays() + { + await base.Subtract_and_TotalDays(); + + AssertSql( + """ +@date='1997-01-01T00:00:00.0000000' (DbType = DateTime) + +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 ef_days(ef_timespan(julianday("b"."DateTime") - julianday(@date))) > 365.0 +"""); + } public override async Task Parse_with_constant() { diff --git a/test/EFCore.Sqlite.FunctionalTests/Query/Translations/Temporal/TimeSpanTranslationsSqliteTest.cs b/test/EFCore.Sqlite.FunctionalTests/Query/Translations/Temporal/TimeSpanTranslationsSqliteTest.cs index 919280880c3..4f83f6b346e 100644 --- a/test/EFCore.Sqlite.FunctionalTests/Query/Translations/Temporal/TimeSpanTranslationsSqliteTest.cs +++ b/test/EFCore.Sqlite.FunctionalTests/Query/Translations/Temporal/TimeSpanTranslationsSqliteTest.cs @@ -12,51 +12,76 @@ public TimeSpanTranslationsSqliteTest(BasicTypesQuerySqliteFixture fixture, ITes Fixture.TestSqlLoggerFactory.SetTestOutputHelper(testOutputHelper); } - // Translate TimeSpan members, #18844 public override async Task Hours() { - await AssertTranslationFailed(() => base.Hours()); - - AssertSql(); + await base.Hours(); + + 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((ef_days("b"."TimeSpan") * 24.0) % 24.0 AS INTEGER) = 3 +"""); } - // Translate TimeSpan members, #18844 public override async Task Minutes() { - await AssertTranslationFailed(() => base.Minutes()); - - AssertSql(); + await base.Minutes(); + + 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((ef_days("b"."TimeSpan") * 1440.0) % 60.0 AS INTEGER) = 4 +"""); } public override async Task Seconds() { - await AssertTranslationFailed(() => base.Seconds()); - - AssertSql(); + await base.Seconds(); + + 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((ef_days("b"."TimeSpan") * 86400.0) % 60.0 AS INTEGER) = 5 +"""); } - // Translate TimeSpan members, #18844 public override async Task Milliseconds() { - await AssertTranslationFailed(() => base.Milliseconds()); - - AssertSql(); + await base.Milliseconds(); + + 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((ef_days("b"."TimeSpan") * 86400000.0) % 1000.0 AS INTEGER) = 678 +"""); } - // Translate TimeSpan members, #18844 public override async Task Microseconds() { - await AssertTranslationFailed(() => base.Microseconds()); - - AssertSql(); + await base.Microseconds(); + + 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((ef_days("b"."TimeSpan") * 86400000000.0) % 1000.0 AS INTEGER) = 912 +"""); } - // Translate TimeSpan members, #18844 public override async Task Nanoseconds() { - await AssertTranslationFailed(() => base.Nanoseconds()); - - AssertSql(); + await base.Nanoseconds(); + + 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((ef_days("b"."TimeSpan") * 86400000000000.0) % 1000.0 AS INTEGER) = 400 +"""); } [ConditionalFact] From d0bb602b25a9373b343d39a70056b0db232a49d4 Mon Sep 17 00:00:00 2001 From: Vadym Buhaiov Date: Wed, 25 Feb 2026 02:29:33 -0500 Subject: [PATCH 2/2] Fix CS0103: use sqlExpressionFactory in aggregate translator - Use constructor parameter sqlExpressionFactory instead of _sqlExpressionFactory in Max/Min TimeSpan branches Fixes #18844 Co-authored-by: Cursor --- .../SqliteQueryableAggregateMethodTranslator.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/EFCore.Sqlite.Core/Query/Internal/Translators/SqliteQueryableAggregateMethodTranslator.cs b/src/EFCore.Sqlite.Core/Query/Internal/Translators/SqliteQueryableAggregateMethodTranslator.cs index 03a17a2d9a0..6e901dc906e 100644 --- a/src/EFCore.Sqlite.Core/Query/Internal/Translators/SqliteQueryableAggregateMethodTranslator.cs +++ b/src/EFCore.Sqlite.Core/Query/Internal/Translators/SqliteQueryableAggregateMethodTranslator.cs @@ -66,11 +66,11 @@ public class SqliteQueryableAggregateMethodTranslator(ISqlExpressionFactory sqlE } if (maxArgumentType == typeof(TimeSpan) - && _sqlExpressionFactory is SqliteSqlExpressionFactory maxSqliteFactory) + && sqlExpressionFactory is SqliteSqlExpressionFactory maxSqliteFactory) { maxSqlExpression = CombineTerms(source, maxSqlExpression); var maxDaysExpression = maxSqliteFactory.EfDays(maxSqlExpression); - var maxAggregate = _sqlExpressionFactory.Function( + var maxAggregate = sqlExpressionFactory.Function( "max", [maxDaysExpression], nullable: true, @@ -106,11 +106,11 @@ public class SqliteQueryableAggregateMethodTranslator(ISqlExpressionFactory sqlE } if (minArgumentType == typeof(TimeSpan) - && _sqlExpressionFactory is SqliteSqlExpressionFactory minSqliteFactory) + && sqlExpressionFactory is SqliteSqlExpressionFactory minSqliteFactory) { minSqlExpression = CombineTerms(source, minSqlExpression); var minDaysExpression = minSqliteFactory.EfDays(minSqlExpression); - var minAggregate = _sqlExpressionFactory.Function( + var minAggregate = sqlExpressionFactory.Function( "min", [minDaysExpression], nullable: true,