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

JingsongLi pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/paimon.git


The following commit(s) were added to refs/heads/master by this push:
     new 1ab8b32729 [core] Reject hidden partition values in value-only format 
table layout (#10750)
1ab8b32729 is described below

commit 1ab8b32729b76893a6c4c1720913c5354535d701
Author: suntectec <[email protected]>
AuthorDate: Mon Oct 26 14:22:28 2026 +0800

    [core] Reject hidden partition values in value-only format table layout 
(#10750)
---
 .../paimon/table/format/FormatTableCommit.java     |   6 +-
 .../paimon/table/format/FormatTableFileWriter.java |  12 +-
 .../apache/paimon/utils/PartitionPathUtils.java    |  40 +++++++
 .../FormatTableValueOnlyHiddenPartitionTest.java   | 123 +++++++++++++++++++++
 .../paimon/utils/PartitionPathUtilsTest.java       |  36 ++++++
 5 files changed, 211 insertions(+), 6 deletions(-)

diff --git 
a/paimon-core/src/main/java/org/apache/paimon/table/format/FormatTableCommit.java
 
b/paimon-core/src/main/java/org/apache/paimon/table/format/FormatTableCommit.java
index d71ab7e258..2b67c26751 100644
--- 
a/paimon-core/src/main/java/org/apache/paimon/table/format/FormatTableCommit.java
+++ 
b/paimon-core/src/main/java/org/apache/paimon/table/format/FormatTableCommit.java
@@ -728,7 +728,7 @@ public class FormatTableCommit implements BatchTableCommit {
         return partitionSpec;
     }
 
-    private static Path buildPartitionPath(
+    private Path buildPartitionPath(
             String location,
             Map<String, String> partitionSpec,
             boolean formatTablePartitionOnlyValueInPath,
@@ -751,6 +751,10 @@ public class FormatTableCommit implements BatchTableCommit 
{
                 throw new RuntimeException("partitionSpec does not contain 
key: " + key);
             }
         }
+        // Fail loudly on value-only partition values that a scan would hide 
('_'/'.') rather than
+        // silently dropping the partition; the configured default partition 
name is exempt.
+        PartitionPathUtils.validatePartitionSpecForWrite(
+                orderedSpec.values(), formatTablePartitionOnlyValueInPath, 
defaultPartName);
         return new Path(
                 location,
                 PartitionPathUtils.generatePartitionPathUtil(
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/table/format/FormatTableFileWriter.java
 
b/paimon-core/src/main/java/org/apache/paimon/table/format/FormatTableFileWriter.java
index a3b48ee7fa..66453d402a 100644
--- 
a/paimon-core/src/main/java/org/apache/paimon/table/format/FormatTableFileWriter.java
+++ 
b/paimon-core/src/main/java/org/apache/paimon/table/format/FormatTableFileWriter.java
@@ -29,6 +29,7 @@ import org.apache.paimon.table.sink.CommitMessage;
 import org.apache.paimon.types.RowType;
 import org.apache.paimon.utils.FileStorePathFactory;
 import org.apache.paimon.utils.IOUtils;
+import org.apache.paimon.utils.PartitionPathUtils;
 
 import java.util.ArrayList;
 import java.util.HashMap;
@@ -134,11 +135,12 @@ public class FormatTableFileWriter {
         if (partition.getFieldCount() > 0) {
             LinkedHashMap<String, String> partValues =
                     
pathFactory.partitionComputer().generatePartValues(partition);
-            parent =
-                    new Path(
-                            parent,
-                            generatePartitionPathUtil(
-                                    partValues, 
options.formatTablePartitionOnlyValueInPath()));
+            boolean onlyValueInPath = 
options.formatTablePartitionOnlyValueInPath();
+            // Reject value-only partition values that scans would hide 
('_'/'.') instead of
+            // silently dropping the whole partition on read; the default 
partition name is exempt.
+            PartitionPathUtils.validatePartitionSpecForWrite(
+                    partValues.values(), onlyValueInPath, 
options.partitionDefaultName());
+            parent = new Path(parent, generatePartitionPathUtil(partValues, 
onlyValueInPath));
         }
         return new FormatTableRecordWriter(
                 fileIO,
diff --git 
a/paimon-core/src/main/java/org/apache/paimon/utils/PartitionPathUtils.java 
b/paimon-core/src/main/java/org/apache/paimon/utils/PartitionPathUtils.java
index aec2b21aae..630a45c21d 100644
--- a/paimon-core/src/main/java/org/apache/paimon/utils/PartitionPathUtils.java
+++ b/paimon-core/src/main/java/org/apache/paimon/utils/PartitionPathUtils.java
@@ -166,6 +166,46 @@ public class PartitionPathUtils {
         validatePartitionSpecForPath(partitionSpec, true);
     }
 
+    /**
+     * Write-side guard for the value-only partition layout.
+     *
+     * <p>Scans treat any value-only directory whose name starts with {@code 
'_'} or {@code '.'} as
+     * a hidden staging or metadata directory (see {@link #isHiddenName}) and 
skip it, so a
+     * partition written under such a value and all of its data would silently 
disappear from every
+     * read with no error at write or scan time. Validating the value here 
turns that silent data
+     * loss into a loud write-time failure.
+     *
+     * <p>The configured default partition name stays allowed because scans 
deliberately un-hide
+     * exactly that name (see {@link #isHiddenFile}). Callers only reach this 
for the value-only
+     * layout; the key-value layout writes {@code pt=<value>} directories 
whose leading {@code '_'}
+     * or {@code '.'} is contributed by the column name, not the value, and is 
therefore never
+     * hidden.
+     */
+    public static void validatePartitionValueForWrite(
+            String value, boolean onlyValueInPath, @Nullable String 
defaultPartValue) {
+        validatePartitionValueForPath(value, onlyValueInPath);
+        if (onlyValueInPath && !value.equals(defaultPartValue) && 
isHiddenName(value)) {
+            throw new IllegalArgumentException(
+                    String.format(
+                            "Partition value '%s' cannot be written in a 
value-only partition "
+                                    + "layout: scans treat a directory whose 
name starts with '_' or "
+                                    + "'.' as hidden and would silently drop 
this partition and all "
+                                    + "of its data. Use a partition value that 
does not start with "
+                                    + "'_' or '.', or switch to the key-value 
partition layout.",
+                            value));
+        }
+    }
+
+    /** Validate every value of a partition spec against the write-side 
value-only rules. */
+    public static void validatePartitionSpecForWrite(
+            Iterable<String> partitionValues,
+            boolean onlyValueInPath,
+            @Nullable String defaultPartValue) {
+        for (String value : partitionValues) {
+            validatePartitionValueForWrite(value, onlyValueInPath, 
defaultPartValue);
+        }
+    }
+
     /**
      * Build the partition-name prefix pattern pushed down to a 
partition-managing catalog from the
      * leading equality prefix of a partition predicate.
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/table/format/FormatTableValueOnlyHiddenPartitionTest.java
 
b/paimon-core/src/test/java/org/apache/paimon/table/format/FormatTableValueOnlyHiddenPartitionTest.java
new file mode 100644
index 0000000000..175ab032ae
--- /dev/null
+++ 
b/paimon-core/src/test/java/org/apache/paimon/table/format/FormatTableValueOnlyHiddenPartitionTest.java
@@ -0,0 +1,123 @@
+/*
+ * 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.paimon.table.format;
+
+import org.apache.paimon.CoreOptions;
+import org.apache.paimon.catalog.Identifier;
+import org.apache.paimon.data.BinaryString;
+import org.apache.paimon.data.GenericRow;
+import org.apache.paimon.fs.Path;
+import org.apache.paimon.fs.local.LocalFileIO;
+import org.apache.paimon.table.FormatTable;
+import org.apache.paimon.table.sink.BatchTableWrite;
+import org.apache.paimon.table.sink.BatchWriteBuilder;
+import org.apache.paimon.table.sink.CommitMessage;
+import org.apache.paimon.types.DataTypes;
+import org.apache.paimon.types.RowType;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/**
+ * Regression tests for writing hidden-name partitions into a value-only 
format table layout.
+ *
+ * <p>Scans skip any value-only directory whose name starts with {@code '_'} 
or {@code '.'} (see
+ * {@link org.apache.paimon.utils.PartitionPathUtils}), so a partition written 
under such a value
+ * used to vanish silently from every read. The write path must now fail 
loudly instead. The
+ * configured default partition name stays writable because scans deliberately 
un-hide exactly it.
+ */
+class FormatTableValueOnlyHiddenPartitionTest {
+
+    private static final String DEFAULT_PART_NAME = "__null__";
+
+    private static final RowType TABLE_TYPE =
+            RowType.builder()
+                    .field("value", DataTypes.STRING())
+                    .field("pt", DataTypes.STRING())
+                    .build();
+
+    @TempDir java.nio.file.Path tempDir;
+
+    @ParameterizedTest
+    @ValueSource(strings = {"_x", ".tmp", "__hidden"})
+    void testHiddenValueOnlyPartitionFailsLoudlyAtWrite(String pt) {
+        // Before the fix this write succeeded and the partition silently 
disappeared on read.
+        assertThatThrownBy(() -> writeOneRow(pt))
+                .isInstanceOf(IllegalArgumentException.class)
+                .hasMessageContaining(pt)
+                .hasMessageContaining("value-only");
+    }
+
+    @Test
+    void testVisibleValueOnlyPartitionStillWrites() throws Exception {
+        List<CommitMessage> messages = writeOneRow("2025");
+        assertThat(messages).isNotEmpty();
+    }
+
+    @Test
+    void testDefaultPartitionNameIsWritableInValueOnlyLayout() throws 
Exception {
+        // A null partition value is substituted with the default name, which 
starts with '_' but is
+        // exempted because scans un-hide exactly that name.
+        List<CommitMessage> messages = writeOneRow(null);
+        assertThat(messages).isNotEmpty();
+        assertThat(tempDir.resolve(DEFAULT_PART_NAME)).exists();
+    }
+
+    private List<CommitMessage> writeOneRow(String pt) throws Exception {
+        FormatTable table = valueOnlyTable();
+        BatchWriteBuilder writeBuilder = table.newBatchWriteBuilder();
+        try (BatchTableWrite write = writeBuilder.newWrite()) {
+            write.write(
+                    GenericRow.of(
+                            BinaryString.fromString("row"),
+                            pt == null ? null : BinaryString.fromString(pt)));
+            return write.prepareCommit();
+        }
+    }
+
+    private FormatTable valueOnlyTable() {
+        Path tablePath = new Path(tempDir.toUri());
+        Map<String, String> options = new HashMap<>();
+        options.put(CoreOptions.PATH.key(), tablePath.toString());
+        options.put(CoreOptions.FILE_FORMAT.key(), "csv");
+        options.put(
+                CoreOptions.FORMAT_TABLE_PARTITION_ONLY_VALUE_IN_PATH.key(),
+                Boolean.TRUE.toString());
+        options.put(CoreOptions.PARTITION_DEFAULT_NAME.key(), 
DEFAULT_PART_NAME);
+        return FormatTable.builder()
+                .fileIO(LocalFileIO.create())
+                .identifier(Identifier.create("test_db", "value_only"))
+                .rowType(TABLE_TYPE)
+                .partitionKeys(Collections.singletonList("pt"))
+                .location(tablePath.toString())
+                .format(FormatTable.Format.CSV)
+                .options(options)
+                .build();
+    }
+}
diff --git 
a/paimon-core/src/test/java/org/apache/paimon/utils/PartitionPathUtilsTest.java 
b/paimon-core/src/test/java/org/apache/paimon/utils/PartitionPathUtilsTest.java
index 6026eadc04..5525afe0ca 100644
--- 
a/paimon-core/src/test/java/org/apache/paimon/utils/PartitionPathUtilsTest.java
+++ 
b/paimon-core/src/test/java/org/apache/paimon/utils/PartitionPathUtilsTest.java
@@ -72,6 +72,42 @@ class PartitionPathUtilsTest {
                 .containsExactly(entry("pt", rawValue));
     }
 
+    @ParameterizedTest
+    @ValueSource(strings = {"_x", ".tmp", "._y", "__hidden", ".", ".."})
+    void testValueOnlyHiddenPrefixRejectedAtWrite(String rawValue) {
+        // A value-only directory starting with '_' or '.' is hidden to scans 
(see isHiddenName), so
+        // writing it would silently drop the partition; the write path must 
reject it loudly.
+        assertThat(PartitionPathUtils.isHiddenName(rawValue)).isTrue();
+        assertThatThrownBy(
+                        () ->
+                                
PartitionPathUtils.validatePartitionValueForWrite(
+                                        rawValue, true, 
"__DEFAULT_PARTITION__"))
+                .isInstanceOf(IllegalArgumentException.class)
+                .hasMessageContaining(rawValue);
+    }
+
+    @Test
+    void testValueOnlyDefaultPartitionNameIsAllowedAtWrite() {
+        // The configured default name starts with '_' yet scans un-hide 
exactly that name, so it
+        // must stay writable.
+        String defaultName = "__DEFAULT_PARTITION__";
+        PartitionPathUtils.validatePartitionValueForWrite(defaultName, true, 
defaultName);
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = {"2025", "a:b", "a b"})
+    void testVisibleValueOnlyValueAcceptedAtWrite(String rawValue) {
+        PartitionPathUtils.validatePartitionValueForWrite(rawValue, true, 
null);
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = {"_x", ".tmp"})
+    void testKeyValueLayoutKeepsHiddenPrefixValuesAtWrite(String rawValue) {
+        // In a key=value layout the directory is 'pt=_x'; the leading char 
comes from the column
+        // name, not the value, so the directory is never hidden and the value 
stays writable.
+        PartitionPathUtils.validatePartitionValueForWrite(rawValue, false, 
null);
+    }
+
     @Test
     void testTrailingPartitionExtractionStopsAtTableBoundary() {
         Path partitionPath = new 
Path("file:///warehouse/env=prod/table/dt=20260718/hh=10");

Reply via email to