From 7ae68b8ec99c8330cc643399288292518d3d4de5 Mon Sep 17 00:00:00 2001 From: Shahriyar Novruzov Date: Tue, 20 Oct 2026 13:57:39 +0400 Subject: [PATCH 1/4] GH-1377: [FlightRPC][Java] Bind DOUBLE to DECIMAL parameters DecimalAvaticaParameterConverter cast the local value to BigDecimal, which only holds when the client called setBigDecimal. TypedValue.toLocal() returns a Double for Rep.DOUBLE, so setDouble against a decimal parameter failed with "Binding value of type DOUBLE is not yet supported". The converter now widens the value and scales it to the vector, which rejects any scale but its own. --- .../DecimalAvaticaParameterConverter.java | 7 +- .../impl/DecimalParameterNarrower.java | 62 +++++++ .../DecimalAvaticaParameterConverterTest.java | 158 ++++++++++++++++++ 3 files changed, 225 insertions(+), 2 deletions(-) create mode 100644 flight/flight-sql-jdbc-core/src/main/java/org/apache/arrow/driver/jdbc/converter/impl/DecimalParameterNarrower.java create mode 100644 flight/flight-sql-jdbc-core/src/test/java/org/apache/arrow/driver/jdbc/converter/impl/DecimalAvaticaParameterConverterTest.java diff --git a/flight/flight-sql-jdbc-core/src/main/java/org/apache/arrow/driver/jdbc/converter/impl/DecimalAvaticaParameterConverter.java b/flight/flight-sql-jdbc-core/src/main/java/org/apache/arrow/driver/jdbc/converter/impl/DecimalAvaticaParameterConverter.java index 92faa119bd..75a4936cbe 100644 --- a/flight/flight-sql-jdbc-core/src/main/java/org/apache/arrow/driver/jdbc/converter/impl/DecimalAvaticaParameterConverter.java +++ b/flight/flight-sql-jdbc-core/src/main/java/org/apache/arrow/driver/jdbc/converter/impl/DecimalAvaticaParameterConverter.java @@ -31,9 +31,12 @@ public DecimalAvaticaParameterConverter(ArrowType.Decimal type) {} @Override public boolean bindParameter(FieldVector vector, TypedValue typedValue, int index) { - BigDecimal value = (BigDecimal) typedValue.toLocal(); if (vector instanceof DecimalVector) { - ((DecimalVector) vector).setSafe(index, value); + DecimalVector decimalVector = (DecimalVector) vector; + // The vector rejects any scale but its own, so widen and scale before writing. + BigDecimal value = + DecimalParameterNarrower.toBigDecimal(typedValue, decimalVector.getScale()); + decimalVector.setSafe(index, value); return true; } return false; diff --git a/flight/flight-sql-jdbc-core/src/main/java/org/apache/arrow/driver/jdbc/converter/impl/DecimalParameterNarrower.java b/flight/flight-sql-jdbc-core/src/main/java/org/apache/arrow/driver/jdbc/converter/impl/DecimalParameterNarrower.java new file mode 100644 index 0000000000..230e0252b4 --- /dev/null +++ b/flight/flight-sql-jdbc-core/src/main/java/org/apache/arrow/driver/jdbc/converter/impl/DecimalParameterNarrower.java @@ -0,0 +1,62 @@ +/* + * 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 java.math.BigDecimal; +import java.math.RoundingMode; +import org.apache.calcite.avatica.remote.TypedValue; + +/** + * Widens Avatica {@link TypedValue}s bound to DECIMAL parameters to {@link BigDecimal}, so calls + * like {@code setDouble} against an Arrow {@code Decimal} parameter bind instead of throwing. + */ +final class DecimalParameterNarrower { + + private DecimalParameterNarrower() {} + + /** + * Returns the {@link BigDecimal} value of a {@link TypedValue} bound to a DECIMAL parameter, + * scaled to what the target vector stores. + * + *

A value is scaled rather than rejected on a mismatch: the client sent the number it had, and + * the column's scale is what it has to fit. Rounding is HALF_UP, as {@code setBigDecimal} does. + */ + static BigDecimal toBigDecimal(TypedValue typedValue, int scale) { + return widen(typedValue).setScale(scale, RoundingMode.HALF_UP); + } + + /** + * Converts the local value to a {@link BigDecimal}, keeping the digits the client sent. + * + *

{@code BigDecimal.valueOf} rather than {@code new BigDecimal(double)}: the constructor keeps + * the binary expansion, so 0.1 arrives as 0.1000000000000000055511151231257827. + */ + private static BigDecimal widen(TypedValue typedValue) { + final Object local = typedValue.toLocal(); + if (local instanceof BigDecimal) { + return (BigDecimal) local; + } + if (local instanceof Double || local instanceof Float) { + return BigDecimal.valueOf(((Number) local).doubleValue()); + } + if (local instanceof Number) { + return BigDecimal.valueOf(((Number) local).longValue()); + } + // Anything else keeps the upstream failure: a cast error the binder reports as unsupported. + return (BigDecimal) local; + } +} diff --git a/flight/flight-sql-jdbc-core/src/test/java/org/apache/arrow/driver/jdbc/converter/impl/DecimalAvaticaParameterConverterTest.java b/flight/flight-sql-jdbc-core/src/test/java/org/apache/arrow/driver/jdbc/converter/impl/DecimalAvaticaParameterConverterTest.java new file mode 100644 index 0000000000..6f54298a65 --- /dev/null +++ b/flight/flight-sql-jdbc-core/src/test/java/org/apache/arrow/driver/jdbc/converter/impl/DecimalAvaticaParameterConverterTest.java @@ -0,0 +1,158 @@ +/* + * 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.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.math.BigDecimal; +import org.apache.arrow.driver.jdbc.utils.RootAllocatorTestExtension; +import org.apache.arrow.vector.DecimalVector; +import org.apache.arrow.vector.IntVector; +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.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +/** + * Tests for {@link DecimalAvaticaParameterConverter}. + * + *

Clients call {@code setDouble} on parameters whose Arrow type is {@code Decimal}. The + * converter must widen the Avatica reps to BigDecimal and scale them to the vector instead of + * throwing. + */ +public class DecimalAvaticaParameterConverterTest { + + @RegisterExtension + public static RootAllocatorTestExtension rootAllocatorTestExtension = + new RootAllocatorTestExtension(); + + private static final int PRECISION = 38; + private static final int SCALE = 3; + + private DecimalVector vector; + private DecimalAvaticaParameterConverter converter; + + @BeforeEach + public void setUp() { + vector = + new DecimalVector( + "decimal_param", rootAllocatorTestExtension.getRootAllocator(), PRECISION, SCALE); + vector.allocateNew(5); + converter = new DecimalAvaticaParameterConverter(new ArrowType.Decimal(PRECISION, SCALE, 128)); + } + + @AfterEach + public void tearDown() { + vector.close(); + } + + @Test + public void testBindDouble() { + // The reported shape: setDouble of 123.456 against a DECIMAL(38,3) parameter. + TypedValue typedValue = TypedValue.ofLocal(ColumnMetaData.Rep.DOUBLE, 123.456d); + + boolean result = converter.bindParameter(vector, typedValue, 0); + + assertTrue(result); + assertThat(vector.getObject(0), is(new BigDecimal("123.456"))); + } + + @Test + public void testBindDoubleKeepsTheDigitsTheClientSent() { + // new BigDecimal(0.1) would store 0.1000000000000000055511151231257827 and fail the scale + // check. + TypedValue typedValue = TypedValue.ofLocal(ColumnMetaData.Rep.DOUBLE, 0.1d); + + assertTrue(converter.bindParameter(vector, typedValue, 0)); + assertThat(vector.getObject(0), is(new BigDecimal("0.100"))); + } + + @Test + public void testBindDoubleRoundsToTheVectorScale() { + // 1.2345 has one digit more than DECIMAL(38,3) holds; HALF_UP is what setBigDecimal does. + TypedValue typedValue = TypedValue.ofLocal(ColumnMetaData.Rep.DOUBLE, 1.2345d); + + assertTrue(converter.bindParameter(vector, typedValue, 0)); + assertThat(vector.getObject(0), is(new BigDecimal("1.235"))); + } + + @Test + public void testBindFloat() { + TypedValue typedValue = TypedValue.ofLocal(ColumnMetaData.Rep.FLOAT, 2.5f); + + assertTrue(converter.bindParameter(vector, typedValue, 0)); + assertThat(vector.getObject(0), is(new BigDecimal("2.500"))); + } + + @Test + public void testBindInteger() { + TypedValue typedValue = TypedValue.ofLocal(ColumnMetaData.Rep.INTEGER, 42); + + assertTrue(converter.bindParameter(vector, typedValue, 0)); + assertThat(vector.getObject(0), is(new BigDecimal("42.000"))); + } + + @Test + public void testBindLong() { + TypedValue typedValue = TypedValue.ofLocal(ColumnMetaData.Rep.LONG, 1000L); + + assertTrue(converter.bindParameter(vector, typedValue, 0)); + assertThat(vector.getObject(0), is(new BigDecimal("1000.000"))); + } + + @Test + public void testBindBigDecimalStillWorks() { + TypedValue typedValue = + TypedValue.ofLocal(ColumnMetaData.Rep.NUMBER, new BigDecimal("1000.000")); + + assertTrue(converter.bindParameter(vector, typedValue, 0)); + assertThat(vector.getObject(0), is(new BigDecimal("1000.000"))); + } + + @Test + public void testBindBigDecimalOfAnotherScaleIsScaled() { + // setBigDecimal of a value the client scaled differently used to fail the vector's scale check. + TypedValue typedValue = TypedValue.ofLocal(ColumnMetaData.Rep.NUMBER, new BigDecimal("7.5")); + + assertTrue(converter.bindParameter(vector, typedValue, 0)); + assertThat(vector.getObject(0), is(new BigDecimal("7.500"))); + } + + @Test + public void testBindNegativeDouble() { + TypedValue typedValue = TypedValue.ofLocal(ColumnMetaData.Rep.DOUBLE, -123.456d); + + assertTrue(converter.bindParameter(vector, typedValue, 0)); + assertThat(vector.getObject(0), is(new BigDecimal("-123.456"))); + } + + @Test + public void testBindReturnsFalseForANonDecimalVector() { + try (IntVector other = new IntVector("other", rootAllocatorTestExtension.getRootAllocator())) { + other.allocateNew(1); + TypedValue typedValue = TypedValue.ofLocal(ColumnMetaData.Rep.DOUBLE, 1.0d); + + // Returning false is what makes the binder report the type as unsupported. + assertThat(converter.bindParameter(other, typedValue, 0), is(false)); + } + } +} From db68d8963abba5ef7ab9cd32a5fa38ee83c05a0a Mon Sep 17 00:00:00 2001 From: Shahriyar Novruzov Date: Tue, 20 Oct 2026 16:09:35 +0400 Subject: [PATCH 2/4] Make the valueOf note a code comment on the line it explains --- .../jdbc/converter/impl/DecimalParameterNarrower.java | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/flight/flight-sql-jdbc-core/src/main/java/org/apache/arrow/driver/jdbc/converter/impl/DecimalParameterNarrower.java b/flight/flight-sql-jdbc-core/src/main/java/org/apache/arrow/driver/jdbc/converter/impl/DecimalParameterNarrower.java index 230e0252b4..9c277eafb7 100644 --- a/flight/flight-sql-jdbc-core/src/main/java/org/apache/arrow/driver/jdbc/converter/impl/DecimalParameterNarrower.java +++ b/flight/flight-sql-jdbc-core/src/main/java/org/apache/arrow/driver/jdbc/converter/impl/DecimalParameterNarrower.java @@ -39,18 +39,14 @@ static BigDecimal toBigDecimal(TypedValue typedValue, int scale) { return widen(typedValue).setScale(scale, RoundingMode.HALF_UP); } - /** - * Converts the local value to a {@link BigDecimal}, keeping the digits the client sent. - * - *

{@code BigDecimal.valueOf} rather than {@code new BigDecimal(double)}: the constructor keeps - * the binary expansion, so 0.1 arrives as 0.1000000000000000055511151231257827. - */ private static BigDecimal widen(TypedValue typedValue) { final Object local = typedValue.toLocal(); if (local instanceof BigDecimal) { return (BigDecimal) local; } if (local instanceof Double || local instanceof Float) { + // valueOf, not new BigDecimal(double): the constructor keeps the binary expansion, so 0.1 + // arrives as 0.1000000000000000055511151231257827. return BigDecimal.valueOf(((Number) local).doubleValue()); } if (local instanceof Number) { From 7149c6acaf293b6f99778e2a8b4201a329e0fd2c Mon Sep 17 00:00:00 2001 From: Shahriyar Novruzov Date: Tue, 20 Oct 2026 16:26:39 +0400 Subject: [PATCH 3/4] Rename DecimalParameterNarrower to DecimalParameterCoercer The class widens and scales rather than narrows, so the name was misleading. --- .../converter/impl/DecimalAvaticaParameterConverter.java | 2 +- ...ParameterNarrower.java => DecimalParameterCoercer.java} | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) rename flight/flight-sql-jdbc-core/src/main/java/org/apache/arrow/driver/jdbc/converter/impl/{DecimalParameterNarrower.java => DecimalParameterCoercer.java} (90%) diff --git a/flight/flight-sql-jdbc-core/src/main/java/org/apache/arrow/driver/jdbc/converter/impl/DecimalAvaticaParameterConverter.java b/flight/flight-sql-jdbc-core/src/main/java/org/apache/arrow/driver/jdbc/converter/impl/DecimalAvaticaParameterConverter.java index 75a4936cbe..4237806cf6 100644 --- a/flight/flight-sql-jdbc-core/src/main/java/org/apache/arrow/driver/jdbc/converter/impl/DecimalAvaticaParameterConverter.java +++ b/flight/flight-sql-jdbc-core/src/main/java/org/apache/arrow/driver/jdbc/converter/impl/DecimalAvaticaParameterConverter.java @@ -35,7 +35,7 @@ public boolean bindParameter(FieldVector vector, TypedValue typedValue, int inde DecimalVector decimalVector = (DecimalVector) vector; // The vector rejects any scale but its own, so widen and scale before writing. BigDecimal value = - DecimalParameterNarrower.toBigDecimal(typedValue, decimalVector.getScale()); + DecimalParameterCoercer.toBigDecimal(typedValue, decimalVector.getScale()); decimalVector.setSafe(index, value); return true; } diff --git a/flight/flight-sql-jdbc-core/src/main/java/org/apache/arrow/driver/jdbc/converter/impl/DecimalParameterNarrower.java b/flight/flight-sql-jdbc-core/src/main/java/org/apache/arrow/driver/jdbc/converter/impl/DecimalParameterCoercer.java similarity index 90% rename from flight/flight-sql-jdbc-core/src/main/java/org/apache/arrow/driver/jdbc/converter/impl/DecimalParameterNarrower.java rename to flight/flight-sql-jdbc-core/src/main/java/org/apache/arrow/driver/jdbc/converter/impl/DecimalParameterCoercer.java index 9c277eafb7..4cf8c246e8 100644 --- a/flight/flight-sql-jdbc-core/src/main/java/org/apache/arrow/driver/jdbc/converter/impl/DecimalParameterNarrower.java +++ b/flight/flight-sql-jdbc-core/src/main/java/org/apache/arrow/driver/jdbc/converter/impl/DecimalParameterCoercer.java @@ -24,9 +24,9 @@ * Widens Avatica {@link TypedValue}s bound to DECIMAL parameters to {@link BigDecimal}, so calls * like {@code setDouble} against an Arrow {@code Decimal} parameter bind instead of throwing. */ -final class DecimalParameterNarrower { +final class DecimalParameterCoercer { - private DecimalParameterNarrower() {} + private DecimalParameterCoercer() {} /** * Returns the {@link BigDecimal} value of a {@link TypedValue} bound to a DECIMAL parameter, @@ -52,7 +52,8 @@ private static BigDecimal widen(TypedValue typedValue) { if (local instanceof Number) { return BigDecimal.valueOf(((Number) local).longValue()); } - // Anything else keeps the upstream failure: a cast error the binder reports as unsupported. + // Anything else keeps the existing behaviour: a cast error AvaticaParameterBinder catches and + // reports as an unsupported binding, naming the rep and the Arrow type. return (BigDecimal) local; } } From 0542622c040ca3568d355bf14b233c297f0386d4 Mon Sep 17 00:00:00 2001 From: Shahriyar Novruzov Date: Thu, 22 Oct 2026 12:40:06 +0400 Subject: [PATCH 4/4] fix: report NaN and infinity as an unsupported decimal binding BigDecimal.valueOf raises NumberFormatException for non-finite doubles, which AvaticaParameterBinder does not catch. Before this converter widened doubles, the same value failed as a ClassCastException and came back as "Binding value of type DOUBLE is not yet supported". Throwing the cast error keeps that message. Also tests that a value too large for a long survives: Avatica has no rep for BigInteger, so toLocal() returns a BigDecimal and the longValue() branch is unreachable for it. --- .../impl/DecimalParameterCoercer.java | 9 +++++- .../DecimalAvaticaParameterConverterTest.java | 30 +++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/flight/flight-sql-jdbc-core/src/main/java/org/apache/arrow/driver/jdbc/converter/impl/DecimalParameterCoercer.java b/flight/flight-sql-jdbc-core/src/main/java/org/apache/arrow/driver/jdbc/converter/impl/DecimalParameterCoercer.java index 4cf8c246e8..9f8e35f1e3 100644 --- a/flight/flight-sql-jdbc-core/src/main/java/org/apache/arrow/driver/jdbc/converter/impl/DecimalParameterCoercer.java +++ b/flight/flight-sql-jdbc-core/src/main/java/org/apache/arrow/driver/jdbc/converter/impl/DecimalParameterCoercer.java @@ -45,9 +45,16 @@ private static BigDecimal widen(TypedValue typedValue) { return (BigDecimal) local; } if (local instanceof Double || local instanceof Float) { + final double value = ((Number) local).doubleValue(); + if (Double.isNaN(value) || Double.isInfinite(value)) { + // No decimal representation exists. Thrown as a cast error so the binder reports it the + // same way it reported every double before this class existed; BigDecimal.valueOf would + // raise NumberFormatException, which the binder does not catch. + throw new ClassCastException("NaN and infinity have no " + BigDecimal.class.getName()); + } // valueOf, not new BigDecimal(double): the constructor keeps the binary expansion, so 0.1 // arrives as 0.1000000000000000055511151231257827. - return BigDecimal.valueOf(((Number) local).doubleValue()); + return BigDecimal.valueOf(value); } if (local instanceof Number) { return BigDecimal.valueOf(((Number) local).longValue()); diff --git a/flight/flight-sql-jdbc-core/src/test/java/org/apache/arrow/driver/jdbc/converter/impl/DecimalAvaticaParameterConverterTest.java b/flight/flight-sql-jdbc-core/src/test/java/org/apache/arrow/driver/jdbc/converter/impl/DecimalAvaticaParameterConverterTest.java index 6f54298a65..6feab4fe65 100644 --- a/flight/flight-sql-jdbc-core/src/test/java/org/apache/arrow/driver/jdbc/converter/impl/DecimalAvaticaParameterConverterTest.java +++ b/flight/flight-sql-jdbc-core/src/test/java/org/apache/arrow/driver/jdbc/converter/impl/DecimalAvaticaParameterConverterTest.java @@ -18,6 +18,7 @@ import static org.hamcrest.CoreMatchers.is; import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import java.math.BigDecimal; @@ -145,6 +146,35 @@ public void testBindNegativeDouble() { assertThat(vector.getObject(0), is(new BigDecimal("-123.456"))); } + @Test + public void testBindBigIntegerArrivesAsBigDecimal() { + // Avatica has no rep for BigInteger, so toLocal() hands one over as a BigDecimal and the + // value survives whole. Nothing reaches the longValue() branch, which would overflow here. + final BigDecimal tooBigForALong = new BigDecimal("123456789012345678901234567890"); + TypedValue typedValue = TypedValue.ofLocal(ColumnMetaData.Rep.NUMBER, tooBigForALong); + + assertTrue(converter.bindParameter(vector, typedValue, 0)); + assertThat(vector.getObject(0), is(tooBigForALong.setScale(SCALE))); + } + + @Test + public void testBindNaNIsReportedAsUnsupported() { + // A cast error is what the binder turns into "Binding value of type DOUBLE is not yet + // supported"; NumberFormatException would escape it. + TypedValue typedValue = TypedValue.ofLocal(ColumnMetaData.Rep.DOUBLE, Double.NaN); + + assertThrows(ClassCastException.class, () -> converter.bindParameter(vector, typedValue, 0)); + } + + @Test + public void testBindInfinityIsReportedAsUnsupported() { + for (double value : new double[] {Double.POSITIVE_INFINITY, Double.NEGATIVE_INFINITY}) { + TypedValue typedValue = TypedValue.ofLocal(ColumnMetaData.Rep.DOUBLE, value); + + assertThrows(ClassCastException.class, () -> converter.bindParameter(vector, typedValue, 0)); + } + } + @Test public void testBindReturnsFalseForANonDecimalVector() { try (IntVector other = new IntVector("other", rootAllocatorTestExtension.getRootAllocator())) {