diff --git a/CHANGELOG.md b/CHANGELOG.md index ae000ca30..bcff44e14 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ # Changelog - v5.3.0 - Introduced shared library for extended telemetry to identify and prepare testing platform for native rust extensions. + - Fixed TIMESTAMP_LTZ datatype to honor session TIMEZONE parameter (ALTER SESSION SET TIMEZONE) instead of using local machine timezone. - v5.2.1 - Bug fix: Fix the extremely rare case where intermittent network issues during uploads to Azure Blob Storage prevent metadata updates - v5.2.0 diff --git a/Snowflake.Data.Tests/IntegrationTests/SFDbDataReaderIT.cs b/Snowflake.Data.Tests/IntegrationTests/SFDbDataReaderIT.cs index d39f96625..501e72bb2 100755 --- a/Snowflake.Data.Tests/IntegrationTests/SFDbDataReaderIT.cs +++ b/Snowflake.Data.Tests/IntegrationTests/SFDbDataReaderIT.cs @@ -533,9 +533,13 @@ public void TestGetTimestampLTZ() { using (var conn = CreateAndOpenConnection()) { + IDbCommand setTimezoneCmd = conn.CreateCommand(); + setTimezoneCmd.CommandText = "ALTER SESSION SET TIMEZONE = 'America/Los_Angeles'"; + setTimezoneCmd.ExecuteNonQuery(); + CreateOrReplaceTable(conn, TableName, new[] { "cola TIMESTAMP_LTZ" }); - DateTimeOffset now = DateTimeOffset.Now; + DateTimeOffset insertValue = new DateTimeOffset(2024, 1, 15, 18, 30, 45, 123, TimeSpan.Zero); IDbCommand cmd = conn.CreateCommand(); @@ -544,7 +548,7 @@ public void TestGetTimestampLTZ() var p1 = (SnowflakeDbParameter)cmd.CreateParameter(); p1.ParameterName = "1"; - p1.Value = now; + p1.Value = insertValue; p1.DbType = DbType.DateTimeOffset; p1.SFDataType = Core.SFDataType.TIMESTAMP_LTZ; cmd.Parameters.Add(p1); @@ -561,8 +565,8 @@ public void TestGetTimestampLTZ() DateTimeOffset dtOffset = (DateTimeOffset)reader.GetValue(0); reader.Close(); - Assert.AreEqual(now, dtOffset); - Assert.AreEqual(now.Offset, dtOffset.Offset); + Assert.AreEqual(insertValue.UtcDateTime, dtOffset.UtcDateTime); + Assert.AreEqual(TimeSpan.FromHours(-8), dtOffset.Offset); CloseConnection(conn); } @@ -1583,16 +1587,20 @@ public void TestTimestampTz(string testValue, int scale) } [Test] - [TestCase("2019-01-01 12:12:12.1234567 +0500", 7)] - [TestCase("2019-01-01 12:12:12.1234567 +1400", 7)] - [TestCase("0001-01-01 00:00:00.0000000 +0000", 9)] - [TestCase("9999-12-31 23:59:59.9999999 +0000", 9)] - public void TestTimestampLtz(string testValue, int scale) + [TestCase("2019-01-01 12:12:12.1234567 +0200", 7, "2019-01-01 02:12:12.1234567 -08:00")] + [TestCase("2019-01-01 12:12:12.1234567 +1400", 7, "2018-12-31 14:12:12.1234567 -08:00")] + [TestCase("1883-11-19 00:00:00.0000000 +0000", 9, "1883-11-18 16:00:00.0000000 -08:00")] // date when time zones were standardized + [TestCase("9999-12-31 23:59:59.9999999 +0000", 9, "9999-12-31 15:59:59.9999999 -08:00")] + [TestCase("2019-01-01 12:12:12.1234567", 7, "2019-01-01 12:12:12.1234567 -08:00")] + public void TestTimestampLtz(string testValue, int scale, string expectedValue) { using (var conn = CreateAndOpenConnection()) { DbCommand cmd = conn.CreateCommand(); + cmd.CommandText = "ALTER SESSION SET TIMEZONE = 'America/Los_Angeles'"; + cmd.ExecuteNonQuery(); + cmd.CommandText = $"select '{testValue}'::TIMESTAMP_LTZ({scale})"; using (SnowflakeDbDataReader reader = (SnowflakeDbDataReader)cmd.ExecuteReader()) { @@ -1600,9 +1608,9 @@ public void TestTimestampLtz(string testValue, int scale) reader.Read(); - var expectedValue = DateTimeOffset.Parse(testValue).ToLocalTime(); + var expected = DateTimeOffset.Parse(expectedValue); - Assert.AreEqual(expectedValue, reader.GetValue(0)); + Assert.AreEqual(expected, reader.GetValue(0)); } CloseConnection(conn); @@ -1667,6 +1675,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..528e91e6a 100644 --- a/Snowflake.Data.Tests/IntegrationTests/StructuredTypesWithEmbeddedUnstructuredIT.cs +++ b/Snowflake.Data.Tests/IntegrationTests/StructuredTypesWithEmbeddedUnstructuredIT.cs @@ -288,6 +288,9 @@ public void TestSelectDateTime(string dbValue, string dbType, DateTime? expected connection.Open(); using (var command = connection.CreateCommand()) { + command.CommandText = "ALTER SESSION SET TIMEZONE = 'America/Los_Angeles'"; + command.ExecuteNonQuery(); + EnableStructuredTypes(connection); SetTimePrecision(connection, 9); var rawValueString = $"'{dbValue}'::{dbType}"; @@ -328,10 +331,10 @@ internal static IEnumerable DateTimeConversionCases() }; yield return new object[] { - "2024-07-11 14:20:05 -7:00", + "2024-07-11 14:20:05", SFDataType.TIMESTAMP_LTZ.ToString(), null, - DateTime.Parse("2024-07-11 21:20:05").ToLocalTime() + DateTime.SpecifyKind(DateTime.Parse("2024-07-11 14:20:05"), DateTimeKind.Local) }; yield return new object[] { @@ -356,10 +359,10 @@ internal static IEnumerable DateTimeConversionCases() }; yield return new object[] { - "2024-07-11 14:20:05.123456789 -7:00", + "2024-07-11 14:20:05.123456789 -2:00", SFDataType.TIMESTAMP_LTZ.ToString(), null, - DateTime.Parse("2024-07-11 21:20:05.1234568").ToLocalTime() + DateTime.SpecifyKind(DateTime.Parse("2024-07-11 09:20:05.1234568"), DateTimeKind.Local) }; yield return new object[] { @@ -377,10 +380,10 @@ internal static IEnumerable DateTimeConversionCases() }; yield return new object[] { - "9999-12-31 23:59:59.999999 +13:00", + "1883-11-19 00:00:00.000000 -5:00", SFDataType.TIMESTAMP_LTZ.ToString(), null, - DateTime.Parse("9999-12-31 10:59:59.999999").ToLocalTime() + DateTime.Parse("1883-11-18 21:00:00.000000").ToLocalTime() }; yield return new object[] { @@ -408,6 +411,9 @@ public void TestSelectDateTimeOffset(string dbValue, string dbType, DateTime? ex connection.Open(); using (var command = connection.CreateCommand()) { + command.CommandText = "ALTER SESSION SET TIMEZONE = 'America/Los_Angeles'"; + command.ExecuteNonQuery(); + EnableStructuredTypes(connection); SetTimePrecision(connection, 9); var rawValueString = $"'{dbValue}'::{dbType}"; @@ -450,7 +456,7 @@ internal static IEnumerable DateTimeOffsetConversionCases() "2024-07-11 14:20:05 -7:00", SFDataType.TIMESTAMP_LTZ.ToString(), null, - DateTimeOffset.Parse("2024-07-11 14:20:05 -7:00").ToLocalTime() + DateTimeOffset.Parse("2024-07-11 14:20:05 -7:00") }; yield return new object[] { @@ -475,10 +481,10 @@ internal static IEnumerable DateTimeOffsetConversionCases() }; yield return new object[] { - "2024-07-11 14:20:05.123456789 -7:00", + "2024-07-11 14:20:05.123456789 -2:00", SFDataType.TIMESTAMP_LTZ.ToString(), null, - DateTimeOffset.Parse("2024-07-11 14:20:05.1234568 -7:00") + DateTimeOffset.Parse("2024-07-11 09:20:05.1234568 -7:00") }; yield return new object[] { diff --git a/Snowflake.Data.Tests/UnitTests/ArrowResultChunkTest.cs b/Snowflake.Data.Tests/UnitTests/ArrowResultChunkTest.cs index cea6fa1eb..d04d3156c 100755 --- a/Snowflake.Data.Tests/UnitTests/ArrowResultChunkTest.cs +++ b/Snowflake.Data.Tests/UnitTests/ArrowResultChunkTest.cs @@ -182,7 +182,7 @@ public void TestExtractCellReturnsNull() var chunk = pair.Key; var type = pair.Value; chunk.Next(); - Assert.AreEqual(DBNull.Value, chunk.ExtractCell(0, type, 0), $"Expected DBNull.Value for SFDataType: {type}"); + Assert.AreEqual(DBNull.Value, chunk.ExtractCell(0, type, 0, TimeZoneInfo.Utc), $"Expected DBNull.Value for SFDataType: {type}"); } } @@ -192,7 +192,7 @@ public void TestExtractCellThrowsExceptionForNoneType() var chunk = new ArrowResultChunk(_recordBatchOne); chunk.Next(); - Assert.Throws(() => chunk.ExtractCell(0, SFDataType.None, 0)); + Assert.Throws(() => chunk.ExtractCell(0, SFDataType.None, 0, TimeZoneInfo.Utc)); } [Test] @@ -417,7 +417,7 @@ void TestExtractCell(IEnumerable testValues, SFDataType sfType, long scale, long chunk.Next(); var expectedValue = (divider == 0) ? testValue : Convert.ToDecimal(testValue) / divider; - Assert.AreEqual(expectedValue, chunk.ExtractCell(0, sfType, scale)); + Assert.AreEqual(expectedValue, chunk.ExtractCell(0, sfType, scale, TimeZoneInfo.Utc)); } } public static RecordBatch PrepareRecordBatch(SFDataType sfType, long scale, object values) diff --git a/Snowflake.Data.Tests/UnitTests/StructuredTypesTest.cs b/Snowflake.Data.Tests/UnitTests/StructuredTypesTest.cs index 218f5f714..6c98e8ed5 100644 --- a/Snowflake.Data.Tests/UnitTests/StructuredTypesTest.cs +++ b/Snowflake.Data.Tests/UnitTests/StructuredTypesTest.cs @@ -19,7 +19,7 @@ public void TestTimeConversions(string value, string sfTypeString, object expect var csharpType = expected.GetType(); // act - var result = timeConverter.Convert(value, sfType, csharpType); + var result = timeConverter.Convert(value, sfType, csharpType, TimeZoneInfo.Local); // assert Assert.AreEqual(expected, result); diff --git a/Snowflake.Data/Client/SnowflakeDbDataReader.cs b/Snowflake.Data/Client/SnowflakeDbDataReader.cs index 16acbe389..6a0c388e6 100755 --- a/Snowflake.Data/Client/SnowflakeDbDataReader.cs +++ b/Snowflake.Data/Client/SnowflakeDbDataReader.cs @@ -268,7 +268,8 @@ public T GetObject(int ordinal) case string stringValue: { var json = JObject.Parse(stringValue); - return JsonToStructuredTypeConverter.ConvertObject(fields, json); + var sessionTimezone = resultSet.sfStatement.SfSession.GetSessionTimezone(); + return JsonToStructuredTypeConverter.ConvertObject(fields, json, sessionTimezone); } case Dictionary structArray: return ArrowConverter.ConvertObject(structArray); @@ -303,7 +304,8 @@ public T[] GetArray(int ordinal) case string stringValue: { 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); } case List listArray: return ArrowConverter.ConvertArray(listArray); @@ -336,7 +338,8 @@ public Dictionary GetMap(int ordinal) case string stringValue: { 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); } case Dictionary mapArray: return ArrowConverter.ConvertMap(mapArray); diff --git a/Snowflake.Data/Core/ArrowResultChunk.cs b/Snowflake.Data/Core/ArrowResultChunk.cs index 5486316c3..370cc19c5 100755 --- a/Snowflake.Data/Core/ArrowResultChunk.cs +++ b/Snowflake.Data/Core/ArrowResultChunk.cs @@ -3,6 +3,7 @@ using System.Text; using Apache.Arrow; using Apache.Arrow.Types; +using Snowflake.Data.Client; namespace Snowflake.Data.Core { @@ -140,7 +141,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); @@ -324,6 +325,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) @@ -332,7 +339,9 @@ 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); + var localDateTime = TimeZoneInfo.ConvertTimeFromUtc(utcDateTime.UtcDateTime, sessionTimezone); + return new DateTimeOffset(localDateTime, sessionTimezone.GetUtcOffset(localDateTime)); } else { @@ -342,7 +351,9 @@ 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); + var localDateTime = TimeZoneInfo.ConvertTimeFromUtc(utcDateTime.UtcDateTime, sessionTimezone); + return new DateTimeOffset(localDateTime, sessionTimezone.GetUtcOffset(localDateTime)); } case SFDataType.TIMESTAMP_NTZ: diff --git a/Snowflake.Data/Core/ArrowResultSet.cs b/Snowflake.Data/Core/ArrowResultSet.cs index 11cb31e51..b440eac8f 100755 --- a/Snowflake.Data/Core/ArrowResultSet.cs +++ b/Snowflake.Data/Core/ArrowResultSet.cs @@ -159,8 +159,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 fbaba1e07..a6dfde158 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 @@ internal static SnowflakeObjectConstructionMethod GetConstructionMethod(Type typ .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..6d7051c14 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 DateTime.SpecifyKind(localDateTime, DateTimeKind.Local); } 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..fbd4717f0 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() ?? TimeZoneInfo.Local; + 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 896c2a1fb..1521613a4 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 e4b979779..33e79cbb6 100644 --- a/Snowflake.Data/Snowflake.Data.csproj +++ b/Snowflake.Data/Snowflake.Data.csproj @@ -34,6 +34,7 @@ +