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 739ab7696979 fix(spark): make slash-separated date partitioning work
on the row writer path (#19648)
739ab7696979 is described below
commit 739ab7696979150304faf221e3344fe4949e6adc
Author: Sepuri Sai Krishna <[email protected]>
AuthorDate: Mon Aug 24 21:14:23 2026 +0530
fix(spark): make slash-separated date partitioning work on the row writer
path (#19648)
* fix(spark): make slash-separated date partitioning work on the row writer
path
* test(spark): clarify partition path formatter test naming and hive-style
case
* address review feedback on slash-separated date partitioning
- assert the InternalRow path for a null partition value at keygen level.
[[KeyGeneratorTestUtilities#getInternalRow]] builds a flat
GenericInternalRow,
so the nested "nested_col.prop1" value stays a Row and reading it back as
a
struct fails; the conversion goes through Spark's CatalystTypeConverters
instead. A null on a top-level field is not an option either -- every
other
field of the example schema is non-nullable and HoodieUnsafeRowUtils
rejects
it before the formatter is reached.
- assert Row + InternalRow for CustomKeyGenerator, which builds one
single-field
sub-key-generator per partition field. The test partitioned on
"timestamp",
a long, which cannot survive the conversion into a Row, so it now uses the
string-typed "ts_ms".
- pin the url-encoding behaviour: encoding runs before the substitution, so
an
already slash-separated value is escaped rather than turned into
directories.
- cover a DATE typed partition column across insert and bulk_insert.
- project datestr in the row-writer assertion so the read-back and the null
row
are covered, and drop the deprecated bulk-insert configs for
hoodie.spark.sql.insert.into.operation.
- drop two formatter assertions already pinned by TestComplexKeyGenerator.
- correct the NOTE on the single-field fast path: CustomKeyGenerator is not
an
exception to it, and getPartitionPath governs the single-field Avro case.
- correct the hive-style comment: the mutual exclusion is documented on
SLASH_SEPARATED_DATE_PARTITIONING but only enforced by HoodieCatalogTable
for
SQL options, so df.write and HoodieStreamer still accept the combination.
* fix(spark): don't slash-separate partition values with a leading dash
A leading dash in a partition value turned into a leading slash, making the
relative partition path absolute. FSUtils#constructAbsolutePath(String,
String)
chops that leading slash while the StoragePath overload used by
AbstractTableFileSystemView lets it URI-resolve the table base path away, so
the writer and the file-system view disagreed on where the partition lives:
"-5" wrote to <base>/5 but was looked up under /5, and "-" collapsed the
partition onto the table root.
Suppress the substitution for such values on all three write paths --
PartitionPathFormatterBase#combine for the Row/InternalRow formatters and
KeyGenUtils#getPartitionPath/#getRecordPartitionPath for Avro.
Also reword the multi-field NOTE: the three write paths agreeing on the
CustomKeyGenerator layout does not make it usable, since it cannot be read
back (HUDI issue #19666).
* docs(spark): move partition path formatter rationale into javadoc
Move the two NOTE blocks out of PartitionPathFormatterBase#combine and onto
the methods they explain -- the single-field/CustomKeyGenerator rationale
onto
replaceDashesWithSlashes, the leading-dash rationale onto startsWithDash --
leaving a two-line pointer at the call site. Comments only, no behavior
change.
* fix(spark): reject slash-separated date partitioning with multiple
partition fields
The multi-field slash layout writes extra path fragments that
doParsePartitionColumnValues cannot line up with the partition columns, so
the write commits cleanly and every subsequent read of the table fails
under the default lazy listing. On master the row-writer leg threw a
ClassCastException, so no table could reach that state; with the CCE fixed
the write succeeded silently. Fail fast at the write instead:
- HoodieWriterUtils#validateTableConfig rejects slash partitioning with
more than one partition field, covering df.write and SQL writes on new
and existing tables
- HoodieCatalogTable#extraTableConfig rejects it at CREATE TABLE, next to
the existing hive-style mutual-exclusion check
- the multi-part substitution in PartitionPathFormatterBase stays: new
writes cannot reach it, and it keeps composeRelativePartitionPath naming
the directory legacy CustomKeyGenerator tables hold on disk
- the multi-field CustomKeyGenerator test becomes rejection asserts at
create and at write time
* test(spark): pin the multi-field slash rejection firing off the table
config alone
An existing table holding slash partitioning with two partition fields can
no longer be created through SQL or df.write, so the write-time rejection
for that legacy shape (e.g. bulk_insert into such a table) had no coverage;
validateTableConfig is exercised with empty write params against a table
config built directly.
* fix(spark): widen the slash-partitioning dash guard to dot segments and
fix the rejection remedy
Review round-4 majors:
- hasPathBreakingDash missed dash-delimited dot segments: "..-a" passed
the leading/trailing/doubled checks, substituted to "../a", and
URI-resolved outside the table base path entirely ("..-..-x" lands in
/tmp/x); escapePathName leaves dots and dashes alone, so url-encoding
does not neutralize it. The guard now rejects any dash-delimited token
that is empty, "." or "..", one rule subsuming the previous three,
char-wise in KeyGenUtils and byte-wise in the UTF8String formatter
('-' and '.' are ASCII, so a byte scan cannot collide with UTF-8
continuation bytes).
- The multi-field rejection message told users to "disable slash-separated
date partitioning", which is not actionable on an existing table: ALTER
TABLE SET TBLPROPERTIES never rewrites hoodie.properties, and an
explicit =false write option trips the config-diff rejection instead.
The message now says to recreate the table.
* chore(spark): address round-4 review minors on slash-separated date
partitioning
- Read the slash flag via equalsIgnoreCase("true") at both validation sites,
matching the Boolean.parseBoolean semantics every other reader of the
config uses (toBoolean threw on values like "1")
- Correct the HoodieCatalogTable comment: the !tableExists gate only avoids
a duplicate error, since validateTableConfig already rejects an existing
multi-field table on that path
- Soften the formatter comments: a HoodieStreamer first write to a
not-yet-existing table bypasses the validation
- Tests: pin the rejection under SaveMode.Overwrite (params alone, ahead
of the overwrite gate); parameterize the SimpleKeyGenerator row-writing
test over url-encoding so encode-before-substitute is pinned on the Avro
path too, and assert the Avro arm of the null case; assert fragments
unique to each rejection site; drop the DATE pruning asserts duplicated
by TestTypedPartitionValues and the upsert dir/MDT asserts duplicated by
the insert test; SAM lambda for checkExceptionContain
---------
Co-authored-by: voon <[email protected]>
---
.../java/org/apache/hudi/keygen/KeyGenUtils.java | 74 ++++-
.../hudi/keygen/PartitionPathFormatterBase.java | 72 ++++-
.../hudi/keygen/StringPartitionPathFormatter.java | 10 +
.../keygen/UTF8StringPartitionPathFormatter.java | 28 ++
.../hudi/keygen/TestComplexKeyGenerator.java | 18 ++
.../apache/hudi/keygen/TestCustomKeyGenerator.java | 15 +-
.../hudi/keygen/TestPartitionPathFormatter.java | 135 +++++++++
.../apache/hudi/keygen/TestSimpleKeyGenerator.java | 77 ++++++
.../scala/org/apache/hudi/HoodieWriterUtils.scala | 28 ++
.../sql/catalyst/catalog/HoodieCatalogTable.scala | 13 +
.../org/apache/hudi/TestHoodieWriterUtils.java | 32 +++
.../common/TestSlashSeparatedPartitionValue.scala | 304 +++++++++++++++------
12 files changed, 714 insertions(+), 92 deletions(-)
diff --git
a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/keygen/KeyGenUtils.java
b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/keygen/KeyGenUtils.java
index 6586e0ee385f..37dc5c8a2e4b 100644
---
a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/keygen/KeyGenUtils.java
+++
b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/keygen/KeyGenUtils.java
@@ -254,12 +254,15 @@ public class KeyGenUtils {
if (encodePartitionPath) {
fieldVal = PartitionPathEncodeUtils.escapePathName(fieldVal);
}
+ // NOTE: See [[slashSeparateDateValue]] on which dashes suppress the
substitution. It runs
+ // before the hive-style prefix so that the guard inspects the
bare value, matching
+ // [[PartitionPathFormatterBase#combine]] -- on "dt=-5" no guard
could ever fire
+ if (partitionPathFields.size() == 1 && slashSeparatedDatePartitioning)
{
+ fieldVal = slashSeparateDateValue(fieldVal);
+ }
if (hiveStylePartitioning) {
fieldVal = partitionPathField + "=" + fieldVal;
}
- if (partitionPathFields.size() == 1 && slashSeparatedDatePartitioning)
{
- fieldVal = fieldVal.replace('-', '/');
- }
partitionPath.append(fieldVal);
}
if (i != partitionPathFields.size() - 1) {
@@ -288,15 +291,74 @@ public class KeyGenUtils {
if (encodePartitionPath) {
partitionPath = PartitionPathEncodeUtils.escapePathName(partitionPath);
}
+ // NOTE: See [[slashSeparateDateValue]] on which dashes suppress the
substitution. It runs
+ // before the hive-style prefix so that the guard inspects the bare
value, matching
+ // [[PartitionPathFormatterBase#combine]] -- on "dt=-5" no guard
could ever fire
+ if (slashSeparatedDatePartitioning) {
+ partitionPath = slashSeparateDateValue(partitionPath);
+ }
if (hiveStylePartitioning) {
partitionPath = partitionPathField + "=" + partitionPath;
}
- if (slashSeparatedDatePartitioning) {
- partitionPath = partitionPath.replace('-', '/');
- }
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 whose dash-delimited tokens would produce a path-breaking
segment -- an empty
+ * token, {@code "."} or {@code ".."} -- is returned as-is, because the
resulting path does not
+ * survive the round trip back from storage:
+ *
+ * <ul>
+ * <li>empty token, leading -- {@code "-5"} becomes {@code "/5"}, and an
absolute
+ * relative-partition-path is resolved inconsistently:
+ * {@code 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 (the writer
+ * lands in {@code "<base>/5"} but the file-system view in {@code "/5"},
and {@code "-"}
+ * resolves to the base path itself)</li>
+ * <li>empty token, trailing or interior -- {@code "5-"} becomes {@code
"5/"}, which
+ * {@code StoragePath} normalizes back to {@code "5"}, and {@code "a--b"}
becomes
+ * {@code "a//b"}, which {@code URI.normalize()} collapses to {@code
"a/b"} -- either way the
+ * writer records a partition string that no longer names its directory, so
+ * {@code FSUtils#getFileName} slices the file name at the wrong offset
and the metadata-table
+ * FILES partition ends up with a truncated entry</li>
+ * <li>dot segment -- {@code "2026-.-05"} becomes {@code "2026/./05"},
which URI-normalizes to
+ * {@code "2026/05"}, and {@code "..-a"} becomes {@code "../a"}, which
resolves OUTSIDE the
+ * table base path entirely ({@code
PartitionPathEncodeUtils#escapePathName} leaves dots and
+ * dashes alone, so url-encoding does not neutralize this)</li>
+ * </ul>
+ *
+ * <p>None of these values is a date to begin with, so nothing is lost by
not slashing them.
+ */
+ private static String slashSeparateDateValue(String partitionPath) {
+ return hasPathBreakingDash(partitionPath) ? partitionPath :
partitionPath.replace('-', '/');
+ }
+
+ /**
+ * Whether substituting the dashes in {@code partitionPath} would yield a
path-breaking segment:
+ * any dash-delimited token that is empty (a leading, trailing or doubled
dash), {@code "."} or
+ * {@code ".."}. See {@link #slashSeparateDateValue} for why each case is
excluded.
+ */
+ public static boolean hasPathBreakingDash(String partitionPath) {
+ int tokenStart = 0;
+ int length = partitionPath.length();
+ for (int i = 0; i <= length; i++) {
+ if (i == length || partitionPath.charAt(i) == '-') {
+ int tokenLength = i - tokenStart;
+ if (tokenLength == 0
+ || (tokenLength == 1 && partitionPath.charAt(tokenStart) == '.')
+ || (tokenLength == 2 && partitionPath.charAt(tokenStart) == '.' &&
partitionPath.charAt(tokenStart + 1) == '.')) {
+ return true;
+ }
+ tokenStart = i + 1;
+ }
+ }
+ return false;
+ }
+
/**
* Create a date time parser class for TimestampBasedKeyGenerator, passing
in any configs needed.
*/
diff --git
a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/keygen/PartitionPathFormatterBase.java
b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/keygen/PartitionPathFormatterBase.java
index d08545cf8fd5..0332b24af0bd 100644
---
a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/keygen/PartitionPathFormatterBase.java
+++
b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/keygen/PartitionPathFormatterBase.java
@@ -62,11 +62,12 @@ public abstract class PartitionPathFormatterBase<S> {
// Avoid creating [[StringBuilder]] in case there's just one
partition-path part,
// and Hive-style of partitioning is not required
if (!useHiveStylePartitioning && partitionPathParts.length == 1) {
- if (slashSeparatedDatePartitioning) {
- return ((S) ((String) toString(partitionPathParts[0])).replace('-',
'/'));
- } else {
- return tryEncode(handleEmpty(toString(partitionPathParts[0])));
- }
+ S partitionPathPart =
tryEncode(handleEmpty(toString(partitionPathParts[0])));
+ // NOTE: See [[replaceDashesWithSlashes]] on how this lines up with the
Avro write path, and
+ // [[hasPathBreakingDash]] on which dashes suppress the
substitution
+ return slashSeparatedDatePartitioning &&
!hasPathBreakingDash(partitionPathPart)
+ ? replaceDashesWithSlashes(partitionPathPart)
+ : partitionPathPart;
}
StringBuilder<S> sb = stringBuilderFactory.get();
@@ -77,9 +78,16 @@ public abstract class PartitionPathFormatterBase<S> {
sb.appendJava(partitionPathFields.get(i))
.appendJava("=")
.append(partitionPathPartStr);
- } else if (slashSeparatedDatePartitioning) {
- String res = ((String) partitionPathPartStr).replace('-', '/');
- sb.append(((S) res));
+ } else if (slashSeparatedDatePartitioning &&
!hasPathBreakingDash(partitionPathPartStr)) {
+ // NOTE: Every part is substituted here, preserving the behaviour this
branch had before the
+ // [[ClassCastException]] fix. Spark writes reject slash
partitioning with more than
+ // one partition field
([[HoodieWriterUtils#validateTableConfig]]), so this branch
+ // mainly serves reads of tables predating that rejection:
[[CustomKeyGenerator]] built
+ // one single-field sub-keygen per field, so such tables slashed
each field
+ // individually, and
[[SparkHoodieTableFileIndex#composeRelativePartitionPath]] has to
+ // land on the same directory when it composes the prefix in one
[[combine]] call over
+ // all N columns. See HUDI issue #19666
+ sb.append(replaceDashesWithSlashes(partitionPathPartStr));
} else {
sb.append(partitionPathPartStr);
}
@@ -102,6 +110,54 @@ public abstract class PartitionPathFormatterBase<S> {
protected abstract S handleEmpty(S partitionPathPart);
+ /**
+ * 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>NOTE: This has to be implemented by every sub-class, since the
substitution has to be
+ * performed on the concrete string representation {@code S} the formatter
operates on.
+ *
+ * <p>NOTE: For {@code SimpleKeyGenerator}/{@code ComplexKeyGenerator} the
single-part branch of
+ * {@link #combine} routes only a table partitioned by a single (date)
column here, mirroring
+ * {@code KeyGenUtils#getPartitionPath} (single field) and {@code
KeyGenUtils#getRecordPartitionPath}
+ * (which guards on a single field as well) driving the Avro write-path:
both write-paths have to
+ * derive the very same partition path for a record.
+ *
+ * <p>NOTE: The multi-part branch substitutes every part, which reads of
legacy tables depend on:
+ * {@code CustomKeyGenerator} built one single-field sub-key-generator per
partition field, so
+ * such tables slashed each field individually, and
+ * {@code SparkHoodieTableFileIndex#composeRelativePartitionPath} -- which
calls {@link #combine}
+ * once over all N columns to compose a listing prefix -- has to name the
very same directory, or
+ * the prefix misses and the query silently returns no rows. Spark writes
cannot reach this
+ * branch with slash partitioning enabled: multi-field slash tables produce
a layout the extra
+ * fragments leave {@code HoodieSparkUtils#doParsePartitionColumnValues}
unable to line up with
+ * the partition columns, so {@code HoodieWriterUtils#validateTableConfig}
rejects them at write
+ * time (a HoodieStreamer first write to a not-yet-existing table bypasses
that validation).
+ * See HUDI issue #19666.
+ */
+ protected abstract S replaceDashesWithSlashes(S partitionPathPart);
+
+ /**
+ * Whether substituting the dashes in {@code partitionPathPart} would yield
a path-breaking
+ * segment -- any dash-delimited token that is empty (a leading, trailing or
doubled dash),
+ * {@code "."} or {@code ".."} -- in which case {@link
#replaceDashesWithSlashes(Object)} must not
+ * be applied to it. Kept in step with {@code
KeyGenUtils#hasPathBreakingDash}, which guards the
+ * Avro write path and documents each case.
+ *
+ * <p>NOTE: This has to be implemented by every sub-class, since the check
has to be performed on
+ * the concrete string representation {@code S} the formatter operates on.
+ *
+ * <p>NOTE: None of these shapes survives the round trip back from storage.
An empty token turns
+ * the path absolute ({@code "-5"} -> {@code "/5"}, resolved inconsistently
by the two
+ * {@code FSUtils#constructAbsolutePath} overloads) or leaves the recorded
partition string
+ * longer than the directory it normalizes to ({@code "5-"} -> {@code "5/"},
+ * {@code "a--b"} -> {@code "a//b"}), so {@code FSUtils#getFileName} slices
the file name at the
+ * wrong offset. A dot segment is resolved away by {@code URI.normalize()}
-- {@code "..-a"}
+ * becomes {@code "../a"} and escapes the table base path entirely. None of
these values is a
+ * date to begin with, so nothing is lost by not slashing them.
+ */
+ protected abstract boolean hasPathBreakingDash(S partitionPathPart);
+
/**
* This is a generic interface closing the gap and unifying the {@link
java.lang.StringBuilder} with
* {@link org.apache.hudi.unsafe.UTF8StringBuilder} implementations,
allowing us to avoid code-duplication by performing
diff --git
a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/keygen/StringPartitionPathFormatter.java
b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/keygen/StringPartitionPathFormatter.java
index 5709193b5fdf..4739b6ca59f7 100644
---
a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/keygen/StringPartitionPathFormatter.java
+++
b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/keygen/StringPartitionPathFormatter.java
@@ -56,6 +56,16 @@ public class StringPartitionPathFormatter extends
PartitionPathFormatterBase<Str
}
}
+ @Override
+ protected String replaceDashesWithSlashes(String partitionPathPart) {
+ return partitionPathPart.replace('-', '/');
+ }
+
+ @Override
+ protected boolean hasPathBreakingDash(String partitionPathPart) {
+ return KeyGenUtils.hasPathBreakingDash(partitionPathPart);
+ }
+
public static class JavaStringBuilder implements
PartitionPathFormatterBase.StringBuilder<String> {
private final java.lang.StringBuilder sb = new java.lang.StringBuilder();
diff --git
a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/keygen/UTF8StringPartitionPathFormatter.java
b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/keygen/UTF8StringPartitionPathFormatter.java
index f9da94ce8bc0..30fac138bfcc 100644
---
a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/keygen/UTF8StringPartitionPathFormatter.java
+++
b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/keygen/UTF8StringPartitionPathFormatter.java
@@ -33,6 +33,9 @@ public class UTF8StringPartitionPathFormatter extends
PartitionPathFormatterBase
protected static final UTF8String HUDI_DEFAULT_PARTITION_PATH_UTF8 =
UTF8String.fromString(HUDI_DEFAULT_PARTITION_PATH);
+ private static final UTF8String DASH_UTF8 = UTF8String.fromString("-");
+ private static final UTF8String SLASH_UTF8 = UTF8String.fromString("/");
+
public UTF8StringPartitionPathFormatter(Supplier<StringBuilder<UTF8String>>
stringBuilderFactory,
boolean useHiveStylePartitioning,
boolean useEncoding,
@@ -59,6 +62,31 @@ public class UTF8StringPartitionPathFormatter extends
PartitionPathFormatterBase
return partitionPathPart;
}
+ @Override
+ protected UTF8String replaceDashesWithSlashes(UTF8String partitionPathPart) {
+ return partitionPathPart.replace(DASH_UTF8, SLASH_UTF8);
+ }
+
+ @Override
+ protected boolean hasPathBreakingDash(UTF8String partitionPathPart) {
+ // Byte-wise mirror of KeyGenUtils#hasPathBreakingDash: '-' and '.' are
ASCII, so they can
+ // never collide with a UTF-8 continuation byte and a plain byte scan is
exact
+ byte[] bytes = partitionPathPart.getBytes();
+ int tokenStart = 0;
+ for (int i = 0; i <= bytes.length; i++) {
+ if (i == bytes.length || bytes[i] == '-') {
+ int tokenLength = i - tokenStart;
+ if (tokenLength == 0
+ || (tokenLength == 1 && bytes[tokenStart] == '.')
+ || (tokenLength == 2 && bytes[tokenStart] == '.' &&
bytes[tokenStart + 1] == '.')) {
+ return true;
+ }
+ tokenStart = i + 1;
+ }
+ }
+ return false;
+ }
+
public static class UTF8StringBuilder implements StringBuilder<UTF8String> {
private final org.apache.hudi.unsafe.UTF8StringBuilder sb = new
org.apache.hudi.unsafe.UTF8StringBuilder();
diff --git
a/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/keygen/TestComplexKeyGenerator.java
b/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/keygen/TestComplexKeyGenerator.java
index 3bb69e0424fb..877125e5d45e 100644
---
a/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/keygen/TestComplexKeyGenerator.java
+++
b/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/keygen/TestComplexKeyGenerator.java
@@ -244,6 +244,24 @@ public class TestComplexKeyGenerator extends
KeyGeneratorTestUtilities {
assertEquals("2026/01/05", key.getPartitionPath());
}
+ @Test
+ void testSlashSeparatedDatePartitioningLeavesLeadingDashesAlone() {
+ TypedProperties properties = new TypedProperties();
+ properties.put(KeyGeneratorOptions.RECORDKEY_FIELD_NAME.key(), "_row_key");
+ properties.put(KeyGeneratorOptions.PARTITIONPATH_FIELD_NAME.key(),
"timestamp");
+
properties.put(KeyGeneratorOptions.SLASH_SEPARATED_DATE_PARTITIONING.key(),
"true");
+ properties.put(KeyGeneratorOptions.HIVE_STYLE_PARTITIONING_ENABLE.key(),
"false");
+
+ ComplexKeyGenerator keyGenerator = new ComplexKeyGenerator(properties);
+
+ // This is the only production caller of the multi-field
KeyGenUtils#getRecordPartitionPath
+ // overload, so the guard on that line is unexercised without this case
+ GenericRecord avroRecord = KeyGeneratorTestUtilities.getRecord();
+ avroRecord.put("timestamp", "-5");
+
+ assertEquals("-5", keyGenerator.getKey(avroRecord).getPartitionPath());
+ }
+
@Test
void testSlashSeparatedDatePartitioningWithAlreadyFormattedInput() {
TypedProperties properties = new TypedProperties();
diff --git
a/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/keygen/TestCustomKeyGenerator.java
b/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/keygen/TestCustomKeyGenerator.java
index 6e526ccb6c73..b2bbf93eafca 100644
---
a/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/keygen/TestCustomKeyGenerator.java
+++
b/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/keygen/TestCustomKeyGenerator.java
@@ -419,7 +419,9 @@ class TestCustomKeyGenerator extends
KeyGeneratorTestUtilities {
void testSlashSeparatedDatePartitioning() {
TypedProperties properties = new TypedProperties();
properties.put(KeyGeneratorOptions.RECORDKEY_FIELD_NAME.key(), "_row_key");
- properties.put(KeyGeneratorOptions.PARTITIONPATH_FIELD_NAME.key(),
"timestamp:simple");
+ // NOTE: "ts_ms" is the string-typed field of the example schema,
"timestamp" is a long, so only
+ // the former survives the conversion into a [[Row]]/[[InternalRow]]
+ properties.put(KeyGeneratorOptions.PARTITIONPATH_FIELD_NAME.key(),
"ts_ms:simple");
properties.put(KeyGeneratorOptions.SLASH_SEPARATED_DATE_PARTITIONING.key(),
"true");
properties.put(KeyGeneratorOptions.HIVE_STYLE_PARTITIONING_ENABLE.key(),
"false");
properties.put(HoodieWriteConfig.KEYGENERATOR_CLASS_NAME.key(),
CustomKeyGenerator.class.getName());
@@ -429,12 +431,21 @@ class TestCustomKeyGenerator extends
KeyGeneratorTestUtilities {
// Create a record with date in yyyy-MM-dd format
GenericRecord avroRecord = KeyGeneratorTestUtilities.getRecord();
- avroRecord.put("timestamp", "2026-01-05");
+ avroRecord.put("ts_ms", "2026-01-05");
// The partition path should be transformed to yyyy/MM/dd format
HoodieKey key = keyGenerator.getKey(avroRecord);
assertEquals("key1", key.getRecordKey());
assertEquals("2026/01/05", key.getPartitionPath());
+
+ // NOTE: [[CustomKeyGenerator]] builds one single-field sub-key-generator
per partition field, so
+ // the row-writer paths have to derive the very same partition path
as the Avro one above
+ Row row = KeyGeneratorTestUtilities.getRow(avroRecord);
+ assertEquals("2026/01/05", keyGenerator.getPartitionPath(row));
+
+ InternalRow internalRow = KeyGeneratorTestUtilities.getInternalRow(row);
+ assertEquals(UTF8String.fromString("2026/01/05"),
+ keyGenerator.getPartitionPath(internalRow, row.schema()));
}
@Test
diff --git
a/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/keygen/TestPartitionPathFormatter.java
b/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/keygen/TestPartitionPathFormatter.java
new file mode 100644
index 000000000000..47911b9eacd5
--- /dev/null
+++
b/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/keygen/TestPartitionPathFormatter.java
@@ -0,0 +1,135 @@
+/*
+ * 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 testSlashSeparatedDatePartitioningAppliesToEveryField(boolean
useRowWriterPath) {
+ // NOTE: Every part is substituted, which is what legacy
[[CustomKeyGenerator]] tables hold on
+ // disk (one single-field sub-keygen per field) and what
+ // [[SparkHoodieTableFileIndex#composeRelativePartitionPath]] has to
reproduce when it
+ // composes a listing prefix over all N columns in one call. New
writes cannot reach this
+ // combination -- [[HoodieWriterUtils#validateTableConfig]] rejects
slash partitioning
+ // with more than one partition field. See HUDI issue #19666
+ assertEquals("2026/01/05/san/francisco",
+ combine(useRowWriterPath, false, false, true, TWO_FIELDS,
"2026-01-05", "san-francisco"));
+ // The guard applies per part, not just to the first one
+ assertEquals("2026/01/05/-5",
+ combine(useRowWriterPath, false, false, true, TWO_FIELDS,
"2026-01-05", "-5"));
+ }
+
+ @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 testSlashSeparatedDatePartitioningLeavesPathBreakingDashesAlone(boolean
useRowWriterPath) {
+ // NOTE: Substituting in any of these would yield a partition path that
does not survive the
+ // round trip back from storage. A leading "/" is resolved
differently by
+ // [[FSUtils#constructAbsolutePath(String, String)]] and the
[[StoragePath]] overload used
+ // by [[AbstractTableFileSystemView]] -- the former chops it, the
latter URI-resolves the
+ // table base path away. A trailing "/" is normalized off by
[[StoragePath#normalize]] and
+ // a doubled "//" is collapsed by [[java.net.URI#normalize]],
leaving the writer recording
+ // a partition string longer than the directory it actually resolves
to
+ 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"));
+ assertEquals("5-", combine(useRowWriterPath, false, false, true,
SINGLE_FIELD, "5-"));
+ assertEquals("a--b", combine(useRowWriterPath, false, false, true,
SINGLE_FIELD, "a--b"));
+ // A dash-delimited "." or ".." would become a URI dot segment:
"2026/./05" normalizes to
+ // "2026/05", and "../a" resolves outside the table base path entirely
+ assertEquals("2026-.-05", combine(useRowWriterPath, false, false, true,
SINGLE_FIELD, "2026-.-05"));
+ assertEquals("a-..-b", combine(useRowWriterPath, false, false, true,
SINGLE_FIELD, "a-..-b"));
+ assertEquals("..-a", combine(useRowWriterPath, false, false, true,
SINGLE_FIELD, "..-a"));
+ // A single interior dash is still a separator, and dots inside a token
are untouched
+ assertEquals("2026/01/05", combine(useRowWriterPath, false, false, true,
SINGLE_FIELD, "2026-01-05"));
+ assertEquals("v1.2/v3.4", combine(useRowWriterPath, false, false, true,
SINGLE_FIELD, "v1.2-v3.4"));
+ }
+
+ @ParameterizedTest
+ @ValueSource(booleans = {true, false})
+ void testSlashSeparatedDatePartitioningEncodesValues(boolean
useRowWriterPath) {
+ // '?' has to be escaped, while the date separators are turned into
directory separators
+ assertEquals("2026/01/05", combine(useRowWriterPath, false, true, true,
SINGLE_FIELD, "2026-01-05"));
+ assertEquals("a%3Fb", combine(useRowWriterPath, false, true, true,
SINGLE_FIELD, "a?b"));
+ // Encoding runs before the substitution (parity with KeyGenUtils), so an
already slash-separated
+ // value is escaped rather than turned into directories
+ assertEquals("2026%2F01%2F05", combine(useRowWriterPath, false, true,
true, SINGLE_FIELD, "2026/01/05"));
+ }
+
+ @ParameterizedTest
+ @ValueSource(booleans = {true, false})
+ void testHiveStylePartitioningTakesPrecedence(boolean useRowWriterPath) {
+ // NOTE: Hive-style partitioning and slash-separated date partitioning are
documented as mutually
+ // exclusive
([[KeyGeneratorOptions#SLASH_SEPARATED_DATE_PARTITIONING]]), but only
+ // [[HoodieCatalogTable#extraTableConfig]] enforces it, and it
inspects the SQL options
+ // alone -- df.write and HoodieStreamer still accept the
combination. The formatter
+ // deliberately leaves the value alone here rather than mirroring
the Avro path, which
+ // produces a layout
[[HoodieSparkUtils#doParsePartitionColumnValues]] cannot read back.
+ // This asserts the pre-existing behavior stays put, it is not a
statement about what the
+ // combination *should* produce
+ assertEquals("date_col=2026-01-05",
+ combine(useRowWriterPath, true, false, true, SINGLE_FIELD,
"2026-01-05"));
+ }
+}
diff --git
a/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/keygen/TestSimpleKeyGenerator.java
b/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/keygen/TestSimpleKeyGenerator.java
index 43d20fee2919..b8739066cb3a 100644
---
a/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/keygen/TestSimpleKeyGenerator.java
+++
b/hudi-client/hudi-spark-client/src/test/java/org/apache/hudi/keygen/TestSimpleKeyGenerator.java
@@ -29,12 +29,14 @@ import org.apache.hudi.keygen.constant.KeyGeneratorOptions;
import org.apache.avro.generic.GenericData;
import org.apache.avro.generic.GenericRecord;
import org.apache.spark.sql.Row;
+import org.apache.spark.sql.catalyst.CatalystTypeConverters;
import org.apache.spark.sql.catalyst.InternalRow;
import org.apache.spark.unsafe.types.UTF8String;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.MethodSource;
+import org.junit.jupiter.params.provider.ValueSource;
import java.util.stream.Stream;
@@ -213,4 +215,79 @@ class TestSimpleKeyGenerator extends
KeyGeneratorTestUtilities {
Assertions.assertEquals("key1", key.getRecordKey());
Assertions.assertEquals("2026/01/01", key.getPartitionPath());
}
+
+ @Test
+ void testSlashSeparatedDatePartitioningLeavesLeadingDashesAlone() {
+ SimpleKeyGenerator keyGenerator = new
SimpleKeyGenerator(getPropsWithSlashSeparatedDatePartitioning());
+
+ // 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
+ GenericRecord avroRecord = new
GenericData.Record(HoodieSchema.parse(KeyGeneratorTestUtilities.EXAMPLE_SCHEMA).getAvroSchema());
+ avroRecord.put("timestamp", "-5");
+ avroRecord.put("_row_key", "key1");
+ avroRecord.put("ts_ms", "-5");
+ avroRecord.put("pii_col", "val1");
+
+ HoodieKey key = keyGenerator.getKey(avroRecord);
+ Assertions.assertEquals("key1", key.getRecordKey());
+ Assertions.assertEquals("-5", key.getPartitionPath());
+ }
+
+ @ParameterizedTest
+ @ValueSource(booleans = {true, false})
+ void testSlashSeparatedDatePartitioningOnRowWritingPaths(boolean urlEncode) {
+ TypedProperties properties = getPropsWithSlashSeparatedDatePartitioning();
+ // NOTE: "ts_ms" is the string-typed field of the example schema,
"timestamp" is a long
+ properties.put(KeyGeneratorOptions.PARTITIONPATH_FIELD_NAME.key(),
"ts_ms");
+ properties.put(KeyGeneratorOptions.URL_ENCODE_PARTITIONING.key(),
String.valueOf(urlEncode));
+ SimpleKeyGenerator keyGenerator = new SimpleKeyGenerator(properties);
+
+ GenericRecord avroRecord = getRecord();
+ Assertions.assertEquals("2020/03/21",
keyGenerator.getPartitionPath(avroRecord));
+
+ Row row = KeyGeneratorTestUtilities.getRow(avroRecord);
+ Assertions.assertEquals("2020/03/21", keyGenerator.getPartitionPath(row));
+
+ InternalRow internalRow = KeyGeneratorTestUtilities.getInternalRow(row);
+ Assertions.assertEquals(UTF8String.fromString("2020/03/21"),
+ keyGenerator.getPartitionPath(internalRow, row.schema()));
+
+ // Encoding runs before the substitution on all three write paths, so an
escapable character
+ // is escaped while the dash still becomes a directory separator. This
pins the ordering at
+ // the KeyGenUtils/Avro level too, which the formatter-level encode test
cannot reach
+ avroRecord.put("ts_ms", "a?b-c");
+ String expected = urlEncode ? "a%3Fb/c" : "a?b/c";
+ Assertions.assertEquals(expected,
keyGenerator.getPartitionPath(avroRecord));
+
+ Row encodedRow = KeyGeneratorTestUtilities.getRow(avroRecord);
+ Assertions.assertEquals(expected,
keyGenerator.getPartitionPath(encodedRow));
+ Assertions.assertEquals(UTF8String.fromString(expected),
+
keyGenerator.getPartitionPath(KeyGeneratorTestUtilities.getInternalRow(encodedRow),
encodedRow.schema()));
+ }
+
+ @Test
+ void testSlashSeparatedDatePartitioningWithNullValue() {
+ TypedProperties properties = getPropsWithSlashSeparatedDatePartitioning();
+ properties.put(KeyGeneratorOptions.PARTITIONPATH_FIELD_NAME.key(),
"nested_col.prop1");
+ SimpleKeyGenerator keyGenerator = new SimpleKeyGenerator(properties);
+
+ GenericRecord avroRecord = getRecord(getNestedColRecord(null, 10L));
+ // The Avro arm covers the HUDI-1888 class (NPE on a null nested partition
value) under slash
+ Assertions.assertEquals(HUDI_DEFAULT_PARTITION_PATH,
keyGenerator.getPartitionPath(avroRecord));
+
+ Row row = KeyGeneratorTestUtilities.getRow(avroRecord);
+ Assertions.assertEquals(HUDI_DEFAULT_PARTITION_PATH,
keyGenerator.getPartitionPath(row));
+
+ // NOTE: [[KeyGeneratorTestUtilities#getInternalRow]] builds a flat
[[GenericInternalRow]], leaving
+ // a nested value as a [[Row]], so the conversion has to go through
Spark here. "nested_col.prop1"
+ // is the only nullable field of the example schema, and a null on a
non-nullable one is
+ // rejected by [[org.apache.spark.sql.HoodieUnsafeRowUtils]] before
the formatter is reached
+ InternalRow internalRow =
+ (InternalRow)
CatalystTypeConverters.createToCatalystConverter(row.schema()).apply(row);
+ Assertions.assertEquals(UTF8String.fromString(HUDI_DEFAULT_PARTITION_PATH),
+ keyGenerator.getPartitionPath(internalRow, row.schema()));
+ }
}
diff --git
a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/HoodieWriterUtils.scala
b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/HoodieWriterUtils.scala
index b831ec165ff9..9fc7b83cc9ec 100644
---
a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/HoodieWriterUtils.scala
+++
b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/hudi/HoodieWriterUtils.scala
@@ -262,6 +262,34 @@ object HoodieWriterUtils {
*/
def validateTableConfig(spark: SparkSession, params: Map[String, String],
tableConfig: HoodieTableConfig, isOverWriteMode:
Boolean): Unit = {
+ // Fail fast on writes that would produce a slash-separated layout with
more than one
+ // partition field. The extra path fragments cannot be lined up with the
partition columns on
+ // read (HUDI issue #19666), so without this check the write commits
cleanly and every
+ // subsequent read of the table fails. Checked regardless of save mode,
since an Overwrite
+ // produces the same layout.
+ // NOTE: equalsIgnoreCase rather than toBoolean, since every other reader
of this flag goes
+ // through Boolean.parseBoolean semantics and treats a value like
"1" as false, while
+ // toBoolean would throw on it
+ val slashSeparatedDatePartitioning =
+
params.get(HoodieTableConfig.SLASH_SEPARATED_DATE_PARTITIONING.key).exists(_.equalsIgnoreCase("true"))
||
+ (null != tableConfig && tableConfig.getSlashSeparatedDatePartitioning)
+ if (slashSeparatedDatePartitioning) {
+ // The table config is the source of truth for an existing table; for a
new one the
+ // datasource value may still carry the CustomKeyGenerator "field:type"
format, which the
+ // comma count is insensitive to
+ val partitionFields = Option(tableConfig)
+ .flatMap(tc =>
Option(HoodieTableConfig.getPartitionFieldProp(tc).orElse(null)))
+ .filter(_.nonEmpty)
+ .getOrElse(params.getOrElse(PARTITIONPATH_FIELD.key(), ""))
+ val partitionFieldCount =
partitionFields.split(",").count(_.trim.nonEmpty)
+ if (partitionFieldCount > 1) {
+ throw new
HoodieException(s"${HoodieTableConfig.SLASH_SEPARATED_DATE_PARTITIONING.key}
requires"
+ + s" a single partition field, but found $partitionFieldCount:
$partitionFields."
+ + " The slash-separated layout of a multi-field partition path
cannot be read back."
+ + " Recreate the table with a single date partition field or without
this config --"
+ + " an existing table's config cannot be changed in place.")
+ }
+ }
// If Overwrite is set as save mode, we don't need to do table config
validation.
if (!isOverWriteMode) {
val resolver = spark.sessionState.conf.resolver
diff --git
a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/catalyst/catalog/HoodieCatalogTable.scala
b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/catalyst/catalog/HoodieCatalogTable.scala
index 5f772cb5a5b0..dab105adcba0 100644
---
a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/catalyst/catalog/HoodieCatalogTable.scala
+++
b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/catalyst/catalog/HoodieCatalogTable.scala
@@ -298,6 +298,19 @@ class HoodieCatalogTable(val spark: SparkSession, var
table: CatalogTable) exten
&&
sqlOptions.contains(KeyGeneratorOptions.SLASH_SEPARATED_DATE_PARTITIONING.key)),
s"Table configs cannot contain both
${HIVE_STYLE_PARTITIONING_ENABLE.key} "
+ s"and ${KeyGeneratorOptions.SLASH_SEPARATED_DATE_PARTITIONING.key}")
+ // Reject slash-separated date partitioning with more than one partition
field at table
+ // creation: the resulting layout cannot be read back (HUDI issue #19666).
+ // HoodieWriterUtils#validateTableConfig enforces the same invariant at
write time and already
+ // rejects a pre-existing table on the tableExists branch
(parseSchemaAndConfigs calls it
+ // before reaching here), so the !tableExists gate only avoids a second,
differently-worded
+ // error on that path.
+ if (!tableExists
+ &&
sqlOptions.get(KeyGeneratorOptions.SLASH_SEPARATED_DATE_PARTITIONING.key).exists(_.equalsIgnoreCase("true")))
{
+ ValidationUtils.checkArgument(table.partitionColumnNames.size <= 1,
+ s"${KeyGeneratorOptions.SLASH_SEPARATED_DATE_PARTITIONING.key}
requires a single partition"
+ + s" field, but found ${table.partitionColumnNames.size}:"
+ + s" ${table.partitionColumnNames.mkString(",")}")
+ }
val extraConfig = mutable.Map.empty[String, String]
if (tableExists) {
val allPartitionPaths = getPartitionPaths
diff --git
a/hudi-spark-datasource/hudi-spark-common/src/test/java/org/apache/hudi/TestHoodieWriterUtils.java
b/hudi-spark-datasource/hudi-spark-common/src/test/java/org/apache/hudi/TestHoodieWriterUtils.java
index 2f745beb2935..54b8ddba1c13 100644
---
a/hudi-spark-datasource/hudi-spark-common/src/test/java/org/apache/hudi/TestHoodieWriterUtils.java
+++
b/hudi-spark-datasource/hudi-spark-common/src/test/java/org/apache/hudi/TestHoodieWriterUtils.java
@@ -60,6 +60,38 @@ class TestHoodieWriterUtils extends HoodieClientTestBase {
assertEquals("randomKey",
HoodieWriterUtils.getKeyInTableConfig("randomKey", null));
}
+ @Test
+ void validateTableConfigRejectsMultiFieldSlashPartitioningOnLegacyTable()
throws IOException {
+ // A legacy table already holding slash-separated date partitioning with
two partition fields:
+ // such a table can no longer be created through SQL or df.write, so the
rejection has to fire
+ // off the table config alone, with the write providing no slash or
partition configs of its own
+ Properties props = new Properties();
+ props.setProperty(HoodieTableConfig.PARTITION_FIELDS.key(),
"datestr,city");
+
props.setProperty(HoodieTableConfig.SLASH_SEPARATED_DATE_PARTITIONING.key(),
"true");
+ HoodieTableMetaClient tableMetaClient =
getMetaClientBuilder(HoodieTableType.COPY_ON_WRITE, props, "")
+ .initTable(storageConf,
tempDir.resolve("legacyMultiFieldSlashTable").toString());
+ HoodieTableConfig tableConfig = tableMetaClient.getTableConfig();
+
+ HoodieException ex = assertThrows(HoodieException.class,
+ () -> HoodieWriterUtils.validateTableConfig(
+ sparkSession,
JavaScalaConverters.convertJavaPropertiesToScalaMap(new TypedProperties()),
tableConfig));
+ assertTrue(ex.getMessage().contains("requires a single partition field"),
ex.getMessage());
+ }
+
+ @Test
+ void validateTableConfigRejectsMultiFieldSlashPartitioningOnOverwrite() {
+ // SaveMode.Overwrite nulls the table config, so the rejection has to fire
off the params
+ // alone, ahead of the isOverWriteMode gate that skips the rest of the
validation
+ TypedProperties writeProps = new TypedProperties();
+ writeProps.put(HoodieTableConfig.SLASH_SEPARATED_DATE_PARTITIONING.key(),
"true");
+ writeProps.put("hoodie.datasource.write.partitionpath.field",
"datestr,city");
+
+ HoodieException ex = assertThrows(HoodieException.class,
+ () -> HoodieWriterUtils.validateTableConfig(
+ sparkSession,
JavaScalaConverters.convertJavaPropertiesToScalaMap(writeProps), null, true));
+ assertTrue(ex.getMessage().contains("requires a single partition field"),
ex.getMessage());
+ }
+
/**
* The meta-fields-mode guard compares normalized modes, not raw legacy
property presence. A table
* written before {@code hoodie.meta.fields.mode} existed is normalized to
ALL or NONE when its
diff --git
a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/common/TestSlashSeparatedPartitionValue.scala
b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/common/TestSlashSeparatedPartitionValue.scala
index 34e6dffd1a13..fd0402117db7 100644
---
a/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/common/TestSlashSeparatedPartitionValue.scala
+++
b/hudi-spark-datasource/hudi-spark/src/test/scala/org/apache/spark/sql/hudi/common/TestSlashSeparatedPartitionValue.scala
@@ -22,34 +22,82 @@ import org.apache.hudi.common.config.HoodieMetadataConfig
import org.apache.hudi.common.table.HoodieTableMetaClient
import org.apache.hudi.hadoop.fs.HadoopFSUtils
import org.apache.hudi.metadata.HoodieBackedTableMetadata
-import org.apache.hudi.storage.{HoodieStorage, StoragePath}
+import org.apache.hudi.storage.StoragePath
import org.junit.jupiter.api.Assertions.assertTrue
class TestSlashSeparatedPartitionValue extends HoodieSparkSqlTestBase {
+ private def createSlashPartitionedTable(targetTable: String,
+ tablePath: String,
+ partitionColumnType: String =
"STRING",
+ tableType: String = "COW",
+ slashSeparatedPartitioning: Boolean
= true): Unit = {
+ spark.sql(
+ s"""
+ |create table $targetTable (
+ | `id` string,
+ | `name` string,
+ | `ts` bigint,
+ | `datestr` $partitionColumnType
+ |) using hudi
+ | tblproperties (
+ | 'primaryKey' = 'id',
+ | 'type' = '$tableType',
+ | 'preCombineField'='ts',
+ |
'hoodie.datasource.write.slash.separated.date.partitioning'='$slashSeparatedPartitioning'
+ | )
+ | partitioned by (`datestr`)
+ | location '$tablePath'
+ """.stripMargin)
+ }
+
+ private def buildMetaClient(tablePath: String): HoodieTableMetaClient = {
+ HoodieTableMetaClient.builder()
+
.setConf(HadoopFSUtils.getStorageConfWithCopy(spark.sparkContext.hadoopConfiguration))
+ .setBasePath(tablePath)
+ .build()
+ }
+
+ private def assertPartitionDirsExist(metaClient: HoodieTableMetaClient,
+ tablePath: String,
+ partitions: String*): Unit = {
+ partitions.foreach { partition =>
+ assertTrue(metaClient.getStorage.exists(new StoragePath(tablePath,
partition)),
+ s"Partition path $partition should exist")
+ }
+ }
+
+ /**
+ * Asserts that the metadata table names the very same directories the
writer created. The
+ * `_hoodie_partition_path` column and an `exists` check on storage both
still pass when the
+ * writer-recorded partition string and the metadata-table entry disagree,
so this is the
+ * assertion that actually pins the two together.
+ */
+ private def assertMetadataTablePartitions(metaClient: HoodieTableMetaClient,
+ tablePath: String,
+ partitions: String*): Unit = {
+ val engine = new HoodieSparkEngineContext(spark.sparkContext)
+ val metadataConfig = HoodieMetadataConfig.newBuilder().build()
+ val metadataTable =
+ new HoodieBackedTableMetadata(engine, metaClient.getStorage,
metadataConfig, tablePath)
+ try {
+ val partitionPaths = metadataTable.getAllPartitionPaths
+ partitions.foreach { partition =>
+ assertTrue(partitionPaths.contains(partition),
+ s"Metadata table should list partition $partition")
+ }
+ } finally {
+ metadataTable.close()
+ }
+ }
+
test("Test slash separated date partitions") {
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)
+ createSlashPartitionedTable(targetTable, tablePath)
spark.sql(
s"""
@@ -58,62 +106,183 @@ class TestSlashSeparatedPartitionValue extends
HoodieSparkSqlTestBase {
| (2, 'a2', 2000, "2026-01-06")
""".stripMargin)
- // check result after insert and merge data into target table
checkAnswer(s"select id, name, ts, _hoodie_partition_path, datestr from
$targetTable limit 10")(
Seq("1", "a1", 1000, "2026/01/05", "2026-01-05"),
Seq("2", "a2", 2000, "2026/01/06", "2026-01-06")
)
- // Verify table config has slash separated date partitioning enabled
- val metaClient = HoodieTableMetaClient.builder()
-
.setConf(HadoopFSUtils.getStorageConfWithCopy(spark.sparkContext.hadoopConfiguration))
- .setBasePath(tablePath)
- .build()
- val tableConfig = metaClient.getTableConfig
- assertTrue(tableConfig.getSlashSeparatedDatePartitioning,
+ val metaClient = buildMetaClient(tablePath)
+ assertTrue(metaClient.getTableConfig.getSlashSeparatedDatePartitioning,
"Table config should have slash separated date partitioning enabled")
- // Verify that partition paths are created with slash separated date
format (yyyy/MM/dd)
- 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")
+ assertPartitionDirsExist(metaClient, tablePath, "2026/01/05",
"2026/01/06")
+ assertMetadataTablePartitions(metaClient, tablePath, "2026/01/05",
"2026/01/06")
+ }
+ }
- val engine = new HoodieSparkEngineContext(spark.sparkContext)
- val storage = metaClient.getStorage()
- val metadataConfig = HoodieMetadataConfig.newBuilder().build()
- val metadataTable = new HoodieBackedTableMetadata(engine, storage,
metadataConfig, tablePath)
- val partitionPaths = metadataTable.getAllPartitionPaths
- assertTrue(partitionPaths.contains("2026/01/05"))
- assertTrue(partitionPaths.contains("2026/01/06"))
- metadataTable.close()
+ 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"
+
+ createSlashPartitionedTable(targetTable, tablePath)
+
+ // 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 = buildMetaClient(tablePath)
+ assertPartitionDirsExist(metaClient, tablePath,
+ "2026/01/05", "2026/01/06", "__HIVE_DEFAULT_PARTITION__")
+ assertMetadataTablePartitions(metaClient, tablePath,
+ "2026/01/05", "2026/01/06", "__HIVE_DEFAULT_PARTITION__")
+ }
}
}
- test("Test slash separated date partitions with already formatted input") {
- Seq(true, false).foreach { slashSeparatedPartitioning =>
+ test("Test slash separated date partitions on a DATE typed partition
column") {
+ // NOTE: Only bulk_insert exercises the row-writer rendering this fixes --
the insert path on a
+ // DATE column is already covered by [[TestTypedPartitionValues]]
+ withSQLConf("hoodie.spark.sql.insert.into.operation" -> "bulk_insert") {
withTempDir { tmp =>
val targetTable = generateTableName
val tablePath = s"${tmp.getCanonicalPath}/$targetTable"
+ createSlashPartitionedTable(targetTable, tablePath,
partitionColumnType = "DATE")
+
+ // NOTE: A DATE partition value is rendered through
[[BuiltinKeyGenerator#convertToLogicalDataType]]
+ // on the row-writer paths and
[[HoodieAvroUtils#convertValueForAvroLogicalTypes]] on the
+ // Avro one, so all three have to land in the very same directory
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'='$slashSeparatedPartitioning'
- | )
- | partitioned by (`datestr`)
- | location '$tablePath'
+ | insert into $targetTable values
+ | (1, 'a1', 1000, date'2026-01-05'),
+ | (2, 'a2', 2000, date'2026-01-06')
""".stripMargin)
+ checkAnswer(s"select id, name, ts, _hoodie_partition_path, datestr
from $targetTable order by id")(
+ Seq("1", "a1", 1000, "2026/01/05",
java.sql.Date.valueOf("2026-01-05")),
+ Seq("2", "a2", 2000, "2026/01/06",
java.sql.Date.valueOf("2026-01-06"))
+ )
+
+ val metaClient = buildMetaClient(tablePath)
+ assertPartitionDirsExist(metaClient, tablePath, "2026/01/05",
"2026/01/06")
+ assertMetadataTablePartitions(metaClient, tablePath, "2026/01/05",
"2026/01/06")
+ }
+ }
+ }
+
+ test("Test upsert into a slash separated date partition") {
+ Seq("COW", "MOR").foreach { tableType =>
+ withTempDir { tmp =>
+ val targetTable = generateTableName
+ val tablePath = s"${tmp.getCanonicalPath}/$targetTable"
+
+ createSlashPartitionedTable(targetTable, tablePath, tableType =
tableType)
+
+ spark.sql(
+ s"""
+ | insert into $targetTable values
+ | (1, 'a1', 1000, "2026-01-05")
+ """.stripMargin)
+
+ // NOTE: The index and the file-system view have to agree on the slash
partition path for
+ // the second write to be recognised as an update. When they
disagree the record is
+ // inserted a second time instead, which surfaces as a duplicate
rather than an error
+ spark.sql(
+ s"""
+ | merge into $targetTable as t
+ | using (select '1' as id, 'a1_updated' as name, 2000L as ts,
cast("2026-01-05" as string) as datestr) as s
+ | on t.id = s.id
+ | when matched then update set *
+ | when not matched then insert *
+ """.stripMargin)
+
+ checkAnswer(s"select id, name, ts, _hoodie_partition_path, datestr
from $targetTable")(
+ Seq("1", "a1_updated", 2000, "2026/01/05", "2026-01-05")
+ )
+
+ val metaClient = buildMetaClient(tablePath)
+ assertTrue(!metaClient.getStorage.exists(new StoragePath(tablePath,
"2026-01-05")),
+ s"No second directory should be created for table type $tableType")
+ }
+ }
+ }
+
+ test("Test slash separated date partitioning rejects multiple partition
fields at create") {
+ withTempDir { tmp =>
+ val targetTable = generateTableName
+ val tablePath = s"${tmp.getCanonicalPath}/$targetTable"
+
+ // NOTE: A multi-field slash table writes extra path fragments that
+ // [[HoodieSparkUtils#doParsePartitionColumnValues]] cannot line
up with the partition
+ // columns, so every read of the table fails under the default
lazy listing. Rejecting
+ // the combination up front keeps that layout from ever being
written -- HUDI issue #19666
+ checkExceptionContain(
+ s"""
+ |create table $targetTable (
+ | `id` string,
+ | `name` string,
+ | `ts` bigint,
+ | `datestr` STRING,
+ | `city` STRING
+ |) using hudi
+ | tblproperties (
+ | 'primaryKey' = 'id',
+ | 'type' = 'COW',
+ | 'preCombineField'='ts',
+ |
'hoodie.datasource.write.keygenerator.class'='org.apache.hudi.keygen.CustomKeyGenerator',
+ |
'hoodie.datasource.write.partitionpath.field'='datestr:simple,city:simple',
+ |
'hoodie.datasource.write.slash.separated.date.partitioning'='true'
+ | )
+ | partitioned by (`datestr`, `city`)
+ | location '$tablePath'
+ """.stripMargin)("but found 2: datestr,city")
+ }
+ }
+
+ test("Test slash separated date partitioning rejects multiple partition
fields at write") {
+ withTempDir { tmp =>
+ val tablePath = s"${tmp.getCanonicalPath}/${generateTableName}"
+
+ // df.write bypasses the catalog-level check, so the rejection has to
come from
+ // [[HoodieWriterUtils#validateTableConfig]]
+ val df = spark.sql(
+ "select '1' as id, 'a1' as name, 1000L as ts, '2026-01-05' as datestr,
'NYC' as city")
+ checkExceptionContain(() =>
+ df.write.format("hudi")
+ .option("hoodie.table.name", "rejected_slash_table")
+ .option("hoodie.datasource.write.recordkey.field", "id")
+ .option("hoodie.datasource.write.partitionpath.field",
"datestr,city")
+ .option("hoodie.datasource.write.slash.separated.date.partitioning",
"true")
+ .mode("append")
+ .save(tablePath)
+ )("cannot be read back")
+ }
+ }
+
+ test("Test slash separated date partitions with already formatted input") {
+ Seq(true, false).foreach { slashSeparatedPartitioning =>
+ withTempDir { tmp =>
+ val targetTable = generateTableName
+ val tablePath = s"${tmp.getCanonicalPath}/$targetTable"
+
+ createSlashPartitionedTable(targetTable, tablePath,
+ slashSeparatedPartitioning = slashSeparatedPartitioning)
+
spark.sql(
s"""
| insert into $targetTable values
@@ -132,29 +301,12 @@ class TestSlashSeparatedPartitionValue extends
HoodieSparkSqlTestBase {
Seq("2", "a2", 2000, "2026/01/02", secondPartitionValue)
)
- // Verify table config
- val metaClient = HoodieTableMetaClient.builder()
-
.setConf(HadoopFSUtils.getStorageConfWithCopy(spark.sparkContext.hadoopConfiguration))
- .setBasePath(tablePath)
- .build()
- val tableConfig = metaClient.getTableConfig
- assertTrue(tableConfig.getSlashSeparatedDatePartitioning ==
slashSeparatedPartitioning,
+ val metaClient = buildMetaClient(tablePath)
+ assertTrue(metaClient.getTableConfig.getSlashSeparatedDatePartitioning
== slashSeparatedPartitioning,
s"Table config should have slash separated date partitioning set to
$slashSeparatedPartitioning")
- // Verify that partition paths are created with slash separated date
format (yyyy/MM/dd)
- assertTrue(metaClient.getStorage.exists(new StoragePath(tablePath,
"2026/01/01")),
- s"Partition path 2026/01/01 should exist")
- assertTrue(metaClient.getStorage.exists(new StoragePath(tablePath,
"2026/01/02")),
- s"Partition path 2026/01/02 should exist")
-
- val engine = new HoodieSparkEngineContext(spark.sparkContext)
- val storage = metaClient.getStorage()
- val metadataConfig = HoodieMetadataConfig.newBuilder().build()
- val metadataTable = new HoodieBackedTableMetadata(engine, storage,
metadataConfig, tablePath)
- val partitionPaths = metadataTable.getAllPartitionPaths
- assertTrue(partitionPaths.contains("2026/01/01"))
- assertTrue(partitionPaths.contains("2026/01/02"))
- metadataTable.close()
+ assertPartitionDirsExist(metaClient, tablePath, "2026/01/01",
"2026/01/02")
+ assertMetadataTablePartitions(metaClient, tablePath, "2026/01/01",
"2026/01/02")
}
}
}