This is an automated email from the ASF dual-hosted git repository.
voonhous 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 c815c3f06ef2 test(spark): extend Spark 4 variant shredding coverage
(#19403)
c815c3f06ef2 is described below
commit c815c3f06ef2fd3c6399af9f198ffaaf47916d98
Author: Y Ethan Guo <[email protected]>
AuthorDate: Fri Jul 31 04:57:41 2026 -0700
test(spark): extend Spark 4 variant shredding coverage (#19403)
* test(spark): extend Spark 4 variant shredding coverage
* test(spark): address review comments on variant shredding coverage
- Drop fixedLeafShredsBinaryToByteBuffer: it pinned a typed_value that
cannot survive a real Avro/parquet write or read (FIXED handling bug
to be filed separately)
- Build every scalar leaf with a nullable typed_value via shreddedLeaf,
matching the shape production emits and the shredding spec requires
- Validate every shredded record against ConvertingGenericData (the
write-path data model) inside the shred helper
- Pin allowNumericScaleChanges: integral-into-decimal, decimal-into-long,
and a lossy decimal declining to the residual
- Assert the positive half of partial object shredding and add the
field-level type-mismatch residual case
- Replace the raw NUL-byte binary literal with an explicit byte array
- Correct the scalar section comment (Byte/Short widening unreachable),
rename the micros-into-millis decline test, fold numericRoundTrips
into longLeafShredsToLong, upgrade string/boolean/decimal tests to
assertScalarShredsTo, add ENUM and no-typed_value decline cases
- Derive the decimal encoding from the schema, restore json context in
round-trip failure messages, note the null-record guard is defensive
---------
Co-authored-by: voon <[email protected]>
---
.../TestSpark4VariantShreddingProvider.java | 367 +++++++++++++++++++--
1 file changed, 337 insertions(+), 30 deletions(-)
diff --git
a/hudi-spark-datasource/hudi-spark4-common/src/test/java/org/apache/hudi/variant/TestSpark4VariantShreddingProvider.java
b/hudi-spark-datasource/hudi-spark4-common/src/test/java/org/apache/hudi/variant/TestSpark4VariantShreddingProvider.java
index 37800e98dc02..8abb9bf992fe 100644
---
a/hudi-spark-datasource/hudi-spark4-common/src/test/java/org/apache/hudi/variant/TestSpark4VariantShreddingProvider.java
+++
b/hudi-spark-datasource/hudi-spark4-common/src/test/java/org/apache/hudi/variant/TestSpark4VariantShreddingProvider.java
@@ -19,10 +19,13 @@
package org.apache.hudi.variant;
+import org.apache.hudi.common.avro.ConvertingGenericData;
import org.apache.hudi.common.schema.HoodieSchema;
import org.apache.hudi.common.schema.HoodieSchemaField;
import org.apache.hudi.common.schema.HoodieSchemaType;
+import org.apache.hudi.exception.HoodieException;
+import org.apache.avro.Conversions;
import org.apache.avro.Schema;
import org.apache.avro.generic.GenericData;
import org.apache.avro.generic.GenericRecord;
@@ -30,68 +33,251 @@ import org.apache.spark.types.variant.Variant;
import org.apache.spark.types.variant.VariantBuilder;
import org.junit.jupiter.api.Test;
+import java.math.BigDecimal;
import java.nio.ByteBuffer;
import java.time.ZoneOffset;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.Map;
+import java.util.UUID;
+import static
org.apache.hudi.common.schema.HoodieSchema.Variant.VARIANT_METADATA_FIELD;
+import static
org.apache.hudi.common.schema.HoodieSchema.Variant.VARIANT_TYPED_VALUE_FIELD;
+import static
org.apache.hudi.common.schema.HoodieSchema.Variant.VARIANT_VALUE_FIELD;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
/**
- * Round-trip coverage for {@link Spark4VariantShreddingProvider}: shred an
unshredded variant, then
- * reconstruct it, and assert it round-trips. This exercises {@code
rebuildVariantRecord} and the
- * {@code AvroVariantRow}/{@code AvroObjectRow}/{@code AvroArrayRow} accessors
across scalar, object,
- * and array shapes - the AVRO read-path reconstruction (#18931) that the
Spark MOR SQL test cannot
- * reach (Spark compaction reads base files via the InternalRow reader, not
HoodieAvroParquetReader).
+ * Round-trip and behavior-pinning coverage for {@link
Spark4VariantShreddingProvider}: shred an
+ * unshredded variant, then reconstruct it, asserting both the intermediate
shredded schema/values and
+ * the reconstructed variant. This exercises {@code shredVariantRecord},
{@code rebuildVariantRecord},
+ * {@code avroTypeToScalarType}, {@code convertScalarToAvro}, and the
+ * {@code AvroVariantRow}/{@code AvroObjectRow}/{@code AvroArrayRow} accessors
across every reachable
+ * scalar leaf type, object/array shapes, partial (residual) shredding, and
the null/error guards - the
+ * AVRO read-path reconstruction that the Spark MOR SQL test cannot reach
(Spark compaction reads base
+ * files via the InternalRow reader, not HoodieAvroParquetReader).
*/
class TestSpark4VariantShreddingProvider {
private final Spark4VariantShreddingProvider provider = new
Spark4VariantShreddingProvider();
private final Schema unshreddedSchema =
HoodieSchema.createVariant().getAvroSchema();
- /** Parse json to a variant, shred it to {@code shredded}, rebuild it,
assert the json round-trips. */
- private void assertRoundTrips(String json, HoodieSchema.Variant shredded)
throws Exception {
- Variant variant = VariantBuilder.parseJson(json, false);
- GenericRecord unshreddedRecord = new GenericData.Record(unshreddedSchema);
- unshreddedRecord.put(HoodieSchema.Variant.VARIANT_METADATA_FIELD,
ByteBuffer.wrap(variant.getMetadata()));
- unshreddedRecord.put(HoodieSchema.Variant.VARIANT_VALUE_FIELD,
ByteBuffer.wrap(variant.getValue()));
+ /** Wrap a fully built {@link Variant} into the unshredded {metadata, value}
Avro record. */
+ private GenericRecord unshredded(Variant variant) {
+ GenericRecord record = new GenericData.Record(unshreddedSchema);
+ record.put(VARIANT_METADATA_FIELD, ByteBuffer.wrap(variant.getMetadata()));
+ record.put(VARIANT_VALUE_FIELD, ByteBuffer.wrap(variant.getValue()));
+ return record;
+ }
+
+ private GenericRecord shred(Variant variant, HoodieSchema.Variant shredded) {
+ GenericRecord record = provider.shredVariantRecord(unshredded(variant),
shredded.getAvroSchema(), shredded);
+ // ConvertingGenericData.INSTANCE is the data model HoodieAvroWriteSupport
hands to parquet-avro,
+ // so this pins that every shredded record produced here is writable at
its declared schema.
+
assertTrue(ConvertingGenericData.INSTANCE.validate(shredded.getAvroSchema(),
record),
+ "shredded record is not writable at its declared schema");
+ return record;
+ }
- Schema shreddedSchema = shredded.getAvroSchema();
- GenericRecord shreddedRecord =
provider.shredVariantRecord(unshreddedRecord, shreddedSchema, shredded);
- GenericRecord rebuilt = provider.rebuildVariantRecord(shreddedRecord,
shreddedSchema, unshreddedSchema);
+ private Variant rebuild(GenericRecord shreddedRecord, HoodieSchema.Variant
shredded) {
+ GenericRecord rebuilt =
+ provider.rebuildVariantRecord(shreddedRecord,
shredded.getAvroSchema(), unshreddedSchema);
+ return new Variant(toBytes(rebuilt.get(VARIANT_VALUE_FIELD)),
toBytes(rebuilt.get(VARIANT_METADATA_FIELD)));
+ }
+
+ private void assertRoundTrips(Variant variant, HoodieSchema.Variant
shredded) {
+ assertRoundTrips(variant, shredded, "");
+ }
- Variant rebuiltVariant = new Variant(
- toBytes(rebuilt.get(HoodieSchema.Variant.VARIANT_VALUE_FIELD)),
- toBytes(rebuilt.get(HoodieSchema.Variant.VARIANT_METADATA_FIELD)));
- assertEquals(variant.toJson(ZoneOffset.UTC),
rebuiltVariant.toJson(ZoneOffset.UTC),
- "variant did not round-trip through shred/rebuild for: " + json);
+ private void assertRoundTrips(Variant variant, HoodieSchema.Variant
shredded, String context) {
+ Variant rebuilt = rebuild(shred(variant, shredded), shredded);
+ assertEquals(variant.toJson(ZoneOffset.UTC),
rebuilt.toJson(ZoneOffset.UTC),
+ "variant did not round-trip through shred/rebuild" + context);
+ }
+
+ /** Parse json to a variant, shred it, rebuild it, assert the json
round-trips. */
+ private void assertRoundTrips(String json, HoodieSchema.Variant shredded)
throws Exception {
+ assertRoundTrips(VariantBuilder.parseJson(json, false), shredded, " for: "
+ json);
+ }
+
+ /**
+ * Wrap a scalar leaf into a shredded variant schema with a nullable {@code
typed_value}, the shape
+ * production always emits ({@code createShreddedFieldStruct} wraps every
leaf in
+ * {@code createNullable}, and the parquet shredding spec requires {@code
typed_value} optional).
+ */
+ private static HoodieSchema.Variant shreddedLeaf(HoodieSchema typedValue) {
+ return
HoodieSchema.createVariantShredded(HoodieSchema.createNullable(typedValue));
}
private void assertScalarRoundTrips(String json, HoodieSchema typedValue)
throws Exception {
- assertRoundTrips(json, HoodieSchema.createVariantShredded(typedValue));
+ assertRoundTrips(json, shreddedLeaf(typedValue));
+ }
+
+ /**
+ * Shred {@code variant} into a scalar {@code typedValue} schema, assert the
value fully shredded
+ * (into {@code typed_value}, exactly {@code expectedTypedValue}, no
residual {@code value}) and
+ * round-trips back to the original variant.
+ */
+ private void assertScalarShredsTo(Variant variant, HoodieSchema typedValue,
Object expectedTypedValue) {
+ HoodieSchema.Variant shredded = shreddedLeaf(typedValue);
+ GenericRecord shreddedRecord = shred(variant, shredded);
+ assertNotNull(shreddedRecord.get(VARIANT_METADATA_FIELD), "shredded record
must carry metadata");
+ assertNull(shreddedRecord.get(VARIANT_VALUE_FIELD), "a matching scalar
leaves no residual value");
+ assertEquals(expectedTypedValue,
shreddedRecord.get(VARIANT_TYPED_VALUE_FIELD),
+ "scalar was not shredded into typed_value as expected");
+ assertEquals(variant.toJson(ZoneOffset.UTC), rebuild(shreddedRecord,
shredded).toJson(ZoneOffset.UTC),
+ "scalar did not round-trip through shred/rebuild");
+ }
+
+ private void assertScalarShredsTo(String json, HoodieSchema typedValue,
Object expectedTypedValue) throws Exception {
+ assertScalarShredsTo(VariantBuilder.parseJson(json, false), typedValue,
expectedTypedValue);
+ }
+
+ /**
+ * Shred {@code variant} against a scalar {@code typedValue} it does not
match: assert it is NOT
+ * shredded (typed_value stays null, the value lands in the residual {@code
value}) yet still
+ * round-trips. Exercises the "decline to shred, keep in residual" fallbacks.
+ */
+ private void assertStaysInResidual(Variant variant, HoodieSchema typedValue)
{
+ HoodieSchema.Variant shredded = shreddedLeaf(typedValue);
+ GenericRecord shreddedRecord = shred(variant, shredded);
+ assertNull(shreddedRecord.get(VARIANT_TYPED_VALUE_FIELD), "value should
not have been shredded");
+ assertNotNull(shreddedRecord.get(VARIANT_VALUE_FIELD), "unshredded value
must survive in residual");
+ assertEquals(variant.toJson(ZoneOffset.UTC), rebuild(shreddedRecord,
shredded).toJson(ZoneOffset.UTC),
+ "residual value did not round-trip through shred/rebuild");
+ }
+
+ private static Variant scalar(java.util.function.Consumer<VariantBuilder>
append) {
+ VariantBuilder builder = new VariantBuilder(false);
+ append.accept(builder);
+ return builder.result();
+ }
+
+ //
---------------------------------------------------------------------------
+ // Scalar leaf types: one per reachable branch of avroTypeToScalarType /
convertScalarToAvro
+ // (the Byte/Short widening arms in convertScalarToAvro are unreachable:
Avro has no byte/short).
+ //
---------------------------------------------------------------------------
+
+ @Test
+ void intLeafShredsToInteger() {
+ // An integral variant shreds into an INT typed_value as a boxed Integer
(Avro has no byte/short,
+ // so avroTypeToScalarType only ever emits IntegralSize.INT here).
+ assertScalarShredsTo(scalar(b -> b.appendLong(1000)),
HoodieSchema.create(HoodieSchemaType.INT), 1000);
+ }
+
+ @Test
+ void longLeafShredsToLong() throws Exception {
+ assertScalarShredsTo(scalar(b -> b.appendLong(100000)),
HoodieSchema.create(HoodieSchemaType.LONG), 100000L);
+ // Same branch through the json entry point.
+ assertScalarShredsTo("42", HoodieSchema.create(HoodieSchemaType.LONG),
42L);
+ }
+
+ @Test
+ void floatShredsAndRoundTrips() {
+ assertScalarShredsTo(scalar(b -> b.appendFloat(1.5f)),
HoodieSchema.create(HoodieSchemaType.FLOAT), 1.5f);
+ }
+
+ @Test
+ void doubleShredsAndRoundTrips() {
+ assertScalarShredsTo(scalar(b -> b.appendDouble(2.5d)),
HoodieSchema.create(HoodieSchemaType.DOUBLE), 2.5d);
+ }
+
+ @Test
+ void stringLeafShredsToString() throws Exception {
+ assertScalarShredsTo("\"hello world\"",
HoodieSchema.create(HoodieSchemaType.STRING), "hello world");
+ }
+
+ @Test
+ void booleanLeafShredsToBoolean() throws Exception {
+ assertScalarShredsTo("true",
HoodieSchema.create(HoodieSchemaType.BOOLEAN), true);
+ }
+
+ @Test
+ void binaryShredsToByteBuffer() {
+ byte[] payload = {'n', 'o', 't', '-', 'u', 't', 'f', '8', (byte) 0xC0,
(byte) 0xFF};
+ assertScalarShredsTo(scalar(b -> b.appendBinary(payload)),
+ HoodieSchema.create(HoodieSchemaType.BYTES), ByteBuffer.wrap(payload));
+ }
+
+ @Test
+ void uuidShredsToString() {
+ UUID uuid = UUID.fromString("12345678-1234-1234-1234-123456789abc");
+ assertScalarShredsTo(scalar(b -> b.appendUuid(uuid)),
HoodieSchema.createUUID(), uuid.toString());
+ }
+
+ @Test
+ void dateShredsToDaysSinceEpoch() {
+ assertScalarShredsTo(scalar(b -> b.appendDate(19000)),
HoodieSchema.createDate(), 19000);
+ }
+
+ @Test
+ void timestampMicrosShredsToMicros() {
+ long micros = 1_700_000_000_000_000L;
+ assertScalarShredsTo(scalar(b -> b.appendTimestamp(micros)),
HoodieSchema.createTimestampMicros(), micros);
+ }
+
+ @Test
+ void localTimestampMicrosShredsToMicros() {
+ long micros = 1_700_000_000_000_000L;
+ assertScalarShredsTo(scalar(b -> b.appendTimestampNtz(micros)),
HoodieSchema.createLocalTimestampMicros(), micros);
}
@Test
- void numericRoundTrips() throws Exception {
- assertScalarRoundTrips("42", HoodieSchema.create(HoodieSchemaType.LONG));
+ void decimalLeafShredsToBigDecimal() throws Exception {
+ assertScalarShredsTo("123.45", HoodieSchema.createDecimal(10, 2), new
BigDecimal("123.45"));
}
@Test
- void stringRoundTrips() throws Exception {
- assertScalarRoundTrips("\"hello world\"",
HoodieSchema.create(HoodieSchemaType.STRING));
+ void numericScaleChangesShredWhenExact() {
+ // allowNumericScaleChanges() lets Spark shred an integral variant into a
decimal leaf (and a
+ // decimal variant into an integral leaf) when the value is exactly
representable.
+ assertScalarShredsTo(scalar(b -> b.appendLong(5)),
HoodieSchema.createDecimal(10, 2), new BigDecimal("5.00"));
+ assertScalarShredsTo(scalar(b -> b.appendDecimal(new BigDecimal("5.00"))),
+ HoodieSchema.create(HoodieSchemaType.LONG), 5L);
}
+ //
---------------------------------------------------------------------------
+ // "Decline to shred" fallbacks: value stays in the residual binary.
+ //
---------------------------------------------------------------------------
+
@Test
- void booleanRoundTrips() throws Exception {
- assertScalarRoundTrips("true",
HoodieSchema.create(HoodieSchemaType.BOOLEAN));
+ void microsTimestampIsNotShreddedIntoMillisLeaf() {
+ // A millisecond-precision typed_value cannot represent a micros variant
timestamp, so
+ // avroTypeToScalarType returns null and the value is left unshredded in
the residual.
+ assertStaysInResidual(scalar(b ->
b.appendTimestamp(1_700_000_000_000_000L)),
+ HoodieSchema.createTimestampMillis());
+ assertStaysInResidual(scalar(b ->
b.appendTimestampNtz(1_700_000_000_000_000L)),
+ HoodieSchema.createLocalTimestampMillis());
}
@Test
- void decimalRoundTrips() throws Exception {
- assertScalarRoundTrips("123.45", HoodieSchema.createDecimal(10, 2));
+ void lossyDecimalIsNotShredded() {
+ // decimal(10,2) cannot represent 123.456 exactly, so the value must fall
to the residual.
+ assertStaysInResidual(scalar(b -> b.appendDecimal(new
BigDecimal("123.456"))),
+ HoodieSchema.createDecimal(10, 2));
}
+ @Test
+ void enumLeafIsNotShredded() {
+ // avroTypeToScalarType has no mapping for ENUM (the default: branch), so
the value declines
+ // to the residual.
+ assertStaysInResidual(scalar(b -> b.appendString("A")),
+ HoodieSchema.createEnum("e", "org.apache.hudi.test", null,
Arrays.asList("A", "B")));
+ }
+
+ @Test
+ void noTypedValueSchemaRoundTrips() throws Exception {
+ // A shredded schema without typed_value keeps the whole variant in the
residual value.
+ assertRoundTrips("{\"a\":1}", HoodieSchema.createVariantShredded(null));
+ }
+
+ //
---------------------------------------------------------------------------
+ // Object and array shapes, including partial (residual) object shredding.
+ //
---------------------------------------------------------------------------
+
@Test
void objectRoundTrips() throws Exception {
Map<String, HoodieSchema> shreddedFields = new LinkedHashMap<>();
@@ -100,15 +286,136 @@ class TestSpark4VariantShreddingProvider {
assertRoundTrips("{\"a\":\"x\",\"b\":5}",
HoodieSchema.createVariantShreddedObject(shreddedFields));
}
+ @Test
+ void partialObjectShreddingKeepsExtraFieldsInResidual() throws Exception {
+ // Shredded schema declares {a, b} but the variant provides {a, c}: "a"
shreds into typed_value,
+ // "b" is absent (null value + null typed_value), and the extra "c" lands
in the residual value.
+ Map<String, HoodieSchema> shreddedFields = new LinkedHashMap<>();
+ shreddedFields.put("a", HoodieSchema.create(HoodieSchemaType.STRING));
+ shreddedFields.put("b", HoodieSchema.create(HoodieSchemaType.LONG));
+ HoodieSchema.Variant shredded =
HoodieSchema.createVariantShreddedObject(shreddedFields);
+
+ Variant variant = VariantBuilder.parseJson("{\"a\":\"x\",\"c\":99}",
false);
+ GenericRecord shreddedRecord = shred(variant, shredded);
+
+ // The unmatched field forces a non-null residual value at the top level.
+ assertNotNull(shreddedRecord.get(VARIANT_VALUE_FIELD), "extra field must
be captured in residual value");
+ GenericRecord typedValue = (GenericRecord)
shreddedRecord.get(VARIANT_TYPED_VALUE_FIELD);
+ GenericRecord aField = (GenericRecord) typedValue.get("a");
+ assertEquals("x", aField.get(VARIANT_TYPED_VALUE_FIELD), "declared field a
must shred into typed_value");
+ assertNull(aField.get(VARIANT_VALUE_FIELD), "matched field a carries no
residual");
+ GenericRecord bField = (GenericRecord) typedValue.get("b");
+ assertNull(bField.get(VARIANT_VALUE_FIELD), "absent field b carries no
residual value");
+ assertNull(bField.get(VARIANT_TYPED_VALUE_FIELD), "absent field b carries
no typed_value");
+
+ assertEquals(variant.toJson(ZoneOffset.UTC), rebuild(shreddedRecord,
shredded).toJson(ZoneOffset.UTC));
+ }
+
+ @Test
+ void typeMismatchedObjectFieldFallsToFieldResidual() throws Exception {
+ // The field is declared LONG but the variant supplies a string: the
field's own value carries
+ // the residual, its typed_value stays null, and the top-level value stays
null.
+ Map<String, HoodieSchema> shreddedFields = new LinkedHashMap<>();
+ shreddedFields.put("a", HoodieSchema.create(HoodieSchemaType.LONG));
+ HoodieSchema.Variant shredded =
HoodieSchema.createVariantShreddedObject(shreddedFields);
+
+ Variant variant = VariantBuilder.parseJson("{\"a\":\"str\"}", false);
+ GenericRecord shreddedRecord = shred(variant, shredded);
+
+ assertNull(shreddedRecord.get(VARIANT_VALUE_FIELD), "no unmatched field,
so the top-level residual stays null");
+ GenericRecord aField = (GenericRecord) ((GenericRecord)
shreddedRecord.get(VARIANT_TYPED_VALUE_FIELD)).get("a");
+ assertNotNull(aField.get(VARIANT_VALUE_FIELD), "mismatched field a keeps
its value in the field residual");
+ assertNull(aField.get(VARIANT_TYPED_VALUE_FIELD), "mismatched field a must
not shred");
+
+ assertEquals(variant.toJson(ZoneOffset.UTC), rebuild(shreddedRecord,
shredded).toJson(ZoneOffset.UTC));
+ }
+
@Test
void arrayRoundTrips() throws Exception {
// typed_value for an array is array<{value, typed_value}>: each element
is itself a shredded struct.
HoodieSchema element = HoodieSchema.createRecord("v_array_element",
"org.apache.hudi.test", null, Arrays.asList(
- HoodieSchemaField.of(HoodieSchema.Variant.VARIANT_VALUE_FIELD,
HoodieSchema.createNullable(HoodieSchemaType.BYTES)),
- HoodieSchemaField.of(HoodieSchema.Variant.VARIANT_TYPED_VALUE_FIELD,
HoodieSchema.create(HoodieSchemaType.LONG))));
+ HoodieSchemaField.of(VARIANT_VALUE_FIELD,
HoodieSchema.createNullable(HoodieSchemaType.BYTES)),
+ HoodieSchemaField.of(VARIANT_TYPED_VALUE_FIELD,
HoodieSchema.create(HoodieSchemaType.LONG))));
assertScalarRoundTrips("[1,2,3]", HoodieSchema.createArray(element));
}
+ //
---------------------------------------------------------------------------
+ // Decimal reconstruction from the on-disk (avro-decoded) encodings a
parquet reader produces:
+ // the shred path emits a BigDecimal, but a base file feeds rebuild a
ByteBuffer / GenericFixed.
+ //
---------------------------------------------------------------------------
+
+ @Test
+ void rebuildDecimalFromBytesEncoding() {
+ assertDecimalRebuildsFromEncoding(HoodieSchema.createDecimal(10, 2));
+ }
+
+ @Test
+ void rebuildDecimalFromFixedEncoding() {
+ assertDecimalRebuildsFromEncoding(
+ HoodieSchema.createDecimal("dec_fixed", "org.apache.hudi.test", null,
10, 2, 8));
+ }
+
+ private void assertDecimalRebuildsFromEncoding(HoodieSchema decimalType) {
+ BigDecimal value = new BigDecimal("123.45");
+ HoodieSchema.Variant shredded = shreddedLeaf(decimalType);
+ GenericRecord shreddedRecord = shred(scalar(b -> b.appendDecimal(value)),
shredded);
+
+ // Encode against the concrete decimal branch (typed_value itself is a
nullable union).
+ Schema tvSchema = decimalType.getAvroSchema();
+ Conversions.DecimalConversion conversion = new
Conversions.DecimalConversion();
+ Object encoded = tvSchema.getType() == Schema.Type.FIXED
+ ? conversion.toFixed(value, tvSchema, tvSchema.getLogicalType())
+ : conversion.toBytes(value, tvSchema, tvSchema.getLogicalType());
+ shreddedRecord.put(VARIANT_TYPED_VALUE_FIELD, encoded);
+
+ Variant original = scalar(b -> b.appendDecimal(value));
+ assertEquals(original.toJson(ZoneOffset.UTC), rebuild(shreddedRecord,
shredded).toJson(ZoneOffset.UTC));
+ }
+
+ @Test
+ void rebuildDecimalRejectsUnexpectedEncoding() {
+ HoodieSchema.Variant shredded =
shreddedLeaf(HoodieSchema.createDecimal(10, 2));
+ GenericRecord shreddedRecord = shred(scalar(b -> b.appendDecimal(new
BigDecimal("1.00"))), shredded);
+ shreddedRecord.put(VARIANT_TYPED_VALUE_FIELD, "not-a-decimal");
+ assertThrows(IllegalStateException.class,
+ () -> provider.rebuildVariantRecord(shreddedRecord,
shredded.getAvroSchema(), unshreddedSchema));
+ }
+
+ //
---------------------------------------------------------------------------
+ // Null / error guards.
+ //
---------------------------------------------------------------------------
+
+ @Test
+ void shredReturnsNullWhenValueOrMetadataMissing() {
+ HoodieSchema.Variant shredded =
shreddedLeaf(HoodieSchema.create(HoodieSchemaType.LONG));
+ Variant variant = scalar(b -> b.appendLong(1));
+
+ GenericRecord missingValue = unshredded(variant);
+ missingValue.put(VARIANT_VALUE_FIELD, null);
+ assertNull(provider.shredVariantRecord(missingValue,
shredded.getAvroSchema(), shredded));
+
+ GenericRecord missingMetadata = unshredded(variant);
+ missingMetadata.put(VARIANT_METADATA_FIELD, null);
+ assertNull(provider.shredVariantRecord(missingMetadata,
shredded.getAvroSchema(), shredded));
+ }
+
+ @Test
+ void rebuildReturnsNullForNullRecord() {
+ // Coverage of a defensive guard only: the sole production caller
(HoodieVariantReconstruction)
+ // already checks instanceof GenericRecord before calling
rebuildVariantRecord.
+ HoodieSchema.Variant shredded =
shreddedLeaf(HoodieSchema.create(HoodieSchemaType.LONG));
+ assertNull(provider.rebuildVariantRecord(null, shredded.getAvroSchema(),
unshreddedSchema));
+ }
+
+ @Test
+ void rebuildThrowsWhenMetadataMissing() {
+ HoodieSchema.Variant shredded =
shreddedLeaf(HoodieSchema.create(HoodieSchemaType.LONG));
+ GenericRecord shreddedRecord = shred(scalar(b -> b.appendLong(7)),
shredded);
+ shreddedRecord.put(VARIANT_METADATA_FIELD, null);
+ assertThrows(HoodieException.class,
+ () -> provider.rebuildVariantRecord(shreddedRecord,
shredded.getAvroSchema(), unshreddedSchema));
+ }
+
private static byte[] toBytes(Object byteBuffer) {
ByteBuffer buf = ((ByteBuffer) byteBuffer).duplicate();
byte[] out = new byte[buf.remaining()];