This is an automated email from the ASF dual-hosted git repository.

stankiewicz pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/beam.git


The following commit(s) were added to refs/heads/master by this push:
     new 8abe4a39a4a Accept integral JSON values that a double represents 
exactly (#39744)
8abe4a39a4a is described below

commit 8abe4a39a4a8dfe88cd5f0ccb8abf1b12506013e
Author: ZIHAN DAI <[email protected]>
AuthorDate: Wed Sep 2 22:43:17 2026 +1000

    Accept integral JSON values that a double represents exactly (#39744)
    
    * Accept integral JSON values that a double represents exactly
    
    doubleValueExtractor's validator compared asLong() against a round-trip
    through asInt():
    
        && jsonNode.asLong() == (long) (double) jsonNode.asInt()
    
    asInt() truncates anything outside int range, so the two sides can never
    agree for a larger integral literal and the value is rejected -- even
    when a double holds it exactly. Epoch millis is the everyday case:
    {"f": 1609459200000} against a DOUBLE field fails today.
    
    Swapping asInt() for asLong() looks like the fix and is not. (double)
    Long.MAX_VALUE rounds up to 2^63, and narrowing 2^63 back to long
    saturates at Long.MAX_VALUE rather than overflowing, so the round-trip
    appears to succeed and the extractor stores 9223372036854775808 -- an
    over-rejection traded for silent corruption.
    
    Comparing through BigDecimal is exact, and it is what the decimal branch
    directly below already does.
    
    Tests: four supported cases (epoch millis, its negative, 2^31, 2^53) and
    two rejections (2^53+1 and Long.MAX_VALUE). The rejections get a method
    each because testUnsupportedConversion uses the ExpectedException rule,
    which is satisfied by the first exception to leave the test method -- a
    second call in the same body never runs.
    
    master fails the supported case; the asLong() variant fails the
    Long.MAX_VALUE case; this passes 78/78.
    
    * Reject an integral JSON value that a float only saturates back to
    
    floatValueExtractor validates an integral literal by round-tripping it
    through float and back to int:
    
        jsonNode.asInt() == (int) (float) jsonNode.asInt()
    
    Narrowing a float that is out of int range saturates rather than
    overflowing, so the trip is not the identity check it looks like.
    Measured across the interesting values:
    
        asInt        (float)asInt    (int)(float)   current  correct
        2147483647   2.14748365E9    2147483647     accept   REJECT
        -2147483648  -2.14748365E9   -2147483648    accept   accept
        16777216     1.6777216E7     16777216       accept   accept
        16777217     1.6777216E7     16777216       reject   reject
        2147483583   2.14748352E9    2147483520     reject   reject
    
    Integer.MAX_VALUE is the one value the check gets wrong: the float it
    goes through is 2147483648, and narrowing that back to int saturates at
    2147483647, so the equality holds and the value is accepted. The
    extractor then stores 2147483648.0 -- a document saying 2147483647 reads
    back as a different number, with no error.
    
    Compared through BigDecimal instead, matching the doubleValueExtractor
    branch this PR already fixes. Integer.MIN_VALUE and 2^24 stay accepted:
    both are exactly representable, so this is not an across-the-board
    tightening.
    
    Two supported cases go into the existing method. The unsupported case
    gets its own, for the reason noted there: the ExpectedException rule is
    satisfied by the first exception to leave the method, so a later call in
    the same body never runs. That is also why the existing
    testUnsupportedFloatConversions cannot cover this -- its INT_STRING call
    is the fourth in the body and has never executed.
    
    Reverting only the float validator fails
    testUnsupportedFloatConversionAtIntegerMaxValue and nothing else: 79
    tests, 1 failed.
---
 .../beam/sdk/util/RowJsonValueExtractors.java      | 22 +++++++++--
 .../java/org/apache/beam/sdk/util/RowJsonTest.java | 44 ++++++++++++++++++++++
 2 files changed, 62 insertions(+), 4 deletions(-)

diff --git 
a/sdks/java/core/src/main/java/org/apache/beam/sdk/util/RowJsonValueExtractors.java
 
b/sdks/java/core/src/main/java/org/apache/beam/sdk/util/RowJsonValueExtractors.java
index 2179b20010d..e769212fce7 100644
--- 
a/sdks/java/core/src/main/java/org/apache/beam/sdk/util/RowJsonValueExtractors.java
+++ 
b/sdks/java/core/src/main/java/org/apache/beam/sdk/util/RowJsonValueExtractors.java
@@ -113,10 +113,17 @@ class RowJsonValueExtractors {
                     || (jsonNode.isFloatingPointNumber()
                         && jsonNode.doubleValue() == (double) (float) 
jsonNode.doubleValue())
 
-                    // Or an integer number which allows lossless conversion 
to float
+                    // Or an integral number which allows lossless conversion 
to float.
+                    // Compared through BigDecimal for the same reason as the 
double branch
+                    // below: narrowing an out-of-range float back to int 
saturates rather than
+                    // overflowing, so Integer.MAX_VALUE survives an int 
round-trip even though
+                    // the float it went through is 2147483648.
                     || (jsonNode.isIntegralNumber()
                         && jsonNode.canConvertToInt()
-                        && jsonNode.asInt() == (int) (float) jsonNode.asInt()))
+                        && jsonNode
+                                .decimalValue()
+                                
.compareTo(BigDecimal.valueOf(jsonNode.floatValue()))
+                            == 0))
         .build();
   }
 
