diff --git a/NEXT_CHANGELOG.md b/NEXT_CHANGELOG.md index 282e45c07..c4453886d 100644 --- a/NEXT_CHANGELOG.md +++ b/NEXT_CHANGELOG.md @@ -8,6 +8,8 @@ ### Bug Fixes +- Accept both JSON numbers and decimal strings when deserializing int64 (`Long`) response fields. + ### Security Vulnerabilities ### Documentation diff --git a/databricks-sdk-java/src/main/java/com/databricks/sdk/core/utils/SerDeUtils.java b/databricks-sdk-java/src/main/java/com/databricks/sdk/core/utils/SerDeUtils.java index 7cad48514..646bcbc1d 100644 --- a/databricks-sdk-java/src/main/java/com/databricks/sdk/core/utils/SerDeUtils.java +++ b/databricks-sdk-java/src/main/java/com/databricks/sdk/core/utils/SerDeUtils.java @@ -5,6 +5,9 @@ import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.databind.cfg.CoercionAction; +import com.fasterxml.jackson.databind.cfg.CoercionInputShape; +import com.fasterxml.jackson.databind.type.LogicalType; import com.fasterxml.jackson.datatype.guava.GuavaModule; import com.fasterxml.jackson.datatype.jdk8.Jdk8Module; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; @@ -26,6 +29,13 @@ public static ObjectMapper createMapper() { .configure(DeserializationFeature.ACCEPT_SINGLE_VALUE_AS_ARRAY, true) .configure(DeserializationFeature.READ_UNKNOWN_ENUM_VALUES_AS_NULL, true) .setSerializationInclusion(JsonInclude.Include.NON_NULL); + + // Databricks services return int64 fields as either JSON numbers (123) or decimal strings + // ("123"). Pin String->integer coercion so both deserialize into Long. Jackson does this by + // default; pinning it guards against a silent regression if that default is ever tightened. + mapper + .coercionConfigFor(LogicalType.Integer) + .setCoercion(CoercionInputShape.String, CoercionAction.TryConvert); return mapper; } } diff --git a/databricks-sdk-java/src/test/java/com/databricks/sdk/core/utils/SerDeUtilsInt64Test.java b/databricks-sdk-java/src/test/java/com/databricks/sdk/core/utils/SerDeUtilsInt64Test.java new file mode 100644 index 000000000..322753659 --- /dev/null +++ b/databricks-sdk-java/src/test/java/com/databricks/sdk/core/utils/SerDeUtilsInt64Test.java @@ -0,0 +1,132 @@ +package com.databricks.sdk.core.utils; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +/** + * Verifies that int64 ({@code Long}) fields deserialize from both JSON numbers ({@code 123}) and + * decimal strings ({@code "123"}), which Databricks services use interchangeably. The behavior is + * pinned in {@link SerDeUtils#createMapper()}; this mirrors the Go SDK's {@code + * marshal/int64_test.go}. + */ +public class SerDeUtilsInt64Test { + private final ObjectMapper mapper = SerDeUtils.createMapper(); + + public static class LongHolder { + public Long value; + } + + public static class Inner { + public Long id; + } + + public static class Outer { + public Inner inner; + } + + public static class ListHolder { + public List values; + } + + public static class MapHolder { + public Map counts; + } + + public static class Mixed { + public Long id; + public String name; + } + + public static class UntypedHolder { + public Object payload; + } + + @Test + public void topLevelLongFromNumberAndString() throws JsonProcessingException { + assertEquals(Long.valueOf(123L), mapper.readValue("123", Long.class)); + assertEquals(Long.valueOf(123L), mapper.readValue("\"123\"", Long.class)); + } + + @Test + public void longFieldFromNumberAndString() throws JsonProcessingException { + assertEquals(Long.valueOf(123L), mapper.readValue("{\"value\":123}", LongHolder.class).value); + assertEquals( + Long.valueOf(123L), mapper.readValue("{\"value\":\"123\"}", LongHolder.class).value); + } + + @Test + public void nestedLongFromString() throws JsonProcessingException { + Outer out = mapper.readValue("{\"inner\":{\"id\":\"456\"}}", Outer.class); + assertEquals(Long.valueOf(456L), out.inner.id); + } + + @Test + public void listOfLongAcceptsMixedForms() throws JsonProcessingException { + ListHolder h = mapper.readValue("{\"values\":[\"1\",2,\"3\"]}", ListHolder.class); + assertEquals(Arrays.asList(1L, 2L, 3L), h.values); + } + + @Test + public void mapOfLongAcceptsMixedForms() throws JsonProcessingException { + MapHolder h = mapper.readValue("{\"counts\":{\"a\":\"1\",\"b\":2}}", MapHolder.class); + assertEquals(Long.valueOf(1L), h.counts.get("a")); + assertEquals(Long.valueOf(2L), h.counts.get("b")); + } + + @Test + public void boundaryValuesFromNumberAndString() throws JsonProcessingException { + assertEquals( + Long.valueOf(Long.MAX_VALUE), + mapper.readValue("{\"value\":9223372036854775807}", LongHolder.class).value); + assertEquals( + Long.valueOf(Long.MAX_VALUE), + mapper.readValue("{\"value\":\"9223372036854775807\"}", LongHolder.class).value); + assertEquals( + Long.valueOf(Long.MIN_VALUE), + mapper.readValue("{\"value\":-9223372036854775808}", LongHolder.class).value); + assertEquals( + Long.valueOf(Long.MIN_VALUE), + mapper.readValue("{\"value\":\"-9223372036854775808\"}", LongHolder.class).value); + } + + @Test + public void invalidNumericStringFails() { + assertThrows( + JsonProcessingException.class, + () -> mapper.readValue("{\"value\":\"not-an-int\"}", LongHolder.class)); + } + + @Test + public void overflowStringFails() { + // 20 nines: larger than Long.MAX_VALUE. + assertThrows( + JsonProcessingException.class, + () -> mapper.readValue("{\"value\":\"99999999999999999999\"}", LongHolder.class)); + } + + @Test + public void stringFieldIsNotCoercedToNumber() throws JsonProcessingException { + Mixed m = mapper.readValue("{\"id\":\"5\",\"name\":\"5\"}", Mixed.class); + assertEquals(Long.valueOf(5L), m.id); + assertEquals("5", m.name); + } + + @Test + public void untypedFieldPreservesJsonForm() throws JsonProcessingException { + // Coercion only targets integer-typed fields, so an untyped field keeps its JSON form. + UntypedHolder asString = mapper.readValue("{\"payload\":\"123\"}", UntypedHolder.class); + assertInstanceOf(String.class, asString.payload); + assertEquals("123", asString.payload); + + UntypedHolder asNumber = mapper.readValue("{\"payload\":123}", UntypedHolder.class); + assertInstanceOf(Number.class, asNumber.payload); + } +}