Skip to content

Commit 8fcc534

Browse files
committed
PostgreSql: Apply connection timezone to datetimeoffset values on reading
1 parent e88b982 commit 8fcc534

5 files changed

Lines changed: 268 additions & 57 deletions

File tree

Orm/Xtensive.Orm.PostgreSql/Sql.Drivers.PostgreSql/DriverFactory.cs

Lines changed: 49 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
using Xtensive.Orm;
1414
using Xtensive.Sql.Info;
1515
using Xtensive.Sql.Drivers.PostgreSql.Resources;
16+
using System.Collections.Generic;
1617

1718
namespace Xtensive.Sql.Drivers.PostgreSql
1819
{
@@ -66,8 +67,9 @@ protected override SqlDriver CreateDriver(string connectionString, SqlDriverConf
6667
else
6768
OpenConnectionFast(connection, configuration, false).GetAwaiter().GetResult();
6869
var version = GetVersion(configuration, connection);
70+
var serverTimezones = GetServerTimeZones(connection, false).GetAwaiter().GetResult();
6971
var defaultSchema = GetDefaultSchema(connection);
70-
return CreateDriverInstance(connectionString, version, defaultSchema);
72+
return CreateDriverInstance(connectionString, version, defaultSchema, serverTimezones, connection.Timezone);
7173
}
7274

7375
/// <inheritdoc/>
@@ -85,8 +87,9 @@ protected override async Task<SqlDriver> CreateDriverAsync(
8587
else
8688
await OpenConnectionFast(connection, configuration, true, token).ConfigureAwait(false);
8789
var version = GetVersion(configuration, connection);
90+
var serverTimezones = await GetServerTimeZones(connection, true, token).ConfigureAwait(false);
8891
var defaultSchema = await GetDefaultSchemaAsync(connection, token: token).ConfigureAwait(false);
89-
return CreateDriverInstance(connectionString, version, defaultSchema);
92+
return CreateDriverInstance(connectionString, version, defaultSchema, serverTimezones, connection.Timezone);
9093
}
9194
}
9295

@@ -99,7 +102,8 @@ private static Version GetVersion(SqlDriverConfiguration configuration, NpgsqlCo
99102
}
100103