@@ -132,10 +139,17 @@ class RowJsonValueExtractors {
             jsonNode ->
                 jsonNode.isDouble()
 
-                    // Either a long number which allows lossless conversion 
to float
+                    // Either an integral number which allows lossless 
conversion to double.
+                    // Compared through BigDecimal, the same way the decimal 
branch below is: a
+                    // long round-trip cannot express this, because narrowing 
an out-of-range
+                    // double back to long saturates rather than overflowing, 
so Long.MAX_VALUE
+                    // would look like it survived the trip when it did not.
                     || (jsonNode.isIntegralNumber()
                         && jsonNode.canConvertToLong()
-                        && jsonNode.asLong() == (long) (double) 
jsonNode.asInt())
+                        && jsonNode
+                                .decimalValue()
+                                
.compareTo(BigDecimal.valueOf(jsonNode.doubleValue()))
+                            == 0)
 
                     // Or a decimal number which allows lossless conversion to 
float
                     || (jsonNode.isFloatingPointNumber()
diff --git 
a/sdks/java/core/src/test/java/org/apache/beam/sdk/util/RowJsonTest.java 
b/sdks/java/core/src/test/java/org/apache/beam/sdk/util/RowJsonTest.java
index 81f69b62c53..fda8fea5480 100644
--- a/sdks/java/core/src/test/java/org/apache/beam/sdk/util/RowJsonTest.java
+++ b/sdks/java/core/src/test/java/org/apache/beam/sdk/util/RowJsonTest.java
@@ -568,6 +568,13 @@ public class RowJsonTest {
     public void testSupportedFloatConversions() throws Exception {
       testSupportedConversion(FieldType.FLOAT, FLOAT_STRING, FLOAT_VALUE);
       testSupportedConversion(FieldType.FLOAT, SHORT_STRING, (float) 
SHORT_VALUE);
+
+      // Integral literals a float still represents exactly: 2^24 is the 
largest contiguous one,
+      // and Integer.MIN_VALUE is exact because it is a power of two. Both are 
accepted today and
+      // must stay accepted -- they are what keeps the fix from being an 
across-the-board reject.
+      testSupportedConversion(FieldType.FLOAT, "16777216", 16777216.0f);
+      testSupportedConversion(
+          FieldType.FLOAT, String.valueOf(Integer.MIN_VALUE), (float) 
Integer.MIN_VALUE);
     }
 
     @Test
@@ -575,6 +582,14 @@ public class RowJsonTest {
       testSupportedConversion(FieldType.DOUBLE, DOUBLE_STRING, DOUBLE_VALUE);
       testSupportedConversion(FieldType.DOUBLE, FLOAT_STRING, (double) 
FLOAT_VALUE);
       testSupportedConversion(FieldType.DOUBLE, INT_STRING, (double) 
INT_VALUE);
+
+      // Integral literals beyond int range that a double still represents 
exactly. Epoch millis
+      // is the everyday case; 2^31 is the exact boundary where the old 
asInt() truncation kicked
+      // in, and 2^53 is the largest contiguous integral double.
+      testSupportedConversion(FieldType.DOUBLE, "1609459200000", 
1609459200000.0d);
+      testSupportedConversion(FieldType.DOUBLE, "-1609459200000", 
-1609459200000.0d);
+      testSupportedConversion(FieldType.DOUBLE, "2147483648", 2147483648.0d);
+      testSupportedConversion(FieldType.DOUBLE, "9007199254740992", 
9007199254740992.0d);
     }
 
     @Test
@@ -678,6 +693,35 @@ public class RowJsonTest {
       testUnsupportedConversion(FieldType.DOUBLE, LONG_STRING); // too large 
to fit
     }
 
+    // The three cases below get a method each on purpose. 
testUnsupportedConversion relies on the
+    // ExpectedException rule, which is satisfied by the first exception to 
leave the test method,
+    // so a second call in the same body never runs and the assertion would be 
silently dead.
+
+    @Test
+    public void testUnsupportedFloatConversionAtIntegerMaxValue() throws 
Exception {
+      // Integer.MAX_VALUE, and the guard against "fixing" this extractor with 
an int round-trip.
+      // (float) 2147483647 rounds up to 2147483648, and narrowing 2147483648 
back to int
+      // saturates at Integer.MAX_VALUE, so the round-trip wrongly accepts it 
and stores
+      // 2147483648.0. INT_STRING in testUnsupportedFloatConversions is far 
smaller and does not
+      // exercise this -- and, being the fourth call in that method, never 
runs anyway.
+      testUnsupportedConversion(FieldType.FLOAT, "2147483647");
+    }
+
+    @Test
+    public void testUnsupportedDoubleConversionJustPastContiguousRange() 
throws Exception {
+      // 2^53 + 1, the first integer a double cannot represent.
+      testUnsupportedConversion(FieldType.DOUBLE, "9007199254740993");
+    }
+
+    @Test
+    public void testUnsupportedDoubleConversionAtLongMaxValue() throws 
Exception {
+      // Long.MAX_VALUE, and the guard against "fixing" this extractor by 
swapping asInt() for
+      // asLong(). (double) Long.MAX_VALUE rounds up to 2^63, and narrowing 
2^63 back to long
+      // saturates at Long.MAX_VALUE, so a long round-trip wrongly accepts it 
and stores
+      // 9223372036854775808. LONG_STRING above is 2^63 - 2 and does not 
exercise this.
+      testUnsupportedConversion(FieldType.DOUBLE, "9223372036854775807");
+    }
+
     private void testUnsupportedConversion(FieldType fieldType, String 
jsonFieldValue)
         throws Exception {
 

Reply via email to