This is an automated email from the ASF dual-hosted git repository.
sivabalan pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/hudi.git
The following commit(s) were added to refs/heads/master by this push:
new 7013912c86f [HUDI-8998] Improve handling of zero scale decimals in
MercifulJsonConverter (#12822)
7013912c86f is described below
commit 7013912c86fadb098dd387d210c7cd732c761fea
Author: Tim Brown <[email protected]>
AuthorDate: Tue Feb 11 13:20:59 2025 -0600
[HUDI-8998] Improve handling of zero scale decimals in
MercifulJsonConverter (#12822)
---
.../java/org/apache/hudi/avro/HoodieAvroUtils.java | 12 ++++
.../apache/hudi/avro/MercifulJsonConverter.java | 3 +-
.../processors/DecimalLogicalTypeProcessor.java | 37 +++++++---
.../hudi/avro/MercifulJsonConverterTestBase.java | 18 ++++-
.../hudi/avro/TestMercifulJsonConverter.java | 83 ++++++++++++++++++----
.../resources/decimal-logical-type-zero-scale.avsc | 25 +++++++
6 files changed, 149 insertions(+), 29 deletions(-)
diff --git
a/hudi-common/src/main/java/org/apache/hudi/avro/HoodieAvroUtils.java
b/hudi-common/src/main/java/org/apache/hudi/avro/HoodieAvroUtils.java
index f0ef00eb333..bcd89abf979 100644
--- a/hudi-common/src/main/java/org/apache/hudi/avro/HoodieAvroUtils.java
+++ b/hudi-common/src/main/java/org/apache/hudi/avro/HoodieAvroUtils.java
@@ -74,6 +74,7 @@ import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.math.BigDecimal;
import java.math.BigInteger;
+import java.math.MathContext;
import java.math.RoundingMode;
import java.nio.ByteBuffer;
import java.sql.Date;
@@ -1156,6 +1157,17 @@ public class HoodieAvroUtils {
throw new HoodieAvroSchemaException(String.format("cannot support rewrite
value for schema type: %s since the old schema type is: %s", newSchema,
oldSchema));
}
+ /**
+ * Use this instead of DECIMAL_CONVERSION.fromBytes() because that method
does not add in precision
+ *
+ * bytes is the result of BigDecimal.unscaledValue().toByteArray();
+ * This is also what Conversions.DecimalConversion.toBytes() outputs inside
a byte buffer
+ */
+ public static BigDecimal convertBytesToBigDecimal(byte[] value,
LogicalTypes.Decimal decimal) {
+ return new BigDecimal(new BigInteger(value),
+ decimal.getScale(), new MathContext(decimal.getPrecision(),
RoundingMode.HALF_UP));
+ }
+
/**
* Checks whether the provided schema contains a decimal with a precision
less than or equal to 18,
* which allows the decimal to be stored as int/long instead of a fixed size
byte array in
diff --git
a/hudi-common/src/main/java/org/apache/hudi/avro/MercifulJsonConverter.java
b/hudi-common/src/main/java/org/apache/hudi/avro/MercifulJsonConverter.java
index a8f5cec20f1..f12a07c8a84 100644
--- a/hudi-common/src/main/java/org/apache/hudi/avro/MercifulJsonConverter.java
+++ b/hudi-common/src/main/java/org/apache/hudi/avro/MercifulJsonConverter.java
@@ -37,6 +37,7 @@ import org.apache.hudi.common.util.collection.Pair;
import org.apache.hudi.exception.HoodieException;
import org.apache.hudi.exception.HoodieJsonToAvroConversionException;
+import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.apache.avro.Conversions;
import org.apache.avro.LogicalType;
@@ -88,7 +89,7 @@ public class MercifulJsonConverter {
* Allows enabling sanitization and allows choice of invalidCharMask for
sanitization
*/
public MercifulJsonConverter(boolean shouldSanitize, String invalidCharMask)
{
- this(new ObjectMapper(), shouldSanitize, invalidCharMask);
+ this(new
ObjectMapper().enable(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS),
shouldSanitize, invalidCharMask);
}
/**
diff --git
a/hudi-common/src/main/java/org/apache/hudi/avro/processors/DecimalLogicalTypeProcessor.java
b/hudi-common/src/main/java/org/apache/hudi/avro/processors/DecimalLogicalTypeProcessor.java
index 04ed97069af..93e02dd4983 100644
---
a/hudi-common/src/main/java/org/apache/hudi/avro/processors/DecimalLogicalTypeProcessor.java
+++
b/hudi-common/src/main/java/org/apache/hudi/avro/processors/DecimalLogicalTypeProcessor.java
@@ -18,12 +18,16 @@
package org.apache.hudi.avro.processors;
+import org.apache.hudi.avro.HoodieAvroUtils;
import org.apache.hudi.common.util.collection.Pair;
import org.apache.avro.LogicalTypes;
import org.apache.avro.Schema;
import java.math.BigDecimal;
+import java.math.MathContext;
+import java.math.RoundingMode;
+import java.util.Base64;
public abstract class DecimalLogicalTypeProcessor extends JsonFieldProcessor {
@@ -51,17 +55,26 @@ public abstract class DecimalLogicalTypeProcessor extends
JsonFieldProcessor {
*/
protected static Pair<Boolean, BigDecimal> parseObjectToBigDecimal(Object
obj, Schema schema) {
BigDecimal bigDecimal = null;
- if (obj instanceof Number) {
- bigDecimal = BigDecimal.valueOf(((Number) obj).doubleValue());
- }
-
- // Case 2: Object is a number in String format.
- if (obj instanceof String) {
- try {
- bigDecimal = new BigDecimal(((String) obj));
- } catch (java.lang.NumberFormatException ignored) {
- /* ignore */
+ LogicalTypes.Decimal logicalType = (LogicalTypes.Decimal)
schema.getLogicalType();
+ try {
+ if (obj instanceof BigDecimal) {
+ bigDecimal = ((BigDecimal) obj).setScale(logicalType.getScale(),
RoundingMode.UNNECESSARY);
+ } else if (obj instanceof String) {
+ // Case 2: Object is a number in String format.
+ try {
+ //encoded big decimal
+ bigDecimal =
HoodieAvroUtils.convertBytesToBigDecimal(decodeStringToBigDecimalBytes(obj),
+ (LogicalTypes.Decimal) schema.getLogicalType());
+ } catch (IllegalArgumentException e) {
+ //no-op
+ }
+ }
+ // None fixed byte or fixed byte conversion failure would end up here.
+ if (bigDecimal == null) {
+ bigDecimal = new BigDecimal(obj.toString(), new
MathContext(logicalType.getPrecision(),
RoundingMode.UNNECESSARY)).setScale(logicalType.getScale(),
RoundingMode.UNNECESSARY);
}
+ } catch (java.lang.NumberFormatException | ArithmeticException ignored) {
+ /* ignore */
}
if (bigDecimal == null) {
@@ -82,4 +95,8 @@ public abstract class DecimalLogicalTypeProcessor extends
JsonFieldProcessor {
}
return Pair.of(true, bigDecimal);
}
+
+ protected static byte[] decodeStringToBigDecimalBytes(Object value) {
+ return Base64.getDecoder().decode(((String) value).getBytes());
+ }
}
diff --git
a/hudi-common/src/test/java/org/apache/hudi/avro/MercifulJsonConverterTestBase.java
b/hudi-common/src/test/java/org/apache/hudi/avro/MercifulJsonConverterTestBase.java
index b6646a993c8..c9e643042d6 100644
---
a/hudi-common/src/test/java/org/apache/hudi/avro/MercifulJsonConverterTestBase.java
+++
b/hudi-common/src/test/java/org/apache/hudi/avro/MercifulJsonConverterTestBase.java
@@ -29,6 +29,7 @@ public class MercifulJsonConverterTestBase {
private static final String DECIMAL_AVRO_FILE_INVALID_PATH =
"/decimal-logical-type-invalid.avsc";
private static final String DECIMAL_AVRO_FILE_PATH =
"/decimal-logical-type.avsc";
private static final String DECIMAL_FIXED_AVRO_FILE_PATH =
"/decimal-logical-type-fixed-type.avsc";
+ protected static final String DECIMAL_ZERO_SCALE_AVRO_FILE_PATH =
"/decimal-logical-type-zero-scale.avsc";
private static final String LOCAL_TIMESTAMP_MICRO_AVRO_FILE_PATH =
"/local-timestamp-micros-logical-type.avsc";
private static final String LOCAL_TIMESTAMP_MILLI_AVRO_FILE_PATH =
"/local-timestamp-millis-logical-type.avsc";
private static final String DURATION_AVRO_FILE_PATH_INVALID =
"/duration-logical-type-invalid.avsc";
@@ -70,7 +71,7 @@ public class MercifulJsonConverterTestBase {
Arguments.of(DECIMAL_AVRO_FILE_PATH, "123.45", null, 123.45, false),
// Test MIN/MAX allowed by the schema.
Arguments.of(DECIMAL_AVRO_FILE_PATH, "-999.99", "-999.99", null,
false),
- Arguments.of(DECIMAL_AVRO_FILE_PATH, "999.99",null, 999.99, false),
+ Arguments.of(DECIMAL_AVRO_FILE_PATH, "999.99", null, 999.99, false),
// Test 0.
Arguments.of(DECIMAL_AVRO_FILE_PATH, "0", null, 0D, false),
Arguments.of(DECIMAL_AVRO_FILE_PATH, "0", "0", null, false),
@@ -79,15 +80,26 @@ public class MercifulJsonConverterTestBase {
Arguments.of(DECIMAL_FIXED_AVRO_FILE_PATH, "123.45", "123.45", null,
false),
Arguments.of(DECIMAL_FIXED_AVRO_FILE_PATH, "123.45", null, 123.45,
false),
Arguments.of(DECIMAL_FIXED_AVRO_FILE_PATH, "-999.99", "-999.99", null,
false),
- Arguments.of(DECIMAL_FIXED_AVRO_FILE_PATH, "999.99",null, 999.99,
false),
+ Arguments.of(DECIMAL_FIXED_AVRO_FILE_PATH, "999.99", null, 999.99,
false),
+ Arguments.of(DECIMAL_FIXED_AVRO_FILE_PATH, "999", null, 999, false),
+ Arguments.of(DECIMAL_FIXED_AVRO_FILE_PATH, "999", null, 999L, false),
+ Arguments.of(DECIMAL_FIXED_AVRO_FILE_PATH, "999", null, (short) 999,
false),
+ Arguments.of(DECIMAL_FIXED_AVRO_FILE_PATH, "100", null, (byte) 100,
false),
Arguments.of(DECIMAL_FIXED_AVRO_FILE_PATH, "0", null, 0D, false),
+ Arguments.of(DECIMAL_FIXED_AVRO_FILE_PATH, "0", null, 0, false),
Arguments.of(DECIMAL_FIXED_AVRO_FILE_PATH, "0", "0", null, true),
Arguments.of(DECIMAL_FIXED_AVRO_FILE_PATH, "0", "000.00", null, true),
Arguments.of(DECIMAL_FIXED_AVRO_FILE_PATH, "123.45", null, null, true),
Arguments.of(DECIMAL_FIXED_AVRO_FILE_PATH, "123.45", null, 123.45,
true),
Arguments.of(DECIMAL_FIXED_AVRO_FILE_PATH, "-999.99", null, null,
true),
Arguments.of(DECIMAL_FIXED_AVRO_FILE_PATH, "999.99", null, 999.99,
true),
- Arguments.of(DECIMAL_FIXED_AVRO_FILE_PATH, "0", null, null, true)
+ Arguments.of(DECIMAL_FIXED_AVRO_FILE_PATH, "0", null, null, true),
+ Arguments.of(DECIMAL_FIXED_AVRO_FILE_PATH, "0", null, null, true),
+ Arguments.of(DECIMAL_ZERO_SCALE_AVRO_FILE_PATH, "12345", "12345.0",
null, false),
+ Arguments.of(DECIMAL_ZERO_SCALE_AVRO_FILE_PATH, "12345", null,
12345.0, false),
+ Arguments.of(DECIMAL_ZERO_SCALE_AVRO_FILE_PATH, "12345", null, 12345,
false),
+ Arguments.of(DECIMAL_ZERO_SCALE_AVRO_FILE_PATH, "1230", null, 1.23e+3,
false),
+ Arguments.of(DECIMAL_ZERO_SCALE_AVRO_FILE_PATH, "1230", "1.23e+3",
null, false)
);
}
diff --git
a/hudi-common/src/test/java/org/apache/hudi/avro/TestMercifulJsonConverter.java
b/hudi-common/src/test/java/org/apache/hudi/avro/TestMercifulJsonConverter.java
index bc12eef5a4d..0844ac98fdb 100644
---
a/hudi-common/src/test/java/org/apache/hudi/avro/TestMercifulJsonConverter.java
+++
b/hudi-common/src/test/java/org/apache/hudi/avro/TestMercifulJsonConverter.java
@@ -27,9 +27,9 @@ import org.apache.avro.Schema;
import org.apache.avro.generic.GenericData;
import org.apache.avro.generic.GenericFixed;
import org.apache.avro.generic.GenericRecord;
-import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import java.io.IOException;
@@ -40,7 +40,9 @@ import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
+import java.util.stream.Stream;
+import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
public class TestMercifulJsonConverter extends MercifulJsonConverterTestBase {
@@ -64,7 +66,7 @@ public class TestMercifulJsonConverter extends
MercifulJsonConverterTestBase {
rec.put("favorite_number", number);
rec.put("favorite_color", color);
- Assertions.assertEquals(rec, CONVERTER.convert(json, simpleSchema));
+ assertEquals(rec, CONVERTER.convert(json, simpleSchema));
}
@ParameterizedTest
@@ -78,11 +80,11 @@ public class TestMercifulJsonConverter extends
MercifulJsonConverterTestBase {
rec.put("favorite_number", 1337);
rec.put("favorite_color", "10");
- Assertions.assertEquals(rec, CONVERTER.convert(json, simpleSchema));
+ assertEquals(rec, CONVERTER.convert(json, simpleSchema));
}
private static final String DECIMAL_AVRO_FILE_PATH =
"/decimal-logical-type.avsc";
- private static final String DECIMAL_FIXED_AVRO_FILE_PATH =
"/decimal-logical-type-fixed-type.avsc";
+
/**
* Covered case:
* Avro Logical Type: Decimal
@@ -122,7 +124,7 @@ public class TestMercifulJsonConverter extends
MercifulJsonConverterTestBase {
@ParameterizedTest
@MethodSource("decimalGoodCases")
void decimalLogicalTypeTest(String avroFilePath, String groundTruth, String
strInput,
- Double numInput, boolean testFixedByteArray)
throws IOException {
+ Number numInput, boolean testFixedByteArray)
throws IOException {
BigDecimal bigDecimal = new BigDecimal(groundTruth);
Map<String, Object> data = new HashMap<>();
@@ -153,7 +155,7 @@ public class TestMercifulJsonConverter extends
MercifulJsonConverterTestBase {
}
// Decide the decimal field expected output according to the test
dimension.
- if (avroFilePath.equals(DECIMAL_AVRO_FILE_PATH)) {
+ if (avroFilePath.equals(DECIMAL_AVRO_FILE_PATH) ||
avroFilePath.equals(DECIMAL_ZERO_SCALE_AVRO_FILE_PATH)) {
record.put("decimalField", conv.toBytes(bigDecimal, decimalFieldSchema,
decimalFieldSchema.getLogicalType()));
} else {
record.put("decimalField", conv.toFixed(bigDecimal, decimalFieldSchema,
decimalFieldSchema.getLogicalType()));
@@ -162,7 +164,58 @@ public class TestMercifulJsonConverter extends
MercifulJsonConverterTestBase {
String json = MAPPER.writeValueAsString(data);
GenericRecord real = CONVERTER.convert(json, schema);
- Assertions.assertEquals(record, real);
+ assertEquals(record, real);
+ }
+
+ // tests cases where decimals with fraction `.0` can be interpreted as
having scale > 0
+ @ParameterizedTest
+ @MethodSource("zeroScaleDecimalCases")
+ void zeroScaleDecimalConversion(String inputValue, String expected, boolean
shouldConvert) {
+ Schema schema = new Schema.Parser().parse("{\"namespace\":
\"example.avro\",\"type\": \"record\",\"name\":
\"decimalLogicalType\",\"fields\": [{\"name\": \"decimalField\", "
+ + "\"type\": {\"type\": \"bytes\", \"logicalType\": \"decimal\",
\"precision\": 38, \"scale\": 0}}]}");
+ String json = String.format("{\"decimalField\":%s}", inputValue);
+
+ if (shouldConvert) {
+ GenericRecord record = new GenericData.Record(schema);
+ Conversions.DecimalConversion conv = new Conversions.DecimalConversion();
+ Schema decimalFieldSchema = schema.getField("decimalField").schema();
+ record.put("decimalField", conv.toBytes(new BigDecimal(expected),
decimalFieldSchema, decimalFieldSchema.getLogicalType()));
+
+ GenericRecord real = CONVERTER.convert(json, schema);
+ assertEquals(record, real);
+ } else {
+ assertThrows(HoodieJsonToAvroConversionException.class, () ->
CONVERTER.convert(json, schema));
+ }
+ }
+
+ static Stream<Object> zeroScaleDecimalCases() {
+ return Stream.of(
+ // Input value in JSON, expected decimal, whether conversion should be
successful
+ // Values that can be converted
+ Arguments.of("0.0", "0", true),
+ Arguments.of("20.0", "20", true),
+ Arguments.of("320", "320", true),
+ Arguments.of("320.00", "320", true),
+ Arguments.of("-1320.00", "-1320", true),
+ Arguments.of("1520423524459", "1520423524459", true),
+ Arguments.of("1520423524459.0", "1520423524459", true),
+ Arguments.of("1000000000000000.0", "1000000000000000", true),
+ // Values that are big enough and out of range of int or long types
+ // Note that we can have at most 17 significant decimal digits in
double values
+ Arguments.of("1.2684037455962608e+16", "12684037455962608", true),
+ Arguments.of("4.0100001e+16", "40100001000000000", true),
+ Arguments.of("3.52838e+17", "352838000000000000", true),
+ Arguments.of("9223372036853999600.0000", "9223372036853999600", true),
+ Arguments.of("999998887654321000000000000000.0000",
"999998887654321000000000000000", true),
+ Arguments.of("-999998887654321000000000000000.0000",
"-999998887654321000000000000000", true),
+ // Values covering high precision decimals that lose precision when
converting to a double
+ Arguments.of("3.781239258857277e+16", "37812392588572770", true),
+ Arguments.of("1.6585135379127473e+18", "1658513537912747300", true),
+ // Values that should not be converted
+ Arguments.of("0.0001", null, false),
+ Arguments.of("300.9999", null, false),
+ Arguments.of("1928943043.0001", null, false)
+ );
}
private static final String DURATION_AVRO_FILE_PATH =
"/duration-logical-type.avsc";
@@ -196,7 +249,7 @@ public class TestMercifulJsonConverter extends
MercifulJsonConverterTestBase {
durationRecord.put("duration", new
GenericData.Fixed(schema.getField("duration").schema(), buffer.array()));
GenericRecord real = CONVERTER.convert(json, schema);
- Assertions.assertEquals(durationRecord, real);
+ assertEquals(durationRecord, real);
}
@ParameterizedTest
@@ -235,7 +288,7 @@ public class TestMercifulJsonConverter extends
MercifulJsonConverterTestBase {
data.put("dateField", dateInput);
String json = MAPPER.writeValueAsString(data);
GenericRecord real = CONVERTER.convert(json, schema);
- Assertions.assertEquals(record, real);
+ assertEquals(record, real);
}
/**
@@ -285,7 +338,7 @@ public class TestMercifulJsonConverter extends
MercifulJsonConverterTestBase {
data.put("localTimestampMicrosField", timeMicro);
String json = MAPPER.writeValueAsString(data);
GenericRecord real = CONVERTER.convert(json, schema);
- Assertions.assertEquals(record, real);
+ assertEquals(record, real);
}
private static final String LOCAL_TIMESTAMP_MILLI_AVRO_FILE_PATH =
"/local-timestamp-millis-logical-type.avsc";
@@ -332,7 +385,7 @@ public class TestMercifulJsonConverter extends
MercifulJsonConverterTestBase {
data.put("timestampMicrosField", timeMicro);
String json = MAPPER.writeValueAsString(data);
GenericRecord real = CONVERTER.convert(json, schema);
- Assertions.assertEquals(record, real);
+ assertEquals(record, real);
}
@ParameterizedTest
@@ -386,7 +439,7 @@ public class TestMercifulJsonConverter extends
MercifulJsonConverterTestBase {
data.put("timeMillisField", timeMilli);
String json = MAPPER.writeValueAsString(data);
GenericRecord real = CONVERTER.convert(json, schema);
- Assertions.assertEquals(record, real);
+ assertEquals(record, real);
}
@ParameterizedTest
@@ -434,7 +487,7 @@ public class TestMercifulJsonConverter extends
MercifulJsonConverterTestBase {
data.put("uuidField", uuid);
String json = MAPPER.writeValueAsString(data);
GenericRecord real = CONVERTER.convert(json, schema);
- Assertions.assertEquals(record, real);
+ assertEquals(record, real);
}
@Test
@@ -456,7 +509,7 @@ public class TestMercifulJsonConverter extends
MercifulJsonConverterTestBase {
rec.put("favorite__number", number);
rec.put("favorite__color__", color);
- Assertions.assertEquals(rec, CONVERTER.convert(json, sanitizedSchema));
+ assertEquals(rec, CONVERTER.convert(json, sanitizedSchema));
}
@Test
@@ -479,6 +532,6 @@ public class TestMercifulJsonConverter extends
MercifulJsonConverterTestBase {
rec.put("favorite_number", number);
rec.put("favorite_color", color);
- Assertions.assertEquals(rec, CONVERTER.convert(json, sanitizedSchema));
+ assertEquals(rec, CONVERTER.convert(json, sanitizedSchema));
}
}
\ No newline at end of file
diff --git
a/hudi-common/src/test/resources/decimal-logical-type-zero-scale.avsc
b/hudi-common/src/test/resources/decimal-logical-type-zero-scale.avsc
new file mode 100644
index 00000000000..c4247a70266
--- /dev/null
+++ b/hudi-common/src/test/resources/decimal-logical-type-zero-scale.avsc
@@ -0,0 +1,25 @@
+/*
+ * 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.
+ */
+{
+ "namespace": "example.avro",
+ "type": "record",
+ "name": "decimalLogicalType",
+ "fields": [
+ {"name": "decimalField", "type": {"type": "bytes", "logicalType":
"decimal", "precision": 5, "scale": 0}}
+ ]
+}