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 581f324b37 [core] Support OR/nested partition predicate pruning in
format table scan (#8367)
581f324b37 is described below
commit 581f324b378735523ee15478895f352960c40287
Author: Zouxxyy <[email protected]>
AuthorDate: Mon Jun 29 13:37:58 2026 +0800
[core] Support OR/nested partition predicate pruning in format table scan
(#8367)
Support generic partition-directory pruning for format table scans when
partition predicates contain OR/nested expressions.
Previously pruning used a per-field \`Map<String, Predicate>\` built
from \`splitAnd\`, so any predicate referencing more than one partition
field (for example \`(dt='20260625' AND hour<'16') OR (dt='20260624' AND
hour>='16')\`) was dropped and the scan fell back to listing every
partition directory.
This change:
- replaces the per-field pruning model with incremental partial
evaluation of the whole partition predicate during directory descent, so
AND/OR/nested/cross-field partition predicates can prune format-table
directories correctly;
- keeps scan-path prefix optimization unchanged for leading equality
conjuncts;
- fixes \`format-table.partition-path-only-value=true\` with
default/null partitions by allowing the configured default partition
directory name through hidden-path filtering and by passing
\`defaultPartName\` into \`listPartitionEntries\`.
---
.../paimon/table/format/FormatTableScan.java | 14 +-
.../table/format/predicate/PredicateUtils.java | 69 -----
.../apache/paimon/utils/PartitionPathUtils.java | 188 ++++++++-----
.../paimon/table/format/FormatTableScanTest.java | 310 +++++++++++++++++++++
.../table/format/predicate/PredicateUtilsTest.java | 77 -----
.../paimon/utils/PartitionPathUtilsTest.java | 106 +++++++
.../paimon/spark/sql/FormatTableTestBase.scala | 43 +++
7 files changed, 583 insertions(+), 224 deletions(-)
diff --git
a/paimon-core/src/main/java/org/apache/paimon/table/format/FormatTableScan.java
b/paimon-core/src/main/java/org/apache/paimon/table/format/FormatTableScan.java
index 9dca62508f..a75e4d92d0 100644
---
a/paimon-core/src/main/java/org/apache/paimon/table/format/FormatTableScan.java
+++
b/paimon-core/src/main/java/org/apache/paimon/table/format/FormatTableScan.java
@@ -43,7 +43,6 @@ import org.apache.paimon.predicate.LeafPredicate;
import org.apache.paimon.predicate.Predicate;
import org.apache.paimon.predicate.PredicateBuilder;
import org.apache.paimon.table.FormatTable;
-import org.apache.paimon.table.format.predicate.PredicateUtils;
import org.apache.paimon.table.source.InnerTableScan;
import org.apache.paimon.table.source.Split;
import org.apache.paimon.table.source.TableScan;
@@ -122,7 +121,10 @@ public class FormatTableScan implements InnerTableScan {
new Path(table.location()),
table.partitionKeys().size(),
table.partitionKeys(),
- coreOptions.formatTablePartitionOnlyValueInPath());
+ coreOptions.formatTablePartitionOnlyValueInPath(),
+ null,
+ table.partitionType(),
+ table.defaultPartName());
List<PartitionEntry> partitionEntries = new ArrayList<>();
for (Pair<LinkedHashMap<String, String>, Path> partition2Path :
partition2Paths) {
BinaryRow row = toPartitionRow(partition2Path.getKey());
@@ -199,17 +201,11 @@ public class FormatTableScan implements InnerTableScan {
// search paths with partition filter optimization
// This will prune partition directories early during traversal,
// which is especially important for cloud storage like OSS/S3
- Map<String, Predicate> partitionPredicates = new HashMap<>();
Optional<Predicate> predicate =
extractPartitionPredicate(partitionFilter);
LOG.debug(
"Extracted predicate for format table {} partition
pruning: {}",
table.name(),
predicate.orElse(null));
- if (predicate.isPresent()) {
- partitionPredicates =
- PredicateUtils.splitPartitionPredicate(
- table.partitionType(), predicate.get());
- }
Pair<Path, Integer> scanPathAndLevel =
computeScanPathAndLevel(
@@ -224,7 +220,7 @@ public class FormatTableScan implements InnerTableScan {
scanPathAndLevel.getRight(),
table.partitionKeys(),
onlyValueInPath,
- partitionPredicates,
+ predicate.orElse(null),
table.partitionType(),
table.defaultPartName());
}
diff --git
a/paimon-core/src/main/java/org/apache/paimon/table/format/predicate/PredicateUtils.java
b/paimon-core/src/main/java/org/apache/paimon/table/format/predicate/PredicateUtils.java
deleted file mode 100644
index 86ef0a13ad..0000000000
---
a/paimon-core/src/main/java/org/apache/paimon/table/format/predicate/PredicateUtils.java
+++ /dev/null
@@ -1,69 +0,0 @@
-/*
- * 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.predicate;
-
-import org.apache.paimon.predicate.Predicate;
-import org.apache.paimon.predicate.PredicateBuilder;
-import org.apache.paimon.types.RowType;
-
-import java.util.Arrays;
-import java.util.HashMap;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Map;
-import java.util.Optional;
-import java.util.Set;
-
-import static
org.apache.paimon.predicate.PredicateBuilder.transformFieldMapping;
-import static org.apache.paimon.predicate.PredicateVisitor.collectFieldNames;
-
-/** Utility methods for working with {@link Predicate}s. */
-public class PredicateUtils {
-
- // Use splitAnd split the predicate, then group them by the partition
fields.
- public static Map<String, Predicate> splitPartitionPredicate(
- RowType partitionType, Predicate predicate) {
- int[] fieldMap = new int[partitionType.getFields().size()];
- Arrays.fill(fieldMap, 0);
- List<Predicate> predicates = PredicateBuilder.splitAnd(predicate);
- Set<String> partitionFieldNames = new
HashSet<>(partitionType.getFieldNames());
- Map<String, Predicate> result = new HashMap<>();
-
- for (Predicate sub : predicates) {
- // Collect all field names referenced by this predicate
- Set<String> referencedFields = collectFieldNames(sub);
- Optional<Predicate> transformed = transformFieldMapping(sub,
fieldMap);
- if (transformed.isPresent() && referencedFields.size() == 1) {
- Predicate child = transformed.get();
- // Only include predicates that reference exactly one
partition field
- String fieldName = referencedFields.iterator().next();
- if (partitionFieldNames.contains(fieldName)) {
- if (result.containsKey(fieldName)) {
- // Combine with existing predicate using AND
- result.put(fieldName,
PredicateBuilder.and(result.get(fieldName), child));
- } else {
- result.put(fieldName, child);
- }
- }
- }
- }
-
- return result;
- }
-}
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 fc2fe23e05..fb7baf5b06 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
@@ -19,10 +19,17 @@
package org.apache.paimon.utils;
import org.apache.paimon.data.GenericRow;
+import org.apache.paimon.data.InternalRow;
import org.apache.paimon.fs.FileIO;
import org.apache.paimon.fs.FileStatus;
import org.apache.paimon.fs.Path;
+import org.apache.paimon.predicate.And;
+import org.apache.paimon.predicate.CompoundPredicate;
+import org.apache.paimon.predicate.FieldRef;
+import org.apache.paimon.predicate.LeafPredicate;
+import org.apache.paimon.predicate.Or;
import org.apache.paimon.predicate.Predicate;
+import org.apache.paimon.predicate.PredicateVisitor;
import org.apache.paimon.types.DataField;
import org.apache.paimon.types.RowType;
@@ -36,6 +43,7 @@ import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
+import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
@@ -286,7 +294,7 @@ public class PartitionPathUtils {
partitionNumber,
partitionKeys,
onlyValueInPath,
- Collections.emptyMap(),
+ (Predicate) null,
null,
null);
}
@@ -297,7 +305,7 @@ public class PartitionPathUtils {
int partitionNumber,
List<String> partitionKeys,
boolean onlyValueInPath,
- Map<String, Predicate> partitionFilter,
+ @Nullable Predicate partitionFilter,
@Nullable RowType partitionType,
@Nullable String defaultPartValue) {
FileStatus[] generatedParts =
@@ -312,8 +320,7 @@ public class PartitionPathUtils {
defaultPartValue);
List<Pair<LinkedHashMap<String, String>, Path>> ret = new
ArrayList<>();
for (FileStatus part : generatedParts) {
- // ignore hidden file
- if (isHiddenFile(part)) {
+ if (isHiddenFile(part, onlyValueInPath, defaultPartValue)) {
continue;
}
if (onlyValueInPath) {
@@ -340,16 +347,22 @@ public class PartitionPathUtils {
FileIO fileIO,
List<String> partitionKeys,
boolean onlyValueInPath,
- Map<String, Predicate> partitionFilter,
+ @Nullable Predicate partitionFilter,
@Nullable RowType partitionType,
@Nullable String defaultPartValue) {
ArrayList<FileStatus> result = new ArrayList<>();
+ // Only predicate-referenced levels are parsed/pruned.
+ Set<String> referencedFields =
+ partitionFilter == null
+ ? Collections.emptySet()
+ : PredicateVisitor.collectFieldNames(partitionFilter);
+ GenericRow values =
+ partitionType == null ? null : new
GenericRow(partitionType.getFieldCount());
+
try {
FileStatus fileStatus = fileIO.getFileStatus(path);
- // Calculate the starting offset when we begin from a prefix path
- // For example, if partitionKeys = [ds, hr] and expectLevel = 1
(only hr remaining),
- // then levelOffset = 2 - 1 = 1, so we access partitionKeys[1] for
level 0
+ // Skip partition levels already fixed by the scan-path prefix.
int levelOffset = partitionKeys.size() - expectLevel;
listStatusRecursively(
fileIO,
@@ -360,9 +373,11 @@ public class PartitionPathUtils {
partitionKeys,
onlyValueInPath,
partitionFilter,
+ referencedFields,
partitionType,
defaultPartValue,
- levelOffset);
+ levelOffset,
+ values);
} catch (FileNotFoundException e) {
return new FileStatus[0];
} catch (IOException e) {
@@ -380,12 +395,14 @@ public class PartitionPathUtils {
List<FileStatus> results,
List<String> partitionKeys,
boolean onlyValueInPath,
- Map<String, Predicate> partitionFilter,
+ @Nullable Predicate partitionFilter,
+ Set<String> referencedFields,
@Nullable RowType partitionType,
@Nullable String defaultPartValue,
- int levelOffset)
+ int levelOffset,
+ @Nullable GenericRow values)
throws IOException {
- if (isHiddenFile(fileStatus.getPath())) {
+ if (isHiddenFile(fileStatus, onlyValueInPath, defaultPartValue)) {
return;
}
@@ -396,69 +413,30 @@ public class PartitionPathUtils {
if (fileStatus.isDir()) {
for (FileStatus stat : fileIO.listStatus(fileStatus.getPath())) {
- // Calculate the actual partition key index considering the
level offset
- // When starting from a prefix path, levelOffset accounts for
already-traversed
- // levels
int partitionKeyIndex = levelOffset + level;
-
- // Apply partition filter if available
- if
(partitionFilter.containsKey(partitionKeys.get(partitionKeyIndex))
- && partitionType != null) {
-
- Predicate partitionPredicate =
-
partitionFilter.get(partitionKeys.get(partitionKeyIndex));
- // Extract the partition value from the directory name
- String dirName = stat.getPath().getName();
- String partitionKey = partitionKeys.get(partitionKeyIndex);
- String partitionValue;
-
- if (onlyValueInPath) {
- partitionValue = unescapePathName(dirName);
- } else {
- // Parse key=value format
- Matcher m = PARTITION_NAME_PATTERN.matcher(dirName);
- if (m.matches()) {
- String key = unescapePathName(m.group(1));
- if (!key.equals(partitionKey)) {
- // Key doesn't match expected partition key,
skip filtering
- partitionValue = null;
- } else {
- partitionValue = unescapePathName(m.group(2));
- }
- } else {
- // Not a valid partition directory format
- partitionValue = null;
- }
- }
-
+ String partitionKey = partitionKeys.get(partitionKeyIndex);
+
+ // Bind the current partition value and prune only when the
partially bound
+ // predicate is provably false. Unreferenced or unparseable
levels are descended
+ // without pruning.
+ if (partitionFilter != null
+ && partitionType != null
+ && values != null
+ && referencedFields.contains(partitionKey)) {
+ String partitionValue =
+ parsePartitionValue(
+ stat.getPath().getName(), partitionKey,
onlyValueInPath);
if (partitionValue != null) {
- // Convert the partition value to internal format
Object internalValue =
defaultPartValue != null &&
defaultPartValue.equals(partitionValue)
? null
: castFromString(
partitionValue,
partitionType.getTypeAt(partitionKeyIndex));
-
- GenericRow partialRow = GenericRow.of(internalValue);
- if (!partitionPredicate.test(partialRow)) {
+ values.setField(partitionKeyIndex, internalValue);
+ if (!mightMatch(partitionFilter, levelOffset,
partitionKeyIndex, values)) {
continue;
}
-
- // Pass the accumulated values to the next level
- listStatusRecursively(
- fileIO,
- stat,
- level + 1,
- expectLevel,
- results,
- partitionKeys,
- onlyValueInPath,
- partitionFilter,
- partitionType,
- defaultPartValue,
- levelOffset);
- continue;
}
}
@@ -471,19 +449,91 @@ public class PartitionPathUtils {
partitionKeys,
onlyValueInPath,
partitionFilter,
+ referencedFields,
partitionType,
defaultPartValue,
- levelOffset);
+ levelOffset,
+ values);
+ }
+ }
+ }
+
+ /** Returns the partition value from a directory name, or {@code null}
when it can't be used. */
+ @Nullable
+ private static String parsePartitionValue(
+ String dirName, String partitionKey, boolean onlyValueInPath) {
+ if (onlyValueInPath) {
+ return unescapePathName(dirName);
+ }
+ Matcher m = PARTITION_NAME_PATTERN.matcher(dirName);
+ if (m.matches()) {
+ String key = unescapePathName(m.group(1));
+ if (!key.equals(partitionKey)) {
+ return null;
+ }
+ return unescapePathName(m.group(2));
+ }
+ return null;
+ }
+
+ /**
+ * Returns whether a subtree might still match the partition predicate.
+ *
+ * <p>{@code values} holds the currently bound partition values in {@code
[minIdx, maxIdx]}.
+ * Indices below {@code minIdx} belong to the scan-path prefix; indices
above {@code maxIdx} are
+ * not known yet. Returning {@code false} means the subtree can be pruned
safely.
+ */
+ static boolean mightMatch(
+ @Nullable Predicate predicate, int minIdx, int maxIdx, InternalRow
values) {
+ if (predicate == null) {
+ return true;
+ }
+ if (predicate instanceof CompoundPredicate) {
+ CompoundPredicate compound = (CompoundPredicate) predicate;
+ if (compound.function() instanceof Or) {
+ for (Predicate child : compound.children()) {
+ if (mightMatch(child, minIdx, maxIdx, values)) {
+ return true;
+ }
+ }
+ return false;
}
+ if (compound.function() instanceof And) {
+ for (Predicate child : compound.children()) {
+ if (!mightMatch(child, minIdx, maxIdx, values)) {
+ return false;
+ }
+ }
+ return true;
+ }
+ return true;
}
+ if (predicate instanceof LeafPredicate) {
+ for (Object input : ((LeafPredicate)
predicate).transform().inputs()) {
+ if (input instanceof FieldRef) {
+ int idx = ((FieldRef) input).index();
+ if (idx < minIdx || idx > maxIdx) {
+ return true;
+ }
+ }
+ }
+ return predicate.test(values);
+ }
+ // Unknown predicate node: be conservative.
+ return true;
}
- private static boolean isHiddenFile(FileStatus fileStatus) {
- return isHiddenFile(fileStatus.getPath());
+ private static boolean isHiddenFile(
+ FileStatus fileStatus, boolean onlyValueInPath, @Nullable String
defaultPartValue) {
+ return isHiddenFile(fileStatus.getPath(), onlyValueInPath,
defaultPartValue);
}
- private static boolean isHiddenFile(Path path) {
+ private static boolean isHiddenFile(
+ Path path, boolean onlyValueInPath, @Nullable String
defaultPartValue) {
String name = path.getName();
+ if (onlyValueInPath && defaultPartValue != null &&
defaultPartValue.equals(name)) {
+ return false;
+ }
return name.startsWith("_") || name.startsWith(".");
}
}
diff --git
a/paimon-core/src/test/java/org/apache/paimon/table/format/FormatTableScanTest.java
b/paimon-core/src/test/java/org/apache/paimon/table/format/FormatTableScanTest.java
index 0584577a1a..18f22c8112 100644
---
a/paimon-core/src/test/java/org/apache/paimon/table/format/FormatTableScanTest.java
+++
b/paimon-core/src/test/java/org/apache/paimon/table/format/FormatTableScanTest.java
@@ -20,6 +20,7 @@ package org.apache.paimon.table.format;
import org.apache.paimon.catalog.Identifier;
import org.apache.paimon.data.BinaryRow;
+import org.apache.paimon.data.BinaryString;
import org.apache.paimon.data.GenericRow;
import org.apache.paimon.data.serializer.InternalRowSerializer;
import org.apache.paimon.format.csv.CsvOptions;
@@ -1018,4 +1019,313 @@ public class FormatTableScanTest {
.collect(java.util.stream.Collectors.toList());
assertEquals(Arrays.asList("4", "5", "6", "7", "8", "9"), months);
}
+
+ @TestTemplate
+ void testFindPartitionsWithOrCrossFieldPredicate() throws IOException {
+ Path tableLocation = new Path(tmpPath.toUri());
+ createYearMonthPartitionDirs(LocalFileIO.create(), tableLocation);
+
+ AtomicInteger listCount = new AtomicInteger(0);
+ FormatTable formatTable =
+ createYearMonthFormatTable(countingFileIO(listCount),
tableLocation);
+
+ // (year = 2024 AND month < 6) OR (year = 2025 AND month >= 6): a
cross-field OR that the
+ // old per-field split model could not express, so it used to fall
back to listing every
+ // year directory.
+ PredicateBuilder builder = new
PredicateBuilder(formatTable.partitionType());
+ Predicate orPredicate =
+ PredicateBuilder.or(
+ PredicateBuilder.and(builder.equal(0, 2024),
builder.lessThan(1, 6)),
+ PredicateBuilder.and(builder.equal(0, 2025),
builder.greaterOrEqual(1, 6)));
+ PartitionPredicate partitionFilter =
+ PartitionPredicate.fromPredicate(formatTable.partitionType(),
orPredicate);
+
+ FormatTableScan scan = new FormatTableScan(formatTable,
partitionFilter, null);
+ List<Pair<LinkedHashMap<String, String>, Path>> result =
scan.findPartitions();
+
+ // Only year=2024 and year=2025 are descended: root + 2 year dirs = 3
list calls.
+ assertEquals(3, listCount.get());
+ // 2024 months 1-5 (5) + 2025 months 6-12 (7) = 12 partitions.
+ assertEquals(12, result.size());
+ for (Pair<LinkedHashMap<String, String>, Path> pair : result) {
+ int year = Integer.parseInt(pair.getKey().get("year"));
+ int month = Integer.parseInt(pair.getKey().get("month"));
+ assertTrue(
+ (year == 2024 && month < 6) || (year == 2025 && month >=
6),
+ "unexpected partition " + year + "-" + month);
+ }
+ }
+
+ @TestTemplate
+ void testFindPartitionsWithNestedAndOr() throws IOException {
+ Path tableLocation = new Path(tmpPath.toUri());
+ createYearMonthPartitionDirs(LocalFileIO.create(), tableLocation);
+
+ AtomicInteger listCount = new AtomicInteger(0);
+ FormatTable formatTable =
+ createYearMonthFormatTable(countingFileIO(listCount),
tableLocation);
+
+ // year > 2022 AND (month = 1 OR month = 12)
+ PredicateBuilder builder = new
PredicateBuilder(formatTable.partitionType());
+ Predicate predicate =
+ PredicateBuilder.and(
+ builder.greaterThan(0, 2022),
+ PredicateBuilder.or(builder.equal(1, 1),
builder.equal(1, 12)));
+ PartitionPredicate partitionFilter =
+ PartitionPredicate.fromPredicate(formatTable.partitionType(),
predicate);
+
+ FormatTableScan scan = new FormatTableScan(formatTable,
partitionFilter, null);
+ List<Pair<LinkedHashMap<String, String>, Path>> result =
scan.findPartitions();
+
+ // year=2022 pruned; 2023-2026 descended: root + 4 year dirs = 5 list
calls.
+ assertEquals(5, listCount.get());
+ // 4 years x months {1, 12} = 8 partitions.
+ assertEquals(8, result.size());
+ for (Pair<LinkedHashMap<String, String>, Path> pair : result) {
+ int year = Integer.parseInt(pair.getKey().get("year"));
+ int month = Integer.parseInt(pair.getKey().get("month"));
+ assertTrue(
+ year > 2022 && (month == 1 || month == 12),
+ "unexpected partition " + year + "-" + month);
+ }
+ }
+
+ @TestTemplate
+ void testFindPartitionsWithOrSingleField() throws IOException {
+ Path tableLocation = new Path(tmpPath.toUri());
+ createYearMonthPartitionDirs(LocalFileIO.create(), tableLocation);
+
+ AtomicInteger listCount = new AtomicInteger(0);
+ FormatTable formatTable =
+ createYearMonthFormatTable(countingFileIO(listCount),
tableLocation);
+
+ // year = 2023 OR year = 2025 (single-field OR now prunes too)
+ PredicateBuilder builder = new
PredicateBuilder(formatTable.partitionType());
+ Predicate predicate = PredicateBuilder.or(builder.equal(0, 2023),
builder.equal(0, 2025));
+ PartitionPredicate partitionFilter =
+ PartitionPredicate.fromPredicate(formatTable.partitionType(),
predicate);
+
+ FormatTableScan scan = new FormatTableScan(formatTable,
partitionFilter, null);
+ List<Pair<LinkedHashMap<String, String>, Path>> result =
scan.findPartitions();
+
+ // Only year=2023 and year=2025 descended: root + 2 = 3 list calls.
+ assertEquals(3, listCount.get());
+ // 2 years x 12 months = 24 partitions.
+ assertEquals(24, result.size());
+ List<String> years =
+ result.stream()
+ .map(pair -> pair.getKey().get("year"))
+ .distinct()
+ .sorted()
+ .collect(java.util.stream.Collectors.toList());
+ assertEquals(Arrays.asList("2023", "2025"), years);
+ }
+
+ @TestTemplate
+ void testFindPartitionsWithPrefixAndOrPredicate() throws IOException {
+ Path tableLocation = new Path(tmpPath.toUri());
+ createYearMonthPartitionDirs(LocalFileIO.create(), tableLocation);
+
+ AtomicInteger listCount = new AtomicInteger(0);
+ FormatTable formatTable =
+ createYearMonthFormatTable(countingFileIO(listCount),
tableLocation);
+
+ // year = 2024 is pushed into the scan-path prefix. The remaining OR
on month must not read
+ // the unbound prefix slot and accidentally prune the whole subtree.
+ PredicateBuilder builder = new
PredicateBuilder(formatTable.partitionType());
+ Predicate predicate =
+ PredicateBuilder.and(
+ builder.equal(0, 2024),
+ PredicateBuilder.or(builder.equal(1, 1),
builder.equal(1, 12)));
+ PartitionPredicate partitionFilter =
+ PartitionPredicate.fromPredicate(formatTable.partitionType(),
predicate);
+
+ FormatTableScan scan = new FormatTableScan(formatTable,
partitionFilter, null);
+ List<Pair<LinkedHashMap<String, String>, Path>> result =
scan.findPartitions();
+
+ // The equality prefix narrows the scan to year=2024, so only its
month directory is listed.
+ assertEquals(1, listCount.get());
+ assertEquals(2, result.size());
+ List<String> months =
+ result.stream()
+ .map(pair -> pair.getKey().get("month"))
+ .distinct()
+ .sorted()
+ .collect(java.util.stream.Collectors.toList());
+ assertEquals(Arrays.asList("1", "12"), months);
+ assertTrue(result.stream().allMatch(pair ->
"2024".equals(pair.getKey().get("year"))));
+ }
+
+ @TestTemplate
+ void testFindPartitionsWithNullDefaultPartitionInOrPredicate() throws
IOException {
+ Path tableLocation = new Path(tmpPath.toUri());
+
+ AtomicInteger listCount = new AtomicInteger(0);
+ FormatTable formatTable =
createDtHourFormatTable(countingFileIO(listCount), tableLocation);
+ createDtHourPartitionDirs(
+ LocalFileIO.create(),
+ tableLocation,
+ formatTable.defaultPartName(),
+ Arrays.asList(
+ Pair.of(formatTable.defaultPartName(), "10"),
+ Pair.of("20260625", "10"),
+ Pair.of("20260624", "20"),
+ Pair.of("20260101", "10")));
+
+ // The default partition name should be treated as null while pruning.
+ PredicateBuilder builder = new
PredicateBuilder(formatTable.partitionType());
+ Predicate predicate =
+ PredicateBuilder.or(
+ builder.isNull(0),
+ PredicateBuilder.and(
+ builder.equal(0,
BinaryString.fromString("20260625")),
+ builder.equal(1,
BinaryString.fromString("10"))));
+ PartitionPredicate partitionFilter =
+ PartitionPredicate.fromPredicate(formatTable.partitionType(),
predicate);
+
+ FormatTableScan scan = new FormatTableScan(formatTable,
partitionFilter, null);
+ List<Pair<LinkedHashMap<String, String>, Path>> result =
scan.findPartitions();
+
+ // Only the default dt partition and dt=20260625 are descended: root +
2 = 3 list calls.
+ assertEquals(3, listCount.get());
+ assertEquals(2, result.size());
+ assertContainsDefaultAndConcreteDt(result,
formatTable.defaultPartName());
+ }
+
+ @TestTemplate
+ void testListPartitionEntriesWithNullDefaultPartitionInOnlyValuePath()
throws IOException {
+ Path tableLocation = new Path(tmpPath.toUri());
+ FormatTable formatTable =
createDtHourFormatTable(LocalFileIO.create(), tableLocation);
+ createDtHourPartitionDirs(
+ LocalFileIO.create(),
+ tableLocation,
+ formatTable.defaultPartName(),
+ Arrays.asList(
+ Pair.of(formatTable.defaultPartName(), "10"),
Pair.of("20260625", "10")));
+
+ FormatTableScan scan = new FormatTableScan(formatTable, null, null);
+ List<org.apache.paimon.manifest.PartitionEntry> entries =
scan.listPartitionEntries();
+
+ assertEquals(2, entries.size());
+ assertTrue(entries.stream().anyMatch(entry ->
entry.partition().isNullAt(0)));
+ assertTrue(
+ entries.stream()
+ .anyMatch(
+ entry ->
+ !entry.partition().isNullAt(0)
+ &&
BinaryString.fromString("20260625")
+
.equals(entry.partition().getString(0))));
+ }
+
+ private void createYearMonthPartitionDirs(LocalFileIO fileIO, Path
tableLocation)
+ throws IOException {
+ for (int year = 2022; year <= 2026; year++) {
+ String partPath = enablePartitionValueOnly ? String.valueOf(year)
: "year=" + year;
+ for (int month = 1; month <= 12; month++) {
+ String monthPart =
+ enablePartitionValueOnly
+ ? partPath + "/" + month
+ : partPath + "/month=" + month;
+ fileIO.mkdirs(new Path(tableLocation, monthPart));
+ }
+ }
+ }
+
+ private void createDtHourPartitionDirs(
+ LocalFileIO fileIO,
+ Path tableLocation,
+ String defaultPartName,
+ List<Pair<String, String>> partitions)
+ throws IOException {
+ for (Pair<String, String> partition : partitions) {
+ String dt = partition.getLeft();
+ String hour = partition.getRight();
+ String partPath;
+ if (enablePartitionValueOnly) {
+ partPath = dt + "/" + hour;
+ } else {
+ partPath =
+ String.format(
+ "dt=%s/hour=%s",
+ dt == null ? defaultPartName : dt,
+ hour == null ? defaultPartName : hour);
+ }
+ fileIO.mkdirs(new Path(tableLocation, partPath));
+ }
+ }
+
+ private FormatTable createYearMonthFormatTable(LocalFileIO fileIO, Path
tableLocation) {
+ RowType rowType =
+ RowType.builder()
+ .field("year", DataTypes.INT())
+ .field("month", DataTypes.INT())
+ .field("a", DataTypes.INT())
+ .build();
+ return FormatTable.builder()
+ .fileIO(fileIO)
+ .identifier(Identifier.create("test_db", "test_table"))
+ .rowType(rowType)
+ .partitionKeys(Arrays.asList("year", "month"))
+ .location(tableLocation.toString())
+ .format(FormatTable.Format.CSV)
+ .options(
+ Collections.singletonMap(
+
FORMAT_TABLE_PARTITION_ONLY_VALUE_IN_PATH.key(),
+ String.valueOf(enablePartitionValueOnly)))
+ .build();
+ }
+
+ private FormatTable createDtHourFormatTable(LocalFileIO fileIO, Path
tableLocation) {
+ RowType rowType =
+ RowType.builder()
+ .field("dt", DataTypes.STRING())
+ .field("hour", DataTypes.STRING())
+ .field("a", DataTypes.INT())
+ .build();
+ return FormatTable.builder()
+ .fileIO(fileIO)
+ .identifier(Identifier.create("test_db", "test_table"))
+ .rowType(rowType)
+ .partitionKeys(Arrays.asList("dt", "hour"))
+ .location(tableLocation.toString())
+ .format(FormatTable.Format.CSV)
+ .options(
+ Collections.singletonMap(
+
FORMAT_TABLE_PARTITION_ONLY_VALUE_IN_PATH.key(),
+ String.valueOf(enablePartitionValueOnly)))
+ .build();
+ }
+
+ private void assertContainsDefaultAndConcreteDt(
+ List<Pair<LinkedHashMap<String, String>, Path>> result, String
defaultPartName) {
+ List<String> dts =
+ result.stream()
+ .map(pair -> pair.getKey().get("dt"))
+ .distinct()
+ .sorted()
+ .collect(java.util.stream.Collectors.toList());
+ assertEquals(Arrays.asList("20260625", defaultPartName), dts);
+ assertTrue(
+ result.stream()
+ .anyMatch(
+ pair ->
+
defaultPartName.equals(pair.getKey().get("dt"))
+ &&
"10".equals(pair.getKey().get("hour"))));
+ assertTrue(
+ result.stream()
+ .anyMatch(
+ pair ->
+
"20260625".equals(pair.getKey().get("dt"))
+ &&
"10".equals(pair.getKey().get("hour"))));
+ }
+
+ private LocalFileIO countingFileIO(AtomicInteger listCount) {
+ return new LocalFileIO() {
+ @Override
+ public FileStatus[] listStatus(Path path) throws IOException {
+ listCount.getAndIncrement();
+ return super.listStatus(path);
+ }
+ };
+ }
}
diff --git
a/paimon-core/src/test/java/org/apache/paimon/table/format/predicate/PredicateUtilsTest.java
b/paimon-core/src/test/java/org/apache/paimon/table/format/predicate/PredicateUtilsTest.java
deleted file mode 100644
index 2713dae75b..0000000000
---
a/paimon-core/src/test/java/org/apache/paimon/table/format/predicate/PredicateUtilsTest.java
+++ /dev/null
@@ -1,77 +0,0 @@
-/*
- * 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.predicate;
-
-import org.apache.paimon.data.GenericRow;
-import org.apache.paimon.predicate.Predicate;
-import org.apache.paimon.predicate.PredicateBuilder;
-import org.apache.paimon.types.DataTypes;
-import org.apache.paimon.types.RowType;
-
-import org.junit.jupiter.api.Test;
-
-import java.util.Map;
-
-import static org.assertj.core.api.Assertions.assertThat;
-
-/** Tests for {@link PredicateUtils}. */
-public class PredicateUtilsTest {
-
- private final RowType partitionType =
- RowType.builder().field("dt", DataTypes.INT()).field("hr",
DataTypes.INT()).build();
-
- private final RowType fullRowType =
- RowType.builder()
- .field("dt", DataTypes.INT())
- .field("hr", DataTypes.INT())
- .field("name", DataTypes.STRING())
- .field("value", DataTypes.INT())
- .build();
-
- @Test
- public void testMultiplePredicatesOnSamePartitionField() {
- PredicateBuilder builder = new PredicateBuilder(fullRowType);
- // hr > 10 AND hr < 20
- Predicate hrGreater = builder.greaterThan(1, 10);
- Predicate hrLess = builder.lessThan(1, 20);
- Predicate dtGreater = builder.greaterThan(0, 0);
- Predicate dtLess = builder.lessThan(0, 10);
-
- Predicate predicate = PredicateBuilder.and(hrGreater, hrLess,
dtGreater, dtLess);
-
- Map<String, Predicate> result =
- PredicateUtils.splitPartitionPredicate(partitionType,
predicate);
-
- assertThat(result).hasSize(2);
- assertThat(result).containsKey("hr");
- assertThat(result).containsKey("dt");
- // Should combine with AND
- Predicate combined = result.get("hr");
- // Test that the combined predicate works correctly
- assertThat(combined.test(GenericRow.of(15))).isTrue();
- assertThat(combined.test(GenericRow.of(5))).isFalse();
- assertThat(combined.test(GenericRow.of(25))).isFalse();
-
- combined = result.get("dt");
- // Test that the combined predicate works correctly
- assertThat(combined.test(GenericRow.of(5))).isTrue();
- assertThat(combined.test(GenericRow.of(11))).isFalse();
- assertThat(combined.test(GenericRow.of(-1))).isFalse();
- }
-}
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
new file mode 100644
index 0000000000..a21b168f54
--- /dev/null
+++
b/paimon-core/src/test/java/org/apache/paimon/utils/PartitionPathUtilsTest.java
@@ -0,0 +1,106 @@
+/*
+ * 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.utils;
+
+import org.apache.paimon.data.GenericRow;
+import org.apache.paimon.predicate.Predicate;
+import org.apache.paimon.predicate.PredicateBuilder;
+import org.apache.paimon.types.DataTypes;
+import org.apache.paimon.types.RowType;
+
+import org.junit.jupiter.api.Test;
+
+import static org.apache.paimon.utils.PartitionPathUtils.mightMatch;
+import static org.assertj.core.api.Assertions.assertThat;
+
+/** Tests for {@link PartitionPathUtils#mightMatch}. */
+class PartitionPathUtilsTest {
+
+ private final RowType partitionType =
+ RowType.builder()
+ .field("year", DataTypes.INT())
+ .field("month", DataTypes.INT())
+ .build();
+ private final PredicateBuilder builder = new
PredicateBuilder(partitionType);
+
+ @Test
+ void testNullPredicate() {
+ assertThat(mightMatch(null, 0, 0, GenericRow.of(2024, 5))).isTrue();
+ }
+
+ @Test
+ void testBoundLeaf() {
+ Predicate p = builder.equal(0, 2024);
+ assertThat(mightMatch(p, 0, 0, GenericRow.of(2024, null))).isTrue();
+ assertThat(mightMatch(p, 0, 0, GenericRow.of(2023, null))).isFalse();
+ }
+
+ @Test
+ void testLeafBeyondMaxIdxIsPossiblyTrue() {
+ // month leaf (idx 1), but only year (idx 0) is bound -> cannot decide
-> possibly true
+ Predicate p = builder.equal(1, 12);
+ assertThat(mightMatch(p, 0, 0, GenericRow.of(2024, null))).isTrue();
+ }
+
+ @Test
+ void testLeafBelowMinIdxIsPossiblyTrue() {
+ // year leaf (idx 0) belongs to the scan-path equality prefix
(minIdx=1): treated as already
+ // satisfied and never read, even if values[0] does not match.
+ Predicate p = builder.equal(0, 2024);
+ assertThat(mightMatch(p, 1, 1, GenericRow.of(2023, 5))).isTrue();
+ }
+
+ @Test
+ void testOrShortCircuit() {
+ Predicate p = PredicateBuilder.or(builder.equal(0, 2024),
builder.equal(0, 2025));
+ assertThat(mightMatch(p, 0, 0, GenericRow.of(2025, null))).isTrue();
+ assertThat(mightMatch(p, 0, 0, GenericRow.of(2023, null))).isFalse();
+ }
+
+ @Test
+ void testAndShortCircuit() {
+ Predicate p = PredicateBuilder.and(builder.equal(0, 2024),
builder.equal(1, 6));
+ assertThat(mightMatch(p, 0, 1, GenericRow.of(2024, 6))).isTrue();
+ assertThat(mightMatch(p, 0, 1, GenericRow.of(2024, 7))).isFalse();
+ // month not yet known (maxIdx=0) -> And not provably false ->
possibly true
+ assertThat(mightMatch(p, 0, 0, GenericRow.of(2024, null))).isTrue();
+ // year already false -> prune regardless of month
+ assertThat(mightMatch(p, 0, 0, GenericRow.of(2023, null))).isFalse();
+ }
+
+ @Test
+ void testNestedCrossFieldOr() {
+ // (year = 2024 AND month < 6) OR (year = 2025 AND month >= 6)
+ Predicate p =
+ PredicateBuilder.or(
+ PredicateBuilder.and(builder.equal(0, 2024),
builder.lessThan(1, 6)),
+ PredicateBuilder.and(builder.equal(0, 2025),
builder.greaterOrEqual(1, 6)));
+
+ // year level (only idx 0 bound): keep 2024 and 2025, prune others
+ assertThat(mightMatch(p, 0, 0, GenericRow.of(2024, null))).isTrue();
+ assertThat(mightMatch(p, 0, 0, GenericRow.of(2025, null))).isTrue();
+ assertThat(mightMatch(p, 0, 0, GenericRow.of(2023, null))).isFalse();
+
+ // month level (both bound): exact
+ assertThat(mightMatch(p, 0, 1, GenericRow.of(2024, 5))).isTrue();
+ assertThat(mightMatch(p, 0, 1, GenericRow.of(2024, 6))).isFalse();
+ assertThat(mightMatch(p, 0, 1, GenericRow.of(2025, 6))).isTrue();
+ assertThat(mightMatch(p, 0, 1, GenericRow.of(2025, 5))).isFalse();
+ }
+}
diff --git
a/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/FormatTableTestBase.scala
b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/FormatTableTestBase.scala
index 4d1d49a799..720d283204 100644
---
a/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/FormatTableTestBase.scala
+++
b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/FormatTableTestBase.scala
@@ -480,6 +480,49 @@ abstract class FormatTableTestBase extends
PaimonHiveTestBase with AdaptiveSpark
}
}
+ for (onlyValueInPath <- Seq(false, true)) {
+ val suffix = if (onlyValueInPath) " (partition-path-only-value)" else ""
+ test(s"Paimon format table: OR cross-field partition pruning$suffix") {
+ withTable("dwd_fact") {
+ val props =
+ if (onlyValueInPath) {
+ "'format-table.implementation'='paimon',
'format-table.partition-path-only-value'='true'"
+ } else {
+ "'format-table.implementation'='paimon'"
+ }
+ sql(s"""
+ |CREATE TABLE dwd_fact (id INT, amount DOUBLE, dt STRING, hour
STRING)
+ |USING PARQUET
+ |TBLPROPERTIES ($props)
+ |PARTITIONED BY (dt, hour)
+ |""".stripMargin)
+
+ sql("""
+ |INSERT INTO dwd_fact VALUES
+ |(1, 10.0, '20260625', '10'),
+ |(2, 20.0, '20260625', '18'),
+ |(3, 30.0, '20260624', '20'),
+ |(4, 40.0, '20260624', '08'),
+ |(5, 50.0, '20260101', '10')
+ |""".stripMargin)
+
+ val df =
+ sql("""
+ |SELECT id, dt, hour FROM dwd_fact
+ |WHERE (dt = '20260625' AND hour < '16') OR (dt = '20260624'
AND hour >= '16')
+ |ORDER BY id
+ |""".stripMargin)
+
+ checkAnswer(df, Seq(Row(1, "20260625", "10"), Row(3, "20260624",
"20")))
+
+ // The cross-field OR is pushed down as a partition filter, so only
the two matching
+ // partitions produce splits. (Pruning effectiveness is covered by
FormatTableScanTest.)
+ val filteredSplits =
collectFilteredInputSplits(df.queryExecution.executedPlan, "dwd_fact")
+ assert(filteredSplits.size == 2)
+ }
+ }
+ }
+
def collectFilteredInputSplits(plan: SparkPlan, tableName: String):
Seq[Split] = {
flatMap(plan) {
case s: BatchScanExec =>