diff --git a/core/src/main/java/org/apache/calcite/avatica/remote/JsonService.java b/core/src/main/java/org/apache/calcite/avatica/remote/JsonService.java index 19c95e7a11..a89ee4f565 100644 --- a/core/src/main/java/org/apache/calcite/avatica/remote/JsonService.java +++ b/core/src/main/java/org/apache/calcite/avatica/remote/JsonService.java @@ -19,6 +19,8 @@ import com.fasterxml.jackson.core.JsonParser; import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.json.JsonMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import java.io.IOException; import java.io.StringWriter; @@ -30,7 +32,9 @@ public abstract class JsonService extends AbstractService { public static final ObjectMapper MAPPER; static { - MAPPER = new ObjectMapper(); + MAPPER = JsonMapper.builder() + .addModule(new JavaTimeModule()) + .build(); MAPPER.configure(JsonParser.Feature.ALLOW_UNQUOTED_FIELD_NAMES, true); MAPPER.configure(JsonParser.Feature.ALLOW_SINGLE_QUOTES, true); MAPPER.configure(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS, true); diff --git a/core/src/main/java/org/apache/calcite/avatica/util/AbstractCursor.java b/core/src/main/java/org/apache/calcite/avatica/util/AbstractCursor.java index c580246ea8..d78d58f6cd 100644 --- a/core/src/main/java/org/apache/calcite/avatica/util/AbstractCursor.java +++ b/core/src/main/java/org/apache/calcite/avatica/util/AbstractCursor.java @@ -39,6 +39,7 @@ import java.sql.Time; import java.sql.Timestamp; import java.sql.Types; +import java.time.Instant; import java.util.ArrayList; import java.util.Calendar; import java.util.List; @@ -161,26 +162,33 @@ protected Accessor createAccessor(ColumnMetaData columnMetaData, throw new AssertionError("bad " + columnMetaData.type.rep); } case Types.TIMESTAMP: + // TIMESTAMP WITH LOCAL TIME ZONE is a standard ISO type without proper JDBC support. + // It represents a global instant in time, as opposed to local clock/calendar parameters, + // so avoid normalizing against the local calendar by setting that to null for this type. + Calendar effectiveCalendar = + "TIMESTAMP_WITH_LOCAL_TIME_ZONE".equals(columnMetaData.type.getName()) + ? null + : localCalendar; switch (columnMetaData.type.rep) { case PRIMITIVE_LONG: case LONG: case NUMBER: - return new TimestampFromNumberAccessor(getter, localCalendar); + return new TimestampFromNumberAccessor(getter, effectiveCalendar); case JAVA_SQL_TIMESTAMP: return new TimestampAccessor(getter); case JAVA_UTIL_DATE: - return new TimestampFromUtilDateAccessor(getter, localCalendar); + return new TimestampFromUtilDateAccessor(getter, effectiveCalendar); default: throw new AssertionError("bad " + columnMetaData.type.rep); } - case 2013: // TIME_WITH_TIMEZONE + case Types.TIME_WITH_TIMEZONE: switch (columnMetaData.type.rep) { case STRING: return new StringAccessor(getter); default: throw new AssertionError("bad " + columnMetaData.type.rep); } - case 2014: // TIMESTAMP_WITH_TIMEZONE + case Types.TIMESTAMP_WITH_TIMEZONE: switch (columnMetaData.type.rep) { case STRING: return new StringAccessor(getter); @@ -276,11 +284,31 @@ static Time intToTime(int v, Calendar calendar) { return new Time(v); } - static Timestamp longToTimestamp(long v, Calendar calendar) { + /** + * Interpret a {@link Number} as a {@link Timestamp}. + * + * If the number is a {@link BigDecimal}, assume it represents seconds since epoch with up to + * nanosecond precision. If it is any other {@link Number}, truncate it to an integer and assume + * it represents milliseconds since epoch. + * + * @param v The number to convert + * @param calendar Subtract the time zone offset of this calendar from the result + */ + static Timestamp numberToTimestamp(Number v, Calendar calendar) { + Instant instant; + if (v instanceof BigDecimal) { + // May overflow if the value is > ~292 *billion* years away from epoch in either direction. + long wholeSeconds = v.longValue(); + long nanoSeconds = ((BigDecimal) v).remainder(BigDecimal.ONE).movePointRight(9).longValue(); + instant = Instant.ofEpochSecond(wholeSeconds, nanoSeconds); + } else { + // May overflow if the value is > ~292 *million* years away from epoch in either direction. + instant = Instant.ofEpochMilli(v.longValue()); + } if (calendar != null) { - v -= calendar.getTimeZone().getOffset(v); + instant = instant.minusMillis(calendar.getTimeZone().getOffset(instant.toEpochMilli())); } - return new Timestamp(v); + return Timestamp.from(instant); } /** Implementation of {@link Cursor.Accessor}. */ @@ -934,8 +962,7 @@ private DateFromNumberAccessor(Getter getter, Calendar localCalendar) { if (v == null) { return null; } - return longToTimestamp(v.longValue() * DateTimeUtils.MILLIS_PER_DAY, - calendar); + return numberToTimestamp(v.longValue() * DateTimeUtils.MILLIS_PER_DAY, calendar); } @Override public String getString() throws SQLException { @@ -990,7 +1017,7 @@ private TimeFromNumberAccessor(Getter getter, Calendar localCalendar) { if (v == null) { return null; } - return longToTimestamp(v.longValue(), calendar); + return numberToTimestamp(v, calendar); } @Override public String getString() throws SQLException { @@ -1018,10 +1045,10 @@ protected Number getNumber() throws SQLException { * in its default representation {@code long}; * corresponds to {@link java.sql.Types#TIMESTAMP}. */ - private static class TimestampFromNumberAccessor extends NumberAccessor { + static class TimestampFromNumberAccessor extends NumberAccessor { private final Calendar localCalendar; - private TimestampFromNumberAccessor(Getter getter, Calendar localCalendar) { + TimestampFromNumberAccessor(Getter getter, Calendar localCalendar) { super(getter, 0); this.localCalendar = localCalendar; } @@ -1035,7 +1062,7 @@ private TimestampFromNumberAccessor(Getter getter, Calendar localCalendar) { if (v == null) { return null; } - return longToTimestamp(v.longValue(), calendar); + return numberToTimestamp(v, calendar); } @Override public Date getDate(Calendar calendar) throws SQLException { diff --git a/core/src/test/java/org/apache/calcite/avatica/util/TimestampFromNumberAccessorTest.java b/core/src/test/java/org/apache/calcite/avatica/util/TimestampFromNumberAccessorTest.java new file mode 100644 index 0000000000..a13e4c4b15 --- /dev/null +++ b/core/src/test/java/org/apache/calcite/avatica/util/TimestampFromNumberAccessorTest.java @@ -0,0 +1,131 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to you under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.calcite.avatica.util; + +import org.apache.calcite.avatica.util.AbstractCursor.Getter; +import org.apache.calcite.avatica.util.AbstractCursor.TimestampFromNumberAccessor; + +import org.junit.Test; + +import java.math.BigDecimal; +import java.sql.SQLException; +import java.sql.Timestamp; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.Calendar; +import java.util.GregorianCalendar; +import java.util.Locale; +import java.util.TimeZone; + +import static org.junit.Assert.assertEquals; + +/** Unit tests for {@link TimestampFromNumberAccessor} */ +public class TimestampFromNumberAccessorTest { + + // An example of a calendar that observes DST. + private static final Calendar LOS_ANGELES_CALENDAR = + GregorianCalendar.getInstance(TimeZone.getTimeZone("America/Los_Angeles"), Locale.ROOT); + + @Test + public void testNoOffset() throws SQLException { + test( + 1673657135052L, // UTC: 2023-01-14 00:45:23.052 + null, + parseUtc("2023-01-14T00:45:35.052")); + } + + @Test + public void testNoOffsetNanoseconds() throws SQLException { + test( + new BigDecimal("1673657135.052637485"), // UTC: 2023-01-14 00:45:23.052637485 + null, + parseUtc("2023-01-14T00:45:35.052637485")); + } + + @Test + public void testNoOffsetDaylightSavings() throws SQLException { + test( + 1689320723052L, // UTC: 2023-07-14 07:45:23.052 + null, + parseUtc("2023-07-14T07:45:23.052")); + } + + @Test + public void testNoOffsetDaylightSavingsNanoseconds() throws SQLException { + test( + new BigDecimal("1689320723.052637485"), // UTC: 2023-07-14 07:45:23.052637485 + null, + parseUtc("2023-07-14T07:45:23.052637485")); + } + + @Test + public void testWithOffset() throws SQLException { + test( + 1673657135052L, // UTC: 2023-01-14 00:45:23.052 + LOS_ANGELES_CALENDAR, + parseUtc("2023-01-14T08:45:35.052")); + } + + @Test + public void testWithOffsetNanoseconds() throws SQLException { + test( + new BigDecimal("1673657135.052637485"), // UTC: 2023-01-14 00:45:23.052637485 + LOS_ANGELES_CALENDAR, + parseUtc("2023-01-14T08:45:35.052637485")); + } + + @Test + public void testWithOffsetDaylightSavings() throws SQLException { + test( + 1689320723052L, // UTC: 2023-07-14 07:45:23.052 + LOS_ANGELES_CALENDAR, + parseUtc("2023-07-14T14:45:23.052")); + } + + @Test + public void testWithOffsetDaylightSavingsNanoseconds() throws SQLException { + test( + new BigDecimal("1689320723.052637485"), // UTC: 2023-07-14 07:45:23.052637485 + LOS_ANGELES_CALENDAR, + parseUtc("2023-07-14T14:45:23.052637485")); + } + + private static void test(Number v, Calendar calendar, Timestamp expectedValue) + throws SQLException { + TimestampFromNumberAccessor accessor = + new TimestampFromNumberAccessor( + new Getter() { + @Override + public Object getObject() { + return v; + } + + @Override + public boolean wasNull() { + return v == null; + } + }, + calendar); + + assertEquals(expectedValue, accessor.getTimestamp(calendar)); + assertEquals(expectedValue, accessor.getObject()); + } + + private static Timestamp parseUtc(String utcTimestamp) { + return Timestamp.from(LocalDateTime.parse(utcTimestamp).toInstant(ZoneOffset.UTC)); + } +}