101104
private static SqlDriver CreateDriverInstance(
102-
string connectionString, Version version, DefaultSchemaInfo defaultSchema)
105+
string connectionString, Version version, DefaultSchemaInfo defaultSchema,
106+
Dictionary<string, TimeSpan> timezones, string defaultTimeZone)
103107
{
104108
var coreServerInfo = new CoreServerInfo {
105109
ServerVersion = version,
@@ -111,7 +115,9 @@ private static SqlDriver CreateDriverInstance(
111115

112116
var pgsqlServerInfo = new PostgreServerInfo() {
113117
InfinityAliasForDatesEnabled = InfinityAliasForDatesEnabled,
114-
LegacyTimestampBehavior = LegacyTimestamptBehaviorEnabled
118+
LegacyTimestampBehavior = LegacyTimestamptBehaviorEnabled,
119+
ServerTimeZones = timezones,
120+
DefaultTimeZone = defaultTimeZone
115121
};
116122

117123
if (version.Major < 8 || (version.Major == 8 && version.Minor < 3)) {
@@ -198,6 +204,45 @@ await SqlHelper.NotifyConnectionInitializingAsync(accessors,
198204
}
199205
}
200206

207+
private static async ValueTask<Dictionary<string, TimeSpan>> GetServerTimeZones(NpgsqlConnection connection, bool isAsync, CancellationToken token = default)
208+
{
209+
var resultZones = new Dictionary<string, TimeSpan>();
210+
211+
var command = connection.CreateCommand();
212+
command.CommandText = "SELECT \"name\", \"abbrev\", \"utc_offset\", \"is_dst\" FROM pg_timezone_names";
213+
if (isAsync) {
214+
await using(command)
215+
await using(var reader = await command.ExecuteReaderAsync()) {
216+
while(await reader.ReadAsync()) {
217+
ReadTimezoneRow(reader, resultZones);
218+
}
219+
}
220+
}
221+
else {
222+
using (command)
223+
using (var reader = command.ExecuteReader()) {
224+
while (reader.Read()) {
225+
ReadTimezoneRow(reader, resultZones);
226+
}
227+
}
228+
}
229+
return resultZones;
230+
231+
232+
static void ReadTimezoneRow(NpgsqlDataReader reader, Dictionary<string, TimeSpan> zones)
233+
{
234+
var name = reader.GetString(0);
235+
var abbrev = reader.GetString(1);
236+
var utcOffset = PostgreSqlHelper.ResurrectTimeSpanFromNpgsqlInterval(reader.GetFieldValue<NpgsqlTypes.NpgsqlInterval>(2));
237+
238+
_ = zones.TryAdd(name, utcOffset);
239+
//flatten results for search convinience
240+
if (!string.IsNullOrEmpty(abbrev)) {
241+
_ = zones.TryAdd(abbrev, utcOffset);
242+
}
243+
}
244+
}
245+
201246
private static bool SetOrGetExistingDisableInfinityAliasForDatesSwitch(bool valueToSet) =>
202247
GetSwitchValueOrSet(Orm.PostgreSql.WellKnown.DateTimeToInfinityConversionSwitchName, valueToSet);
203248

Orm/Xtensive.Orm.PostgreSql/Sql.Drivers.PostgreSql/PostgreServerInfo.cs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22
// This code is distributed under MIT license terms.
33
// See the License.txt file in the project root for more information.
44

5+
using System;
6+
using System.Collections.Generic;
7+
58
namespace Xtensive.Sql.Drivers.PostgreSql
69
{
710
/// <summary>
@@ -22,5 +25,15 @@ internal sealed class PostgreServerInfo
2225
/// The setting has effect on parameter binding and also value reading from DbDataReader.
2326
/// </summary>
2427
public bool LegacyTimestampBehavior { get; init; }
28+
29+
/// <summary>
30+
/// Contains server timezone names and their base Utc offset (including abbreviations).
31+
/// </summary>
32+
public IReadOnlyDictionary<string, TimeSpan> ServerTimeZones { get; init; }
33+
34+
/// <summary>
35+
/// Gets time zone of connection after connection initialization script was executed.
36+
/// </summary>
37+
public string DefaultTimeZone { get; init; }
2538
}
2639
}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
// Copyright (C) 2025 Xtensive LLC.
2+
// This code is distributed under MIT license terms.
3+
// See the License.txt file in the project root for more information.
4+
5+
using System;
6+
using System.Collections.Generic;
7+
using Npgsql;
8+
using NpgsqlTypes;
9+
using Xtensive.Orm.PostgreSql;
10+
11+
namespace Xtensive.Sql.Drivers.PostgreSql
12+
{
13+
internal static class PostgreSqlHelper
14+
{
15+
internal static NpgsqlInterval CreateNativeIntervalFromTimeSpan(in TimeSpan timeSpan)
16+
{
17+
// Previous Npgsql versions used days and time, no months.
18+
// Thought we can write everything as time, we keep days and time format
19+
20+
var ticks = timeSpan.Ticks;
21+
22+
var days = timeSpan.Days;
23+
var timeTicks = ticks - (days * TimeSpan.TicksPerDay);
24+
#if NET7_0_OR_GREATER
25+
var microseconds = timeTicks / TimeSpan.TicksPerMicrosecond;
26+
#else
27+
var microseconds = timeTicks / 10L; // same as TimeSpan.TicksPerMicrosecond available in .NET7+
28+
#endif
29+
// no months!
30+
return new NpgsqlInterval(0, days, microseconds);
31+
}
32+
33+
internal static TimeSpan ResurrectTimeSpanFromNpgsqlInterval(in NpgsqlInterval npgsqlInterval)
34+
{
35+
// We don't write "Months" part of NpgsqlInterval to database
36+
// because days in months is variable measure in PostgreSQL.
37+
// We better use exact number of days.
38+
// But if for some reason, there is Months value > 0 we treat it like each month has 30 days,
39+
// it seems that Npgsql did the same assumption internally.
40+
41+
var days = (npgsqlInterval.Months != 0)
42+
? npgsqlInterval.Months * WellKnown.IntervalDaysInMonth + npgsqlInterval.Days
43+
: npgsqlInterval.Days;
44+
45+
var ticksOfDays = days * TimeSpan.TicksPerDay;
46+
#if NET7_0_OR_GREATER
47+
var overallTicks = ticksOfDays + (npgsqlInterval.Time * TimeSpan.TicksPerMicrosecond);
48+
#else
49+
var overallTicks = ticksOfDays + (npgsqlInterval.Time * 10); //same as TimeSpan.TicksPerMicrosecond available in .NET7+
50+
#endif
51+
return TimeSpan.FromTicks(overallTicks);
52+
}
53+
54+
/// <summary>
55+
/// Checks if timezone is declared in POSIX format (example &lt;+07&gt;-07 )
56+
/// and returns number between '&lt;' and '&gt;' as timezone.
57+
/// </summary>
58+
/// <param name="timezone">Timezone in possible POSIX format</param>
59+
/// <returns>Timezone shift declared in oritinal POSIX format as timezone or original value.</returns>
60+
internal static string TryGetZoneFromPosix(string timezone)
61+
{
62+
if (timezone.StartsWith('<')) {
63+
// if POSIX format
64+
var closing = timezone.IndexOf('>');
65+
var result = timezone.Substring(1, closing - 1);
66+
return result;
67+
}
68+
return timezone;
69+
}
70+
}
71+
}

Orm/Xtensive.Orm.PostgreSql/Sql.Drivers.PostgreSql/v8_0/TypeMapper.cs

Lines changed: 21 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
// Created: 2009.06.23
66

77
using System;
8+
using System.Collections.Generic;
89
using System.Data;
910
using System.Data.Common;
1011
using System.Security;
@@ -22,6 +23,7 @@ internal class TypeMapper : Sql.TypeMapper
2223
private const long DateTimeMaxValueAdjustedTicks = 3155378975999999990;
2324

2425
protected readonly bool legacyTimestampBehaviorEnabled;
26+
protected readonly TimeSpan? defaultTimeZone;
2527

2628
public override bool IsParameterCastRequired(Type type)
2729
{
@@ -122,7 +124,7 @@ public override void BindTimeSpan(DbParameter parameter, object value)
122124
nativeParameter.NpgsqlValue = value is null
123125
? DBNull.Value
124126
: value is TimeSpan timeSpanValue
125-
? (object) CreateNativeIntervalFromTimeSpan(timeSpanValue)
127+
? (object) PostgreSqlHelper.CreateNativeIntervalFromTimeSpan(timeSpanValue)
126128
: throw ValueNotOfTypeError(nameof(WellKnownTypes.TimeSpanType));
127129
}
128130

@@ -229,7 +231,7 @@ public override object ReadTimeSpan(DbDataReader reader, int index)
229231

230232
// support for full-range of Timespans required us to use raw type
231233
// and construct timespan from its' values.
232-
return ResurrectTimeSpanFromNpgsqlInterval(nativeInterval);
234+
return PostgreSqlHelper.ResurrectTimeSpanFromNpgsqlInterval(nativeInterval);
233235
}
234236

235237
[SecuritySafeCritical]
@@ -272,59 +274,19 @@ public override object ReadDateTimeOffset(DbDataReader reader, int index)
272274
return value;
273275
}
274276
else {
275-
// Probably the "mastermind" who made parameter conversion before setting value to parameter be required
276-
// also forgot about PostgreSQL's built-in "SET TIME ZONE" feature for session, which affects values of TimeStampTz
277-
// Now applications have to use either local/utc timezone everywhere OR somehow "remember" what they've set in SET TIME ZONE
278-
// for being able to get values in the timezone they've set. (facapalm)
279-
//
280-
// BTW, Npgsql has no API that would provide us current connection timezone so we could apply it to values,
281-
// there is internal setting in NpgsqlConnection but it is null no matter what is set by
282-
// 'SET TIME ZONE' statement :-)
283-
//
284-
// We'll use local time, that's it! SET TIME ZONE will not work!
285-
return value.ToLocalTime();
277+
// Here we try to apply connection timezone.
278+
// To not get it from internal connection of DbDataReader
279+
// we assume that time zone switch happens (if happens)
280+
// in DomainConfiguration.ConnectionInitializationSql and
281+
// we cache time zone of native connection after the script
282+
// has been executed.
283+
284+
return (defaultTimeZone.HasValue)
285+
? value.ToOffset(defaultTimeZone.Value)
286+
: value.ToLocalTime();
286287
}
287288
}
288289

