Skip to content

Commit 94fb322

Browse files
authored
Additional translations for DateOnly/TimeOnly (#2151)
Closes #4217
1 parent d00cafb commit 94fb322

File tree

3 files changed

+350
-105
lines changed

3 files changed

+350
-105
lines changed

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

Lines changed: 194 additions & 92 deletions
Original file line numberDiff line numberDiff line change
@@ -30,13 +30,25 @@ public class NpgsqlDateTimeMethodTranslator : IMethodCallTranslator
3030
{ typeof(TimeOnly).GetRuntimeMethod(nameof(TimeOnly.AddMinutes), new[] { typeof(int) })!, "mins" },
3131
};
3232

33+
// ReSharper disable InconsistentNaming
3334
private static readonly MethodInfo DateTime_ToUniversalTime
3435
= typeof(DateTime).GetRuntimeMethod(nameof(DateTime.ToUniversalTime), Array.Empty<Type>())!;
3536
private static readonly MethodInfo DateTime_ToLocalTime
3637
= typeof(DateTime).GetRuntimeMethod(nameof(DateTime.ToLocalTime), Array.Empty<Type>())!;
3738
private static readonly MethodInfo DateTime_SpecifyKind
3839
= typeof(DateTime).GetRuntimeMethod(nameof(DateTime.SpecifyKind), new[] { typeof(DateTime), typeof(DateTimeKind) })!;
3940

41+
private static readonly MethodInfo DateOnly_FromDateTime
42+
= typeof(DateOnly).GetRuntimeMethod(nameof(DateOnly.FromDateTime), new[] { typeof(DateTime) })!;
43+
private static readonly MethodInfo DateOnly_ToDateTime
44+
= typeof(DateOnly).GetRuntimeMethod(nameof(DateOnly.ToDateTime), new[] { typeof(TimeOnly) })!;
45+
46+
private static readonly MethodInfo TimeOnly_FromDateTime
47+
= typeof(TimeOnly).GetRuntimeMethod(nameof(TimeOnly.FromDateTime), new[] { typeof(DateTime) })!;
48+
private static readonly MethodInfo TimeOnly_FromTimeSpan
49+
= typeof(TimeOnly).GetRuntimeMethod(nameof(TimeOnly.FromTimeSpan), new[] { typeof(TimeSpan) })!;
50+
private static readonly MethodInfo TimeOnly_ToTimeSpan
51+
= typeof(TimeOnly).GetRuntimeMethod(nameof(TimeOnly.ToTimeSpan), Type.EmptyTypes)!;
4052
private static readonly MethodInfo TimeOnly_IsBetween
4153
= typeof(TimeOnly).GetRuntimeMethod(nameof(TimeOnly.IsBetween), new[] { typeof(TimeOnly), typeof(TimeOnly) })!;
4254
private static readonly MethodInfo TimeOnly_Add_TimeSpan
@@ -48,7 +60,9 @@ private static readonly MethodInfo TimeZoneInfo_ConvertTimeBySystemTimeZoneId
4860

4961
private static readonly MethodInfo TimeZoneInfo_ConvertTimeToUtc
5062
= typeof(TimeZoneInfo).GetRuntimeMethod(nameof(TimeZoneInfo.ConvertTimeToUtc), new[] { typeof(DateTime) })!;
63+
// ReSharper restore InconsistentNaming
5164

