diff --git a/NEXT_CHANGELOG.md b/NEXT_CHANGELOG.md index e5d88e859..17a3a0ac7 100644 --- a/NEXT_CHANGELOG.md +++ b/NEXT_CHANGELOG.md @@ -15,6 +15,7 @@ - Fixed `rollback()` to throw `SQLException` when called in auto-commit mode (no active transaction), aligning with JDBC spec. Previously it silently sent a ROLLBACK command to the server. - Fixed `fetchAutoCommitStateFromServer()` to accept both `"1"`/`"0"` and `"true"`/`"false"` responses from `SET AUTOCOMMIT` query, since different server implementations return different formats. - Fixed socket leak in SDK HTTP client that prevented CRaC checkpointing. The SDK's connection pool was not shut down on `connection.close()`, leaving TCP sockets open. +- Fixed Date fields within complex types (ARRAY, STRUCT, MAP) being returned as epoch day integers instead of proper date values. --- *Note: When making changes, please add your change under the appropriate section diff --git a/src/main/java/com/databricks/jdbc/api/impl/ComplexDataTypeParser.java b/src/main/java/com/databricks/jdbc/api/impl/ComplexDataTypeParser.java index 834ffd304..787ba6e6a 100644 --- a/src/main/java/com/databricks/jdbc/api/impl/ComplexDataTypeParser.java +++ b/src/main/java/com/databricks/jdbc/api/impl/ComplexDataTypeParser.java @@ -15,6 +15,8 @@ import java.sql.Date; import java.sql.Time; import java.sql.Timestamp; +import java.time.DateTimeException; +import java.time.LocalDate; import java.util.ArrayList; import java.util.Iterator; import java.util.LinkedHashMap; @@ -204,7 +206,18 @@ private Object convertPrimitive(String text, String type) { case DatabricksTypeUtil.BOOLEAN: return Boolean.parseBoolean(text); case DatabricksTypeUtil.DATE: - return Date.valueOf(text); + try { + return Date.valueOf(text); + } catch (IllegalArgumentException e) { + // Arrow serializes DATE fields in nested types as epoch day integers. + // Fall back to parsing as epoch day count (days since 1970-01-01). + try { + return Date.valueOf(LocalDate.ofEpochDay(Long.parseLong(text))); + } catch (NumberFormatException | DateTimeException nfe) { + LOGGER.error(e, "Failed to parse DATE value '{}' as epoch day integer", text); + throw e; + } + } case DatabricksTypeUtil.TIMESTAMP: return parseTimestamp(text); case DatabricksTypeUtil.TIME: diff --git a/src/test/java/com/databricks/jdbc/api/impl/ComplexDataTypeParserTest.java b/src/test/java/com/databricks/jdbc/api/impl/ComplexDataTypeParserTest.java index def3f19a5..a935952f3 100644 --- a/src/test/java/com/databricks/jdbc/api/impl/ComplexDataTypeParserTest.java +++ b/src/test/java/com/databricks/jdbc/api/impl/ComplexDataTypeParserTest.java @@ -150,6 +150,92 @@ void testComplexPrimitiveTimestampWithOffset() throws DatabricksParsingException } } + @Test + void testDateAsEpochDayInStruct() throws DatabricksParsingException { + // Reproduces GitHub issue #1247: Date fields within ARRAY are serialized + // as epoch day integers instead of ISO-8601 strings when coming from Arrow. + // Arrow's getObject() on nested types returns epoch day integers for DATE fields. + // 20487 = epoch day for 2026-02-03 + String json = "[{\"event_date\":20487}]"; + + DatabricksArray dbArray = + parser.parseJsonStringToDbArray(json, "ARRAY>"); + assertNotNull(dbArray); + + try { + Object[] elements = (Object[]) dbArray.getArray(); + assertEquals(1, elements.length); + DatabricksStruct struct = (DatabricksStruct) elements[0]; + Object[] attrs = struct.getAttributes(); + assertEquals(1, attrs.length); + assertEquals(Date.valueOf("2026-02-03"), attrs[0]); + } catch (Exception e) { + fail("Should not throw: " + e.getMessage()); + } + } + + @Test + void testDateAsEpochDayInArray() throws DatabricksParsingException { + // DATE inside a plain ARRAY — Arrow returns epoch day integers + String json = "[20487, 20488]"; + + DatabricksArray dbArray = parser.parseJsonStringToDbArray(json, "ARRAY"); + assertNotNull(dbArray); + + try { + Object[] elements = (Object[]) dbArray.getArray(); + assertEquals(2, elements.length); + assertEquals(Date.valueOf("2026-02-03"), elements[0]); + assertEquals(Date.valueOf("2026-02-04"), elements[1]); + } catch (Exception e) { + fail("Should not throw: " + e.getMessage()); + } + } + + @Test + void testDateAsEpochDayInMap() throws DatabricksParsingException { + // DATE as value in a MAP — Arrow returns epoch day integers + String json = "{\"key1\":20487, \"key2\":20488}"; + + DatabricksMap dbMap = parser.parseJsonStringToDbMap(json, "MAP"); + assertNotNull(dbMap); + + assertEquals(Date.valueOf("2026-02-03"), dbMap.get("key1")); + assertEquals(Date.valueOf("2026-02-04"), dbMap.get("key2")); + } + + @Test + void testInvalidDateStringInStructThrowsOriginalException() { + // Non-numeric invalid date string should throw IllegalArgumentException, not + // NumberFormatException + String json = "[{\"event_date\":\"2026/02/03\"}]"; + + assertThrows( + IllegalArgumentException.class, + () -> parser.parseJsonStringToDbArray(json, "ARRAY>")); + } + + @Test + void testDateAsStringInStruct() throws DatabricksParsingException { + // Ensure ISO-8601 date strings still work in nested structs + String json = "[{\"event_date\":\"2026-02-03\"}]"; + + DatabricksArray dbArray = + parser.parseJsonStringToDbArray(json, "ARRAY>"); + assertNotNull(dbArray); + + try { + Object[] elements = (Object[]) dbArray.getArray(); + assertEquals(1, elements.length); + DatabricksStruct struct = (DatabricksStruct) elements[0]; + Object[] attrs = struct.getAttributes(); + assertEquals(1, attrs.length); + assertEquals(Date.valueOf("2026-02-03"), attrs[0]); + } catch (Exception e) { + fail("Should not throw: " + e.getMessage()); + } + } + @Test void testFormatComplexTypeString_withMapType() { String jsonString = "[{\"key\":1,\"value\":2},{\"key\":3,\"value\":4}]";