voonhous commented on code in PR #19403:
URL: https://github.com/apache/hudi/pull/19403#discussion_r3689102600


##########
hudi-spark-datasource/hudi-spark4-common/src/test/java/org/apache/hudi/variant/TestSpark4VariantShreddingProvider.java:
##########
@@ -87,11 +174,68 @@ void booleanRoundTrips() throws Exception {
     assertScalarRoundTrips("true", 
HoodieSchema.create(HoodieSchemaType.BOOLEAN));
   }
 
+  @Test
+  void binaryShredsToByteBuffer() {
+    byte[] payload = "not-utf8-�ÿ".getBytes(StandardCharsets.ISO_8859_1);
+    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 decimalRoundTrips() throws Exception {
     assertScalarRoundTrips("123.45", HoodieSchema.createDecimal(10, 2));
   }
 
+  // 
---------------------------------------------------------------------------
+  // "Decline to shred" fallbacks: value stays in the residual binary.
+  // 
---------------------------------------------------------------------------
+
+  @Test
+  void millisTimestampIsNotShreddedIntoMicrosLeaf() {

Review Comment:
   Renamed to `microsTimestampIsNotShreddedIntoMillisLeaf` in caba485a3f5b.



##########
hudi-spark-datasource/hudi-spark4-common/src/test/java/org/apache/hudi/variant/TestSpark4VariantShreddingProvider.java:
##########
@@ -22,61 +22,148 @@
 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;
 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.nio.charset.StandardCharsets;
 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;
 
 /**
- * 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 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) {
+    return provider.shredVariantRecord(unshredded(variant), 
shredded.getAvroSchema(), shredded);
+  }
 
-    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)));
+  }
 
-    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) {
+    Variant rebuilt = rebuild(shred(variant, shredded), shredded);
+    assertEquals(variant.toJson(ZoneOffset.UTC), 
rebuilt.toJson(ZoneOffset.UTC),
+        "variant did not round-trip through shred/rebuild");

Review Comment:
   Addressed in caba485a3f5b: the `String` overload threads `" for: " + json` 
into the round-trip failure message.



##########
hudi-spark-datasource/hudi-spark4-common/src/test/java/org/apache/hudi/variant/TestSpark4VariantShreddingProvider.java:
##########
@@ -100,15 +244,111 @@ void objectRoundTrips() throws Exception {
     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 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 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), 
false);
+  }
+
+  @Test
+  void rebuildDecimalFromFixedEncoding() {
+    assertDecimalRebuildsFromEncoding(
+        HoodieSchema.createDecimal("dec_fixed", "org.apache.hudi.test", null, 
10, 2, 8), true);
+  }
+
+  private void assertDecimalRebuildsFromEncoding(HoodieSchema decimalType, 
boolean fixed) {

Review Comment:
   Addressed in caba485a3f5b: parameter dropped; the encoding is derived from 
`decimalType.getAvroSchema().getType() == FIXED`.



##########
hudi-spark-datasource/hudi-spark4-common/src/test/java/org/apache/hudi/variant/TestSpark4VariantShreddingProvider.java:
##########
@@ -100,15 +244,111 @@ void objectRoundTrips() throws Exception {
     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 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 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), 
false);
+  }
+
+  @Test
+  void rebuildDecimalFromFixedEncoding() {
+    assertDecimalRebuildsFromEncoding(
+        HoodieSchema.createDecimal("dec_fixed", "org.apache.hudi.test", null, 
10, 2, 8), true);
+  }
+
+  private void assertDecimalRebuildsFromEncoding(HoodieSchema decimalType, 
boolean fixed) {
+    BigDecimal value = new BigDecimal("123.45");
+    HoodieSchema.Variant shredded = 
HoodieSchema.createVariantShredded(decimalType);
+    GenericRecord shreddedRecord = shred(scalar(b -> b.appendDecimal(value)), 
shredded);
+
+    Schema tvSchema = 
shredded.getAvroSchema().getField(VARIANT_TYPED_VALUE_FIELD).schema();
+    Conversions.DecimalConversion conversion = new 
Conversions.DecimalConversion();
+    Object encoded = 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 = 
HoodieSchema.createVariantShredded(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 = 
HoodieSchema.createVariantShredded(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() {

Review Comment:
   Kept, with a comment marking it as defensive-guard coverage only 
(caba485a3f5b).



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to