289-
protected internal static NpgsqlInterval CreateNativeIntervalFromTimeSpan(in TimeSpan timeSpan)
290-
{
291-
// Previous Npgsql versions used days and time, no months.
292-
// Thought we can write everything as time, we keep days and time format
293-
294-
var ticks = timeSpan.Ticks;
295-
296-
var days = timeSpan.Days;
297-
var timeTicks = ticks - (days * TimeSpan.TicksPerDay);
298-
#if NET7_0_OR_GREATER
299-
var microseconds = timeTicks / TimeSpan.TicksPerMicrosecond;
300-
#else
301-
var microseconds = timeTicks / 10L; // same as TimeSpan.TicksPerMicrosecond available in .NET7+
302-
#endif
303-
// no months!
304-
return new NpgsqlInterval(0, days, microseconds);
305-
}
306-
307-
protected internal static TimeSpan ResurrectTimeSpanFromNpgsqlInterval(in NpgsqlInterval npgsqlInterval)
308-
{
309-
// We don't write "Months" part of NpgsqlInterval to database
310-
// because days in months is variable measure in PostgreSQL.
311-
// We better use exact number of days.
312-
// But if for some reason, there is Months value > 0 we treat it like each month has 30 days,
313-
// it seems that Npgsql did the same assumption internally.
314-
315-
var days = (npgsqlInterval.Months != 0)
316-
? npgsqlInterval.Months * WellKnown.IntervalDaysInMonth + npgsqlInterval.Days
317-
: npgsqlInterval.Days;
318-
319-
var ticksOfDays = days * TimeSpan.TicksPerDay;
320-
#if NET7_0_OR_GREATER
321-
var overallTicks = ticksOfDays + (npgsqlInterval.Time * TimeSpan.TicksPerMicrosecond);
322-
#else
323-
var overallTicks = ticksOfDays + (npgsqlInterval.Time * 10); //same as TimeSpan.TicksPerMicrosecond available in .NET7+
324-
#endif
325-
return TimeSpan.FromTicks(overallTicks);
326-
}
327-
328290
internal protected ArgumentException ValueNotOfTypeError(string typeName)
329291
{
330292
return new ArgumentException($"Value is not of '{typeName}' type.");
@@ -335,7 +297,13 @@ internal protected ArgumentException ValueNotOfTypeError(string typeName)
335297
public TypeMapper(PostgreSql.Driver driver)
336298
: base(driver)
337299
{
338-
legacyTimestampBehaviorEnabled = driver.PostgreServerInfo.LegacyTimestampBehavior;
300+
var postgreServerInfo = driver.PostgreServerInfo;
301+
legacyTimestampBehaviorEnabled = postgreServerInfo.LegacyTimestampBehavior;
302+
defaultTimeZone = postgreServerInfo.ServerTimeZones.TryGetValue(
303+
PostgreSqlHelper.TryGetZoneFromPosix(postgreServerInfo.DefaultTimeZone), out var offset)
304+
? offset
305+
: null;
306+
;
339307
}
340308
}
341309
}

0 commit comments

Comments
 (0)