diff --git a/flight/flight-sql-jdbc-core/src/main/java/org/apache/arrow/driver/jdbc/ArrowFlightMetaImpl.java b/flight/flight-sql-jdbc-core/src/main/java/org/apache/arrow/driver/jdbc/ArrowFlightMetaImpl.java index 64529b50c..0ca0a31b3 100644 --- a/flight/flight-sql-jdbc-core/src/main/java/org/apache/arrow/driver/jdbc/ArrowFlightMetaImpl.java +++ b/flight/flight-sql-jdbc-core/src/main/java/org/apache/arrow/driver/jdbc/ArrowFlightMetaImpl.java @@ -19,6 +19,7 @@ import java.sql.Connection; import java.sql.SQLException; import java.sql.SQLTimeoutException; +import java.sql.Timestamp; import java.util.ArrayList; import java.util.Collections; import java.util.List; @@ -31,6 +32,7 @@ import org.apache.arrow.vector.types.pojo.Schema; import org.apache.calcite.avatica.AvaticaConnection; import org.apache.calcite.avatica.AvaticaParameter; +import org.apache.calcite.avatica.AvaticaStatement; import org.apache.calcite.avatica.ColumnMetaData; import org.apache.calcite.avatica.MetaImpl; import org.apache.calcite.avatica.NoSuchStatementException; @@ -105,9 +107,10 @@ public ExecuteResult execute( throw new IllegalStateException("Prepared statement not found: " + statementHandle); } + Map rawTimestamps = getRawTimestamps(statementHandle); new AvaticaParameterBinder( preparedStatement, ((ArrowFlightConnection) connection).getBufferAllocator()) - .bind(typedValues); + .bind(typedValues, 0, rawTimestamps); if (statementHandle.signature == null || statementHandle.signature.statementType == StatementType.IS_DML) { @@ -149,11 +152,12 @@ public ExecuteBatchResult executeBatch( throw new IllegalStateException("Prepared statement not found: " + statementHandle); } + Map rawTimestamps = getRawTimestamps(statementHandle); final AvaticaParameterBinder binder = new AvaticaParameterBinder( preparedStatement, ((ArrowFlightConnection) connection).getBufferAllocator()); for (int i = 0; i < parameterValuesList.size(); i++) { - binder.bind(parameterValuesList.get(i), i); + binder.bind(parameterValuesList.get(i), i, rawTimestamps); } // Update query @@ -173,6 +177,14 @@ public Frame fetch( String.format("%s does not use frames.", this), AvaticaConnection.HELPER.unsupported()); } + private Map getRawTimestamps(StatementHandle statementHandle) { + AvaticaStatement avaticaStmt = connection.statementMap.get(statementHandle.id); + if (avaticaStmt instanceof ArrowFlightPreparedStatement) { + return ((ArrowFlightPreparedStatement) avaticaStmt).getRawTimestamps(); + } + return Collections.emptyMap(); + } + private PreparedStatement prepareForHandle(final String query, StatementHandle handle) { final PreparedStatement preparedStatement = ((ArrowFlightConnection) connection).getClientHandler().prepare(query); diff --git a/flight/flight-sql-jdbc-core/src/main/java/org/apache/arrow/driver/jdbc/ArrowFlightPreparedStatement.java b/flight/flight-sql-jdbc-core/src/main/java/org/apache/arrow/driver/jdbc/ArrowFlightPreparedStatement.java index d7af6902f..6166fd21b 100644 --- a/flight/flight-sql-jdbc-core/src/main/java/org/apache/arrow/driver/jdbc/ArrowFlightPreparedStatement.java +++ b/flight/flight-sql-jdbc-core/src/main/java/org/apache/arrow/driver/jdbc/ArrowFlightPreparedStatement.java @@ -18,6 +18,11 @@ import java.sql.PreparedStatement; import java.sql.SQLException; +import java.sql.Timestamp; +import java.util.Calendar; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; import org.apache.arrow.driver.jdbc.client.ArrowFlightSqlClientHandler; import org.apache.arrow.flight.FlightInfo; import org.apache.arrow.util.Preconditions; @@ -30,6 +35,7 @@ public class ArrowFlightPreparedStatement extends AvaticaPreparedStatement implements ArrowFlightInfoStatement { private final ArrowFlightSqlClientHandler.PreparedStatement preparedStatement; + private final Map rawTimestamps = new HashMap<>(); private ArrowFlightPreparedStatement( final ArrowFlightConnection connection, @@ -74,6 +80,41 @@ public synchronized void close() throws SQLException { super.close(); } + @Override + public void setTimestamp(int parameterIndex, Timestamp x) throws SQLException { + if (x != null) { + rawTimestamps.put(parameterIndex, x); + } else { + rawTimestamps.remove(parameterIndex); + } + super.setTimestamp(parameterIndex, x); + } + + @Override + public void setTimestamp(int parameterIndex, Timestamp x, Calendar cal) throws SQLException { + if (x != null) { + rawTimestamps.put(parameterIndex, x); + } else { + rawTimestamps.remove(parameterIndex); + } + super.setTimestamp(parameterIndex, x, cal); + } + + @Override + public void clearParameters() throws SQLException { + rawTimestamps.clear(); + super.clearParameters(); + } + + /** + * Returns the raw java.sql.Timestamp objects set via setTimestamp(), keyed by 1-based parameter + * index. These preserve sub-millisecond precision (getNanos()) that Avatica's TypedValue + * serialization discards. + */ + Map getRawTimestamps() { + return Collections.unmodifiableMap(rawTimestamps); + } + @Override public FlightInfo executeFlightInfoQuery() throws SQLException { return preparedStatement.executeQuery(); diff --git a/flight/flight-sql-jdbc-core/src/main/java/org/apache/arrow/driver/jdbc/converter/impl/FixedSizeListAvaticaParameterConverter.java b/flight/flight-sql-jdbc-core/src/main/java/org/apache/arrow/driver/jdbc/converter/impl/FixedSizeListAvaticaParameterConverter.java index 05c82a932..5a401663f 100644 --- a/flight/flight-sql-jdbc-core/src/main/java/org/apache/arrow/driver/jdbc/converter/impl/FixedSizeListAvaticaParameterConverter.java +++ b/flight/flight-sql-jdbc-core/src/main/java/org/apache/arrow/driver/jdbc/converter/impl/FixedSizeListAvaticaParameterConverter.java @@ -66,7 +66,10 @@ public boolean bindParameter(FieldVector vector, TypedValue typedValue, int inde .getType() .accept( new AvaticaParameterBinder.BinderVisitor( - childVector, TypedValue.ofSerial(typedValue.componentType, val), childIndex)); + childVector, + TypedValue.ofSerial(typedValue.componentType, val), + childIndex, + null)); } } listVector.setValueCount(index + 1); diff --git a/flight/flight-sql-jdbc-core/src/main/java/org/apache/arrow/driver/jdbc/converter/impl/LargeListAvaticaParameterConverter.java b/flight/flight-sql-jdbc-core/src/main/java/org/apache/arrow/driver/jdbc/converter/impl/LargeListAvaticaParameterConverter.java index 3d03e93b1..d7782419e 100644 --- a/flight/flight-sql-jdbc-core/src/main/java/org/apache/arrow/driver/jdbc/converter/impl/LargeListAvaticaParameterConverter.java +++ b/flight/flight-sql-jdbc-core/src/main/java/org/apache/arrow/driver/jdbc/converter/impl/LargeListAvaticaParameterConverter.java @@ -55,7 +55,10 @@ public boolean bindParameter(FieldVector vector, TypedValue typedValue, int inde .getType() .accept( new AvaticaParameterBinder.BinderVisitor( - childVector, TypedValue.ofSerial(typedValue.componentType, val), childIndex)); + childVector, + TypedValue.ofSerial(typedValue.componentType, val), + childIndex, + null)); } } listVector.endValue(index, values.size()); diff --git a/flight/flight-sql-jdbc-core/src/main/java/org/apache/arrow/driver/jdbc/converter/impl/ListAvaticaParameterConverter.java b/flight/flight-sql-jdbc-core/src/main/java/org/apache/arrow/driver/jdbc/converter/impl/ListAvaticaParameterConverter.java index f4f9faaa2..b838314ec 100644 --- a/flight/flight-sql-jdbc-core/src/main/java/org/apache/arrow/driver/jdbc/converter/impl/ListAvaticaParameterConverter.java +++ b/flight/flight-sql-jdbc-core/src/main/java/org/apache/arrow/driver/jdbc/converter/impl/ListAvaticaParameterConverter.java @@ -54,7 +54,10 @@ public boolean bindParameter(FieldVector vector, TypedValue typedValue, int inde .getType() .accept( new AvaticaParameterBinder.BinderVisitor( - childVector, TypedValue.ofSerial(typedValue.componentType, val), childIndex)); + childVector, + TypedValue.ofSerial(typedValue.componentType, val), + childIndex, + null)); } } listVector.endValue(index, values.size()); diff --git a/flight/flight-sql-jdbc-core/src/main/java/org/apache/arrow/driver/jdbc/converter/impl/TimestampAvaticaParameterConverter.java b/flight/flight-sql-jdbc-core/src/main/java/org/apache/arrow/driver/jdbc/converter/impl/TimestampAvaticaParameterConverter.java index add3e3059..a493a43cc 100644 --- a/flight/flight-sql-jdbc-core/src/main/java/org/apache/arrow/driver/jdbc/converter/impl/TimestampAvaticaParameterConverter.java +++ b/flight/flight-sql-jdbc-core/src/main/java/org/apache/arrow/driver/jdbc/converter/impl/TimestampAvaticaParameterConverter.java @@ -16,6 +16,8 @@ */ package org.apache.arrow.driver.jdbc.converter.impl; +import java.sql.Timestamp; +import org.checkerframework.checker.nullness.qual.Nullable; import org.apache.arrow.vector.FieldVector; import org.apache.arrow.vector.TimeStampMicroTZVector; import org.apache.arrow.vector.TimeStampMicroVector; @@ -33,11 +35,79 @@ /** AvaticaParameterConverter for Timestamp Arrow types. */ public class TimestampAvaticaParameterConverter extends BaseAvaticaParameterConverter { - public TimestampAvaticaParameterConverter(ArrowType.Timestamp type) {} + private final ArrowType.Timestamp type; + + public TimestampAvaticaParameterConverter(ArrowType.Timestamp type) { + this.type = type; + } + + /** + * Converts a raw java.sql.Timestamp to the value in the target Arrow time unit, preserving + * sub-millisecond precision from Timestamp.getNanos(). + */ + private long convertFromTimestamp(Timestamp ts) { + // Timestamp.getTime() returns epoch millis (truncated, no sub-ms precision). + // Timestamp.getNanos() returns the fractional-second component in nanoseconds (0..999_999_999). + // We reconstruct the full-precision value from epoch seconds + nanos to avoid double-counting. + long epochSeconds = Math.floorDiv(ts.getTime(), 1_000L); + int nanos = ts.getNanos(); // 0..999_999_999, full fractional second + switch (type.getUnit()) { + case SECOND: + return epochSeconds; + case MILLISECOND: + return epochSeconds * 1_000L + nanos / 1_000_000; + case MICROSECOND: + return epochSeconds * 1_000_000L + nanos / 1_000; + case NANOSECOND: + return epochSeconds * 1_000_000_000L + nanos; + default: + throw new UnsupportedOperationException("Unsupported time unit: " + type.getUnit()); + } + } + + /** Converts an epoch millisecond value from Avatica to the target time unit. */ + private long convertFromMillis(long epochMillis) { + switch (type.getUnit()) { + case SECOND: + return epochMillis / 1_000L; + case MILLISECOND: + return epochMillis; + case MICROSECOND: + return epochMillis * 1_000L; + case NANOSECOND: + return epochMillis * 1_000_000L; + default: + throw new UnsupportedOperationException("Unsupported time unit: " + type.getUnit()); + } + } + + /** + * Bind a timestamp parameter, using the raw java.sql.Timestamp if available for full precision. + * + * @param vector FieldVector to bind to. + * @param typedValue TypedValue from Avatica (epoch millis, may have lost sub-ms precision). + * @param index Vector index to bind the value at. + * @param rawTimestamp Optional raw java.sql.Timestamp preserving sub-millisecond nanos. + * @return true if binding was successful. + */ + public boolean bindParameter( + FieldVector vector, TypedValue typedValue, int index, @Nullable Timestamp rawTimestamp) { + long value; + if (rawTimestamp != null) { + value = convertFromTimestamp(rawTimestamp); + } else { + value = convertFromMillis((long) typedValue.toLocal()); + } + return setTimestampVector(vector, index, value); + } @Override public boolean bindParameter(FieldVector vector, TypedValue typedValue, int index) { - long value = (long) typedValue.toLocal(); + long value = convertFromMillis((long) typedValue.toLocal()); + return setTimestampVector(vector, index, value); + } + + private boolean setTimestampVector(FieldVector vector, int index, long value) { if (vector instanceof TimeStampSecVector) { ((TimeStampSecVector) vector).setSafe(index, value); return true; diff --git a/flight/flight-sql-jdbc-core/src/main/java/org/apache/arrow/driver/jdbc/utils/AvaticaParameterBinder.java b/flight/flight-sql-jdbc-core/src/main/java/org/apache/arrow/driver/jdbc/utils/AvaticaParameterBinder.java index 8f40d6698..8ffec37e5 100644 --- a/flight/flight-sql-jdbc-core/src/main/java/org/apache/arrow/driver/jdbc/utils/AvaticaParameterBinder.java +++ b/flight/flight-sql-jdbc-core/src/main/java/org/apache/arrow/driver/jdbc/utils/AvaticaParameterBinder.java @@ -16,7 +16,10 @@ */ package org.apache.arrow.driver.jdbc.utils; +import java.sql.Timestamp; +import java.util.Collections; import java.util.List; +import java.util.Map; import org.apache.arrow.driver.jdbc.client.ArrowFlightSqlClientHandler.PreparedStatement; import org.apache.arrow.driver.jdbc.converter.impl.BinaryAvaticaParameterConverter; import org.apache.arrow.driver.jdbc.converter.impl.BinaryViewAvaticaParameterConverter; @@ -81,7 +84,7 @@ public AvaticaParameterBinder( * @param typedValues The parameter values. */ public void bind(List typedValues) { - bind(typedValues, 0); + bind(typedValues, 0, Collections.emptyMap()); } /** @@ -91,6 +94,19 @@ public void bind(List typedValues) { * @param index index for parameter. */ public void bind(List typedValues, int index) { + bind(typedValues, index, Collections.emptyMap()); + } + + /** + * Bind the given Avatica values to the prepared statement at the given index, with optional raw + * java.sql.Timestamp values that preserve sub-millisecond precision. + * + * @param typedValues The parameter values. + * @param index index for parameter. + * @param rawTimestamps Raw java.sql.Timestamp objects keyed by 1-based parameter index. + */ + public void bind( + List typedValues, int index, Map rawTimestamps) { if (preparedStatement.getParameterSchema().getFields().size() != typedValues.size()) { throw new IllegalStateException( String.format( @@ -99,7 +115,9 @@ public void bind(List typedValues, int index) { } for (int i = 0; i < typedValues.size(); i++) { - bind(parameters.getVector(i), typedValues.get(i), index); + // rawTimestamps uses 1-based JDBC parameter indices + Timestamp rawTs = rawTimestamps.get(i + 1); + bind(parameters.getVector(i), typedValues.get(i), index, rawTs); } if (!typedValues.isEmpty()) { @@ -114,8 +132,13 @@ public void bind(List typedValues, int index) { * @param vector FieldVector to bind to. * @param typedValue TypedValue to bind to the vector. * @param index Vector index to bind the value at. + * @param rawTimestamp Optional raw java.sql.Timestamp with sub-millisecond precision. */ - private void bind(FieldVector vector, @Nullable TypedValue typedValue, int index) { + private void bind( + FieldVector vector, + @Nullable TypedValue typedValue, + int index, + @Nullable Timestamp rawTimestamp) { try { if (typedValue == null || typedValue.value == null) { if (vector.getField().isNullable()) { @@ -126,7 +149,7 @@ private void bind(FieldVector vector, @Nullable TypedValue typedValue, int index } else if (!vector .getField() .getType() - .accept(new BinderVisitor(vector, typedValue, index))) { + .accept(new BinderVisitor(vector, typedValue, index, rawTimestamp))) { throw new UnsupportedOperationException( String.format("Binding to vector type %s is not yet supported", vector.getClass())); } @@ -146,6 +169,7 @@ public static class BinderVisitor implements ArrowType.ArrowTypeVisitor private final FieldVector vector; private final TypedValue typedValue; private final int index; + @Nullable private final Timestamp rawTimestamp; /** * Instantiate a new BinderVisitor. @@ -153,11 +177,14 @@ public static class BinderVisitor implements ArrowType.ArrowTypeVisitor * @param vector FieldVector to bind values to. * @param value TypedValue to bind. * @param index Vector index (0-based) to bind the value to. + * @param rawTimestamp Optional raw java.sql.Timestamp preserving sub-millisecond precision. */ - public BinderVisitor(FieldVector vector, TypedValue value, int index) { + public BinderVisitor( + FieldVector vector, TypedValue value, int index, @Nullable Timestamp rawTimestamp) { this.vector = vector; this.typedValue = value; this.index = index; + this.rawTimestamp = rawTimestamp; } @Override @@ -266,7 +293,8 @@ public Boolean visit(ArrowType.Time type) { @Override public Boolean visit(ArrowType.Timestamp type) { - return new TimestampAvaticaParameterConverter(type).bindParameter(vector, typedValue, index); + return new TimestampAvaticaParameterConverter(type) + .bindParameter(vector, typedValue, index, rawTimestamp); } @Override diff --git a/flight/flight-sql-jdbc-core/src/test/java/org/apache/arrow/driver/jdbc/ArrowFlightPreparedStatementTest.java b/flight/flight-sql-jdbc-core/src/test/java/org/apache/arrow/driver/jdbc/ArrowFlightPreparedStatementTest.java index 0369c3a16..81b11594e 100644 --- a/flight/flight-sql-jdbc-core/src/test/java/org/apache/arrow/driver/jdbc/ArrowFlightPreparedStatementTest.java +++ b/flight/flight-sql-jdbc-core/src/test/java/org/apache/arrow/driver/jdbc/ArrowFlightPreparedStatementTest.java @@ -28,6 +28,7 @@ import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; +import java.sql.Timestamp; import java.util.Arrays; import java.util.Collections; import java.util.List; @@ -38,6 +39,7 @@ import org.apache.arrow.memory.RootAllocator; import org.apache.arrow.vector.IntVector; import org.apache.arrow.vector.VectorSchemaRoot; +import org.apache.arrow.vector.types.TimeUnit; import org.apache.arrow.vector.types.Types; import org.apache.arrow.vector.types.pojo.ArrowType; import org.apache.arrow.vector.types.pojo.Field; @@ -251,4 +253,55 @@ public void testUpdateQueryWithBatchedParameters() throws SQLException { assertEquals(42, updated[0]); } } + + @Test + public void testTimestampParameterWithMicrosecondPrecision() throws SQLException { + String query = "Fake timestamp micro update"; + // Server schema declares parameter as TIMESTAMP(MICROSECOND, UTC) + Schema parameterSchema = + new Schema( + Collections.singletonList( + Field.nullable("ts", new ArrowType.Timestamp(TimeUnit.MICROSECOND, "UTC")))); + + // TimeStampMicroTZVector.getObject() returns Long (raw epoch micros) + // epochSeconds=1730637909, nanos=869885001 → micros = 1730637909 * 1_000_000 + 869885 + List> expected = + Collections.singletonList(Collections.singletonList(1730637909869885L)); + + PRODUCER.addUpdateQuery(query, 1); + PRODUCER.addExpectedParameters(query, parameterSchema, expected); + + try (PreparedStatement stmt = connection.prepareStatement(query)) { + Timestamp ts = new Timestamp(1730637909869L); + ts.setNanos(869885001); // .869885001 seconds — sub-ms precision + stmt.setTimestamp(1, ts); + int updated = stmt.executeUpdate(); + assertEquals(1, updated); + } + } + + @Test + public void testTimestampParameterWithMillisecondPrecision() throws SQLException { + String query = "Fake timestamp milli update"; + Schema parameterSchema = + new Schema( + Collections.singletonList( + Field.nullable("ts", new ArrowType.Timestamp(TimeUnit.MILLISECOND, "UTC")))); + + // TimeStampMilliTZVector.getObject() returns Long (raw epoch millis) + // Sub-ms nanos are correctly truncated for MILLISECOND target + List> expected = + Collections.singletonList(Collections.singletonList(1730637909869L)); + + PRODUCER.addUpdateQuery(query, 1); + PRODUCER.addExpectedParameters(query, parameterSchema, expected); + + try (PreparedStatement stmt = connection.prepareStatement(query)) { + Timestamp ts = new Timestamp(1730637909869L); + ts.setNanos(869885001); + stmt.setTimestamp(1, ts); + int updated = stmt.executeUpdate(); + assertEquals(1, updated); + } + } } diff --git a/flight/flight-sql-jdbc-core/src/test/java/org/apache/arrow/driver/jdbc/converter/impl/TimestampAvaticaParameterConverterTest.java b/flight/flight-sql-jdbc-core/src/test/java/org/apache/arrow/driver/jdbc/converter/impl/TimestampAvaticaParameterConverterTest.java new file mode 100644 index 000000000..bf4ba00dd --- /dev/null +++ b/flight/flight-sql-jdbc-core/src/test/java/org/apache/arrow/driver/jdbc/converter/impl/TimestampAvaticaParameterConverterTest.java @@ -0,0 +1,244 @@ +/* + * 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.arrow.driver.jdbc.converter.impl; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.sql.Timestamp; +import org.apache.arrow.driver.jdbc.utils.RootAllocatorTestExtension; +import org.apache.arrow.memory.BufferAllocator; +import org.apache.arrow.vector.BaseFixedWidthVector; +import org.apache.arrow.vector.FieldVector; +import org.apache.arrow.vector.TimeStampMicroTZVector; +import org.apache.arrow.vector.TimeStampMicroVector; +import org.apache.arrow.vector.TimeStampMilliTZVector; +import org.apache.arrow.vector.TimeStampMilliVector; +import org.apache.arrow.vector.TimeStampNanoTZVector; +import org.apache.arrow.vector.TimeStampNanoVector; +import org.apache.arrow.vector.TimeStampSecTZVector; +import org.apache.arrow.vector.TimeStampSecVector; +import org.apache.arrow.vector.types.TimeUnit; +import org.apache.arrow.vector.types.pojo.ArrowType; +import org.apache.calcite.avatica.ColumnMetaData; +import org.apache.calcite.avatica.remote.TypedValue; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +/** Tests for {@link TimestampAvaticaParameterConverter}. */ +public class TimestampAvaticaParameterConverterTest { + + @RegisterExtension + public static RootAllocatorTestExtension rootAllocatorTestExtension = + new RootAllocatorTestExtension(); + + // 2024-11-03 12:45:09.869 UTC — fractional seconds exercise the SECOND truncation path + private static final long TEST_EPOCH_MILLIS_WITH_FRACTIONAL_SECONDS = 1730637909869L; + + @Test + public void testSecVector() { + assertBindConvertsMillis( + TimeUnit.SECOND, null, TEST_EPOCH_MILLIS_WITH_FRACTIONAL_SECONDS / 1_000L); + } + + @Test + public void testMilliVector() { + assertBindConvertsMillis(TimeUnit.MILLISECOND, null, TEST_EPOCH_MILLIS_WITH_FRACTIONAL_SECONDS); + } + + @Test + public void testMicroVector() { + assertBindConvertsMillis( + TimeUnit.MICROSECOND, null, TEST_EPOCH_MILLIS_WITH_FRACTIONAL_SECONDS * 1_000L); + } + + @Test + public void testNanoVector() { + assertBindConvertsMillis( + TimeUnit.NANOSECOND, null, TEST_EPOCH_MILLIS_WITH_FRACTIONAL_SECONDS * 1_000_000L); + } + + @Test + public void testSecTZVector() { + assertBindConvertsMillis( + TimeUnit.SECOND, "UTC", TEST_EPOCH_MILLIS_WITH_FRACTIONAL_SECONDS / 1_000L); + } + + @Test + public void testMicroTZVector() { + assertBindConvertsMillis( + TimeUnit.MICROSECOND, "UTC", TEST_EPOCH_MILLIS_WITH_FRACTIONAL_SECONDS * 1_000L); + } + + @Test + public void testNanoTZVector() { + assertBindConvertsMillis( + TimeUnit.NANOSECOND, "UTC", TEST_EPOCH_MILLIS_WITH_FRACTIONAL_SECONDS * 1_000_000L); + } + + @Test + public void testMicroVectorPreservesSubMillisecondPrecision() { + BufferAllocator allocator = rootAllocatorTestExtension.getRootAllocator(); + ArrowType.Timestamp type = new ArrowType.Timestamp(TimeUnit.MICROSECOND, null); + TimestampAvaticaParameterConverter converter = new TimestampAvaticaParameterConverter(type); + + // Issue #838 exact values: 2024-11-03 12:45:09.869885001 + Timestamp ts = new Timestamp(TEST_EPOCH_MILLIS_WITH_FRACTIONAL_SECONDS); + ts.setNanos(869885001); // .869885001 seconds — sub-ms precision + + try (TimeStampMicroVector vector = new TimeStampMicroVector("ts", allocator)) { + vector.allocateNew(1); + TypedValue typedValue = + TypedValue.ofLocal( + ColumnMetaData.Rep.JAVA_SQL_TIMESTAMP, TEST_EPOCH_MILLIS_WITH_FRACTIONAL_SECONDS); + assertTrue(converter.bindParameter(vector, typedValue, 0, ts)); + // epochSeconds=1730637909, nanos=869885001 → micros = 1730637909 * 1_000_000 + 869885 + assertEquals(1730637909869885L, vector.get(0)); + } + } + + @Test + public void testNanoVectorPreservesFullNanosecondPrecision() { + BufferAllocator allocator = rootAllocatorTestExtension.getRootAllocator(); + ArrowType.Timestamp type = new ArrowType.Timestamp(TimeUnit.NANOSECOND, null); + TimestampAvaticaParameterConverter converter = new TimestampAvaticaParameterConverter(type); + + Timestamp ts = new Timestamp(TEST_EPOCH_MILLIS_WITH_FRACTIONAL_SECONDS); + ts.setNanos(869885001); + + try (TimeStampNanoVector vector = new TimeStampNanoVector("ts", allocator)) { + vector.allocateNew(1); + TypedValue typedValue = + TypedValue.ofLocal( + ColumnMetaData.Rep.JAVA_SQL_TIMESTAMP, TEST_EPOCH_MILLIS_WITH_FRACTIONAL_SECONDS); + assertTrue(converter.bindParameter(vector, typedValue, 0, ts)); + // epochSeconds=1730637909, nanos=869885001 → nanos = 1730637909 * 1_000_000_000 + 869885001 + assertEquals(1730637909869885001L, vector.get(0)); + } + } + + @Test + public void testMilliVectorFromRawTimestampTruncatesSubMillisecond() { + BufferAllocator allocator = rootAllocatorTestExtension.getRootAllocator(); + ArrowType.Timestamp type = new ArrowType.Timestamp(TimeUnit.MILLISECOND, null); + TimestampAvaticaParameterConverter converter = new TimestampAvaticaParameterConverter(type); + + Timestamp ts = new Timestamp(TEST_EPOCH_MILLIS_WITH_FRACTIONAL_SECONDS); + ts.setNanos(869885001); // sub-ms nanos are truncated for MILLISECOND target + + try (TimeStampMilliVector vector = new TimeStampMilliVector("ts", allocator)) { + vector.allocateNew(1); + TypedValue typedValue = + TypedValue.ofLocal( + ColumnMetaData.Rep.JAVA_SQL_TIMESTAMP, TEST_EPOCH_MILLIS_WITH_FRACTIONAL_SECONDS); + assertTrue(converter.bindParameter(vector, typedValue, 0, ts)); + assertEquals(TEST_EPOCH_MILLIS_WITH_FRACTIONAL_SECONDS, vector.get(0)); + } + } + + @Test + public void testSecVectorFromRawTimestampTruncatesSubSecond() { + BufferAllocator allocator = rootAllocatorTestExtension.getRootAllocator(); + ArrowType.Timestamp type = new ArrowType.Timestamp(TimeUnit.SECOND, null); + TimestampAvaticaParameterConverter converter = new TimestampAvaticaParameterConverter(type); + + Timestamp ts = new Timestamp(TEST_EPOCH_MILLIS_WITH_FRACTIONAL_SECONDS); + ts.setNanos(869885001); + + try (TimeStampSecVector vector = new TimeStampSecVector("ts", allocator)) { + vector.allocateNew(1); + TypedValue typedValue = + TypedValue.ofLocal( + ColumnMetaData.Rep.JAVA_SQL_TIMESTAMP, TEST_EPOCH_MILLIS_WITH_FRACTIONAL_SECONDS); + assertTrue(converter.bindParameter(vector, typedValue, 0, ts)); + assertEquals(TEST_EPOCH_MILLIS_WITH_FRACTIONAL_SECONDS / 1_000L, vector.get(0)); + } + } + + @Test + public void testFallbackToMillisWhenRawTimestampIsNull() { + BufferAllocator allocator = rootAllocatorTestExtension.getRootAllocator(); + ArrowType.Timestamp type = new ArrowType.Timestamp(TimeUnit.MICROSECOND, null); + TimestampAvaticaParameterConverter converter = new TimestampAvaticaParameterConverter(type); + + try (TimeStampMicroVector vector = new TimeStampMicroVector("ts", allocator)) { + vector.allocateNew(1); + TypedValue typedValue = + TypedValue.ofLocal( + ColumnMetaData.Rep.JAVA_SQL_TIMESTAMP, TEST_EPOCH_MILLIS_WITH_FRACTIONAL_SECONDS); + // null rawTimestamp → falls back to convertFromMillis + assertTrue(converter.bindParameter(vector, typedValue, 0, null)); + assertEquals(TEST_EPOCH_MILLIS_WITH_FRACTIONAL_SECONDS * 1_000L, vector.get(0)); + } + } + + @Test + public void testSecVectorTruncatesSubSecond() { + BufferAllocator allocator = rootAllocatorTestExtension.getRootAllocator(); + ArrowType.Timestamp type = new ArrowType.Timestamp(TimeUnit.SECOND, null); + TimestampAvaticaParameterConverter converter = new TimestampAvaticaParameterConverter(type); + + try (TimeStampSecVector vector = new TimeStampSecVector("ts", allocator)) { + vector.allocateNew(1); + // 1999 millis should truncate to 1 second, not round to 2 + TypedValue typedValue = TypedValue.ofLocal(ColumnMetaData.Rep.JAVA_SQL_TIMESTAMP, 1999L); + assertTrue(converter.bindParameter(vector, typedValue, 0)); + assertEquals(1L, vector.get(0)); + } + } + + private void assertBindConvertsMillis(TimeUnit unit, String tz, long expectedValue) { + BufferAllocator allocator = rootAllocatorTestExtension.getRootAllocator(); + ArrowType.Timestamp type = new ArrowType.Timestamp(unit, tz); + TimestampAvaticaParameterConverter converter = new TimestampAvaticaParameterConverter(type); + + try (FieldVector vector = createTestTimestampVector(unit, tz, allocator)) { + ((BaseFixedWidthVector) vector).allocateNew(1); + TypedValue typedValue = + TypedValue.ofLocal( + ColumnMetaData.Rep.JAVA_SQL_TIMESTAMP, TEST_EPOCH_MILLIS_WITH_FRACTIONAL_SECONDS); + assertTrue(converter.bindParameter(vector, typedValue, 0)); + long actual = vector.getDataBuffer().getLong(0); + assertEquals(expectedValue, actual); + } + } + + private static FieldVector createTestTimestampVector( + TimeUnit unit, String tz, BufferAllocator allocator) { + boolean hasTz = tz != null; + switch (unit) { + case SECOND: + return hasTz + ? new TimeStampSecTZVector("ts", allocator, tz) + : new TimeStampSecVector("ts", allocator); + case MILLISECOND: + return hasTz + ? new TimeStampMilliTZVector("ts", allocator, tz) + : new TimeStampMilliVector("ts", allocator); + case MICROSECOND: + return hasTz + ? new TimeStampMicroTZVector("ts", allocator, tz) + : new TimeStampMicroVector("ts", allocator); + case NANOSECOND: + return hasTz + ? new TimeStampNanoTZVector("ts", allocator, tz) + : new TimeStampNanoVector("ts", allocator); + default: + throw new IllegalArgumentException("Unsupported time unit: " + unit); + } + } +}