Skip to content

Commit c484080

Browse files
authored
Translations around DateOnly.DayNumber (#3333)
Closes #3194
1 parent 02190c1 commit c484080

File tree

5 files changed

+125
-19
lines changed

5 files changed

+125
-19
lines changed

src/EFCore.PG/Query/ExpressionTranslators/Internal/NpgsqlDateTimeMemberTranslator.cs

+17-7
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,11 @@ public NpgsqlDateTimeMemberTranslator(IRelationalTypeMappingSource typeMappingSo
5656
return translated;
5757
}
5858

59+
if (declaringType == typeof(DateOnly) && TranslateDateOnly(instance, member) is { } translated2)
60+
{
61+
return translated2;
62+
}
63+
5964
if (member.Name == nameof(DateTime.Date))
6065
{
6166
// Note that DateTime.Date returns a DateTime, not a DateOnly (introduced later); so we convert using date_trunc (which returns
@@ -190,13 +195,7 @@ SqlExpression LocalNow()
190195
return _sqlExpressionFactory.Convert(result, typeof(int));
191196
}
192197

193-
/// <summary>
194-
/// This is an internal API that supports the Entity Framework Core infrastructure and not subject to
195-
/// the same compatibility standards as public APIs. It may be changed or removed without notice in
196-
/// any release. You should only use it directly in your code with extreme caution and knowing that
197-
/// doing so can result in application failures when updating to a new Entity Framework Core release.
198-
/// </summary>
199-
public virtual SqlExpression? TranslateDateTimeOffset(SqlExpression instance, MemberInfo member)
198+
private SqlExpression? TranslateDateTimeOffset(SqlExpression instance, MemberInfo member)
200199
=> member.Name switch
201200
{
202201
// We only support UTC DateTimeOffset, so DateTimeOffset.DateTime is just a matter of converting to timestamp without time zone
@@ -226,6 +225,17 @@ SqlExpression LocalNow()
226225
_ => null
227226
};
228227

228+
private SqlExpression? TranslateDateOnly(SqlExpression? instance, MemberInfo member)
229+
=> member.Name switch
230+
{
231+
// We use fragment rather than a DateOnly constant, since 0001-01-01 gets rendered as -infinity by default.
232+
// TODO: Set the right type/type mapping after https://github.com/dotnet/efcore/pull/34995 is merged
233+
nameof(DateOnly.DayNumber) when instance is not null
234+
=> _sqlExpressionFactory.Subtract(instance, _sqlExpressionFactory.Fragment("DATE '0001-01-01'")),
235+
236+
_ => null
237+
};
238+
229239
// Various conversion functions translated here (date_part, ::time) exist only for timestamp without time zone, so if we pass in a
230240
// timestamptz it gets implicitly converted to a local timestamp based on TimeZone; that's the wrong behavior (these conversions are not
231241
// supposed to be sensitive to TimeZone).

src/EFCore.PG/Query/ExpressionTranslators/Internal/NpgsqlDateTimeMethodTranslator.cs

+16
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,10 @@ private static readonly MethodInfo DateOnly_AddMonths
6767
private static readonly MethodInfo DateOnly_AddYears
6868
= typeof(DateOnly).GetRuntimeMethod(nameof(DateOnly.AddYears), [typeof(int)])!;
6969

70+
private static readonly MethodInfo DateOnly_FromDayNumber
71+
= typeof(DateOnly).GetRuntimeMethod(
72+
nameof(DateOnly.FromDayNumber), [typeof(int)])!;
73+
7074
private static readonly MethodInfo TimeOnly_FromDateTime
7175
= typeof(TimeOnly).GetRuntimeMethod(nameof(TimeOnly.FromDateTime), [typeof(DateTime)])!;
7276

@@ -226,6 +230,18 @@ public NpgsqlDateTimeMethodTranslator(
226230
{
227231
return _sqlExpressionFactory.MakePostgresBinary(PgExpressionType.Distance, arguments[1], arguments[2]);
228232
}
233+
234+
if (method == DateOnly_FromDayNumber)
235+
{
236+
// We use fragment rather than a DateOnly constant, since 0001-01-01 gets rendered as -infinity by default.
237+
// TODO: Set the right type/type mapping after https://github.com/dotnet/efcore/pull/34995 is merged
238+
return new SqlBinaryExpression(
239+
ExpressionType.Add,
240+
_sqlExpressionFactory.Fragment("DATE '0001-01-01'"),
241+
arguments[0],
242+
typeof(DateOnly),
243+
_typeMappingSource.FindMapping(typeof(DateOnly)));
244+
}
229245
}
230246
else
231247
{

src/EFCore.PG/Query/Internal/NpgsqlSqlTranslatingExpressionVisitor.cs

+30-12
Original file line numberDiff line numberDiff line change
@@ -218,21 +218,39 @@ when binaryExpression.Left.Type.UnwrapNullableType().FullName == "NodaTime.Local
218218

219219
var translation = base.VisitBinary(binaryExpression);
220220

221-
// A somewhat hacky workaround for #2942.
222-
// When an optional owned JSON entity is compared to null, we get WHERE (x -> y) IS NULL.
223-
// The -> operator (returning jsonb) is used rather than ->> (returning text), since an entity type is being extracted, and further
224-
// JSON operations may need to be composed. However, when the value extracted is a JSON null, a non-NULL jsonb value is returned,
225-
// and comparing that to relational NULL returns false.
226-
// Pattern-match this and force the use of ->> by changing the mapping to be a scalar rather than an entity type.
227-
if (translation is SqlUnaryExpression
221+
switch (translation)
222+
{
223+
// Optimize (x - c) - (y - c) to x - y.
224+
// This is particularly useful for DateOnly.DayNumber - DateOnly.DayNumber, which is the way to express DateOnly subtraction
225+
// (the subtraction operator isn't defined over DateOnly in .NET). The translation of x.DayNumber is x - DATE '0001-01-01',
226+
// so the below is a useful simplification.
227+
// TODO: As this is a generic mathematical simplification, we should move it to a generic optimization phase in EF Core.
228+
case SqlBinaryExpression
229+
{
230+
OperatorType: ExpressionType.Subtract,
231+
Left: SqlBinaryExpression { OperatorType: ExpressionType.Subtract, Left: var left1, Right: var right1 },
232+
Right: SqlBinaryExpression { OperatorType: ExpressionType.Subtract, Left: var left2, Right: var right2 }
233+
} originalBinary when right1.Equals(right2):
234+
{
235+
return new SqlBinaryExpression(ExpressionType.Subtract, left1, left2, originalBinary.Type, originalBinary.TypeMapping);
236+
}
237+
238+
// A somewhat hacky workaround for #2942.
239+
// When an optional owned JSON entity is compared to null, we get WHERE (x -> y) IS NULL.
240+
// The -> operator (returning jsonb) is used rather than ->> (returning text), since an entity type is being extracted, and
241+
// further JSON operations may need to be composed. However, when the value extracted is a JSON null, a non-NULL jsonb value is
242+
// returned, and comparing that to relational NULL returns false.
243+
// Pattern-match this and force the use of ->> by changing the mapping to be a scalar rather than an entity type.
244+
case SqlUnaryExpression
228245
{
229246
OperatorType: ExpressionType.Equal or ExpressionType.NotEqual,
230247
Operand: JsonScalarExpression { TypeMapping: NpgsqlOwnedJsonTypeMapping } operand
231-
} unary)
232-
{
233-
return unary.Update(
234-
new JsonScalarExpression(
235-
operand.Json, operand.Path, operand.Type, _typeMappingSource.FindMapping("text"), operand.IsNullable));
248+
} unary:
249+
{
250+
return unary.Update(
251+
new JsonScalarExpression(
252+
operand.Json, operand.Path, operand.Type, _typeMappingSource.FindMapping("text"), operand.IsNullable));
253+
}
236254
}
237255

238256
return translation;

src/EFCore.PG/Query/NpgsqlSqlExpressionFactory.cs

+12
Original file line numberDiff line numberDiff line change
@@ -476,6 +476,18 @@ private SqlBinaryExpression ApplyTypeMappingOnSqlBinary(SqlBinaryExpression bina
476476
binary.Type,
477477
typeMapping ?? _typeMappingSource.FindMapping(binary.Type, "interval"));
478478
}
479+
480+
// TODO: This is a hack until https://github.com/dotnet/efcore/pull/34995 is done; the translation of DateOnly.DayNumber
481+
// generates a substraction with a fragment, but for now we can't assign a type/type mapping to a fragment.
482+
case ExpressionType.Subtract when left.Type == typeof(DateOnly) && right is SqlFragmentExpression:
483+
{
484+
return new SqlBinaryExpression(
485+
ExpressionType.Subtract,
486+
ApplyDefaultTypeMapping(left),
487+
right,
488+
typeof(int),
489+
_typeMappingSource.FindMapping(typeof(int)));
490+
}
479491
}
480492

