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

vinishjail97 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/incubator-xtable.git


The following commit(s) were added to refs/heads/main by this push:
     new 244003d2 fix(delta): null partition value for composite 
generated-column partitions (#828)
244003d2 is described below

commit 244003d2d61f9371713c711236bb0cb0f1eda79a
Author: Aleksandr Andreev <[email protected]>
AuthorDate: Mon Jun 22 22:52:24 2026 +0300

    fix(delta): null partition value for composite generated-column partitions 
(#828)
    
    * Fix composite partition value corruption when a component is missing
    
    DeltaPartitionExtractor#getSerializedPartitionValue joined the values of
    a composite (generated column) partition with Collectors.joining("-").
    When one of the component values was absent from the partition values
    map, the missing value was rendered as the literal string "null",
    producing a corrupted partition value such as "2013-null-20" that was
    then fed to the date parser.
    
    Return null when any component value is missing so the partition value
    resolves to null, consistent with the single-field branch that uses
    getOrDefault(name, null). Added a regression test covering a composite
    generated-column partition with a missing component.
    
    * Apply composite partition null-component fix to Delta Kernel extractor
    
    DeltaKernelPartitionExtractor#getSerializedPartitionValue contained the
    same composite (generated column) partition handling as the Spark-based
    DeltaPartitionExtractor: joining component values with
    Collectors.joining("-") rendered a missing component as the literal
    string "null", corrupting the partition value.
    
    Apply the same fix here - return null when any component value is
    missing - and add a regression test mirroring the one added for
    DeltaPartitionExtractor.
    
    * Strengthen Delta composite partition tests with null-source scenario
    
    The composite (generated column) partition columns are all derived from
    a single source column, so the realistic trigger for a missing component
    is a null source value, which makes every derived partition column null.
    
    - Reframe the DeltaPartitionExtractor and DeltaKernelPartitionExtractor
      unit tests around that null-source case (all components null) instead
      of an artificial single-missing-component map.
    - Add an integration test (ITDeltaConversionSource) that creates a Delta
      table partitioned by year/month/day generated columns derived from a
      nullable timestamp, inserts a row with a null timestamp, and verifies
      the snapshot resolves the partition value to null. Without the fix this
      reproduces the failure (ParseException on "null-null-null").
    
    * Address review feedback
    
    - Create the gen-col partitioned test table via the DeltaTable builder API
      instead of CREATE TABLE SQL (Spark 3.4 rejects generated partition columns
      via SQL), and use a named-column INSERT so generated columns are computed.
    - Drop misleading scalaMap alias in the kernel test.
    - Tighten comments.
    
    ---------
    
    Co-authored-by: Aleksandr Andreev <persistentengineer.com>
    Co-authored-by: Vinish Reddy <[email protected]>
---
 .../xtable/delta/DeltaPartitionExtractor.java      |  8 ++-
 .../kernel/DeltaKernelPartitionExtractor.java      |  8 ++-
 .../xtable/delta/ITDeltaConversionSource.java      | 69 ++++++++++++++++++++++
 .../xtable/delta/TestDeltaPartitionExtractor.java  | 60 +++++++++++++++++++
 .../kernel/TestDeltaKernelPartitionExtractor.java  | 58 ++++++++++++++++++
 5 files changed, 197 insertions(+), 6 deletions(-)

diff --git 
a/xtable-core/src/main/java/org/apache/xtable/delta/DeltaPartitionExtractor.java
 
b/xtable-core/src/main/java/org/apache/xtable/delta/DeltaPartitionExtractor.java
index 7d9db06e..326b03c0 100644
--- 
a/xtable-core/src/main/java/org/apache/xtable/delta/DeltaPartitionExtractor.java
+++ 
b/xtable-core/src/main/java/org/apache/xtable/delta/DeltaPartitionExtractor.java
@@ -326,9 +326,11 @@ public class DeltaPartitionExtractor {
     if (partitionFieldNames.size() == 1) {
       return values.getOrDefault(partitionFieldNames.get(0), null);
     }
-    return partitionFieldNames.stream()
-        .map(name -> values.get(name))
-        .collect(Collectors.joining("-"));
+    // Composite partition: any null component yields a null value, not joined 
"null"s.
+    if (partitionFieldNames.stream().anyMatch(name -> values.get(name) == 
null)) {
+      return null;
+    }
+    return 
partitionFieldNames.stream().map(values::get).collect(Collectors.joining("-"));
   }
 
   private String getGeneratedColumnName(InternalPartitionField 
internalPartitionField) {
diff --git 
a/xtable-core/src/main/java/org/apache/xtable/kernel/DeltaKernelPartitionExtractor.java
 
b/xtable-core/src/main/java/org/apache/xtable/kernel/DeltaKernelPartitionExtractor.java
index 95ffc9d4..4a236867 100644
--- 
a/xtable-core/src/main/java/org/apache/xtable/kernel/DeltaKernelPartitionExtractor.java
+++ 
b/xtable-core/src/main/java/org/apache/xtable/kernel/DeltaKernelPartitionExtractor.java
@@ -319,9 +319,11 @@ public class DeltaKernelPartitionExtractor {
     if (partitionFieldNames.size() == 1) {
       return values.getOrDefault(partitionFieldNames.get(0), null);
     }
-    return partitionFieldNames.stream()
-        .map(name -> values.get(name))
-        .collect(Collectors.joining("-"));
+    // Composite partition: any null component yields a null value, not joined 
"null"s.
+    if (partitionFieldNames.stream().anyMatch(name -> values.get(name) == 
null)) {
+      return null;
+    }
+    return 
partitionFieldNames.stream().map(values::get).collect(Collectors.joining("-"));
   }
 
   private String getGeneratedColumnName(InternalPartitionField 
internalPartitionField) {
diff --git 
a/xtable-core/src/test/java/org/apache/xtable/delta/ITDeltaConversionSource.java
 
b/xtable-core/src/test/java/org/apache/xtable/delta/ITDeltaConversionSource.java
index 80ba46a3..326a98d8 100644
--- 
a/xtable-core/src/test/java/org/apache/xtable/delta/ITDeltaConversionSource.java
+++ 
b/xtable-core/src/test/java/org/apache/xtable/delta/ITDeltaConversionSource.java
@@ -18,6 +18,9 @@
  
 package org.apache.xtable.delta;
 
+import static org.apache.spark.sql.types.DataTypes.IntegerType;
+import static org.apache.spark.sql.types.DataTypes.LongType;
+import static org.apache.spark.sql.types.DataTypes.TimestampType;
 import static org.apache.xtable.testutil.ITTestUtils.validateTable;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertFalse;
@@ -52,6 +55,8 @@ import org.junit.jupiter.params.ParameterizedTest;
 import org.junit.jupiter.params.provider.Arguments;
 import org.junit.jupiter.params.provider.MethodSource;
 
+import io.delta.tables.DeltaTable;
+
 import org.apache.xtable.GenericTable;
 import org.apache.xtable.TestSparkDeltaTable;
 import org.apache.xtable.ValidationTestHelper;
@@ -368,6 +373,70 @@ public class ITDeltaConversionSource {
     InternalSnapshot snapshot = conversionSource.getCurrentSnapshot();
   }
 
+  @Test
+  void getCurrentSnapshotGenColPartitionedWithNullSourceTest() {
+    // year/month/day partition columns generated from one source column; a 
null source produces a
+    // null component for each, which must resolve to a null partition value, 
not "null-null-null".
+    final String tableName = GenericTable.getTableName();
+    final Path basePath = tempDir.resolve(tableName);
+    // Builder API, not CREATE TABLE SQL: Spark 3.4 rejects generated 
partition columns via SQL.
+    DeltaTable.createIfNotExists(sparkSession)
+        .tableName(tableName)
+        .location(basePath.toString())
+        .addColumn("id", LongType)
+        .addColumn("event_time", TimestampType)
+        .addColumn(
+            DeltaTable.columnBuilder("year_col")
+                .dataType(IntegerType)
+                .generatedAlwaysAs("YEAR(event_time)")
+                .build())
+        .addColumn(
+            DeltaTable.columnBuilder("month_col")
+                .dataType(IntegerType)
+                .generatedAlwaysAs("MONTH(event_time)")
+                .build())
+        .addColumn(
+            DeltaTable.columnBuilder("day_col")
+                .dataType(IntegerType)
+                .generatedAlwaysAs("DAY(event_time)")
+                .build())
+        .partitionedBy("year_col", "month_col", "day_col")
+        .execute();
+    // Second row has a null timestamp, so every generated partition column is 
null.
+    sparkSession.sql(
+        "INSERT INTO TABLE `"
+            + tableName
+            + "` (id, event_time) VALUES (1, CAST('2013-08-20 00:00:00' AS 
TIMESTAMP)),"
+            + " (2, CAST(NULL AS TIMESTAMP))");
+
+    SourceTable tableConfig =
+        SourceTable.builder()
+            .name(tableName)
+            .basePath(basePath.toString())
+            .formatName(TableFormat.DELTA)
+            .build();
+    DeltaConversionSource conversionSource =
+        conversionSourceProvider.getConversionSourceInstance(tableConfig);
+
+    InternalSnapshot snapshot = conversionSource.getCurrentSnapshot();
+
+    List<InternalDataFile> dataFiles =
+        snapshot.getPartitionedDataFiles().stream()
+            .flatMap(group -> group.getDataFiles().stream())
+            .collect(Collectors.toList());
+    assertEquals(2, dataFiles.size());
+
+    // Exactly one file is in the null-source partition, with a null partition 
value.
+    long nullPartitionFiles =
+        dataFiles.stream()
+            .filter(
+                file ->
+                    file.getPartitionValues().stream()
+                        .anyMatch(pv -> pv.getRange().getMaxValue() == null))
+            .count();
+    assertEquals(1, nullPartitionFiles);
+  }
+
   @ParameterizedTest
   @MethodSource("testWithPartitionToggle")
   public void testInsertsUpsertsAndDeletes(boolean isPartitioned) {
diff --git 
a/xtable-core/src/test/java/org/apache/xtable/delta/TestDeltaPartitionExtractor.java
 
b/xtable-core/src/test/java/org/apache/xtable/delta/TestDeltaPartitionExtractor.java
index 5b767096..a405de08 100644
--- 
a/xtable-core/src/test/java/org/apache/xtable/delta/TestDeltaPartitionExtractor.java
+++ 
b/xtable-core/src/test/java/org/apache/xtable/delta/TestDeltaPartitionExtractor.java
@@ -496,6 +496,66 @@ public class TestDeltaPartitionExtractor {
     assertEquals(expectedPartitionValues, partitionValues);
   }
 
+  @Test
+  public void testGeneratedPartitionValueExtractionWithNullSource() {
+    // Composite partition whose components (year/month/day) are all null must 
resolve to a null
+    // partition value, not the literal "null-null-null".
+    Map<String, String> partitionValuesMap =
+        new HashMap<String, String>() {
+          {
+            put("partition_column1", "partition_value1");
+            put("year_partition_column", null);
+            put("month_partition_column", null);
+            put("day_partition_column", null);
+          }
+        };
+    scala.collection.mutable.Map<String, String> scalaMap =
+        convertJavaMapToScalaMap(partitionValuesMap);
+    InternalPartitionField internalPartitionField1 =
+        InternalPartitionField.builder()
+            .sourceField(
+                InternalField.builder()
+                    .name("partition_column1")
+                    .schema(
+                        InternalSchema.builder()
+                            .name("string")
+                            .dataType(InternalType.STRING)
+                            .build())
+                    .build())
+            .transformType(PartitionTransformType.VALUE)
+            .build();
+    InternalPartitionField internalPartitionField2 =
+        InternalPartitionField.builder()
+            .sourceField(
+                InternalField.builder()
+                    .name("some_date_column")
+                    .schema(
+                        InternalSchema.builder()
+                            .name("timestamp")
+                            .dataType(InternalType.TIMESTAMP)
+                            .build())
+                    .build())
+            .partitionFieldNames(
+                Arrays.asList(
+                    "year_partition_column", "month_partition_column", 
"day_partition_column"))
+            .transformType(PartitionTransformType.DAY)
+            .build();
+    List<PartitionValue> expectedPartitionValues =
+        Arrays.asList(
+            PartitionValue.builder()
+                .partitionField(internalPartitionField1)
+                .range(Range.scalar("partition_value1"))
+                .build(),
+            PartitionValue.builder()
+                .partitionField(internalPartitionField2)
+                .range(Range.scalar(null))
+                .build());
+    List<PartitionValue> partitionValues =
+        deltaPartitionExtractor.partitionValueExtraction(
+            scalaMap, Arrays.asList(internalPartitionField1, 
internalPartitionField2));
+    assertEquals(expectedPartitionValues, partitionValues);
+  }
+
   @Test
   void convertBucketPartition() {
     InternalPartitionField internalPartitionField =
diff --git 
a/xtable-core/src/test/java/org/apache/xtable/kernel/TestDeltaKernelPartitionExtractor.java
 
b/xtable-core/src/test/java/org/apache/xtable/kernel/TestDeltaKernelPartitionExtractor.java
index 536bd116..bfdbaf8f 100644
--- 
a/xtable-core/src/test/java/org/apache/xtable/kernel/TestDeltaKernelPartitionExtractor.java
+++ 
b/xtable-core/src/test/java/org/apache/xtable/kernel/TestDeltaKernelPartitionExtractor.java
@@ -513,6 +513,64 @@ public class TestDeltaKernelPartitionExtractor {
     assertEquals(expectedPartitionValues, partitionValues);
   }
 
+  @Test
+  public void testGeneratedPartitionValueExtractionWithNullSource() {
+    // Composite partition whose components (year/month/day) are all null must 
resolve to a null
+    // partition value, not the literal "null-null-null".
+    Map<String, String> partitionValuesMap =
+        new HashMap<String, String>() {
+          {
+            put("partition_column1", "partition_value1");
+            put("year_partition_column", null);
+            put("month_partition_column", null);
+            put("day_partition_column", null);
+          }
+        };
+    InternalPartitionField internalPartitionField1 =
+        InternalPartitionField.builder()
+            .sourceField(
+                InternalField.builder()
+                    .name("partition_column1")
+                    .schema(
+                        InternalSchema.builder()
+                            .name("string")
+                            .dataType(InternalType.STRING)
+                            .build())
+                    .build())
+            .transformType(PartitionTransformType.VALUE)
+            .build();
+    InternalPartitionField internalPartitionField2 =
+        InternalPartitionField.builder()
+            .sourceField(
+                InternalField.builder()
+                    .name("some_date_column")
+                    .schema(
+                        InternalSchema.builder()
+                            .name("timestamp")
+                            .dataType(InternalType.TIMESTAMP)
+                            .build())
+                    .build())
+            .partitionFieldNames(
+                Arrays.asList(
+                    "year_partition_column", "month_partition_column", 
"day_partition_column"))
+            .transformType(PartitionTransformType.DAY)
+            .build();
+    List<PartitionValue> expectedPartitionValues =
+        Arrays.asList(
+            PartitionValue.builder()
+                .partitionField(internalPartitionField1)
+                .range(Range.scalar("partition_value1"))
+                .build(),
+            PartitionValue.builder()
+                .partitionField(internalPartitionField2)
+                .range(Range.scalar(null))
+                .build());
+    List<PartitionValue> partitionValues =
+        deltaKernelPartitionExtractor.partitionValueExtraction(
+            partitionValuesMap, Arrays.asList(internalPartitionField1, 
internalPartitionField2));
+    assertEquals(expectedPartitionValues, partitionValues);
+  }
+
   @Test
   void convertBucketPartition() {
     InternalPartitionField internalPartitionField =

Reply via email to