voonhous commented on code in PR #19648:
URL: https://github.com/apache/hudi/pull/19648#discussion_r3829072959
##########
hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/keygen/PartitionPathFormatterBase.java:
##########
@@ -75,15 +76,11 @@ public final S combine(List<String> partitionPathFields,
Object... partitionPath
if (useHiveStylePartitioning) {
sb.appendJava(partitionPathFields.get(i))
- .appendJava("=")
- .append(partitionPathPartStr);
- } else if (slashSeparatedDatePartitioning) {
- String res = ((String) partitionPathPartStr).replace('-', '/');
- sb.append(((S) res));
- } else {
- sb.append(partitionPathPartStr);
+ .appendJava("=");
}
+ sb.append(partitionPathPartStr);
Review Comment:
Dropping the substitution from this branch is right for
`ComplexKeyGenerator`, but for `CustomKeyGenerator` it is a regression that
turns a working query into a silent empty result.
`CustomKeyGenerator` builds one single-field `SimpleKeyGenerator` per
partition field (`CustomKeyGenerator.java:91`, assembled at `:130-149`; Avro
mirror at `CustomAvroKeyGenerator.java:156-164`), so every field takes the
single-field fast path above and *is* slash-separated on disk. A
`dt:simple,city:simple` table lands in `2026/01/05/NYC` on the Avro, Row and
InternalRow paths alike -- your own javadoc on `replaceDashesWithSlashes` says
exactly this.
The query side does not decompose that way.
`SparkHoodieTableFileIndex.composeRelativePartitionPath` (`:471-489`) calls
`combine` once with all N partition columns, so it lands here in the
multi-field branch:
* before this PR it produced `2026/01/05/NYC`, `exists()` at
`SparkHoodieTableFileIndex.scala:444` passed, and the all-columns-bound short
circuit at `:447-448` returned the partition directly, without ever parsing the
path;
* after this PR it produces `2026-01-05/NYC`, `exists()` is false, and
`:445` returns `Seq()`.
So `select ... where dt = '2026-01-05' and city = 'NYC'` on a
`CustomKeyGenerator` slash table goes from returning rows to returning nothing,
with no error. This is distinct from #19666: that one is about the listing
path, whereas this short circuit never parses the partition path and therefore
worked end to end before this change. Nothing covers it --
`TestCustomKeyGenerator:419-448` is single-column, and every table in
`TestSlashSeparatedPartitionValue.scala` is `partitioned by (datestr)`.
One `combine` call cannot serve both generators: `KeyGenUtils.java:261`
guards the Avro multi-field path on `size() == 1`, while `CustomKeyGenerator`
decomposes into single-field sub-generators that each slash.
**Ask:** add a regression test that writes a two-column `CustomKeyGenerator`
slash table and reads it back with both partition columns bound in equality
predicates, asserting a non-zero row count. Then either make
`composeRelativePartitionPath` key-generator aware (one `combine` per column,
joined, when the table's key generator is `CustomKeyGenerator`), or, if you
would rather let #19666 reject the combination outright, say so in the PR
description and link it here. Silently converting rows into an empty result is
worse than the hard `HoodieException` #19666 describes.
##########
hudi-client/hudi-client-common/src/main/java/org/apache/hudi/keygen/KeyGenUtils.java:
##########
@@ -291,12 +292,30 @@ public static String getPartitionPath(GenericRecord
record, String partitionPath
if (hiveStylePartitioning) {
partitionPath = partitionPathField + "=" + partitionPath;
}
+ // NOTE: See [[slashSeparateDateValue]] on why a leading dash suppresses
the substitution
if (slashSeparatedDatePartitioning) {
- partitionPath = partitionPath.replace('-', '/');
+ partitionPath = slashSeparateDateValue(partitionPath);
}
return partitionPath;
}
+ /**
+ * Turns a {@code yyyy-MM-dd} formatted date value into the {@code
yyyy/MM/dd} directory structure
+ * requested by {@code
hoodie.datasource.write.slash.separated.date.partitioning}.
+ *
+ * <p>A value with a leading dash is returned as-is: substituting would make
the partition path
+ * start with {@code "/"}, and an absolute relative-partition-path is
resolved inconsistently --
+ * {@link org.apache.hudi.common.fs.FSUtils#constructAbsolutePath(String,
String)} chops the
+ * leading {@code "/"} while the {@link org.apache.hudi.storage.StoragePath}
overload used by
+ * {@code AbstractTableFileSystemView} lets it URI-resolve away the table
base path (a value of
+ * {@code "-5"} lands the writer in {@code "<base>/5"} but the file-system
view in {@code "/5"},
+ * and {@code "-"} resolves to the base path itself). Such a value is not a
date to begin with,
+ * so nothing is lost by not slashing it.
+ */
+ private static String slashSeparateDateValue(String partitionPath) {
+ return partitionPath.startsWith("-") ? partitionPath :
partitionPath.replace('-', '/');
Review Comment:
The leading-dash guard covers one of three value shapes that produce a
partition path nothing agrees on. A trailing dash and a doubled dash are
equally broken, and the damage is worse than the leading-dash case.
The invariant that matters is that the recorded partition-path string
survives `FSUtils.getRelativePartitionPath(base, constructAbsolutePath(base,
p))`. `StoragePath.normalize` (`StoragePath.java:300`) strips trailing slashes
and `java.net.URI.normalize()` collapses an inner `//`:
```
value slashed directory listed back round-trips
2026-01-05 2026/01/05 <base>/2026/01/05 2026/01/05 yes
-5 -5 (guarded) <base>/-5 -5 yes
5- 5/ <base>/5 5 NO
a--b a//b <base>/a/b a/b NO
2026-01-05- 2026/01/05/ <base>/2026/01/05 2026/01/05 NO (collides
with 2026-01-05)
```
The writer resolves the directory with normalization but records the raw
string: `HoodieRowCreateHandle.java:303` builds the path via `new
StoragePath(basePath, partitionPath)`, while `:132` and `:278` store `5/` into
`WriteStatus` and `HoodieWriteStat.partitionPath`. Two concrete consequences:
1. `FilesIndexer.java:185` calls `FSUtils.getFileName(stat.getPath(),
partitionStatName)`, which slices at `partition.length() + 1`
(`FSUtils.java:615-618`). With partition `5/` and path `5/xxx.parquet` that
returns `xx.parquet` -- the metadata-table FILES record stores a filename
missing its first character.
2. On the read side `"5/".split("/")` yields a single fragment, so
`HoodieSparkUtils.doParsePartitionColumnValues` takes the equal-length branch
at `:344` and never applies `replace('/', '-')`; the value reads back as `5`,
not `5-`.
Your own compatibility argument for preferring this guard over a
`yyyy-MM-dd` match applies unchanged: these are values whose current output is
already unreadable, so no layout that works today moves.
**Ask:** widen the predicate in all three write paths.
```suggestion
return partitionPath.startsWith("-") || partitionPath.endsWith("-") ||
partitionPath.contains("--")
? partitionPath
: partitionPath.replace('-', '/');
```
and correspondingly in the formatter: rename `startsWithDash` to something
like `hasUnslashableDash` and implement it as `startsWith(DASH) ||
endsWith(DASH) || contains(DOUBLE_DASH)` in both
`StringPartitionPathFormatter:65` and `UTF8StringPartitionPathFormatter:71`.
`UTF8String.endsWith` and `UTF8String.contains` are present in spark-unsafe
3.3.4 through 3.5.5 (I checked the bytecode), so no version gate is needed. The
javadoc above and the one on `PartitionPathFormatterBase:125-136` need the same
widening.
##########
hudi-client/hudi-client-common/src/main/java/org/apache/hudi/keygen/KeyGenUtils.java:
##########
@@ -291,12 +292,30 @@ public static String getPartitionPath(GenericRecord
record, String partitionPath
if (hiveStylePartitioning) {
partitionPath = partitionPathField + "=" + partitionPath;
}
+ // NOTE: See [[slashSeparateDateValue]] on why a leading dash suppresses
the substitution
if (slashSeparatedDatePartitioning) {
- partitionPath = partitionPath.replace('-', '/');
+ partitionPath = slashSeparateDateValue(partitionPath);
Review Comment:
The guard runs after the hive-style prefix has been prepended, so
`startsWith("-")` can never fire when `hiveStylePartitioning` is on: by `:293`
the value has already become `dt=-5`, and `slashSeparateDateValue` returns
`dt=/5`. The formatter checks the bare value instead
(`PartitionPathFormatterBase.java:68`, before any prefix is added), so the two
write paths apply the same guard to different strings.
`dt=/5` is a two-level directory for a single-column table, and a value of
`-` gives `dt=/`, whose directory is `<base>/dt=` -- so the recorded string
stops round-tripping to its own directory, which is the same failure
`FSUtils.getFileName` mis-slices on. `HoodieCatalogTable.scala:296-300` blocks
hive-style plus slash on the SQL DDL path only, so `df.write.format("hudi")`
and HoodieStreamer still reach this (the gap you filed as #19669).
**Ask:** move `slashSeparateDateValue(...)` so it runs on the bare field
value before the `field=` prefix is prepended, here and at `:262`. It is
behaviour-preserving for every value the guard does not catch, since `"dt=" +
"2026-01-05".replace('-','/')` equals `("dt=2026-01-05").replace('-','/')`, and
it makes the guard actually fire under hive-style partitioning.
##########
hudi-client/hudi-client-common/src/main/java/org/apache/hudi/keygen/KeyGenUtils.java:
##########
@@ -257,8 +257,9 @@ public static String getRecordPartitionPath(GenericRecord
record,
if (hiveStylePartitioning) {
fieldVal = partitionPathField + "=" + fieldVal;
}
+ // NOTE: See [[slashSeparateDateValue]] on why a leading dash
suppresses the substitution
if (partitionPathFields.size() == 1 && slashSeparatedDatePartitioning)
{
- fieldVal = fieldVal.replace('-', '/');
+ fieldVal = slashSeparateDateValue(fieldVal);
Review Comment:
This new guard has no test. `SimpleAvroKeyGenerator.java:55` calls the
*other* overload, so
`TestSimpleKeyGenerator#testSlashSeparatedDatePartitioningLeavesLeadingDashesAlone`
exercises `:297`, not this line. The only production caller of
`getRecordPartitionPath` is `ComplexAvroKeyGenerator.java:53`, and neither
slash test that reaches it uses a leading-dash value:
```
git grep -n "getRecordPartitionPath" -- '*.java' | grep -v /test/
grep -n "slash\|Slash"
hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/keygen/TestComplexKeyGenerator.java
```
That leaves half of the new `KeyGenUtils` guard shipping unexercised.
**Ask:** add `testSlashSeparatedDatePartitioningLeavesLeadingDashesAlone` to
`TestComplexKeyGenerator` next to the existing `:228` -- same props,
`avroRecord.put("timestamp", "-5")`, assert `key.getPartitionPath()` equals
`-5`. It is three lines off the existing test and goes red without this guard
(`/5`), so it is a real regression guard rather than another characterization
test.
##########
hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/keygen/TestPartitionPathFormatter.java:
##########
@@ -0,0 +1,118 @@
+/*
+ * 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.
+ */
+
+package org.apache.hudi.keygen;
+
+import org.apache.spark.unsafe.types.UTF8String;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+import static org.apache.hudi.keygen.KeyGenUtils.HUDI_DEFAULT_PARTITION_PATH;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+/**
+ * Tests the partition-path formatters backing the key generators, making sure
that both the
+ * {@link String} (Avro/{@link org.apache.spark.sql.Row} write path) and the
{@link UTF8String}
+ * (row-writer/{@link org.apache.spark.sql.catalyst.InternalRow} write path)
flavors produce
+ * identical partition paths.
+ */
+class TestPartitionPathFormatter {
+
+ private static final List<String> SINGLE_FIELD =
Collections.singletonList("date_col");
+ private static final List<String> TWO_FIELDS = Arrays.asList("date_col",
"city");
+
+ private String combine(boolean useRowWriterPath,
+ boolean hiveStylePartitioning,
+ boolean encode,
+ boolean slashSeparatedDatePartitioning,
+ List<String> fields,
+ Object... parts) {
+ if (useRowWriterPath) {
+ return new UTF8StringPartitionPathFormatter(
+ UTF8StringPartitionPathFormatter.UTF8StringBuilder::new,
hiveStylePartitioning, encode,
+ slashSeparatedDatePartitioning).combine(fields, parts).toString();
+ }
+ return new StringPartitionPathFormatter(
+ StringPartitionPathFormatter.JavaStringBuilder::new,
hiveStylePartitioning, encode,
+ slashSeparatedDatePartitioning).combine(fields, parts);
+ }
+
+ @ParameterizedTest
+ @ValueSource(booleans = {true, false})
+ void
testSlashSeparatedDatePartitioningOnlyAppliesToSingleFieldPartitioning(boolean
useRowWriterPath) {
+ // NOTE: This mirrors [[KeyGenUtils#getRecordPartitionPath]] driving the
Avro write-path
+ assertEquals("2026-01-05/san-francisco",
+ combine(useRowWriterPath, false, false, true, TWO_FIELDS,
"2026-01-05", "san-francisco"));
+ }
+
+ @ParameterizedTest
+ @ValueSource(booleans = {true, false})
+ void testSlashSeparatedDatePartitioningHandlesNullAndEmptyValues(boolean
useRowWriterPath) {
+ assertEquals(HUDI_DEFAULT_PARTITION_PATH,
+ combine(useRowWriterPath, false, false, true, SINGLE_FIELD, new
Object[] {null}));
+ assertEquals(HUDI_DEFAULT_PARTITION_PATH,
+ combine(useRowWriterPath, false, false, true, SINGLE_FIELD, ""));
+ }
+
+ @ParameterizedTest
+ @ValueSource(booleans = {true, false})
+ void testSlashSeparatedDatePartitioningLeavesLeadingDashesAlone(boolean
useRowWriterPath) {
+ // NOTE: Substituting here would yield a partition path starting with "/",
which
+ // [[FSUtils#constructAbsolutePath(String, String)]] and the
[[StoragePath]] overload used
+ // by [[AbstractTableFileSystemView]] resolve differently -- the
former chops the leading
+ // "/", the latter URI-resolves the table base path away -- so the
writer and the
+ // file-system view would disagree on where the partition lives
+ assertEquals("-5", combine(useRowWriterPath, false, false, true,
SINGLE_FIELD, "-5"));
+ assertEquals("-", combine(useRowWriterPath, false, false, true,
SINGLE_FIELD, "-"));
+ assertEquals("--5", combine(useRowWriterPath, false, false, true,
SINGLE_FIELD, "--5"));
+ // A dash anywhere else is still a separator: only a leading one produces
an absolute path
+ assertEquals("5/", combine(useRowWriterPath, false, false, true,
SINGLE_FIELD, "5-"));
Review Comment:
This pins the broken shape as intended behaviour, and the comment states an
invariant that does not hold. A trailing dash produces a trailing-slash path
and `a--b` produces a doubled-slash path; both fail the same round trip the
leading-dash guard exists to protect. `5-` writes to `<base>/5` but records
`5/`, and `a--b` writes to `<base>/a/b` but records `a//b` -- evidence in my
comment on `KeyGenUtils#slashSeparateDateValue`. Once this merges, a green test
locks the wrong behaviour in and the follow-up has to argue with an assertion
rather than with a gap.
**Ask:** assert the guarded values instead, and widen the comment to name
all three bad shapes. This goes green once the guard change on
`slashSeparateDateValue` and the two formatters is in.
```suggestion
// A dash that would turn into a leading, trailing or doubled "/" is
left alone as well
assertEquals("5-", combine(useRowWriterPath, false, false, true,
SINGLE_FIELD, "5-"));
assertEquals("a--b", combine(useRowWriterPath, false, false, true,
SINGLE_FIELD, "a--b"));
```
##########
hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/common/TestSlashSeparatedPartitionValue.scala:
##########
@@ -90,6 +90,113 @@ class TestSlashSeparatedPartitionValue extends
HoodieSparkSqlTestBase {
}
}
+ test("Test slash separated date partitions written through the row writer") {
+ withSQLConf("hoodie.spark.sql.insert.into.operation" -> "bulk_insert") {
+ withTempDir { tmp =>
+ val targetTable = generateTableName
+ val tablePath = s"${tmp.getCanonicalPath}/$targetTable"
+
+ spark.sql(
+ s"""
+ |create table $targetTable (
+ | `id` string,
+ | `name` string,
+ | `ts` bigint,
+ | `datestr` STRING
+ |) using hudi
+ | tblproperties (
+ | 'primaryKey' = 'id',
+ | 'type' = 'COW',
+ | 'preCombineField'='ts',
+ |
'hoodie.datasource.write.slash.separated.date.partitioning'='true'
+ | )
+ | partitioned by (`datestr`)
+ | location '$tablePath'
+ """.stripMargin)
+
+ // NOTE: The row writer derives the partition path off of an
[[InternalRow]], which used to
+ // blow up with a [[ClassCastException]]; a null partition value
used to NPE
+ spark.sql(
+ s"""
+ | insert into $targetTable values
+ | (1, 'a1', 1000, "2026-01-05"),
+ | (2, 'a2', 2000, "2026-01-06"),
+ | (3, 'a3', 3000, null)
+ """.stripMargin)
+
+ checkAnswer(s"select id, name, ts, _hoodie_partition_path, datestr
from $targetTable order by id")(
+ Seq("1", "a1", 1000, "2026/01/05", "2026-01-05"),
+ Seq("2", "a2", 2000, "2026/01/06", "2026-01-06"),
+ Seq("3", "a3", 3000, "__HIVE_DEFAULT_PARTITION__", null)
+ )
+
+ val metaClient = HoodieTableMetaClient.builder()
+
.setConf(HadoopFSUtils.getStorageConfWithCopy(spark.sparkContext.hadoopConfiguration))
+ .setBasePath(tablePath)
+ .build()
+ assertTrue(metaClient.getStorage.exists(new StoragePath(tablePath,
"2026/01/05")),
+ s"Partition path 2026/01/05 should exist")
+ assertTrue(metaClient.getStorage.exists(new StoragePath(tablePath,
"2026/01/06")),
+ s"Partition path 2026/01/06 should exist")
+ assertTrue(metaClient.getStorage.exists(new StoragePath(tablePath,
"__HIVE_DEFAULT_PARTITION__")),
+ s"Partition path __HIVE_DEFAULT_PARTITION__ should exist")
Review Comment:
Both new tests drop the one assertion in this file that checks writer-name
against metadata-table-name agreement, which is the invariant this whole PR is
about. The two pre-existing tests do make it: `:80-88` builds a
`HoodieBackedTableMetadata` and asserts `getAllPartitionPaths` contains
`2026/01/05`, and the test at `:257-262` does the same.
`_hoodie_partition_path` plus `storage.exists(...)` are not a substitute --
both still pass when the writer records a partition name that the metadata
table and directory listing disagree with (see my comment on
`KeyGenUtils#slashSeparateDateValue` for values where they do).
**Ask:** copy the `HoodieBackedTableMetadata` block from `:80-88` into both
new tests and assert `getAllPartitionPaths` contains `2026/01/05`, `2026/01/06`
and, for this one, `__HIVE_DEFAULT_PARTITION__`.
##########
hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/common/TestSlashSeparatedPartitionValue.scala:
##########
@@ -90,6 +90,113 @@ class TestSlashSeparatedPartitionValue extends
HoodieSparkSqlTestBase {
}
}
+ test("Test slash separated date partitions written through the row writer") {
+ withSQLConf("hoodie.spark.sql.insert.into.operation" -> "bulk_insert") {
+ withTempDir { tmp =>
+ val targetTable = generateTableName
+ val tablePath = s"${tmp.getCanonicalPath}/$targetTable"
+
+ spark.sql(
+ s"""
+ |create table $targetTable (
+ | `id` string,
+ | `name` string,
+ | `ts` bigint,
+ | `datestr` STRING
+ |) using hudi
+ | tblproperties (
+ | 'primaryKey' = 'id',
+ | 'type' = 'COW',
+ | 'preCombineField'='ts',
+ |
'hoodie.datasource.write.slash.separated.date.partitioning'='true'
+ | )
+ | partitioned by (`datestr`)
+ | location '$tablePath'
+ """.stripMargin)
+
+ // NOTE: The row writer derives the partition path off of an
[[InternalRow]], which used to
+ // blow up with a [[ClassCastException]]; a null partition value
used to NPE
+ spark.sql(
+ s"""
+ | insert into $targetTable values
+ | (1, 'a1', 1000, "2026-01-05"),
+ | (2, 'a2', 2000, "2026-01-06"),
+ | (3, 'a3', 3000, null)
+ """.stripMargin)
+
+ checkAnswer(s"select id, name, ts, _hoodie_partition_path, datestr
from $targetTable order by id")(
+ Seq("1", "a1", 1000, "2026/01/05", "2026-01-05"),
+ Seq("2", "a2", 2000, "2026/01/06", "2026-01-06"),
+ Seq("3", "a3", 3000, "__HIVE_DEFAULT_PARTITION__", null)
+ )
+
+ val metaClient = HoodieTableMetaClient.builder()
+
.setConf(HadoopFSUtils.getStorageConfWithCopy(spark.sparkContext.hadoopConfiguration))
+ .setBasePath(tablePath)
+ .build()
+ assertTrue(metaClient.getStorage.exists(new StoragePath(tablePath,
"2026/01/05")),
+ s"Partition path 2026/01/05 should exist")
+ assertTrue(metaClient.getStorage.exists(new StoragePath(tablePath,
"2026/01/06")),
+ s"Partition path 2026/01/06 should exist")
+ assertTrue(metaClient.getStorage.exists(new StoragePath(tablePath,
"__HIVE_DEFAULT_PARTITION__")),
+ s"Partition path __HIVE_DEFAULT_PARTITION__ should exist")
+ }
+ }
+ }
+
+ test("Test slash separated date partitions on a DATE typed partition
column") {
+ Seq("insert", "bulk_insert").foreach { operation =>
Review Comment:
The `insert` leg here duplicates an already-merged test, and is the weaker
of the two. `TestTypedPartitionValues.scala:28` ("Test reading a date partition
column laid out as yyyy/MM/dd", added in #19652, which is an ancestor of this
branch) already creates a DATE partition column with
`slash.separated.date.partitioning = 'true'`, writes with the default operation
-- `SPARK_SQL_INSERT_INTO_OPERATION` defaults to `insert` -- and asserts both
`_hoodie_partition_path` and the DATE round trip, plus two partition-pruning
assertions this test does not make. So the `insert` iteration passes on master
unchanged, and only the `bulk_insert` iteration discriminates the row-writer
rendering this PR fixes.
**Ask:** keep only the leg that can fail.
```suggestion
Seq("bulk_insert").foreach { operation =>
```
Better still, drop the loop entirely and pull in the two pruning assertions
from `TestTypedPartitionValues.scala:63-68`, so the new test covers the
row-writer DATE path *and* pruning rather than re-running the Avro leg.
##########
hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/common/TestSlashSeparatedPartitionValue.scala:
##########
@@ -90,6 +90,113 @@ class TestSlashSeparatedPartitionValue extends
HoodieSparkSqlTestBase {
}
}
+ test("Test slash separated date partitions written through the row writer") {
+ withSQLConf("hoodie.spark.sql.insert.into.operation" -> "bulk_insert") {
+ withTempDir { tmp =>
+ val targetTable = generateTableName
+ val tablePath = s"${tmp.getCanonicalPath}/$targetTable"
+
+ spark.sql(
+ s"""
+ |create table $targetTable (
+ | `id` string,
+ | `name` string,
+ | `ts` bigint,
+ | `datestr` STRING
+ |) using hudi
+ | tblproperties (
+ | 'primaryKey' = 'id',
+ | 'type' = 'COW',
+ | 'preCombineField'='ts',
+ |
'hoodie.datasource.write.slash.separated.date.partitioning'='true'
+ | )
+ | partitioned by (`datestr`)
+ | location '$tablePath'
+ """.stripMargin)
+
+ // NOTE: The row writer derives the partition path off of an
[[InternalRow]], which used to
+ // blow up with a [[ClassCastException]]; a null partition value
used to NPE
+ spark.sql(
+ s"""
+ | insert into $targetTable values
+ | (1, 'a1', 1000, "2026-01-05"),
+ | (2, 'a2', 2000, "2026-01-06"),
+ | (3, 'a3', 3000, null)
+ """.stripMargin)
+
+ checkAnswer(s"select id, name, ts, _hoodie_partition_path, datestr
from $targetTable order by id")(
+ Seq("1", "a1", 1000, "2026/01/05", "2026-01-05"),
+ Seq("2", "a2", 2000, "2026/01/06", "2026-01-06"),
+ Seq("3", "a3", 3000, "__HIVE_DEFAULT_PARTITION__", null)
+ )
+
+ val metaClient = HoodieTableMetaClient.builder()
+
.setConf(HadoopFSUtils.getStorageConfWithCopy(spark.sparkContext.hadoopConfiguration))
+ .setBasePath(tablePath)
+ .build()
+ assertTrue(metaClient.getStorage.exists(new StoragePath(tablePath,
"2026/01/05")),
+ s"Partition path 2026/01/05 should exist")
+ assertTrue(metaClient.getStorage.exists(new StoragePath(tablePath,
"2026/01/06")),
+ s"Partition path 2026/01/06 should exist")
+ assertTrue(metaClient.getStorage.exists(new StoragePath(tablePath,
"__HIVE_DEFAULT_PARTITION__")),
+ s"Partition path __HIVE_DEFAULT_PARTITION__ should exist")
+ }
+ }
+ }
+
+ test("Test slash separated date partitions on a DATE typed partition
column") {
+ Seq("insert", "bulk_insert").foreach { operation =>
+ withSQLConf("hoodie.spark.sql.insert.into.operation" -> operation) {
+ withTempDir { tmp =>
+ val targetTable = generateTableName
+ val tablePath = s"${tmp.getCanonicalPath}/$targetTable"
+
+ spark.sql(
Review Comment:
Cleanliness, not urgent: this is the fourth copy of the same DDL block in
this file, and this PR added two of the four. Exact counts in the post-PR file
(268 lines):
* `create table ... tblproperties(... slash.separated.date.partitioning ...)
partitioned by ... location`: four 17-line copies at `:36-52`, `:99-115`,
`:154-170`, `:206-222`, so 68 lines. Only two things vary -- the partition
column type (`STRING` three times, `DATE` once) and the property value.
*
`HoodieTableMetaClient.builder().setConf(...).setBasePath(tablePath).build()`:
four 4-line copies at `:68-71`, `:133-136`, `:187-190`, `:243-246`, so 16 lines.
* `assertTrue(metaClient.getStorage.exists(new StoragePath(tablePath,
...)))`: nine asserts over 18 lines.
**Ask:** extract three private helpers -- `createSlashPartitionedTable(name,
path, partitionColType = "STRING", slashSeparated = "true")`,
`buildMetaClient(path)` and `assertPartitionDirsExist(metaClient, path,
partitions*)`. That is roughly 31 lines of helper replacing 102 lines of
copy-paste, takes the file to about 209 lines, and makes the next slash test
cost three lines instead of forty.
##########
hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/common/TestSlashSeparatedPartitionValue.scala:
##########
@@ -90,6 +90,113 @@ class TestSlashSeparatedPartitionValue extends
HoodieSparkSqlTestBase {
}
}
+ test("Test slash separated date partitions written through the row writer") {
Review Comment:
Coverage gap worth closing while you are in this file: nothing in the repo
upserts into a slash-partitioned table. All four tests here are `type = 'COW'`
and insert-only, `TestTypedPartitionValues.scala` is insert-only, and no slash
test uses MOR:
```
git grep -n "slash.separated.date.partitioning\|SLASH_SEPARATED_DATE" --
'*/test/*'
```
That leaves index lookup and file-system-view agreement on a slash partition
path entirely unexercised, which is exactly the surface where a partition-path
string that does not match what listing produces shows up as duplicate records
rather than as an exception.
**Ask:** add one upsert case to this suite -- insert into `2026/01/05`, then
upsert a row with the same key and the same partition value, and assert the row
count in that partition is still one and that no second partition directory
appeared. A MOR variant would be a bonus, not a requirement.
--
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]