481493
// If this is a row value comparison (e.g. (a, b) > (5, 6)), doing type mapping inference on each corresponding pair.

test/EFCore.PG.FunctionalTests/Query/TimestampQueryTest.cs

+50
Original file line numberDiff line numberDiff line change
@@ -744,6 +744,56 @@ WHERE CAST(e."TimestamptzDateTime" AT TIME ZONE 'UTC' AS date) + TIME '15:26:38'
744744
""");
745745
}
746746

747+
[ConditionalTheory]
748+
[MemberData(nameof(IsAsyncData))]
749+
public virtual async Task DateOnly_DayNumber(bool async)
750+
{
751+
await AssertQuery(
752+
async,
753+
ss => ss.Set<Entity>().Where(e => DateOnly.FromDateTime(e.TimestamptzDateTime).DayNumber == 729490));
754+
755+
AssertSql(
756+
"""
757+
SELECT e."Id", e."TimestampDateTime", e."TimestampDateTimeArray", e."TimestampDateTimeOffset", e."TimestampDateTimeOffsetArray", e."TimestampDateTimeRange", e."TimestamptzDateTime", e."TimestamptzDateTimeArray", e."TimestamptzDateTimeRange"
758+
FROM "Entities" AS e
759+
WHERE CAST(e."TimestamptzDateTime" AT TIME ZONE 'UTC' AS date) - DATE '0001-01-01' = 729490
760+
""");
761+
}
762+
763+
[ConditionalTheory]
764+
[MemberData(nameof(IsAsyncData))]
765+
public virtual async Task DateOnly_DayNumber_subtraction(bool async)
766+
{
767+
await AssertQuery(
768+
async,
769+
ss => ss.Set<Entity>().Where(
770+
e => DateOnly.FromDateTime(e.TimestamptzDateTime).DayNumber -
771+
DateOnly.FromDateTime(e.TimestamptzDateTime - TimeSpan.FromDays(3)).DayNumber == 3));
772+
773+
AssertSql(
774+
"""
775+
SELECT e."Id", e."TimestampDateTime", e."TimestampDateTimeArray", e."TimestampDateTimeOffset", e."TimestampDateTimeOffsetArray", e."TimestampDateTimeRange", e."TimestamptzDateTime", e."TimestamptzDateTimeArray", e."TimestamptzDateTimeRange"
776+
FROM "Entities" AS e
777+
WHERE CAST(e."TimestamptzDateTime" AT TIME ZONE 'UTC' AS date) - CAST((e."TimestamptzDateTime" - INTERVAL '3 00:00:00') AT TIME ZONE 'UTC' AS date) = 3
778+
""");
779+
}
780+
781+
[ConditionalTheory]
782+
[MemberData(nameof(IsAsyncData))]
783+
public virtual async Task DateOnly_FromDayNumber(bool async)
784+
{
785+
await AssertQuery(
786+
async,
787+
ss => ss.Set<Entity>().Where(e => DateOnly.FromDayNumber(e.Id) == new DateOnly(0001, 01, 03)));
788+
789+
AssertSql(
790+
"""
791+
SELECT e."Id", e."TimestampDateTime", e."TimestampDateTimeArray", e."TimestampDateTimeOffset", e."TimestampDateTimeOffsetArray", e."TimestampDateTimeRange", e."TimestamptzDateTime", e."TimestamptzDateTimeArray", e."TimestamptzDateTimeRange"
792+
FROM "Entities" AS e
793+
WHERE DATE '0001-01-01' + e."Id" = DATE '0001-01-03'
794+
""");
795+
}
796+
747797
#endregion DateOnly
748798

749799
#region TimeOnly

0 commit comments

Comments
 (0)