65+
private readonly IRelationalTypeMappingSource _typeMappingSource;
5266
private readonly NpgsqlSqlExpressionFactory _sqlExpressionFactory;
5367
private readonly RelationalTypeMapping _timestampMapping;
5468
private readonly RelationalTypeMapping _timestampTzMapping;
@@ -59,6 +73,7 @@ public NpgsqlDateTimeMethodTranslator(
5973
IRelationalTypeMappingSource typeMappingSource,
6074
NpgsqlSqlExpressionFactory sqlExpressionFactory)
6175
{
76+
_typeMappingSource = typeMappingSource;
6277
_sqlExpressionFactory = sqlExpressionFactory;
6378
_timestampMapping = typeMappingSource.FindMapping("timestamp without time zone")!;
6479
_timestampTzMapping = typeMappingSource.FindMapping("timestamp with time zone")!;
@@ -72,85 +87,188 @@ public NpgsqlDateTimeMethodTranslator(
7287
MethodInfo method,
7388
IReadOnlyList<SqlExpression> arguments,
7489
IDiagnosticsLogger<DbLoggerCategory.Query> logger)
90+
=> TranslateDatePart(instance, method, arguments)
91+
?? TranslateDateTime(instance, method, arguments)
92+
?? TranslateDateOnly(instance, method, arguments)
93+
?? TranslateTimeOnly(instance, method, arguments)
94+
?? TranslateTimeZoneInfo(instance, method, arguments);
95+
96+
private SqlExpression? TranslateDatePart(
97+
SqlExpression? instance,
98+
MethodInfo method,
99+
IReadOnlyList<SqlExpression> arguments)
75100
{
76-
if (instance is null)
101+
if (instance is null || !MethodInfoDatePartMapping.TryGetValue(method, out var datePart))
77102
{
78-
if (method == TimeZoneInfo_ConvertTimeBySystemTimeZoneId)
79-
{
80-
var typeMapping = arguments[0].TypeMapping;
81-
if (typeMapping is null
82-
|| (typeMapping.StoreType != "timestamp with time zone" && typeMapping.StoreType != "timestamptz"))
83-
{
84-
throw new InvalidOperationException(
85-
"TimeZoneInfo.ConvertTimeBySystemTimeZoneId is only supported on columns with type 'timestamp with time zone'");
86-
}
87-
88-
return _sqlExpressionFactory.AtTimeZone(arguments[0], arguments[1], typeof(DateTime), _timestampMapping);
89-
}
103+
return null;
104+
}
90105

91-
if (method == TimeZoneInfo_ConvertTimeToUtc)
92-
{
93-
var typeMapping = arguments[0].TypeMapping;
94-
if (typeMapping is null
95-
|| (typeMapping.StoreType != "timestamp without time zone" && typeMapping.StoreType != "timestamp"))
96-
{
97-
throw new InvalidOperationException(
98-
"TimeZoneInfo.ConvertTimeToUtc) is only supported on columns with type 'timestamp without time zone'");
99-
}
100-
101-
return _sqlExpressionFactory.Convert(arguments[0], arguments[0].Type, _timestampTzMapping);
102-
}
106+
if (arguments[0] is not { } interval)
107+
{
108+
return null;
109+
}
103110

104-
if (method == DateTime_SpecifyKind)
111+
// Note: ideally we'd simply generate a PostgreSQL interval expression, but the .NET mapping of that is TimeSpan,
112+
// which does not work for months, years, etc. So we generate special fragments instead.
113+
if (interval is SqlConstantExpression constantExpression)
114+
{
115+
// We generate constant intervals as INTERVAL '1 days'
116+
if (constantExpression.Type == typeof(double) &&
117+
((double)constantExpression.Value! >= int.MaxValue ||
118+
(double)constantExpression.Value <= int.MinValue))
105119
{
106-
if (arguments[1] is not SqlConstantExpression { Value: DateTimeKind kind })
107-
{
108-
throw new InvalidOperationException("Translating SpecifyKind is only supported with a constant Kind argument");
109-
}
110-
111-
var typeMapping = arguments[0].TypeMapping;
112-
113-
if (typeMapping is not NpgsqlTimestampTypeMapping and not NpgsqlTimestampTzTypeMapping)
114-
{
115-
throw new InvalidOperationException("Translating SpecifyKind is only supported on timestamp/timestamptz columns");
116-
}
117-
118-
if (kind == DateTimeKind.Utc)
119-
{
120-
return typeMapping is NpgsqlTimestampTypeMapping
121-
? _sqlExpressionFactory.AtUtc(arguments[0])
122-
: arguments[0];
123-
}
124-
125-
if (kind is DateTimeKind.Unspecified or DateTimeKind.Local)
126-
{
127-
return typeMapping is NpgsqlTimestampTzTypeMapping
128-
? _sqlExpressionFactory.AtUtc(arguments[0])
129-
: arguments[0];
130-
}
120+
return null;
131121
}
132122

133-
return null;
123+
interval = _sqlExpressionFactory.Fragment(FormattableString.Invariant($"INTERVAL '{constantExpression.Value} {datePart}'"));
134124
}
135-
136-
if (TranslateDatePart(instance, method, arguments) is { } translated)
125+
else
137126
{
138-
return translated;
127+
// For non-constants, we can't parameterize INTERVAL '1 days'. Instead, we use CAST($1 || ' days' AS interval).
128+
// Note that a make_interval() function also exists, but accepts only int (for all fields except for
129+
// seconds), so we don't use it.
130+
// Note: we instantiate SqlBinaryExpression manually rather than via sqlExpressionFactory because
131+
// of the non-standard Add expression (concatenate int with text)
132+
interval = _sqlExpressionFactory.Convert(
133+
new SqlBinaryExpression(
134+
ExpressionType.Add,
135+
_sqlExpressionFactory.Convert(interval, typeof(string), _textMapping),
136+
_sqlExpressionFactory.Constant(' ' + datePart, _textMapping),
137+
typeof(string),
138+
_textMapping),
139+
typeof(TimeSpan),
140+
_intervalMapping);
139141
}
140142

141-
if (method.DeclaringType == typeof(DateTime))
143+
return _sqlExpressionFactory.Add(instance, interval, instance.TypeMapping);
144+
}
145+
146+
private SqlExpression? TranslateDateTime(
147+
SqlExpression? instance,
148+
MethodInfo method,
149+
IReadOnlyList<SqlExpression> arguments)
150+
{
151+
if (instance is not null)
142152
{
143153
if (method == DateTime_ToUniversalTime)
144154
{
145155
return _sqlExpressionFactory.Convert(instance, method.ReturnType, _timestampTzMapping);
146156
}
157+
147158
if (method == DateTime_ToLocalTime)
148159
{
149160
return _sqlExpressionFactory.Convert(instance, method.ReturnType, _timestampMapping);
150161
}
151162
}
152-
else if (method.DeclaringType == typeof(TimeOnly))
163+
164+
if (method == DateTime_SpecifyKind)
153165
{
166+
if (arguments[1] is not SqlConstantExpression { Value: DateTimeKind kind })
167+
{
168+
throw new InvalidOperationException("Translating SpecifyKind is only supported with a constant Kind argument");
169+
}
170+
171+
var typeMapping = arguments[0].TypeMapping;
172+
173+
if (typeMapping is not NpgsqlTimestampTypeMapping and not NpgsqlTimestampTzTypeMapping)
174+
{
175+
throw new InvalidOperationException("Translating SpecifyKind is only supported on timestamp/timestamptz columns");
176+
}
177+
178+
if (kind == DateTimeKind.Utc)
179+
{
180+
return typeMapping is NpgsqlTimestampTypeMapping
181+
? _sqlExpressionFactory.AtUtc(arguments[0])
182+
: arguments[0];
183+
}
184+
185+
if (kind is DateTimeKind.Unspecified or DateTimeKind.Local)
186+
{
187+
return typeMapping is NpgsqlTimestampTzTypeMapping
188+
? _sqlExpressionFactory.AtUtc(arguments[0])
189+
: arguments[0];
190+
}
191+
}
192+
193+
return null;
194+
}
195+
196+
private SqlExpression? TranslateDateOnly(
197+
SqlExpression? instance,
198+
MethodInfo method,
199+
IReadOnlyList<SqlExpression> arguments)
200+
{
201+
if (method == DateOnly_FromDateTime)
202+
{
203+
// Note: converting timestamptz to date performs a timezone conversion, which is not what .NET DateOnly.FromDateTime does.
204+
// So if our operand is a timestamptz, we first change the type to timestamp with AT TIME ZONE 'UTC' (returns the same value
205+
// but as a timestamptz).
206+
// If our operand is already timestamp, no need to do anything. We throw for anything else to avoid accidentally applying
207+
// AT TIME ZONE to a non-timestamptz, which would do a timezone conversion
208+
var dateTime = arguments[0].TypeMapping switch
209+
{
210+
NpgsqlTimestampTypeMapping => arguments[0],
211+
NpgsqlTimestampTzTypeMapping => _sqlExpressionFactory.AtUtc(arguments[0]),
212+
_ => throw new NotSupportedException("Can only apply TimeOnly.FromDateTime on a timestamp or timestamptz column")
213+
};
214+
215+
return _sqlExpressionFactory.Convert(dateTime, typeof(DateOnly), _typeMappingSource.FindMapping(typeof(DateOnly)));
216+
}
217+
218+
if (instance is not null)
219+
{
220+
if (method == DateOnly_ToDateTime)
221+
{
222+
return new SqlBinaryExpression(
223+
ExpressionType.Add,
224+
_sqlExpressionFactory.ApplyDefaultTypeMapping(instance),
225+
_sqlExpressionFactory.ApplyDefaultTypeMapping(arguments[0]),
226+
typeof(DateTime),
227+
_timestampMapping);
228+
}
229+
}
230+
231+
return null;
232+
}
233+
234+
235+
private SqlExpression? TranslateTimeOnly(
236+
SqlExpression? instance,
237+
MethodInfo method,
238+
IReadOnlyList<SqlExpression> arguments)
239+
{
240+
if (method == TimeOnly_FromDateTime)
241+
{
242+
// Note: converting timestamptz to time performs a timezone conversion, which is not what .NET TimeOnly.FromDateTime does.
243+
// So if our operand is a timestamptz, we first change the type to timestamp with AT TIME ZONE 'UTC' (returns the same value
244+
// but as a timestamptz).
245+
// If our operand is already timestamp, no need to do anything. We throw for anything else to avoid accidentally applying
246+
// AT TIME ZONE to a non-timestamptz, which would do a timezone conversion
247+
var dateTime = arguments[0].TypeMapping switch
248+
{
249+
NpgsqlTimestampTypeMapping => arguments[0],
250+
NpgsqlTimestampTzTypeMapping => _sqlExpressionFactory.AtUtc(arguments[0]),
251+
_ => throw new NotSupportedException("Can only apply TimeOnly.FromDateTime on a timestamp or timestamptz column")
252+
};
253+
254+
return _sqlExpressionFactory.Convert(
255+
dateTime,
256+
typeof(TimeOnly),
257+
_typeMappingSource.FindMapping(typeof(TimeOnly)));
258+
}
259+
260+
if (method == TimeOnly_FromTimeSpan)
261+
{
262+
return _sqlExpressionFactory.Convert(arguments[0], typeof(TimeOnly), _typeMappingSource.FindMapping(typeof(TimeOnly)));
263+
}
264+
265+
if (instance is not null)
266+
{
267+
if (method == TimeOnly_ToTimeSpan)
268+
{
269+
return _sqlExpressionFactory.Convert(instance, typeof(TimeSpan), _typeMappingSource.FindMapping(typeof(TimeSpan)));
270+
}
271+
154272
if (method == TimeOnly_IsBetween)
155273
{
156274
return _sqlExpressionFactory.And(
@@ -167,53 +285,37 @@ public NpgsqlDateTimeMethodTranslator(
167285
return null;
168286
}
169287

170-
private SqlExpression? TranslateDatePart(
171-
SqlExpression instance,
288+
private SqlExpression? TranslateTimeZoneInfo(
289+
SqlExpression? instance,
172290
MethodInfo method,
173291
IReadOnlyList<SqlExpression> arguments)
174292
{
175-
if (!MethodInfoDatePartMapping.TryGetValue(method, out var datePart))
293+
if (method == TimeZoneInfo_ConvertTimeBySystemTimeZoneId)
176294
{
177-
return null;
178-
}
295+
var typeMapping = arguments[0].TypeMapping;
296+
if (typeMapping is null
297+
|| (typeMapping.StoreType != "timestamp with time zone" && typeMapping.StoreType != "timestamptz"))
298+
{
299+
throw new InvalidOperationException(
300+
"TimeZoneInfo.ConvertTimeBySystemTimeZoneId is only supported on columns with type 'timestamp with time zone'");
301+
}
179302

180-
if (arguments[0] is not { } interval)
181-
{
182-
return null;
303+
return _sqlExpressionFactory.AtTimeZone(arguments[0], arguments[1], typeof(DateTime), _timestampMapping);
183304
}
184305

185-
// Note: ideally we'd simply generate a PostgreSQL interval expression, but the .NET mapping of that is TimeSpan,
186-
// which does not work for months, years, etc. So we generate special fragments instead.
187-
if (interval is SqlConstantExpression constantExpression)
306+
if (method == TimeZoneInfo_ConvertTimeToUtc)
188307
{
189-
// We generate constant intervals as INTERVAL '1 days'
190-
if (constantExpression.Type == typeof(double) &&
191-
((double)constantExpression.Value! >= int.MaxValue ||
192-
(double)constantExpression.Value <= int.MinValue))
308+
var typeMapping = arguments[0].TypeMapping;
309+
if (typeMapping is null
310+
|| (typeMapping.StoreType != "timestamp without time zone" && typeMapping.StoreType != "timestamp"))
193311
{
194-
return null;
312+
throw new InvalidOperationException(
313+
"TimeZoneInfo.ConvertTimeToUtc) is only supported on columns with type 'timestamp without time zone'");
195314
}
196315

197-
interval = _sqlExpressionFactory.Fragment(FormattableString.Invariant($"INTERVAL '{constantExpression.Value} {datePart}'"));
198-
}
199-
else
200-
{
201-
// For non-constants, we can't parameterize INTERVAL '1 days'. Instead, we use CAST($1 || ' days' AS interval).
202-
// Note that a make_interval() function also exists, but accepts only int (for all fields except for
203-
// seconds), so we don't use it.
204-
// Note: we instantiate SqlBinaryExpression manually rather than via sqlExpressionFactory because
205-
// of the non-standard Add expression (concatenate int with text)
206-
interval = _sqlExpressionFactory.Convert(
207-
new SqlBinaryExpression(
208-
ExpressionType.Add,
209-
_sqlExpressionFactory.Convert(interval, typeof(string), _textMapping),
210-
_sqlExpressionFactory.Constant(' ' + datePart, _textMapping),
211-
typeof(string),
212-
_textMapping),
213-
typeof(TimeSpan),
214-
_intervalMapping);
316+
return _sqlExpressionFactory.Convert(arguments[0], arguments[0].Type, _timestampTzMapping);
215317
}
216318

217-
return _sqlExpressionFactory.Add(instance, interval, instance.TypeMapping);
319+
return null;
218320
}
219321
}

0 commit comments

Comments
 (0)