diff --git a/CHANGELOG.md b/CHANGELOG.md index 160e4f580..29b9d9cee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ # Changelog - v5.1.1 - Fixed CRL validation to reject newly downloaded CRLs if their NextUpdate has already expired. + - Fixed TIMESTAMP_LTZ datatype to honor session TIMEZONE parameter (ALTER SESSION SET TIMEZONE) instead of using local machine timezone. - v5.1.0 - Added `APPLICATION_PATH` to `CLIENT_ENVIRONMENT` sent during authentication to identify the application connecting to Snowflake. - Renew idle sessions in the pool if keep alive is enabled. diff --git a/Snowflake.Data.Tests/IntegrationTests/SFDbDataReaderIT.cs b/Snowflake.Data.Tests/IntegrationTests/SFDbDataReaderIT.cs index d39f96625..b9b9af76c 100755 --- a/Snowflake.Data.Tests/IntegrationTests/SFDbDataReaderIT.cs +++ b/Snowflake.Data.Tests/IntegrationTests/SFDbDataReaderIT.cs @@ -1667,6 +1667,110 @@ public void TestDataTableLoadOnSemiStructuredColumn(string type) } } + [Test] + public void TestTimestampLtzHonorsSessionTimezone() + { + using (var conn = CreateAndOpenConnection()) + { + CreateOrReplaceTable(conn, "test_timestamp_ltz_timezone", new[] { "val TIMESTAMP_LTZ" }); + + using (var cmd = conn.CreateCommand()) + { + cmd.CommandText = "ALTER SESSION SET TIMEZONE = 'Europe/Warsaw'"; + cmd.ExecuteNonQuery(); + + cmd.CommandText = "INSERT INTO test_timestamp_ltz_timezone VALUES('2023-08-09 10:00:00')"; + cmd.ExecuteNonQuery(); + + cmd.CommandText = "SELECT * FROM test_timestamp_ltz_timezone"; + using (var reader = cmd.ExecuteReader()) + { + Assert.IsTrue(reader.Read(), "Should read a record"); + var timestamp1 = reader.GetDateTime(0); + + var warsawTz = TimeZoneConverter.TZConvert.GetTimeZoneInfo("Europe/Warsaw"); + var expectedTime1 = new DateTime(2023, 8, 9, 10, 0, 0, DateTimeKind.Unspecified); + var expectedUtc1 = TimeZoneInfo.ConvertTimeToUtc(expectedTime1, warsawTz); + var expectedInWarsaw = TimeZoneInfo.ConvertTimeFromUtc(expectedUtc1, warsawTz); + + Assert.AreEqual(expectedInWarsaw, timestamp1, + $"Timestamp should be returned in Warsaw timezone. Expected: {expectedInWarsaw}, Got: {timestamp1}"); + } + + cmd.CommandText = "ALTER SESSION SET TIMEZONE = 'Pacific/Honolulu'"; + cmd.ExecuteNonQuery(); + + cmd.CommandText = "SELECT * FROM test_timestamp_ltz_timezone"; + using (var reader = cmd.ExecuteReader()) + { + Assert.IsTrue(reader.Read(), "Should read a record"); + var timestamp2 = reader.GetDateTime(0); + + var honoluluTz = TimeZoneConverter.TZConvert.GetTimeZoneInfo("Pacific/Honolulu"); + var warsawTz = TimeZoneConverter.TZConvert.GetTimeZoneInfo("Europe/Warsaw"); + + var originalTimeInWarsaw = new DateTime(2023, 8, 9, 10, 0, 0, DateTimeKind.Unspecified); + var utcTime = TimeZoneInfo.ConvertTimeToUtc(originalTimeInWarsaw, warsawTz); + var expectedInHonolulu = TimeZoneInfo.ConvertTimeFromUtc(utcTime, honoluluTz); + + Assert.AreEqual(expectedInHonolulu, timestamp2, + $"Timestamp should be returned in Honolulu timezone. Expected: {expectedInHonolulu}, Got: {timestamp2}"); + } + } + + CloseConnection(conn); + } + } + + [Test] + public void TestTimestampLtzWithMultipleSessionTimezones() + { + using (var conn = CreateAndOpenConnection()) + { + CreateOrReplaceTable(conn, "test_ltz_multi_tz", new[] { "val TIMESTAMP_LTZ" }); + + using (var cmd = conn.CreateCommand()) + { + cmd.CommandText = "ALTER SESSION SET TIMEZONE = 'UTC'"; + cmd.ExecuteNonQuery(); + + cmd.CommandText = "INSERT INTO test_ltz_multi_tz VALUES('2024-01-01 00:00:00')"; + cmd.ExecuteNonQuery(); + + var utcBase = new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc); + + // Test reading with different timezones + var timezones = new[] + { + "Europe/Warsaw", + "Asia/Tokyo", + "America/Los_Angeles" + }; + + foreach (var tzName in timezones) + { + cmd.CommandText = $"ALTER SESSION SET TIMEZONE = '{tzName}'"; + cmd.ExecuteNonQuery(); + + cmd.CommandText = "SELECT val FROM test_ltz_multi_tz"; + using (var reader = cmd.ExecuteReader()) + { + Assert.IsTrue(reader.Read()); + var timestamp = reader.GetDateTime(0); + + var tz = TimeZoneConverter.TZConvert.GetTimeZoneInfo(tzName); + var expected = TimeZoneInfo.ConvertTimeFromUtc(utcBase, tz); + + Assert.AreEqual(expected, timestamp, + $"TIMESTAMP_LTZ should be in {tzName} timezone"); + } + } + } + + CloseConnection(conn); + } + } + private DbConnection CreateAndOpenConnection() { var conn = new SnowflakeDbConnection(ConnectionString); diff --git a/Snowflake.Data.Tests/IntegrationTests/StructuredTypesWithEmbeddedUnstructuredIT.cs b/Snowflake.Data.Tests/IntegrationTests/StructuredTypesWithEmbeddedUnstructuredIT.cs index e9f27fd5a..f414ff258 100644 --- a/Snowflake.Data.Tests/IntegrationTests/StructuredTypesWithEmbeddedUnstructuredIT.cs +++ b/Snowflake.Data.Tests/IntegrationTests/StructuredTypesWithEmbeddedUnstructuredIT.cs @@ -549,5 +549,55 @@ private void SetTimePrecision(SnowflakeDbConnection connection, int precision) command.ExecuteNonQuery(); } } + + [Test] + public void TestStructuredTypeWithTimestampLtzHonorsSessionTimezone() + { + using (var connection = new SnowflakeDbConnection(ConnectionString)) + { + connection.Open(); + + using (var command = connection.CreateCommand()) + { + command.CommandText = "ALTER SESSION SET TIMEZONE = 'Europe/Warsaw'"; + command.ExecuteNonQuery(); + } + + CreateOrReplaceTable(connection, "test_struct_ltz", new[] + { + "id INT", + "data OBJECT(timestamp_value TIMESTAMP_LTZ)" + }); + + using (var command = connection.CreateCommand()) + { + command.CommandText = "INSERT INTO test_struct_ltz SELECT 1, {'timestamp_value': '2024-03-20 15:45:30'::TIMESTAMP_LTZ}"; + command.ExecuteNonQuery(); + + command.CommandText = "SELECT data FROM test_struct_ltz"; + using (var reader = (SnowflakeDbDataReader)command.ExecuteReader()) + { + Assert.IsTrue(reader.Read()); + + var obj = reader.GetObject(0); + + var warsawTz = TimeZoneConverter.TZConvert.GetTimeZoneInfo("Europe/Warsaw"); + var inputTime = new DateTime(2024, 3, 20, 15, 45, 30, DateTimeKind.Unspecified); + var utcTime = TimeZoneInfo.ConvertTimeToUtc(inputTime, warsawTz); + var expectedInWarsaw = TimeZoneInfo.ConvertTimeFromUtc(utcTime, warsawTz); + + Assert.AreEqual(expectedInWarsaw, obj.TimestampValue, + "Structured type TIMESTAMP_LTZ should honor session timezone"); + } + } + } + } + + [SnowflakeObject(ConstructionMethod = SnowflakeObjectConstructionMethod.PROPERTIES_NAMES)] + private class TestObjectWithTimestampLtz + { + [SnowflakeColumn(Name = "timestamp_value")] + public DateTime TimestampValue { get; set; } + } } } diff --git a/Snowflake.Data/Client/SnowflakeDbDataReader.cs b/Snowflake.Data/Client/SnowflakeDbDataReader.cs index 1b0ae172e..cb2384751 100755 --- a/Snowflake.Data/Client/SnowflakeDbDataReader.cs +++ b/Snowflake.Data/Client/SnowflakeDbDataReader.cs @@ -264,7 +264,8 @@ public T GetObject(int ordinal) } var stringValue = GetString(ordinal); var json = stringValue == null ? null : JObject.Parse(stringValue); - return JsonToStructuredTypeConverter.ConvertObject(fields, json); + var sessionTimezone = resultSet.sfStatement.SfSession.GetSessionTimezone(); + return JsonToStructuredTypeConverter.ConvertObject(fields, json, sessionTimezone); } catch (Exception e) { @@ -289,7 +290,8 @@ public T[] GetArray(int ordinal) var stringValue = GetString(ordinal); var json = stringValue == null ? null : JArray.Parse(stringValue); - return JsonToStructuredTypeConverter.ConvertArray(fields, json); + var sessionTimezone = resultSet.sfStatement.SfSession.GetSessionTimezone(); + return JsonToStructuredTypeConverter.ConvertArray(fields, json, sessionTimezone); } catch (Exception e) { @@ -312,7 +314,8 @@ public Dictionary GetMap(int ordinal) var stringValue = GetString(ordinal); var json = stringValue == null ? null : JObject.Parse(stringValue); - return JsonToStructuredTypeConverter.ConvertMap(fields, json); + var sessionTimezone = resultSet.sfStatement.SfSession.GetSessionTimezone(); + return JsonToStructuredTypeConverter.ConvertMap(fields, json, sessionTimezone); } catch (Exception e) { diff --git a/Snowflake.Data/Core/ArrowResultChunk.cs b/Snowflake.Data/Core/ArrowResultChunk.cs index c9213e827..547dab6d5 100755 --- a/Snowflake.Data/Core/ArrowResultChunk.cs +++ b/Snowflake.Data/Core/ArrowResultChunk.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Text; using Apache.Arrow; +using Snowflake.Data.Client; namespace Snowflake.Data.Core { @@ -139,7 +140,7 @@ public override UTF8Buffer ExtractCell(int columnIndex) throw new NotSupportedException(); } - public object ExtractCell(int columnIndex, SFDataType srcType, long scale) + public object ExtractCell(int columnIndex, SFDataType srcType, long scale, TimeZoneInfo sessionTimezone) { var column = RecordBatch[_currentBatchIndex].Column(columnIndex); @@ -313,6 +314,12 @@ public object ExtractCell(int columnIndex, SFDataType srcType, long scale) } case SFDataType.TIMESTAMP_LTZ: + if (sessionTimezone == null) + { + throw new SnowflakeDbException(SFError.INTERNAL_ERROR, + "Session timezone is required for TIMESTAMP_LTZ conversion"); + } + if (column.GetType() == typeof(StructArray)) { if (_long[columnIndex] == null) @@ -321,7 +328,8 @@ public object ExtractCell(int columnIndex, SFDataType srcType, long scale) _fraction[columnIndex] = ((Int32Array)((StructArray)column).Fields[1]).Values.ToArray(); var epoch = _long[columnIndex][_currentRecordIndex]; var fraction = _fraction[columnIndex][_currentRecordIndex]; - return s_epochDate.AddSeconds(epoch).AddTicks(fraction / 100).ToLocalTime(); + var utcDateTime = s_epochDate.AddSeconds(epoch).AddTicks(fraction / 100); + return TimeZoneInfo.ConvertTimeFromUtc(utcDateTime.UtcDateTime, sessionTimezone); } else { @@ -331,7 +339,8 @@ public object ExtractCell(int columnIndex, SFDataType srcType, long scale) var value = _long[columnIndex][_currentRecordIndex]; var epoch = ExtractEpoch(value, scale); var fraction = ExtractFraction(value, scale); - return s_epochDate.AddSeconds(epoch).AddTicks(fraction / 100).ToLocalTime(); + var utcDateTime = s_epochDate.AddSeconds(epoch).AddTicks(fraction / 100); + return TimeZoneInfo.ConvertTimeFromUtc(utcDateTime.UtcDateTime, sessionTimezone); } case SFDataType.TIMESTAMP_NTZ: diff --git a/Snowflake.Data/Core/ArrowResultSet.cs b/Snowflake.Data/Core/ArrowResultSet.cs index 3859fd501..6aa3b47e8 100755 --- a/Snowflake.Data/Core/ArrowResultSet.cs +++ b/Snowflake.Data/Core/ArrowResultSet.cs @@ -158,8 +158,9 @@ private object GetObjectInternal(int ordinal) var type = sfResultSetMetaData.GetTypesByIndex(ordinal).Item1; var scale = sfResultSetMetaData.GetScaleByIndex(ordinal); + var sessionTimezone = sfStatement.SfSession.GetSessionTimezone(); - var value = ((ArrowResultChunk)_currentChunk).ExtractCell(ordinal, type, (int)scale); + var value = ((ArrowResultChunk)_currentChunk).ExtractCell(ordinal, type, (int)scale, sessionTimezone); return value ?? DBNull.Value; diff --git a/Snowflake.Data/Core/Converter/JsonToStructuredTypeConverter.cs b/Snowflake.Data/Core/Converter/JsonToStructuredTypeConverter.cs index fae3a83b0..6bbb72914 100644 --- a/Snowflake.Data/Core/Converter/JsonToStructuredTypeConverter.cs +++ b/Snowflake.Data/Core/Converter/JsonToStructuredTypeConverter.cs @@ -13,29 +13,28 @@ internal static class JsonToStructuredTypeConverter { private static readonly TimeConverter s_timeConverter = new TimeConverter(); - public static T ConvertObject(List fields, JObject value) + public static T ConvertObject(List fields, JObject value, TimeZoneInfo sessionTimezone) { var type = typeof(T); - return (T)ConvertToObject(type, fields, new StructurePath(), value); + return (T)ConvertToObject(type, fields, new StructurePath(), value, sessionTimezone); } - public static T[] ConvertArray(List fields, JArray value) + public static T[] ConvertArray(List fields, JArray value, TimeZoneInfo sessionTimezone) { var type = typeof(T[]); var elementType = typeof(T); - return (T[])ConvertToArray(type, elementType, fields, new StructurePath(), value); + return (T[])ConvertToArray(type, elementType, fields, new StructurePath(), value, sessionTimezone); } - public static Dictionary ConvertMap(List fields, JObject value) + public static Dictionary ConvertMap(List fields, JObject value, TimeZoneInfo sessionTimezone) { var type = typeof(Dictionary); var keyType = typeof(TKey); var valueType = typeof(TValue); - return (Dictionary)ConvertToMap(type, keyType, valueType, fields, new StructurePath(), value); + return (Dictionary)ConvertToMap(type, keyType, valueType, fields, new StructurePath(), value, sessionTimezone); } - - private static object ConvertToObject(Type type, List fields, StructurePath structurePath, JToken json) + private static object ConvertToObject(Type type, List fields, StructurePath structurePath, JToken json, TimeZoneInfo sessionTimezone) { if (json == null || json.Type == JTokenType.Null || json.Type == JTokenType.Undefined) { @@ -75,7 +74,7 @@ private static object ConvertToObject(Type type, List fields, Str try { var fieldType = objectBuilder.MoveNext(key); - var value = ConvertToStructuredOrUnstructuredValue(fieldType, fieldMetadata, propertyPath, fieldValue); + var value = ConvertToStructuredOrUnstructuredValue(fieldType, fieldMetadata, propertyPath, fieldValue, sessionTimezone); objectBuilder.BuildPart(value); } catch (Exception e) @@ -98,7 +97,7 @@ private static SnowflakeObjectConstructionMethod GetConstructionMethod(Type type .FirstOrDefault(); } - private static object ConvertToUnstructuredType(FieldMetadata fieldMetadata, Type fieldType, JToken json) + private static object ConvertToUnstructuredType(FieldMetadata fieldMetadata, Type fieldType, JToken json, TimeZoneInfo sessionTimezone) { if (json == null || json.Type == JTokenType.Null || json.Type == JTokenType.Undefined) { @@ -185,27 +184,27 @@ private static object ConvertToUnstructuredType(FieldMetadata fieldMetadata, Typ if (IsTimestampNtzMetadata(fieldMetadata)) { var value = json.Value(); - return s_timeConverter.Convert(value, SFDataType.TIMESTAMP_NTZ, fieldType); + return s_timeConverter.Convert(value, SFDataType.TIMESTAMP_NTZ, fieldType, sessionTimezone); } if (IsTimestampLtzMetadata(fieldMetadata)) { var value = json.Value(); - return s_timeConverter.Convert(value, SFDataType.TIMESTAMP_LTZ, fieldType); + return s_timeConverter.Convert(value, SFDataType.TIMESTAMP_LTZ, fieldType, sessionTimezone); } if (IsTimestampTzMetadata(fieldMetadata)) { var value = json.Value(); - return s_timeConverter.Convert(value, SFDataType.TIMESTAMP_TZ, fieldType); + return s_timeConverter.Convert(value, SFDataType.TIMESTAMP_TZ, fieldType, sessionTimezone); } if (IsTimeMetadata(fieldMetadata)) { var value = json.Value(); - return s_timeConverter.Convert(value, SFDataType.TIME, fieldType); + return s_timeConverter.Convert(value, SFDataType.TIME, fieldType, sessionTimezone); } if (IsDateMetadata(fieldMetadata)) { var value = json.Value(); - return s_timeConverter.Convert(value, SFDataType.DATE, fieldType); + return s_timeConverter.Convert(value, SFDataType.DATE, fieldType, sessionTimezone); } if (IsBinaryMetadata(fieldMetadata)) { @@ -231,7 +230,7 @@ private static object ConvertToUnstructuredType(FieldMetadata fieldMetadata, Typ throw new StructuredTypesReadingException($"Could not read {fieldMetadata.type} type into {fieldType}"); } - private static object ConvertToArray(Type type, Type elementType, List fields, StructurePath structurePath, JToken json) + private static object ConvertToArray(Type type, Type elementType, List fields, StructurePath structurePath, JToken json, TimeZoneInfo sessionTimezone) { if (json == null || json.Type == JTokenType.Null || json.Type == JTokenType.Undefined) { @@ -248,7 +247,7 @@ private static object ConvertToArray(Type type, Type elementType, List fields, StructurePath structurePath, JToken json) + private static object ConvertToMap(Type type, Type keyType, Type valueType, List fields, StructurePath structurePath, JToken json, TimeZoneInfo sessionTimezone) { if (keyType != typeof(string) && keyType != typeof(int) && keyType != typeof(int?) @@ -299,37 +298,37 @@ private static object ConvertToMap(Type type, Type keyType, Type valueType, List var jsonPropertyWithValue = jsonEnumerator.Current; var fieldValue = jsonPropertyWithValue.Value; var key = IsTextMetadata(keyMetadata) || IsFixedMetadata(keyMetadata) - ? ConvertToUnstructuredType(keyMetadata, keyType, jsonPropertyWithValue.Key) + ? ConvertToUnstructuredType(keyMetadata, keyType, jsonPropertyWithValue.Key, sessionTimezone) : throw new StructuredTypesReadingException($"Unsupported key type for map {keyMetadata.type}. Occured for path {mapElementPath}"); - var value = ConvertToStructuredOrUnstructuredValue(valueType, fieldMetadata, mapElementPath, fieldValue); + var value = ConvertToStructuredOrUnstructuredValue(valueType, fieldMetadata, mapElementPath, fieldValue, sessionTimezone); result.Add(key, value); } } return result; } - private static object ConvertToStructuredOrUnstructuredValue(Type valueType, FieldMetadata fieldMetadata, StructurePath structurePath, JToken fieldValue) + private static object ConvertToStructuredOrUnstructuredValue(Type valueType, FieldMetadata fieldMetadata, StructurePath structurePath, JToken fieldValue, TimeZoneInfo sessionTimezone) { try { if (IsObjectMetadata(fieldMetadata) && IsStructuredMetadata(fieldMetadata)) { - return ConvertToObject(valueType, fieldMetadata.fields, structurePath, fieldValue); + return ConvertToObject(valueType, fieldMetadata.fields, structurePath, fieldValue, sessionTimezone); } if (IsArrayMetadata(fieldMetadata) && IsStructuredMetadata(fieldMetadata)) { var nestedType = GetNestedType(valueType); - return ConvertToArray(valueType, nestedType, fieldMetadata.fields, structurePath, fieldValue); + return ConvertToArray(valueType, nestedType, fieldMetadata.fields, structurePath, fieldValue, sessionTimezone); } if (IsMapMetadata(fieldMetadata) && IsStructuredMetadata(fieldMetadata)) { var keyValueTypes = GetMapKeyValueTypes(valueType); - return ConvertToMap(valueType, keyValueTypes[0], keyValueTypes[1], fieldMetadata.fields, structurePath, fieldValue); + return ConvertToMap(valueType, keyValueTypes[0], keyValueTypes[1], fieldMetadata.fields, structurePath, fieldValue, sessionTimezone); } - return ConvertToUnstructuredType(fieldMetadata, valueType, fieldValue); + return ConvertToUnstructuredType(fieldMetadata, valueType, fieldValue, sessionTimezone); } catch (Exception e) { diff --git a/Snowflake.Data/Core/Converter/TimeConverter.cs b/Snowflake.Data/Core/Converter/TimeConverter.cs index c0db6824a..df2940e47 100644 --- a/Snowflake.Data/Core/Converter/TimeConverter.cs +++ b/Snowflake.Data/Core/Converter/TimeConverter.cs @@ -4,7 +4,7 @@ namespace Snowflake.Data.Core.Converter { internal class TimeConverter { - public object Convert(string value, SFDataType timestampType, Type fieldType) + public object Convert(string value, SFDataType timestampType, Type fieldType, TimeZoneInfo sessionTimezone) { if (fieldType == typeof(string)) { @@ -42,14 +42,22 @@ public object Convert(string value, SFDataType timestampType, Type fieldType) } if (timestampType == SFDataType.TIMESTAMP_LTZ) { - var dateTimeOffsetLocal = DateTimeOffset.Parse(value).ToLocalTime(); + if (sessionTimezone == null) + { + throw new StructuredTypesReadingException("Session timezone is required for TIMESTAMP_LTZ conversion"); + } + + var utcDateTimeOffset = DateTimeOffset.Parse(value); + var localDateTime = TimeZoneInfo.ConvertTimeFromUtc(utcDateTimeOffset.UtcDateTime, sessionTimezone); + var dateTimeOffsetInSessionTz = new DateTimeOffset(localDateTime, sessionTimezone.GetUtcOffset(localDateTime)); + if (fieldType == typeof(DateTimeOffset) || fieldType == typeof(DateTimeOffset?)) { - return dateTimeOffsetLocal; + return dateTimeOffsetInSessionTz; } if (fieldType == typeof(DateTime) || fieldType == typeof(DateTime?)) { - return dateTimeOffsetLocal.LocalDateTime; + return dateTimeOffsetInSessionTz.DateTime; } throw new StructuredTypesReadingException($"Cannot read TIMESTAMP_LTZ into {fieldType} type"); } diff --git a/Snowflake.Data/Core/SFBindUploader.cs b/Snowflake.Data/Core/SFBindUploader.cs index cb4644b75..d337c0a0f 100644 --- a/Snowflake.Data/Core/SFBindUploader.cs +++ b/Snowflake.Data/Core/SFBindUploader.cs @@ -264,8 +264,10 @@ internal string GetCSVData(string sType, string sValue) ? nsLtz / 100 : (long)(decimal.Parse(sValue) / 100); - DateTime ltz = epoch.AddTicks(ticksFromEpochLtz); - return ltz.ToLocalTime().ToString("O"); // ISO 8601 format + DateTime utcDateTime = DateTime.SpecifyKind(epoch.AddTicks(ticksFromEpochLtz), DateTimeKind.Utc); + var sessionTimezone = session.GetSessionTimezone(); + DateTime ltz = TimeZoneInfo.ConvertTimeFromUtc(utcDateTime, sessionTimezone); + return new DateTimeOffset(ltz, sessionTimezone.GetUtcOffset(ltz)).ToString("O"); // ISO 8601 format case "TIMESTAMP_NTZ": long ticksFromEpochNtz = long.TryParse(sValue, out var nsNtz) diff --git a/Snowflake.Data/Core/SFDataConverter.cs b/Snowflake.Data/Core/SFDataConverter.cs index 7404a9957..1b7601449 100755 --- a/Snowflake.Data/Core/SFDataConverter.cs +++ b/Snowflake.Data/Core/SFDataConverter.cs @@ -41,7 +41,7 @@ static class SFDataConverter [typeof(object)] = DbType.Object }; - internal static object ConvertToCSharpVal(UTF8Buffer srcVal, SFDataType srcType, Type destType) + internal static object ConvertToCSharpVal(UTF8Buffer srcVal, SFDataType srcType, Type destType, TimeZoneInfo sessionTimezone = null) { if (srcVal == null) return DBNull.Value; @@ -67,7 +67,7 @@ internal static object ConvertToCSharpVal(UTF8Buffer srcVal, SFDataType srcType, } else if (destType == typeof(DateTime)) { - return ConvertToDateTime(srcVal, srcType); + return ConvertToDateTime(srcVal, srcType, sessionTimezone); } else if (destType == typeof(TimeSpan)) { @@ -75,7 +75,7 @@ internal static object ConvertToCSharpVal(UTF8Buffer srcVal, SFDataType srcType, } else if (destType == typeof(DateTimeOffset)) { - return ConvertToDateTimeOffset(srcVal, srcType); + return ConvertToDateTimeOffset(srcVal, srcType, sessionTimezone); } else if (destType == typeof(Boolean)) { @@ -142,7 +142,7 @@ private static object ConvertToTimeSpan(UTF8Buffer srcVal, SFDataType srcType) } } - private static DateTime ConvertToDateTime(UTF8Buffer srcVal, SFDataType srcType) + private static DateTime ConvertToDateTime(UTF8Buffer srcVal, SFDataType srcType, TimeZoneInfo sessionTimezone) { switch (srcType) { @@ -155,12 +155,22 @@ private static DateTime ConvertToDateTime(UTF8Buffer srcVal, SFDataType srcType) var tickDiff = GetTicksFromSecondAndNanosecond(srcVal); return DateTime.SpecifyKind(UnixEpoch.AddTicks(tickDiff), DateTimeKind.Unspecified); + case SFDataType.TIMESTAMP_LTZ: + if (sessionTimezone == null) + { + throw new SnowflakeDbException(SFError.INTERNAL_ERROR, + "Session timezone is required for TIMESTAMP_LTZ conversion"); + } + var tickDiffLtz = GetTicksFromSecondAndNanosecond(srcVal); + var utcDateTime = DateTime.SpecifyKind(UnixEpoch.AddTicks(tickDiffLtz), DateTimeKind.Utc); + return TimeZoneInfo.ConvertTimeFromUtc(utcDateTime, sessionTimezone); + default: throw new SnowflakeDbException(SFError.INVALID_DATA_CONVERSION, srcVal, srcType, typeof(DateTime)); } } - private static DateTimeOffset ConvertToDateTimeOffset(UTF8Buffer srcVal, SFDataType srcType) + private static DateTimeOffset ConvertToDateTimeOffset(UTF8Buffer srcVal, SFDataType srcType, TimeZoneInfo sessionTimezone) { switch (srcType) { @@ -180,8 +190,15 @@ private static DateTimeOffset ConvertToDateTimeOffset(UTF8Buffer srcVal, SFDataT return new DateTimeOffset(UnixEpoch.Ticks + GetTicksFromSecondAndNanosecond(timeVal), TimeSpan.Zero).ToOffset(offSetTimespan); } case SFDataType.TIMESTAMP_LTZ: - return new DateTimeOffset(UnixEpoch.Ticks + - GetTicksFromSecondAndNanosecond(srcVal), TimeSpan.Zero).ToLocalTime(); + if (sessionTimezone == null) + { + throw new SnowflakeDbException(SFError.INTERNAL_ERROR, + "Session timezone is required for TIMESTAMP_LTZ conversion"); + } + var utcDateTimeOffset = new DateTimeOffset(UnixEpoch.Ticks + + GetTicksFromSecondAndNanosecond(srcVal), TimeSpan.Zero); + var localDateTime = TimeZoneInfo.ConvertTimeFromUtc(utcDateTimeOffset.UtcDateTime, sessionTimezone); + return new DateTimeOffset(localDateTime, sessionTimezone.GetUtcOffset(localDateTime)); default: throw new SnowflakeDbException(SFError.INVALID_DATA_CONVERSION, srcVal, diff --git a/Snowflake.Data/Core/SFResultSet.cs b/Snowflake.Data/Core/SFResultSet.cs index 2dffebd65..6a96f52f7 100755 --- a/Snowflake.Data/Core/SFResultSet.cs +++ b/Snowflake.Data/Core/SFResultSet.cs @@ -291,14 +291,16 @@ internal override object GetValue(int ordinal) { UTF8Buffer val = GetObjectInternal(ordinal); var types = sfResultSetMetaData.GetTypesByIndex(ordinal); - return SFDataConverter.ConvertToCSharpVal(val, types.Item1, types.Item2); + var sessionTimezone = sfStatement.SfSession.GetSessionTimezone(); + return SFDataConverter.ConvertToCSharpVal(val, types.Item1, types.Item2, sessionTimezone); } private T GetValue(int ordinal) { UTF8Buffer val = GetObjectInternal(ordinal); var types = sfResultSetMetaData.GetTypesByIndex(ordinal); - return (T)SFDataConverter.ConvertToCSharpVal(val, types.Item1, typeof(T)); + var sessionTimezone = sfStatement.SfSession.GetSessionTimezone(); + return (T)SFDataConverter.ConvertToCSharpVal(val, types.Item1, typeof(T), sessionTimezone); } // diff --git a/Snowflake.Data/Core/Session/SFSession.cs b/Snowflake.Data/Core/Session/SFSession.cs index 4581b15e3..964f09d5c 100644 --- a/Snowflake.Data/Core/Session/SFSession.cs +++ b/Snowflake.Data/Core/Session/SFSession.cs @@ -553,6 +553,24 @@ internal RequestQueryContext GetQueryContextRequest() return _queryContextCache.GetQueryContextRequest(); } + internal TimeZoneInfo GetSessionTimezone() + { + if (ParameterMap.TryGetValue(SFSessionParameter.TIMEZONE, out var value)) + { + var timezoneString = value.ToString(); + try + { + return TimeZoneConverter.TZConvert.GetTimeZoneInfo(timezoneString); + } + catch (TimeZoneNotFoundException) + { + logger.Warn($"Session timezone '{timezoneString}' not found, falling back to local time"); + return TimeZoneInfo.Local; + } + } + return TimeZoneInfo.Local; + } + internal void UpdateSessionProperties(QueryExecResponseData responseData) { // with HTAP session metadata removal database/schema might be not returned in query result diff --git a/Snowflake.Data/Core/Session/SFSessionParameter.cs b/Snowflake.Data/Core/Session/SFSessionParameter.cs index b0a33b6d5..daf6f2fb6 100644 --- a/Snowflake.Data/Core/Session/SFSessionParameter.cs +++ b/Snowflake.Data/Core/Session/SFSessionParameter.cs @@ -11,6 +11,7 @@ internal enum SFSessionParameter DATE_OUTPUT_FORMAT, TIME_OUTPUT_FORMAT, CLIENT_REQUEST_MFA_TOKEN, - CLIENT_STORE_TEMPORARY_CREDENTIAL + CLIENT_STORE_TEMPORARY_CREDENTIAL, + TIMEZONE } } diff --git a/Snowflake.Data/Snowflake.Data.csproj b/Snowflake.Data/Snowflake.Data.csproj index 883e2296f..41c351cb6 100644 --- a/Snowflake.Data/Snowflake.Data.csproj +++ b/Snowflake.Data/Snowflake.Data.csproj @@ -1,65 +1,66 @@ - - - netstandard2.0 - Snowflake.Data - Snowflake.Data - Apache-2.0 - https://github.com/snowflakedb/snowflake-connector-net - true - https://raw.githubusercontent.com/snowflakedb/snowflake-connector-net/master/Snowflake.Data/snowflake.ico - https://github.com/snowflakedb/snowflake-connector-net - git - Snowflake Connector for .NET - Snowflake Computing, Inc - Snowflake Connector for .NET - Snowflake - 5.1.0 - Full - 8 - - - - - - - - - - - - - - - - - - - - - - - - - full - True - - - - full - True - - - - true - true - $(Version) - - - - $(DefineConstants);$(DefineAdditionalConstants) - - - - - - + + + netstandard2.0 + Snowflake.Data + Snowflake.Data + Apache-2.0 + https://github.com/snowflakedb/snowflake-connector-net + true + https://raw.githubusercontent.com/snowflakedb/snowflake-connector-net/master/Snowflake.Data/snowflake.ico + https://github.com/snowflakedb/snowflake-connector-net + git + Snowflake Connector for .NET + Snowflake Computing, Inc + Snowflake Connector for .NET + Snowflake + 5.1.0 + Full + 8 + + + + + + + + + + + + + + + + + + + + + + + + + + full + True + + + + full + True + + + + true + true + $(Version) + + + + $(DefineConstants);$(DefineAdditionalConstants) + + + + + +