@@ -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