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 b52f55f780 [core][rest] Push Format Table partition filters to REST
catalog (#8785)
b52f55f780 is described below
commit b52f55f7801e16cdfd64b2fc935b3d3f14257af8
Author: Dapeng Sun(孙大鹏) <[email protected]>
AuthorDate: Wed Jul 22 11:38:02 2026 +0800
[core][rest] Push Format Table partition filters to REST catalog (#8785)
---
docs/static/rest-catalog-open-api.yaml | 62 +++++++++
.../main/java/org/apache/paimon/rest/RESTApi.java | 43 ++++++
.../java/org/apache/paimon/rest/ResourcePaths.java | 12 ++
.../requests/ListPartitionsByFilterRequest.java | 91 +++++++++++++
.../java/org/apache/paimon/catalog/Catalog.java | 27 ++++
.../org/apache/paimon/catalog/DelegateCatalog.java | 13 ++
.../java/org/apache/paimon/rest/RESTCatalog.java | 28 ++++
.../format/CatalogFormatTablePartitionManager.java | 84 +++++++++---
.../table/format/FormatTablePartitionManager.java | 10 +-
.../paimon/table/format/FormatTableScan.java | 20 +--
.../apache/paimon/rest/MockRESTCatalogTest.java | 52 ++++++-
.../org/apache/paimon/rest/RESTApiJsonTest.java | 28 ++++
.../org/apache/paimon/rest/RESTCatalogServer.java | 123 ++++++++++++++++-
.../org/apache/paimon/rest/RESTCatalogTest.java | 102 ++++++++++++++
.../CatalogFormatTablePartitionManagerTest.java | 149 +++++++++++++++++++--
.../format/CatalogManagedPartitionScanTest.java | 62 ++++++++-
.../spark/format/FormatTablePartitionRepair.java | 3 +-
.../paimon/spark/format/PaimonFormatTable.scala | 2 +-
.../format/FormatTablePartitionRepairTest.java | 9 +-
.../spark/PaimonSparkTestWithRestCatalogBase.scala | 8 +-
.../FormatTablePartitionDdlPlanningTest.scala | 38 ++++--
...est.scala => CatalogManagedPartitionTest.scala} | 26 +++-
.../FormatTablePartitionManagementTest.scala | 45 +++++--
.../CatalogManagedPartitionMsckRepairTest.scala | 7 +-
24 files changed, 959 insertions(+), 85 deletions(-)
diff --git a/docs/static/rest-catalog-open-api.yaml
b/docs/static/rest-catalog-open-api.yaml
index 3956a92806..51def16c43 100644
--- a/docs/static/rest-catalog-open-api.yaml
+++ b/docs/static/rest-catalog-open-api.yaml
@@ -1145,6 +1145,50 @@ paths:
$ref: '#/components/responses/TableNotExistErrorResponse'
"500":
$ref: '#/components/responses/ServerErrorResponse'
+ /v1/{prefix}/databases/{database}/tables/{table}/partitions/list-by-filter:
+ post:
+ tags:
+ - partition
+ summary: List partitions by filter
+ description: Lists a page of partitions using a serialized Paimon
partition predicate. The response may be a superset of the matching partitions,
so clients must re-evaluate the predicate and continue pagination while
nextPageToken is non-empty, even if the current page contains no partitions.
+ operationId: listPartitionsByFilter
+ parameters:
+ - name: prefix
+ in: path
+ required: true
+ schema:
+ type: string
+ - name: database
+ in: path
+ required: true
+ schema:
+ type: string
+ - name: table
+ in: path
+ required: true
+ schema:
+ type: string
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ListPartitionsByFilterRequest'
+ responses:
+ "200":
+ description: OK
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ListPartitionsResponse'
+ "400":
+ $ref: '#/components/responses/BadRequestErrorResponse'
+ "401":
+ $ref: '#/components/responses/UnauthorizedErrorResponse'
+ "404":
+ $ref: '#/components/responses/TableNotExistErrorResponse'
+ "500":
+ $ref: '#/components/responses/ServerErrorResponse'
/v1/{prefix}/databases/{database}/tables/{table}/branches:
get:
tags:
@@ -2522,6 +2566,24 @@ components:
type: object
additionalProperties:
type: string
+ ListPartitionsByFilterRequest:
+ type: object
+ required:
+ - filter
+ properties:
+ filter:
+ type: string
+ description: JSON serialization of a Paimon Predicate tree over the
partition columns.
+ partitionNamePattern:
+ type: string
+ description: A SQL LIKE pattern (%) for partition names. Currently,
only prefix matching is supported.
+ maxResults:
+ type: integer
+ format: int32
+ description: Maximum number of matching partitions to return in this
page. A missing or zero value uses the server default.
+ pageToken:
+ type: string
+ description: Token returned by the previous page. Omit for the first
page.
CreateDatabaseResponse:
type: object
properties:
diff --git a/paimon-api/src/main/java/org/apache/paimon/rest/RESTApi.java
b/paimon-api/src/main/java/org/apache/paimon/rest/RESTApi.java
index 4d321ed279..e8e1ff96c8 100644
--- a/paimon-api/src/main/java/org/apache/paimon/rest/RESTApi.java
+++ b/paimon-api/src/main/java/org/apache/paimon/rest/RESTApi.java
@@ -48,6 +48,7 @@ import org.apache.paimon.rest.requests.CreateTagRequest;
import org.apache.paimon.rest.requests.CreateViewRequest;
import org.apache.paimon.rest.requests.DropPartitionsRequest;
import org.apache.paimon.rest.requests.ForwardBranchRequest;
+import org.apache.paimon.rest.requests.ListPartitionsByFilterRequest;
import org.apache.paimon.rest.requests.ListPartitionsByNamesRequest;
import org.apache.paimon.rest.requests.MarkDonePartitionsRequest;
import org.apache.paimon.rest.requests.RegisterTableRequest;
@@ -983,6 +984,48 @@ public class RESTApi {
return partitions;
}
+ /**
+ * List a page of partitions using a serialized partition predicate.
+ *
+ * <p>{@code filterJson} is the JSON serialization of a Paimon {@code
Predicate}. The result may
+ * be a superset, so callers must re-evaluate the predicate. A non-empty
next page token must be
+ * followed even when the current page is empty.
+ *
+ * @param identifier database name and table name
+ * @param filterJson JSON serialization of the partition predicate
+ * @param maxResults maximum page size, or {@code null}/0 to use the
server default
+ * @param pageToken token returned by the previous page, or {@code null}
for the first page
+ * @param partitionNamePattern optional SQL LIKE prefix pattern (%) for
partition names,
+ * conjunctive with the predicate
+ * @return {@link PagedList}: elements and nextPageToken
+ * @throws NoSuchResourceException Exception thrown on HTTP 404 means the
table not exists, or
+ * the server does not provide this endpoint
+ * @throws ForbiddenException Exception thrown on HTTP 403 means don't
have the permission for
+ * this table
+ */
+ public PagedList<Partition> listPartitionsByFilterPaged(
+ Identifier identifier,
+ String filterJson,
+ @Nullable Integer maxResults,
+ @Nullable String pageToken,
+ @Nullable String partitionNamePattern) {
+ ListPartitionsByFilterRequest request =
+ new ListPartitionsByFilterRequest(
+ filterJson, partitionNamePattern, maxResults,
pageToken);
+ ListPartitionsResponse response =
+ client.post(
+ resourcePaths.listPartitionsByFilter(
+ identifier.getDatabaseName(),
identifier.getObjectName()),
+ request,
+ ListPartitionsResponse.class,
+ restAuthFunction);
+ List<Partition> partitions = response.getPartitions();
+ if (partitions == null) {
+ return new PagedList<>(emptyList(), response.getNextPageToken());
+ }
+ return new PagedList<>(partitions, response.getNextPageToken());
+ }
+
/**
* Create branch for table.
*
diff --git a/paimon-api/src/main/java/org/apache/paimon/rest/ResourcePaths.java
b/paimon-api/src/main/java/org/apache/paimon/rest/ResourcePaths.java
index 0ab36e348c..ae09705467 100644
--- a/paimon-api/src/main/java/org/apache/paimon/rest/ResourcePaths.java
+++ b/paimon-api/src/main/java/org/apache/paimon/rest/ResourcePaths.java
@@ -249,6 +249,18 @@ public class ResourcePaths {
"list-by-names");
}
+ public String listPartitionsByFilter(String databaseName, String
objectName) {
+ return SLASH.join(
+ V1,
+ prefix,
+ DATABASES,
+ encodeString(databaseName),
+ TABLES,
+ encodeString(objectName),
+ PARTITIONS,
+ "list-by-filter");
+ }
+
public String branches(String databaseName, String objectName) {
return SLASH.join(
V1,
diff --git
a/paimon-api/src/main/java/org/apache/paimon/rest/requests/ListPartitionsByFilterRequest.java
b/paimon-api/src/main/java/org/apache/paimon/rest/requests/ListPartitionsByFilterRequest.java
new file mode 100644
index 0000000000..cd0fa058c4
--- /dev/null
+++
b/paimon-api/src/main/java/org/apache/paimon/rest/requests/ListPartitionsByFilterRequest.java
@@ -0,0 +1,91 @@
+/*
+ * 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.rest.requests;
+
+import org.apache.paimon.rest.RESTRequest;
+
+import
org.apache.paimon.shade.jackson2.com.fasterxml.jackson.annotation.JsonCreator;
+import
org.apache.paimon.shade.jackson2.com.fasterxml.jackson.annotation.JsonGetter;
+import
org.apache.paimon.shade.jackson2.com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import
org.apache.paimon.shade.jackson2.com.fasterxml.jackson.annotation.JsonInclude;
+import
org.apache.paimon.shade.jackson2.com.fasterxml.jackson.annotation.JsonProperty;
+
+import javax.annotation.Nullable;
+
+/** Request for listing partitions by filter. */
+@JsonIgnoreProperties(ignoreUnknown = true)
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public class ListPartitionsByFilterRequest implements RESTRequest {
+
+ private static final String FIELD_FILTER = "filter";
+ private static final String FIELD_PARTITION_NAME_PATTERN =
"partitionNamePattern";
+ private static final String FIELD_MAX_RESULTS = "maxResults";
+ private static final String FIELD_PAGE_TOKEN = "pageToken";
+
+ /** JSON serialization of a Paimon {@code Predicate} tree over the
partition columns. */
+ @JsonProperty(FIELD_FILTER)
+ private final String filter;
+
+ @JsonProperty(FIELD_PARTITION_NAME_PATTERN)
+ @Nullable
+ private final String partitionNamePattern;
+
+ @JsonProperty(FIELD_MAX_RESULTS)
+ @Nullable
+ private final Integer maxResults;
+
+ @JsonProperty(FIELD_PAGE_TOKEN)
+ @Nullable
+ private final String pageToken;
+
+ @JsonCreator
+ public ListPartitionsByFilterRequest(
+ @JsonProperty(FIELD_FILTER) String filter,
+ @JsonProperty(FIELD_PARTITION_NAME_PATTERN) @Nullable String
partitionNamePattern,
+ @JsonProperty(FIELD_MAX_RESULTS) @Nullable Integer maxResults,
+ @JsonProperty(FIELD_PAGE_TOKEN) @Nullable String pageToken) {
+ this.filter = filter;
+ this.partitionNamePattern = partitionNamePattern;
+ this.maxResults = maxResults;
+ this.pageToken = pageToken;
+ }
+
+ @JsonGetter(FIELD_FILTER)
+ public String getFilter() {
+ return filter;
+ }
+
+ @JsonGetter(FIELD_PARTITION_NAME_PATTERN)
+ @Nullable
+ public String getPartitionNamePattern() {
+ return partitionNamePattern;
+ }
+
+ @JsonGetter(FIELD_MAX_RESULTS)
+ @Nullable
+ public Integer getMaxResults() {
+ return maxResults;
+ }
+
+ @JsonGetter(FIELD_PAGE_TOKEN)
+ @Nullable
+ public String getPageToken() {
+ return pageToken;
+ }
+}
diff --git a/paimon-core/src/main/java/org/apache/paimon/catalog/Catalog.java
b/paimon-core/src/main/java/org/apache/paimon/catalog/Catalog.java
index 8e4ad8d518..fd477ffcf1 100644
--- a/paimon-core/src/main/java/org/apache/paimon/catalog/Catalog.java
+++ b/paimon-core/src/main/java/org/apache/paimon/catalog/Catalog.java
@@ -27,6 +27,7 @@ import org.apache.paimon.function.Function;
import org.apache.paimon.function.FunctionChange;
import org.apache.paimon.partition.Partition;
import org.apache.paimon.partition.PartitionStatistics;
+import org.apache.paimon.predicate.Predicate;
import org.apache.paimon.rest.responses.GetTagResponse;
import org.apache.paimon.schema.Schema;
import org.apache.paimon.schema.SchemaChange;
@@ -423,6 +424,32 @@ public interface Catalog extends AutoCloseable {
@Nullable String partitionNamePattern)
throws TableNotExistException;
+ /**
+ * Get a page of partitions using {@code predicate} as a best-effort
filter.
+ *
+ * <p>The result may be a superset — an implementation must never exclude
a partition it cannot
+ * prove non-matching — so callers must evaluate {@code predicate} again.
Continue pagination
+ * while {@link PagedList#getNextPageToken()} is non-empty, even if a page
has no elements.
+ *
+ * @param identifier path of the table
+ * @param predicate partition predicate
+ * @param maxResults maximum page size, or {@code null}/0 to use the
catalog default
+ * @param pageToken token returned by the previous page, or {@code null}
for the first page
+ * @param partitionNamePattern optional SQL LIKE prefix pattern (%) for
partition names,
+ * conjunctive with {@code predicate}
+ * @return a page of candidate partitions
+ * @throws TableNotExistException if the table does not exist
+ */
+ default PagedList<Partition> listPartitionsByFilterPaged(
+ Identifier identifier,
+ Predicate predicate,
+ @Nullable Integer maxResults,
+ @Nullable String pageToken,
+ @Nullable String partitionNamePattern)
+ throws TableNotExistException {
+ return listPartitionsPaged(identifier, maxResults, pageToken,
partitionNamePattern);
+ }
+
/**
* Get Partition list by partition names of the table.
*
diff --git
a/paimon-core/src/main/java/org/apache/paimon/catalog/DelegateCatalog.java
b/paimon-core/src/main/java/org/apache/paimon/catalog/DelegateCatalog.java
index 290849024f..10ae54ea45 100644
--- a/paimon-core/src/main/java/org/apache/paimon/catalog/DelegateCatalog.java
+++ b/paimon-core/src/main/java/org/apache/paimon/catalog/DelegateCatalog.java
@@ -24,6 +24,7 @@ import org.apache.paimon.function.Function;
import org.apache.paimon.function.FunctionChange;
import org.apache.paimon.partition.Partition;
import org.apache.paimon.partition.PartitionStatistics;
+import org.apache.paimon.predicate.Predicate;
import org.apache.paimon.rest.responses.GetTagResponse;
import org.apache.paimon.schema.Schema;
import org.apache.paimon.schema.SchemaChange;
@@ -455,6 +456,18 @@ public abstract class DelegateCatalog implements Catalog {
return wrapped.listPartitionsPaged(identifier, maxResults, pageToken,
partitionNamePattern);
}
+ @Override
+ public PagedList<Partition> listPartitionsByFilterPaged(
+ Identifier identifier,
+ Predicate predicate,
+ Integer maxResults,
+ String pageToken,
+ String partitionNamePattern)
+ throws TableNotExistException {
+ return wrapped.listPartitionsByFilterPaged(
+ identifier, predicate, maxResults, pageToken,
partitionNamePattern);
+ }
+
@Override
public List<Partition> listPartitionsByNames(
Identifier identifier, List<Map<String, String>> partitions)
diff --git a/paimon-core/src/main/java/org/apache/paimon/rest/RESTCatalog.java
b/paimon-core/src/main/java/org/apache/paimon/rest/RESTCatalog.java
index efef63fc92..86256a9342 100644
--- a/paimon-core/src/main/java/org/apache/paimon/rest/RESTCatalog.java
+++ b/paimon-core/src/main/java/org/apache/paimon/rest/RESTCatalog.java
@@ -42,6 +42,7 @@ import org.apache.paimon.function.FunctionChange;
import org.apache.paimon.options.Options;
import org.apache.paimon.partition.Partition;
import org.apache.paimon.partition.PartitionStatistics;
+import org.apache.paimon.predicate.Predicate;
import org.apache.paimon.rest.exceptions.AlreadyExistsException;
import org.apache.paimon.rest.exceptions.BadRequestException;
import org.apache.paimon.rest.exceptions.ForbiddenException;
@@ -65,6 +66,7 @@ import org.apache.paimon.table.Table;
import org.apache.paimon.table.TableSnapshot;
import org.apache.paimon.table.sink.BatchTableCommit;
import org.apache.paimon.table.system.SystemTableLoader;
+import org.apache.paimon.utils.JsonSerdeUtil;
import org.apache.paimon.utils.Pair;
import org.apache.paimon.utils.SnapshotNotExistException;
import org.apache.paimon.utils.StringUtils;
@@ -830,6 +832,32 @@ public class RESTCatalog implements Catalog {
}
}
+ @Override
+ public PagedList<Partition> listPartitionsByFilterPaged(
+ Identifier identifier,
+ Predicate predicate,
+ @Nullable Integer maxResults,
+ @Nullable String pageToken,
+ @Nullable String partitionNamePattern)
+ throws TableNotExistException {
+ try {
+ return api.listPartitionsByFilterPaged(
+ identifier,
+ JsonSerdeUtil.toFlatJson(predicate),
+ maxResults,
+ pageToken,
+ partitionNamePattern);
+ } catch (NoSuchResourceException e) {
+ throw new TableNotExistException(identifier);
+ } catch (ForbiddenException e) {
+ throw new TableNoPermissionException(identifier, e);
+ } catch (NotImplementedException e) {
+ // HTTP 501 is transport detail; callers only see the
Catalog-level capability.
+ throw new UnsupportedOperationException(
+ "The REST server does not support listing partitions by
filter.", e);
+ }
+ }
+
@Override
public List<Partition> listPartitionsByNames(
Identifier identifier, List<Map<String, String>> partitions)
diff --git
a/paimon-core/src/main/java/org/apache/paimon/table/format/CatalogFormatTablePartitionManager.java
b/paimon-core/src/main/java/org/apache/paimon/table/format/CatalogFormatTablePartitionManager.java
index 7085135f17..ed266db2a5 100644
---
a/paimon-core/src/main/java/org/apache/paimon/table/format/CatalogFormatTablePartitionManager.java
+++
b/paimon-core/src/main/java/org/apache/paimon/table/format/CatalogFormatTablePartitionManager.java
@@ -23,10 +23,13 @@ import org.apache.paimon.catalog.Catalog;
import org.apache.paimon.catalog.CatalogLoader;
import org.apache.paimon.catalog.Identifier;
import org.apache.paimon.partition.Partition;
+import org.apache.paimon.predicate.Predicate;
import org.apache.paimon.utils.FunctionWithException;
import org.apache.paimon.utils.PartitionPathUtils;
import org.apache.paimon.utils.StringUtils;
+import javax.annotation.Nullable;
+
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashSet;
@@ -62,7 +65,7 @@ class CatalogFormatTablePartitionManager implements
FormatTablePartitionManager
}
@Override
- public List<Partition> listPartitions(Map<String, String> prefix) {
+ public List<Partition> listPartitions(Map<String, String> prefix,
@Nullable Predicate filter) {
LinkedHashMap<String, String> ordered = orderedPrefix(partitionKeys,
prefix);
checkArgument(
ordered.size() == prefix.size(),
@@ -73,31 +76,68 @@ class CatalogFormatTablePartitionManager implements
FormatTablePartitionManager
String pattern =
PartitionPathUtils.buildPartitionNamePrefixPattern(partitionKeys, ordered);
return execute(
catalog -> {
- List<Partition> partitions = new ArrayList<>();
- Set<String> seenPageTokens = new HashSet<>();
- String pageToken = null;
- do {
- PagedList<Partition> page =
- catalog.listPartitionsPaged(
- identifier, REQUEST_SIZE, pageToken,
pattern);
- if (page.getElements() != null) {
- partitions.addAll(page.getElements());
- }
- pageToken = page.getNextPageToken();
- if (StringUtils.isNotEmpty(pageToken) &&
!seenPageTokens.add(pageToken)) {
- throw new IllegalStateException(
- String.format(
- "Catalog returned repeated
partition page token '%s' for format table %s.",
- pageToken,
identifier.getFullName()));
- }
- } while (StringUtils.isNotEmpty(pageToken));
- // A catalog may ignore the pattern, so the prefix is
enforced here as well.
- partitions.removeIf(partition -> !matchesPrefix(partition,
prefix));
- return Collections.unmodifiableList(partitions);
+ if (filter != null) {
+ return collectAllPages(
+ prefix,
+ pageToken ->
+ catalog.listPartitionsByFilterPaged(
+ identifier,
+ filter,
+ REQUEST_SIZE,
+ pageToken,
+ pattern));
+ }
+ return collectAllPages(
+ prefix,
+ pageToken ->
+ catalog.listPartitionsPaged(
+ identifier, REQUEST_SIZE,
pageToken, pattern));
},
"list partitions");
}
+ /**
+ * Drains a paged listing. Pages may be sparse (fewer elements than
requested, or none, with a
+ * next page token), so only an empty token ends the loop.
+ */
+ private List<Partition> collectAllPages(Map<String, String> prefix,
PageSupplier pageSupplier)
+ throws Exception {
+ List<Partition> partitions = new ArrayList<>();
+ Set<String> seenPageTokens = new HashSet<>();
+ String pageToken = null;
+ do {
+ PagedList<Partition> page = pageSupplier.page(pageToken);
+ if (page == null) {
+ // A missing page cannot be told apart from an empty listing;
failing loudly
+ // beats silently reading fewer partitions.
+ throw new IllegalStateException(
+ String.format(
+ "Catalog returned a null partition page for
format table %s.",
+ identifier.getFullName()));
+ }
+ if (page.getElements() != null) {
+ for (Partition partition : page.getElements()) {
+ if (matchesPrefix(partition, prefix)) {
+ partitions.add(partition);
+ }
+ }
+ }
+ pageToken = page.getNextPageToken();
+ if (StringUtils.isNotEmpty(pageToken) &&
!seenPageTokens.add(pageToken)) {
+ throw new IllegalStateException(
+ String.format(
+ "Catalog returned repeated partition page
token '%s' for format table %s.",
+ pageToken, identifier.getFullName()));
+ }
+ } while (StringUtils.isNotEmpty(pageToken));
+ return Collections.unmodifiableList(partitions);
+ }
+
+ /** A single page of a partition listing. */
+ private interface PageSupplier {
+ PagedList<Partition> page(@Nullable String pageToken) throws Exception;
+ }
+
@Override
public List<Partition> listPartitionsByNames(List<Map<String, String>>
partitions) {
if (partitions.isEmpty()) {
diff --git
a/paimon-core/src/main/java/org/apache/paimon/table/format/FormatTablePartitionManager.java
b/paimon-core/src/main/java/org/apache/paimon/table/format/FormatTablePartitionManager.java
index 64b643fba2..259f88bbfb 100644
---
a/paimon-core/src/main/java/org/apache/paimon/table/format/FormatTablePartitionManager.java
+++
b/paimon-core/src/main/java/org/apache/paimon/table/format/FormatTablePartitionManager.java
@@ -22,6 +22,9 @@ import org.apache.paimon.annotation.Experimental;
import org.apache.paimon.catalog.CatalogLoader;
import org.apache.paimon.catalog.Identifier;
import org.apache.paimon.partition.Partition;
+import org.apache.paimon.predicate.Predicate;
+
+import javax.annotation.Nullable;
import java.io.Serializable;
import java.util.List;
@@ -46,8 +49,13 @@ public interface FormatTablePartitionManager extends
Serializable {
* List every partition whose leading partition values match {@code
prefix}, or all partitions
* when it is empty. The prefix must be a contiguous leading subset of the
table's partition
* keys.
+ *
+ * <p>The optional {@code filter} is a pushdown hint combined with the
prefix as a conjunction:
+ * an implementation may apply it partially or not at all, so the result
is a superset of the
+ * matching partitions and never misses one. Callers keep applying their
full predicate on the
+ * returned partitions.
*/
- List<Partition> listPartitions(Map<String, String> prefix);
+ List<Partition> listPartitions(Map<String, String> prefix, @Nullable
Predicate filter);
/** Return those of the given complete partition specs that are
registered. */
List<Partition> listPartitionsByNames(List<Map<String, String>>
partitions);
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 44908749ca..2dee6fe123 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
@@ -121,7 +121,8 @@ public class FormatTableScan implements InnerTableScan {
@Override
public List<PartitionEntry> listPartitionEntries() {
if (partitionManager != null) {
- List<Partition> partitions =
partitionManager.listPartitions(Collections.emptyMap());
+ List<Partition> partitions =
+ partitionManager.listPartitions(Collections.emptyMap(),
null);
if (partitions.isEmpty()) {
warnIfFilesystemPartitionsExist();
}
@@ -228,13 +229,17 @@ public class FormatTableScan implements InnerTableScan {
List<Pair<LinkedHashMap<String, String>, Path>> findPartitions() {
if (partitionManager != null) {
- Map<String, String> prefix = leadingEqualityPrefix();
- List<Partition> partitions =
partitionManager.listPartitions(prefix);
- if (partitions.isEmpty() && prefix.isEmpty()) {
+ Optional<Predicate> extracted =
extractPartitionPredicate(partitionFilter);
+ Map<String, String> prefix = leadingEqualityPrefix(extracted);
+ // The whole predicate goes to the manager as a pushdown hint,
together with the
+ // prefix; the catalog may return a superset.
+ Predicate catalogFilter = extracted.orElse(null);
+ List<Partition> partitions =
partitionManager.listPartitions(prefix, catalogFilter);
+ if (partitions.isEmpty() && prefix.isEmpty() && catalogFilter ==
null) {
warnIfFilesystemPartitionsExist();
}
- // The prefix is a coarse pre-filter; the full predicate is
applied per partition in
- // the plan, as it is for filesystem discovery.
+ // The prefix and filter are coarse pre-filters; the full
predicate is applied per
+ // partition in the plan, as it is for filesystem discovery.
return toSpecsAndPaths(partitions,
coreOptions.formatTablePartitionOnlyValueInPath());
}
LOG.debug(
@@ -342,8 +347,7 @@ public class FormatTableScan implements InnerTableScan {
* to the partition catalog. Empty when there is no predicate or it does
not start with
* equalities on the leading partition keys.
*/
- private Map<String, String> leadingEqualityPrefix() {
- Optional<Predicate> predicate =
extractPartitionPredicate(partitionFilter);
+ private Map<String, String> leadingEqualityPrefix(Optional<Predicate>
predicate) {
if (!predicate.isPresent()) {
return Collections.emptyMap();
}
diff --git
a/paimon-core/src/test/java/org/apache/paimon/rest/MockRESTCatalogTest.java
b/paimon-core/src/test/java/org/apache/paimon/rest/MockRESTCatalogTest.java
index 6cc8f70420..ff3736d889 100644
--- a/paimon-core/src/test/java/org/apache/paimon/rest/MockRESTCatalogTest.java
+++ b/paimon-core/src/test/java/org/apache/paimon/rest/MockRESTCatalogTest.java
@@ -30,6 +30,7 @@ import org.apache.paimon.fs.Path;
import org.apache.paimon.fs.local.LocalFileIO;
import org.apache.paimon.options.CatalogOptions;
import org.apache.paimon.options.Options;
+import org.apache.paimon.partition.Partition;
import org.apache.paimon.predicate.FieldRef;
import org.apache.paimon.predicate.Predicate;
import org.apache.paimon.predicate.PredicateBuilder;
@@ -63,6 +64,7 @@ import org.junit.jupiter.api.Test;
import java.io.File;
import java.io.IOException;
+import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
@@ -258,19 +260,19 @@ class MockRESTCatalogTest extends RESTCatalogTest {
FormatTable table = (FormatTable) restCatalog.getTable(identifier);
FormatTablePartitionManager partitionManager =
table.partitionManager();
assertThat(partitionManager).isNotNull();
-
assertThat(partitionManager.listPartitions(Collections.emptyMap())).isEmpty();
+ assertThat(partitionManager.listPartitions(Collections.emptyMap(),
null)).isEmpty();
Map<String, String> partition = Collections.singletonMap("dt",
"20260717");
restCatalog.createPartitions(identifier,
Collections.singletonList(partition));
// Listings are not cached, so a mutation through the catalog is
visible to the next read.
- assertThat(partitionManager.listPartitions(Collections.emptyMap()))
+ assertThat(partitionManager.listPartitions(Collections.emptyMap(),
null))
.extracting(org.apache.paimon.partition.Partition::spec)
.containsExactly(partition);
restCatalog.dropPartitions(identifier,
Collections.singletonList(partition));
-
assertThat(partitionManager.listPartitions(Collections.emptyMap())).isEmpty();
+ assertThat(partitionManager.listPartitions(Collections.emptyMap(),
null)).isEmpty();
}
@Test
@@ -284,11 +286,45 @@ class MockRESTCatalogTest extends RESTCatalogTest {
FormatTablePartitionManager roundTripped =
InstantiationUtil.clone(table.partitionManager());
- assertThat(roundTripped.listPartitions(Collections.emptyMap()))
+ assertThat(roundTripped.listPartitions(Collections.emptyMap(), null))
.extracting(org.apache.paimon.partition.Partition::spec)
.containsExactly(partition);
}
+ @Test
+ void testFilteredListingPreservesNextTokenAcrossSparsePage() throws
Exception {
+ Identifier identifier =
createFormatTableWithCatalogManagedPartitions();
+ Predicate predicate = partitionFilter("20260717");
+ Partition partition =
+ new Partition(Collections.singletonMap("dt", "20260717"), 0,
0, 0, 0, -1, false);
+ restCatalogServer.enqueueListPartitionsByFilterResponse(null, "p2");
+ restCatalogServer.enqueueListPartitionsByFilterResponse(
+ Collections.singletonList(partition), null);
+
+ PagedList<Partition> firstPage =
+ restCatalog.listPartitionsByFilterPaged(identifier, predicate,
1, null, "dt=2026%");
+ assertThat(firstPage.getElements()).isEmpty();
+ assertThat(firstPage.getNextPageToken()).isEqualTo("p2");
+
+ PagedList<Partition> secondPage =
+ restCatalog.listPartitionsByFilterPaged(
+ identifier, predicate, 1,
firstPage.getNextPageToken(), "dt=2026%");
+ assertThat(secondPage.getElements()).containsExactly(partition);
+ assertThat(secondPage.getNextPageToken()).isNull();
+
+
assertThat(restCatalogServer.getReceivedListPartitionsByFilterRequests())
+ .extracting(
+ request ->
+ Arrays.asList(
+ request.getFilter(),
+ request.getMaxResults(),
+ request.getPageToken(),
+ request.getPartitionNamePattern()))
+ .containsExactly(
+ Arrays.asList(JsonSerdeUtil.toFlatJson(predicate), 1,
null, "dt=2026%"),
+ Arrays.asList(JsonSerdeUtil.toFlatJson(predicate), 1,
"p2", "dt=2026%"));
+ }
+
@Test
void testRejectCatalogManagedPartitionsOnExternalTableBeforeCreate()
throws Exception {
Identifier identifier = Identifier.create("db1",
"external_partitioned_format_table");
@@ -351,6 +387,14 @@ class MockRESTCatalogTest extends RESTCatalogTest {
return identifier;
}
+ private static Predicate partitionFilter(String value) {
+ return new PredicateBuilder(
+ RowType.of(
+ new org.apache.paimon.types.DataType[]
{DataTypes.STRING()},
+ new String[] {"dt"}))
+ .equal(0, value);
+ }
+
@Test
void testBaseHeadersInRequests() throws Exception {
// Set custom headers in options
diff --git
a/paimon-core/src/test/java/org/apache/paimon/rest/RESTApiJsonTest.java
b/paimon-core/src/test/java/org/apache/paimon/rest/RESTApiJsonTest.java
index bdf2b5f82c..7508e00473 100644
--- a/paimon-core/src/test/java/org/apache/paimon/rest/RESTApiJsonTest.java
+++ b/paimon-core/src/test/java/org/apache/paimon/rest/RESTApiJsonTest.java
@@ -28,6 +28,7 @@ import
org.apache.paimon.rest.requests.CreatePartitionsRequest;
import org.apache.paimon.rest.requests.CreateTableRequest;
import org.apache.paimon.rest.requests.CreateViewRequest;
import org.apache.paimon.rest.requests.DropPartitionsRequest;
+import org.apache.paimon.rest.requests.ListPartitionsByFilterRequest;
import org.apache.paimon.rest.requests.RenameTableRequest;
import org.apache.paimon.rest.requests.RollbackTableRequest;
import org.apache.paimon.rest.responses.AlterDatabaseResponse;
@@ -210,6 +211,33 @@ public class RESTApiJsonTest {
parseData.getPartitions().get(0).fileCount());
}
+ @Test
+ public void listPartitionsByFilterRequestJsonShapeTest() throws Exception {
+ ListPartitionsByFilterRequest request =
+ new ListPartitionsByFilterRequest("filter-json", "dt=2026%",
2, "p2");
+ String json = RESTApi.toJson(request);
+
+ Map<String, Object> expected = new HashMap<>();
+ expected.put("filter", "filter-json");
+ expected.put("partitionNamePattern", "dt=2026%");
+ expected.put("maxResults", 2);
+ expected.put("pageToken", "p2");
+ assertEquals(expected, RESTApi.fromJson(json, Map.class));
+ ListPartitionsByFilterRequest parsed =
+ RESTApi.fromJson(json, ListPartitionsByFilterRequest.class);
+ assertEquals(request.getFilter(), parsed.getFilter());
+ assertEquals(request.getPartitionNamePattern(),
parsed.getPartitionNamePattern());
+ assertEquals(request.getMaxResults(), parsed.getMaxResults());
+ assertEquals(request.getPageToken(), parsed.getPageToken());
+
+ assertEquals(
+ Collections.singletonMap("filter", "filter-json"),
+ RESTApi.fromJson(
+ RESTApi.toJson(
+ new
ListPartitionsByFilterRequest("filter-json", null, null, null)),
+ Map.class));
+ }
+
@Test
public void createPartitionsResponseParseTest() throws Exception {
Map<String, String> created = new HashMap<>();
diff --git
a/paimon-core/src/test/java/org/apache/paimon/rest/RESTCatalogServer.java
b/paimon-core/src/test/java/org/apache/paimon/rest/RESTCatalogServer.java
index 3a0e1539ee..6796e1ddf7 100644
--- a/paimon-core/src/test/java/org/apache/paimon/rest/RESTCatalogServer.java
+++ b/paimon-core/src/test/java/org/apache/paimon/rest/RESTCatalogServer.java
@@ -28,8 +28,10 @@ import org.apache.paimon.catalog.Identifier;
import org.apache.paimon.catalog.PropertyChange;
import org.apache.paimon.catalog.RenamingSnapshotCommit;
import org.apache.paimon.catalog.TableMetadata;
+import org.apache.paimon.catalog.TableQueryAuthResult;
import org.apache.paimon.consumer.ConsumerInfo;
import org.apache.paimon.consumer.ConsumerManager;
+import org.apache.paimon.data.GenericRow;
import org.apache.paimon.fs.FileIO;
import org.apache.paimon.fs.Path;
import org.apache.paimon.fs.local.LocalFileIO;
@@ -61,6 +63,7 @@ import org.apache.paimon.rest.requests.CreateTableRequest;
import org.apache.paimon.rest.requests.CreateTagRequest;
import org.apache.paimon.rest.requests.CreateViewRequest;
import org.apache.paimon.rest.requests.DropPartitionsRequest;
+import org.apache.paimon.rest.requests.ListPartitionsByFilterRequest;
import org.apache.paimon.rest.requests.ListPartitionsByNamesRequest;
import org.apache.paimon.rest.requests.MarkDonePartitionsRequest;
import org.apache.paimon.rest.requests.RenameTableRequest;
@@ -109,8 +112,11 @@ import org.apache.paimon.table.Instant;
import org.apache.paimon.table.TableSnapshot;
import org.apache.paimon.table.object.ObjectTable;
import org.apache.paimon.tag.Tag;
+import org.apache.paimon.types.DataField;
+import org.apache.paimon.types.RowType;
import org.apache.paimon.utils.BranchManager;
import org.apache.paimon.utils.ChangelogManager;
+import org.apache.paimon.utils.InternalRowPartitionComputer;
import org.apache.paimon.utils.JsonSerdeUtil;
import org.apache.paimon.utils.LazyField;
import org.apache.paimon.utils.Pair;
@@ -147,12 +153,15 @@ import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
+import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
+import java.util.Queue;
import java.util.Set;
import java.util.UUID;
+import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
@@ -193,6 +202,13 @@ public class RESTCatalogServer {
private final Map<String, Database> databaseStore = new HashMap<>();
private final Map<String, TableMetadata> tableMetadataStore = new
HashMap<>();
+
+ private final List<ListPartitionsByFilterRequest>
receivedListPartitionsByFilterRequests =
+ new java.util.concurrent.CopyOnWriteArrayList<>();
+
+ private final Queue<ListPartitionsResponse>
scriptedListPartitionsByFilterResponses =
+ new ConcurrentLinkedQueue<>();
+
private final Map<String, List<Partition>> tablePartitionsStore = new
HashMap<>();
private final Map<String, View> viewStore = new HashMap<>();
private final Map<String, TableSnapshot> tableLatestSnapshotStore = new
HashMap<>();
@@ -279,6 +295,25 @@ public class RESTCatalogServer {
this.partitionListingSupported = partitionListingSupported;
}
+ public void clearReceivedListPartitionsByFilterRequests() {
+ receivedListPartitionsByFilterRequests.clear();
+ }
+
+ public List<ListPartitionsByFilterRequest>
getReceivedListPartitionsByFilterRequests() {
+ return Collections.unmodifiableList(
+ new ArrayList<>(receivedListPartitionsByFilterRequests));
+ }
+
+ public boolean hasReceivedListPartitionsByFilterRequest() {
+ return !receivedListPartitionsByFilterRequests.isEmpty();
+ }
+
+ public void enqueueListPartitionsByFilterResponse(
+ @Nullable List<Partition> partitions, @Nullable String
nextPageToken) {
+ scriptedListPartitionsByFilterResponses.add(
+ new ListPartitionsResponse(partitions, nextPageToken));
+ }
+
public void addNoPermissionDatabase(String database) {
noPermissionDatabases.add(database);
}
@@ -459,6 +494,11 @@ public class RESTCatalogServer {
&&
ResourcePaths.TABLES.equals(resources[1])
&& "partitions".equals(resources[3])
&&
"list-by-names".equals(resources[4]);
+ boolean isListPartitionsByFilter =
+ resources.length == 5
+ &&
ResourcePaths.TABLES.equals(resources[1])
+ && "partitions".equals(resources[3])
+ &&
"list-by-filter".equals(resources[4]);
boolean isDropPartitions =
resources.length == 5
&&
ResourcePaths.TABLES.equals(resources[1])
@@ -511,7 +551,8 @@ public class RESTCatalogServer {
return new MockResponse().setResponseCode(200);
} else if (!partitionListingSupported
&& ((isPartitions &&
"GET".equals(restAuthParameter.method()))
- || isListPartitionsByNames)) {
+ || isListPartitionsByNames
+ || isListPartitionsByFilter)) {
return mockResponse(new ErrorResponse(null, null,
"", 501), 501);
} else if (isDropPartitions) {
return
dropPartitionsHandle(restAuthParameter.data(), identifier);
@@ -521,6 +562,11 @@ public class RESTCatalogServer {
restAuthParameter.data(),
parameters,
identifier);
+ } else if (isListPartitionsByFilter) {
+ ListPartitionsByFilterRequest
listPartitionsByFilterRequest =
+ RESTApi.fromJson(data,
ListPartitionsByFilterRequest.class);
+ return listPartitionsByFilter(
+ identifier, listPartitionsByFilterRequest);
} else if (isListPartitionsByNames) {
ListPartitionsByNamesRequest
listPartitionsByNamesRequest =
RESTApi.fromJson(data,
ListPartitionsByNamesRequest.class);
@@ -1943,6 +1989,81 @@ public class RESTCatalogServer {
return mockResponse(new DropPartitionsResponse(dropped, missing), 200);
}
+ private MockResponse listPartitionsByFilter(
+ Identifier tableIdentifier, ListPartitionsByFilterRequest request)
{
+ receivedListPartitionsByFilterRequests.add(request);
+ ListPartitionsResponse scriptedResponse =
scriptedListPartitionsByFilterResponses.poll();
+ if (scriptedResponse != null) {
+ return mockResponse(scriptedResponse, 200);
+ }
+ if (request.getFilter() == null || request.getFilter().isEmpty()) {
+ return mockResponse(new ErrorResponse(null, null, "filter is
required", 400), 400);
+ }
+ TableMetadata metadata =
tableMetadataStore.get(tableIdentifier.getFullName());
+ RowType partitionType = metadata == null ? null :
metadata.schema().logicalPartitionType();
+ String defaultPartName =
+ metadata == null
+ ? CoreOptions.PARTITION_DEFAULT_NAME.defaultValue()
+ : new
CoreOptions(metadata.schema().options()).partitionDefaultName();
+ // Best-effort contract: a predicate the server cannot restore
(version skew) or
+ // re-anchor (unknown column) counts as always-true, so the response
stays a superset
+ // of the matching partitions and never misses one.
+ Predicate predicate;
+ try {
+ predicate =
+ TableQueryAuthResult.remapPredicate(
+ JsonSerdeUtil.fromJson(request.getFilter(),
Predicate.class),
+ partitionType);
+ } catch (Exception e) {
+ predicate = null;
+ }
+ List<Partition> partitions = new ArrayList<>();
+ for (Partition partition :
+ tablePartitionsStore.getOrDefault(
+ tableIdentifier.getFullName(),
Collections.emptyList())) {
+ boolean patternMatched =
+ request.getPartitionNamePattern() == null
+ || matchNamePattern(
+ getPagedKey(partition),
request.getPartitionNamePattern());
+ if (patternMatched
+ && matchesPredicate(
+ predicate, partition.spec(), partitionType,
defaultPartName)) {
+ partitions.add(partition);
+ }
+ }
+ Map<String, String> pagingParameters = new HashMap<>();
+ if (request.getMaxResults() != null) {
+ pagingParameters.put(RESTApi.MAX_RESULTS,
request.getMaxResults().toString());
+ }
+ if (request.getPageToken() != null) {
+ pagingParameters.put(RESTApi.PAGE_TOKEN, request.getPageToken());
+ }
+ return generateFinalListPartitionsResponse(pagingParameters,
partitions);
+ }
+
+ /** Evaluates the restored predicate against a spec; anything it cannot
handle matches. */
+ private static boolean matchesPredicate(
+ @Nullable Predicate predicate,
+ Map<String, String> spec,
+ @Nullable RowType partitionType,
+ String defaultPartName) {
+ if (predicate == null || partitionType == null) {
+ return true;
+ }
+ try {
+ LinkedHashMap<String, String> ordered = new LinkedHashMap<>();
+ for (DataField field : partitionType.getFields()) {
+ ordered.put(field.name(), spec.get(field.name()));
+ }
+ GenericRow row =
+ InternalRowPartitionComputer.convertSpecToInternalRow(
+ ordered, partitionType, defaultPartName);
+ return predicate.test(row);
+ } catch (Exception e) {
+ return true;
+ }
+ }
+
private MockResponse listPartitionsByNames(
Map<String, String> parameters,
Identifier tableIdentifier,
diff --git
a/paimon-core/src/test/java/org/apache/paimon/rest/RESTCatalogTest.java
b/paimon-core/src/test/java/org/apache/paimon/rest/RESTCatalogTest.java
index 0e386e8b10..05376d3585 100644
--- a/paimon-core/src/test/java/org/apache/paimon/rest/RESTCatalogTest.java
+++ b/paimon-core/src/test/java/org/apache/paimon/rest/RESTCatalogTest.java
@@ -96,6 +96,7 @@ import org.apache.paimon.table.source.TableRead;
import org.apache.paimon.table.system.SystemTableLoader;
import org.apache.paimon.types.DataField;
import org.apache.paimon.types.DataTypes;
+import org.apache.paimon.types.RowType;
import org.apache.paimon.utils.SnapshotManager;
import org.apache.paimon.utils.SnapshotNotExistException;
import org.apache.paimon.utils.StringUtils;
@@ -372,6 +373,17 @@ public abstract class RESTCatalogTest extends
CatalogTestBase {
assertThrows(
Catalog.TableNoPermissionException.class,
() -> catalog.listPartitionsPaged(identifier, 100, null,
null));
+ Predicate partitionFilter =
+ new PredicateBuilder(
+ RowType.of(
+ new org.apache.paimon.types.DataType[]
{DataTypes.INT()},
+ new String[] {"col1"}))
+ .equal(0, 1);
+ assertThrows(
+ Catalog.TableNoPermissionException.class,
+ () ->
+ catalog.listPartitionsByFilterPaged(
+ identifier, partitionFilter, 100, null, null));
assertThrows(
Catalog.TableNoPermissionException.class,
() -> restCatalog.createBranch(identifier, "test_branch",
null));
@@ -1877,6 +1889,96 @@ public abstract class RESTCatalogTest extends
CatalogTestBase {
() -> catalog.listPartitionsPaged(identifier, null, null,
"dt=01%01"));
}
+ @Test
+ public void testListPartitionsByFilterPaged() throws Exception {
+ if (!supportPartitions()) {
+ return;
+ }
+
+ String databaseName = "partitions_filter_db";
+ List<Map<String, String>> partitionSpecs =
+ Arrays.asList(
+ singletonMap("dt", "20250101"),
+ singletonMap("dt", "20250102"),
+ singletonMap("dt", "20250103"),
+ singletonMap("dt", "20260101"));
+ Schema schema =
+ Schema.newBuilder()
+ .option(METASTORE_PARTITIONED_TABLE.key(), "true")
+ .option(METASTORE_TAG_TO_PARTITION.key(), "dt")
+ .column("col", DataTypes.INT())
+ .column("dt", DataTypes.STRING())
+ .partitionKeys("dt")
+ .build();
+ PredicateBuilder builder =
+ new PredicateBuilder(
+ RowType.of(
+ new org.apache.paimon.types.DataType[]
{DataTypes.STRING()},
+ new String[] {"dt"}));
+ Predicate range =
+ PredicateBuilder.and(
+ builder.greaterOrEqual(0,
BinaryString.fromString("20250101")),
+ builder.lessThan(0,
BinaryString.fromString("20260101")));
+ catalog.dropDatabase(databaseName, true, true);
+ catalog.createDatabase(databaseName, true);
+ Identifier identifier = Identifier.create(databaseName, "table");
+ assertThrows(
+ Catalog.TableNotExistException.class,
+ () -> catalog.listPartitionsByFilterPaged(identifier, range,
2, null, "dt=2025%"));
+ catalog.createTable(identifier, schema, true);
+ BatchWriteBuilder writeBuilder =
catalog.getTable(identifier).newBatchWriteBuilder();
+ try (BatchTableWrite write = writeBuilder.newWrite();
+ BatchTableCommit commit = writeBuilder.newCommit()) {
+ for (Map<String, String> partitionSpec : partitionSpecs) {
+ write.write(GenericRow.of(0,
BinaryString.fromString(partitionSpec.get("dt"))));
+ }
+ commit.commit(write.prepareCommit());
+ }
+
+ String distractorDatabase = databaseName + "_other";
+ catalog.dropDatabase(distractorDatabase, true, true);
+ catalog.createDatabase(distractorDatabase, true);
+ Identifier distractor = Identifier.create(distractorDatabase,
identifier.getObjectName());
+ catalog.createTable(distractor, schema, true);
+ catalog.createPartitions(distractor, singletonList(singletonMap("dt",
"20250104")));
+
+ // The predicate goes on the wire as its own JSON serialization; the
server evaluates
+ // the very same tree the scan would evaluate locally.
+ PagedList<Partition> firstPage =
+ catalog.listPartitionsByFilterPaged(identifier, range, 2,
null, "dt=2025%");
+ assertThat(firstPage.getElements())
+ .extracting(partition -> partition.spec().get("dt"))
+ .containsExactly("20250103", "20250102");
+ assertThat(firstPage.getNextPageToken()).isEqualTo("dt=20250102");
+
+ PagedList<Partition> secondPage =
+ catalog.listPartitionsByFilterPaged(
+ identifier, range, 2, firstPage.getNextPageToken(),
"dt=2025%");
+ assertThat(secondPage.getElements())
+ .extracting(partition -> partition.spec().get("dt"))
+ .containsExactly("20250101");
+ assertThat(secondPage.getNextPageToken()).isNull();
+
+ // A predicate the server cannot re-anchor (unknown column) counts as
always-true: the
+ // response is a superset of the matching partitions and the client
keeps filtering
+ // locally.
+ PredicateBuilder unknownColumn =
+ new PredicateBuilder(
+ RowType.of(
+ new org.apache.paimon.types.DataType[]
{DataTypes.STRING()},
+ new String[] {"nope"}));
+ assertThat(
+ catalog.listPartitionsByFilterPaged(
+ identifier,
+ unknownColumn.equal(0,
BinaryString.fromString("x")),
+ null,
+ null,
+ null)
+ .getElements())
+ .extracting(Partition::spec)
+ .containsExactlyInAnyOrderElementsOf(partitionSpecs);
+ }
+
@Test
public void testListPartitionsPagedWithMultiLevel() throws Exception {
if (!supportPartitions()) {
diff --git
a/paimon-core/src/test/java/org/apache/paimon/table/format/CatalogFormatTablePartitionManagerTest.java
b/paimon-core/src/test/java/org/apache/paimon/table/format/CatalogFormatTablePartitionManagerTest.java
index d7a5d97e69..7d246bf4f5 100644
---
a/paimon-core/src/test/java/org/apache/paimon/table/format/CatalogFormatTablePartitionManagerTest.java
+++
b/paimon-core/src/test/java/org/apache/paimon/table/format/CatalogFormatTablePartitionManagerTest.java
@@ -22,7 +22,13 @@ import org.apache.paimon.PagedList;
import org.apache.paimon.catalog.Catalog;
import org.apache.paimon.catalog.CatalogLoader;
import org.apache.paimon.catalog.Identifier;
+import org.apache.paimon.data.BinaryString;
import org.apache.paimon.partition.Partition;
+import org.apache.paimon.predicate.Predicate;
+import org.apache.paimon.predicate.PredicateBuilder;
+import org.apache.paimon.types.DataType;
+import org.apache.paimon.types.DataTypes;
+import org.apache.paimon.types.RowType;
import org.apache.paimon.utils.InstantiationUtil;
import org.junit.jupiter.api.Test;
@@ -46,6 +52,7 @@ import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
@@ -63,9 +70,16 @@ class CatalogFormatTablePartitionManagerTest {
private static final List<String> PARTITION_KEYS = Arrays.asList("year",
"month");
- /** One catalog request never carries more than this many partitions. */
private static final int REQUEST_SIZE = 1000;
+ /** Any non-null partition predicate; the manager passes it through
untouched. */
+ private static final Predicate FILTER =
+ new PredicateBuilder(
+ RowType.of(
+ new DataType[] {DataTypes.STRING(),
DataTypes.STRING()},
+ new String[] {"year", "month"}))
+ .greaterThan(1, BinaryString.fromString("01"));
+
/** Handed to the serializable loader, which cannot capture a test
instance field. */
private static Catalog staticCatalog;
@@ -84,7 +98,7 @@ class CatalogFormatTablePartitionManagerTest {
new PagedList<>(Collections.singletonList(second),
null));
List<Partition> partitions =
-
partitionManager(catalog).listPartitions(Collections.emptyMap());
+
partitionManager(catalog).listPartitions(Collections.emptyMap(), null);
assertThat(partitions).containsExactly(first, second);
ArgumentCaptor<String> pageTokens =
ArgumentCaptor.forClass(String.class);
@@ -101,7 +115,7 @@ class CatalogFormatTablePartitionManagerTest {
.thenReturn(new PagedList<>(Collections.singletonList(only),
""));
List<Partition> partitions =
-
partitionManager(catalog).listPartitions(Collections.emptyMap());
+
partitionManager(catalog).listPartitions(Collections.emptyMap(), null);
assertThat(partitions).containsExactly(only);
verify(catalog, times(1)).listPartitionsPaged(any(), any(), any(),
any());
@@ -115,16 +129,122 @@ class CatalogFormatTablePartitionManagerTest {
.thenReturn(
new
PagedList<>(Collections.singletonList(partition("2025", "01")), "a"),
new
PagedList<>(Collections.singletonList(partition("2025", "02")), "b"),
- new
PagedList<>(Collections.singletonList(partition("2025", "03")), "a"));
+ new
PagedList<>(Collections.singletonList(partition("2025", "03")), "a"))
+ .thenThrow(new AssertionError("unexpected extra page
request"));
FormatTablePartitionManager partitionManager =
partitionManager(catalog);
- assertThatThrownBy(() ->
partitionManager.listPartitions(Collections.emptyMap()))
+ assertThatThrownBy(() ->
partitionManager.listPartitions(Collections.emptyMap(), null))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("repeated partition page token")
.hasMessageContaining("catalog_partition_db.catalog_partition_table");
}
+ // ------------------------------------------------------------------------
+ // filtered listing
+ // ------------------------------------------------------------------------
+
+ @Test
+ void testFilteredListingPassesRequestAndPreservesSupersetAcrossPages()
throws Exception {
+ Catalog catalog = mock(Catalog.class);
+ Partition first = partition("2025", "01");
+ Partition second = partition("2025", "02");
+ when(catalog.listPartitionsByFilterPaged(any(), any(), any(), any(),
any()))
+ .thenReturn(
+ new PagedList<>(Arrays.asList(partition("2024", "12"),
first), "page-2"),
+ new PagedList<>(Collections.singletonList(second),
null));
+
+ List<Partition> partitions =
+ partitionManager(catalog)
+ .listPartitions(Collections.singletonMap("year",
"2025"), FILTER);
+
+ assertThat(partitions).containsExactly(first, second);
+ ArgumentCaptor<String> pageTokens =
ArgumentCaptor.forClass(String.class);
+ verify(catalog, times(2))
+ .listPartitionsByFilterPaged(
+ eq(IDENTIFIER),
+ eq(FILTER),
+ eq(REQUEST_SIZE),
+ pageTokens.capture(),
+ eq("year=2025/%"));
+ assertThat(pageTokens.getAllValues()).containsExactly(null, "page-2");
+ verify(catalog, never()).listPartitionsPaged(any(), any(), any(),
any());
+ }
+
+ @Test
+ void testFilteredListingFollowsSparsePages() throws Exception {
+ // Filtering may leave a page empty while more partitions remain to
check: only an empty
+ // token ends the loop.
+ Catalog catalog = mock(Catalog.class);
+ Partition only = partition("2025", "02");
+ when(catalog.listPartitionsByFilterPaged(any(), any(), any(), any(),
any()))
+ .thenReturn(
+ new PagedList<>(Collections.emptyList(), "page-2"),
+ new PagedList<>(Collections.singletonList(only),
null));
+
+ List<Partition> partitions =
+
partitionManager(catalog).listPartitions(Collections.emptyMap(), FILTER);
+
+ assertThat(partitions).containsExactly(only);
+ verify(catalog, times(2)).listPartitionsByFilterPaged(any(), any(),
any(), any(), any());
+ }
+
+ @Test
+ void testCatalogDefaultFilteredListingDelegatesToPlainListing() throws
Exception {
+ Catalog catalog = mock(Catalog.class);
+ Partition only = partition("2025", "01");
+ when(catalog.listPartitionsByFilterPaged(any(), any(), any(), any(),
any()))
+ .thenCallRealMethod();
+ when(catalog.listPartitionsPaged(any(), any(), any(), any()))
+ .thenReturn(new PagedList<>(Collections.singletonList(only),
null));
+
+ PagedList<Partition> page =
+ catalog.listPartitionsByFilterPaged(
+ IDENTIFIER, FILTER, REQUEST_SIZE, "page-2",
"year=2025/%");
+
+ assertThat(page.getElements()).containsExactly(only);
+ verify(catalog)
+ .listPartitionsPaged(
+ eq(IDENTIFIER), eq(REQUEST_SIZE), eq("page-2"),
eq("year=2025/%"));
+ }
+
+ @Test
+ void testOtherFilteredFailuresDoNotFallBack() throws Exception {
+ Catalog catalog = mock(Catalog.class);
+ Catalog.TableNoPermissionException failure =
+ new Catalog.TableNoPermissionException(IDENTIFIER);
+ when(catalog.listPartitionsByFilterPaged(any(), any(), any(), any(),
any()))
+ .thenThrow(failure);
+ FormatTablePartitionManager partitionManager =
partitionManager(catalog);
+
+ Throwable thrown =
+ catchThrowable(
+ () ->
partitionManager.listPartitions(Collections.emptyMap(), FILTER));
+
+ assertThat(thrown).isSameAs(failure);
+ verify(catalog, never()).listPartitionsPaged(any(), any(), any(),
any());
+ }
+
+ @Test
+ void testMissingTableDoesNotTriggerPlainListing() throws Exception {
+ Catalog catalog = mock(Catalog.class);
+ Catalog.TableNotExistException notExist = new
Catalog.TableNotExistException(IDENTIFIER);
+ when(catalog.listPartitionsByFilterPaged(any(), any(), any(), any(),
any()))
+ .thenThrow(notExist);
+ FormatTablePartitionManager partitionManager =
partitionManager(catalog);
+
+ Throwable thrown =
+ catchThrowable(
+ () ->
partitionManager.listPartitions(Collections.emptyMap(), FILTER));
+
+ assertThat(thrown)
+ .isInstanceOf(RuntimeException.class)
+ .hasMessageContaining("list partitions")
+
.hasMessageContaining("catalog_partition_db.catalog_partition_table");
+ assertThat(thrown.getCause()).isSameAs(notExist);
+ verify(catalog, never()).listPartitionsPaged(any(), any(), any(),
any());
+ }
+
// ------------------------------------------------------------------------
// prefix pushdown and client side filtering
// ------------------------------------------------------------------------
@@ -135,7 +255,7 @@ class CatalogFormatTablePartitionManagerTest {
when(catalog.listPartitionsPaged(any(), any(), any(), any()))
.thenReturn(new PagedList<>(Collections.emptyList(), null));
-
partitionManager(catalog).listPartitions(Collections.singletonMap("year",
"2025"));
+
partitionManager(catalog).listPartitions(Collections.singletonMap("year",
"2025"), null);
verify(catalog)
.listPartitionsPaged(eq(IDENTIFIER), eq(REQUEST_SIZE),
isNull(), eq("year=2025/%"));
@@ -147,7 +267,7 @@ class CatalogFormatTablePartitionManagerTest {
when(catalog.listPartitionsPaged(any(), any(), any(), any()))
.thenReturn(new PagedList<>(Collections.emptyList(), null));
- partitionManager(catalog).listPartitions(Collections.emptyMap());
+ partitionManager(catalog).listPartitions(Collections.emptyMap(), null);
verify(catalog).listPartitionsPaged(eq(IDENTIFIER), eq(REQUEST_SIZE),
isNull(), isNull());
}
@@ -161,7 +281,8 @@ class CatalogFormatTablePartitionManagerTest {
.thenReturn(new PagedList<>(Arrays.asList(matching, other),
null));
List<Partition> partitions =
-
partitionManager(catalog).listPartitions(Collections.singletonMap("year",
"2025"));
+ partitionManager(catalog)
+ .listPartitions(Collections.singletonMap("year",
"2025"), null);
assertThat(partitions).containsExactly(matching);
}
@@ -173,7 +294,7 @@ class CatalogFormatTablePartitionManagerTest {
// "month" without "year" cannot be expressed as a partition path
prefix.
Map<String, String> prefix = Collections.singletonMap("month", "10");
- assertThatThrownBy(() -> partitionManager.listPartitions(prefix))
+ assertThatThrownBy(() -> partitionManager.listPartitions(prefix, null))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("leading prefix");
}
@@ -268,7 +389,7 @@ class CatalogFormatTablePartitionManagerTest {
Catalog listCatalog = mock(Catalog.class);
when(listCatalog.listPartitionsPaged(any(), any(), any(), any()))
.thenReturn(new PagedList<>(Collections.emptyList(), null));
- partitionManager(listCatalog).listPartitions(Collections.emptyMap());
+ partitionManager(listCatalog).listPartitions(Collections.emptyMap(),
null);
verify(listCatalog).close();
Catalog byNamesCatalog = mock(Catalog.class);
@@ -293,7 +414,7 @@ class CatalogFormatTablePartitionManagerTest {
.thenThrow(new RuntimeException("catalog unavailable"));
FormatTablePartitionManager partitionManager =
partitionManager(catalog);
- assertThatThrownBy(() ->
partitionManager.listPartitions(Collections.emptyMap()))
+ assertThatThrownBy(() ->
partitionManager.listPartitions(Collections.emptyMap(), null))
.isInstanceOf(RuntimeException.class);
verify(catalog).close();
@@ -305,7 +426,7 @@ class CatalogFormatTablePartitionManagerTest {
FormatTablePartitionManager partitionManager =
partitionManager(catalog);
Map<String, String> prefix = Collections.singletonMap("month", "10");
- assertThatThrownBy(() -> partitionManager.listPartitions(prefix))
+ assertThatThrownBy(() -> partitionManager.listPartitions(prefix, null))
.isInstanceOf(IllegalArgumentException.class);
verifyNoInteractions(catalog);
@@ -323,7 +444,7 @@ class CatalogFormatTablePartitionManagerTest {
FormatTablePartitionManager partitionManager =
partitionManager(catalog);
Throwable thrown =
- catchThrowable(() ->
partitionManager.listPartitions(Collections.emptyMap()));
+ catchThrowable(() ->
partitionManager.listPartitions(Collections.emptyMap(), null));
assertThat(thrown)
.isInstanceOf(RuntimeException.class)
@@ -360,7 +481,7 @@ class CatalogFormatTablePartitionManagerTest {
FormatTablePartitionManager.create(IDENTIFIER, PARTITION_KEYS,
new TestLoader());
FormatTablePartitionManager cloned =
InstantiationUtil.clone(partitionManager);
-
assertThat(cloned.listPartitions(Collections.emptyMap())).containsExactly(only);
+ assertThat(cloned.listPartitions(Collections.emptyMap(),
null)).containsExactly(only);
verify(catalog).close();
}
diff --git
a/paimon-core/src/test/java/org/apache/paimon/table/format/CatalogManagedPartitionScanTest.java
b/paimon-core/src/test/java/org/apache/paimon/table/format/CatalogManagedPartitionScanTest.java
index da74ea3273..de2127f493 100644
---
a/paimon-core/src/test/java/org/apache/paimon/table/format/CatalogManagedPartitionScanTest.java
+++
b/paimon-core/src/test/java/org/apache/paimon/table/format/CatalogManagedPartitionScanTest.java
@@ -22,6 +22,7 @@ import org.apache.paimon.PagedList;
import org.apache.paimon.catalog.Catalog;
import org.apache.paimon.catalog.Identifier;
import org.apache.paimon.data.BinaryString;
+import org.apache.paimon.data.GenericRow;
import org.apache.paimon.fs.FileStatus;
import org.apache.paimon.fs.Path;
import org.apache.paimon.fs.PositionOutputStream;
@@ -40,6 +41,9 @@ import org.apache.paimon.utils.PartitionPathUtils;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
+import org.mockito.ArgumentCaptor;
+
+import javax.annotation.Nullable;
import java.io.FileNotFoundException;
import java.io.IOException;
@@ -54,9 +58,11 @@ import java.util.stream.Collectors;
import static
org.apache.paimon.CoreOptions.FORMAT_TABLE_PARTITION_ONLY_VALUE_IN_PATH;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/** Tests for Format Table scans whose partitions are catalog-managed. */
@@ -70,7 +76,14 @@ class CatalogManagedPartitionScanTest {
@Test
void testLeadingPatternResidualFilterAndUnregisteredDirectory() throws
Exception {
Catalog catalog = mock(Catalog.class);
- when(catalog.listPartitionsPaged(eq(IDENTIFIER), eq(1000), isNull(),
eq("year=2025/%")))
+ // The residual predicate beyond the prefix goes to the filter
endpoint; the filter is a
+ // hint, so returning a superset here is legal and the plan still
filters per partition.
+ when(catalog.listPartitionsByFilterPaged(
+ eq(IDENTIFIER),
+ any(Predicate.class),
+ eq(1000),
+ isNull(),
+ eq("year=2025/%")))
.thenReturn(
new PagedList<>(
Arrays.asList(partition("2025", "10"),
partition("2025", "11")),
@@ -93,6 +106,47 @@ class CatalogManagedPartitionScanTest {
assertThat(plannedFiles).containsExactly(novemberFile);
assertThat(plannedFiles).doesNotContain(octoberFile, unregisteredFile);
assertThat(fileIO.listedPaths).containsExactly(new Path(tablePath,
"year=2025/month=11"));
+ ArgumentCaptor<Predicate> pushedFilter =
ArgumentCaptor.forClass(Predicate.class);
+ verify(catalog)
+ .listPartitionsByFilterPaged(
+ eq(IDENTIFIER),
+ pushedFilter.capture(),
+ eq(1000),
+ isNull(),
+ eq("year=2025/%"));
+ assertThat(pushedFilter.getValue().test(GenericRow.of(2025,
11))).isTrue();
+ assertThat(pushedFilter.getValue().test(GenericRow.of(2025,
10))).isFalse();
+ }
+
+ @Test
+ void testPushesFilterWithoutLeadingPrefixAndAppliesResidualFilter() throws
Exception {
+ TrackingLocalFileIO fileIO = new TrackingLocalFileIO();
+ Path tablePath = new Path(tempDir.toUri());
+ Path octoberFile = writeDataFile(fileIO, tablePath,
"year=2025/month=10");
+ Path novemberFile = writeDataFile(fileIO, tablePath,
"year=2025/month=11");
+ FormatTable table =
+ createTable(
+ fileIO,
+ tablePath,
+ recordingCatalog(
+ Arrays.asList(partition("2025", "10"),
partition("2025", "11"))),
+ false);
+ Predicate predicate = new
PredicateBuilder(table.partitionType()).greaterThan(1, 10);
+ PartitionPredicate filter =
+ PartitionPredicate.fromPredicate(table.partitionType(),
predicate);
+
+ List<Path> plannedFiles =
+ plannedFiles(new FormatTableScan(table, filter,
null).plan().splits());
+
+ assertThat(plannedFiles).containsExactly(novemberFile);
+ assertThat(plannedFiles).doesNotContain(octoberFile);
+ assertThat(fileIO.listedPaths).containsExactly(new Path(tablePath,
"year=2025/month=11"));
+ assertThat(requestedPrefixes).containsExactly(Collections.emptyMap());
+ assertThat(requestedFilters).hasSize(1);
+ Predicate pushedFilter = requestedFilters.get(0);
+ assertThat(pushedFilter).isNotNull();
+ assertThat(pushedFilter.test(GenericRow.of(2025, 11))).isTrue();
+ assertThat(pushedFilter.test(GenericRow.of(2025, 10))).isFalse();
}
@Test
@@ -302,6 +356,7 @@ class CatalogManagedPartitionScanTest {
}
private final List<Map<String, String>> requestedPrefixes = new
ArrayList<>();
+ private final List<Predicate> requestedFilters = new ArrayList<>();
private FormatTablePartitionManager partitionManager(Catalog catalog) {
return FormatTablePartitionManager.create(
@@ -311,10 +366,13 @@ class CatalogManagedPartitionScanTest {
/** Records the prefix the scan pushes down and answers from a fixed
partition list. */
private FormatTablePartitionManager recordingCatalog(List<Partition>
partitions) {
List<Map<String, String>> prefixes = requestedPrefixes;
+ List<Predicate> filters = requestedFilters;
return new FormatTablePartitionManager() {
@Override
- public List<Partition> listPartitions(Map<String, String> prefix) {
+ public List<Partition> listPartitions(
+ Map<String, String> prefix, @Nullable Predicate filter) {
prefixes.add(new LinkedHashMap<>(prefix));
+ filters.add(filter);
List<Partition> matching = new ArrayList<>();
for (Partition partition : partitions) {
boolean matches = true;
diff --git
a/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/format/FormatTablePartitionRepair.java
b/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/format/FormatTablePartitionRepair.java
index 4aa99b812c..d77968f248 100644
---
a/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/format/FormatTablePartitionRepair.java
+++
b/paimon-spark/paimon-spark-common/src/main/java/org/apache/paimon/spark/format/FormatTablePartitionRepair.java
@@ -109,7 +109,8 @@ public class FormatTablePartitionRepair {
boolean addPartitions,
boolean dropPartitions) {
Set<Map<String, String>> registeredPartitions = new HashSet<>();
- for (Partition partition :
partitionManager.listPartitions(Collections.emptyMap())) {
+ for (Partition partition :
+ partitionManager.listPartitions(Collections.<String,
String>emptyMap(), null)) {
registeredPartitions.add(partition.spec());
}
diff --git
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/format/PaimonFormatTable.scala
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/format/PaimonFormatTable.scala
index eecaed851b..6b5e5210f9 100644
---
a/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/format/PaimonFormatTable.scala
+++
b/paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/format/PaimonFormatTable.scala
@@ -166,7 +166,7 @@ case class PaimonFormatTable(table: FormatTable)
// One unfiltered traversal resolves every partial spec; the requested
constraints are
// enforced client-side.
requirePartitionManager()
- .listPartitions(Collections.emptyMap[String, String]())
+ .listPartitions(Collections.emptyMap[String, String](), null)
.asScala
.foreach {
partition =>
diff --git
a/paimon-spark/paimon-spark-common/src/test/java/org/apache/paimon/spark/format/FormatTablePartitionRepairTest.java
b/paimon-spark/paimon-spark-common/src/test/java/org/apache/paimon/spark/format/FormatTablePartitionRepairTest.java
index 5bf21ded18..bc6e24d219 100644
---
a/paimon-spark/paimon-spark-common/src/test/java/org/apache/paimon/spark/format/FormatTablePartitionRepairTest.java
+++
b/paimon-spark/paimon-spark-common/src/test/java/org/apache/paimon/spark/format/FormatTablePartitionRepairTest.java
@@ -25,6 +25,7 @@ import org.apache.paimon.fs.FileStatus;
import org.apache.paimon.fs.Path;
import org.apache.paimon.fs.local.LocalFileIO;
import org.apache.paimon.partition.Partition;
+import org.apache.paimon.predicate.Predicate;
import org.apache.paimon.table.FormatTable;
import org.apache.paimon.table.format.FormatTablePartitionManager;
import org.apache.paimon.types.DataTypes;
@@ -33,6 +34,8 @@ import org.apache.paimon.types.RowType;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
+import javax.annotation.Nullable;
+
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
@@ -307,7 +310,8 @@ class FormatTablePartitionRepairTest {
RecordingPartitionManager catalog =
new RecordingPartitionManager() {
@Override
- public List<Partition> listPartitions(Map<String, String>
prefix) {
+ public List<Partition> listPartitions(
+ Map<String, String> prefix, @Nullable Predicate
filter) {
listCount.incrementAndGet();
throw listFailure;
}
@@ -456,7 +460,8 @@ class FormatTablePartitionRepairTest {
}
@Override
- public List<Partition> listPartitions(Map<String, String> prefix) {
+ public List<Partition> listPartitions(
+ Map<String, String> prefix, @Nullable Predicate filter) {
requestedPrefixes.add(prefix);
List<Partition> partitions = new ArrayList<>(registered.size());
for (Map<String, String> spec : registered) {
diff --git
a/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/PaimonSparkTestWithRestCatalogBase.scala
b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/PaimonSparkTestWithRestCatalogBase.scala
index c880ba7f36..b8b8d53e21 100644
---
a/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/PaimonSparkTestWithRestCatalogBase.scala
+++
b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/PaimonSparkTestWithRestCatalogBase.scala
@@ -25,8 +25,6 @@ import org.apache.paimon.rest.responses.ConfigResponse
import org.apache.paimon.shade.guava30.com.google.common.collect.ImmutableMap
import org.apache.spark.SparkConf
-import org.apache.spark.sql.Row
-import org.assertj.core.api.Assertions
import java.util.UUID
@@ -62,6 +60,12 @@ class PaimonSparkTestWithRestCatalogBase extends
PaimonSparkTestBase {
}
}
+ protected def clearFilteredListingRequests(): Unit =
+ restCatalogServer.clearReceivedListPartitionsByFilterRequests()
+
+ protected def filteredListingWasRequested: Boolean =
+ restCatalogServer.hasReceivedListPartitionsByFilterRequest
+
override protected def sparkConf: SparkConf = {
super.sparkConf
.set("spark.sql.catalog.paimon.metastore", RESTCatalogFactory.IDENTIFIER)
diff --git
a/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/execution/FormatTablePartitionDdlPlanningTest.scala
b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/execution/FormatTablePartitionDdlPlanningTest.scala
index 7ef72caad0..7968c8d49c 100644
---
a/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/execution/FormatTablePartitionDdlPlanningTest.scala
+++
b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/execution/FormatTablePartitionDdlPlanningTest.scala
@@ -23,6 +23,7 @@ import org.apache.paimon.fs.{FileIO, Path}
import org.apache.paimon.fs.local.LocalFileIO
import org.apache.paimon.options.Options
import org.apache.paimon.partition.Partition
+import org.apache.paimon.predicate.Predicate
import org.apache.paimon.spark.PaimonSparkTestWithRestCatalogBase
import org.apache.paimon.spark.catalyst.plans.logical.PaimonDropPartitions
import org.apache.paimon.spark.format.PaimonFormatTable
@@ -375,7 +376,9 @@ class FormatTablePartitionDdlPlanningTest extends
PaimonSparkTestWithRestCatalog
partitions: JList[JMap[String, String]]): JList[Partition] =
Collections.emptyList()
- override def listPartitions(prefix: JMap[String, String]):
JList[Partition] =
+ override def listPartitions(
+ prefix: JMap[String, String],
+ filter: Predicate): JList[Partition] =
throw new AssertionError("Complete specs must resolve through
list-by-names")
}
@@ -429,7 +432,9 @@ class FormatTablePartitionDdlPlanningTest extends
PaimonSparkTestWithRestCatalog
partitions.asScala.map(_.asScala.toMap).filter(_ ==
registeredSpec).toSeq: _*)
}
- override def listPartitions(prefix: JMap[String, String]):
JList[Partition] =
+ override def listPartitions(
+ prefix: JMap[String, String],
+ filter: Predicate): JList[Partition] =
throw new AssertionError("Complete specs must resolve through
list-by-names")
}
@@ -495,7 +500,9 @@ class FormatTablePartitionDdlPlanningTest extends
PaimonSparkTestWithRestCatalog
partitions: JList[JMap[String, String]]): JList[Partition] =
registeredPartitions(partitions.asScala.map(_.asScala.toMap).filter(registered).toSeq:
_*)
- override def listPartitions(prefix: JMap[String, String]):
JList[Partition] =
+ override def listPartitions(
+ prefix: JMap[String, String],
+ filter: Predicate): JList[Partition] =
throw new AssertionError("Exact specs must resolve through
list-by-names")
}
var refreshCalls = 0
@@ -562,7 +569,9 @@ class FormatTablePartitionDdlPlanningTest extends
PaimonSparkTestWithRestCatalog
partitions: JList[JMap[String, String]]): JList[Partition] =
registeredPartitions(partitions.asScala.map(_.asScala.toMap).filter(registered).toSeq:
_*)
- override def listPartitions(prefix: JMap[String, String]):
JList[Partition] =
+ override def listPartitions(
+ prefix: JMap[String, String],
+ filter: Predicate): JList[Partition] =
throw new AssertionError("Exact specs must resolve through
list-by-names")
}
val firstFileIO = LocalFileIO.create
@@ -616,7 +625,9 @@ class FormatTablePartitionDdlPlanningTest extends
PaimonSparkTestWithRestCatalog
partitions: JList[JMap[String, String]]): JList[Partition] =
Collections.emptyList()
- override def listPartitions(prefix: JMap[String, String]):
JList[Partition] = {
+ override def listPartitions(
+ prefix: JMap[String, String],
+ filter: Predicate): JList[Partition] = {
listedPrefixes :+= prefix.asScala.toMap
registeredPartitions(matching)
}
@@ -656,7 +667,9 @@ class FormatTablePartitionDdlPlanningTest extends
PaimonSparkTestWithRestCatalog
partitions: JList[JMap[String, String]]): JList[Partition] =
Collections.emptyList()
- override def listPartitions(prefix: JMap[String, String]):
JList[Partition] = {
+ override def listPartitions(
+ prefix: JMap[String, String],
+ filter: Predicate): JList[Partition] = {
listedPrefixes :+= prefix.asScala.toMap
registeredPartitions(first, unrelated, second, third)
}
@@ -708,7 +721,9 @@ class FormatTablePartitionDdlPlanningTest extends
PaimonSparkTestWithRestCatalog
partitions: JList[JMap[String, String]]): JList[Partition] =
Collections.emptyList()
- override def listPartitions(prefix: JMap[String, String]):
JList[Partition] =
+ override def listPartitions(
+ prefix: JMap[String, String],
+ filter: Predicate): JList[Partition] =
registeredPartitions(Map("dt" -> "20260715"))
}
val table = new
PaimonFormatTable(rawFormatTable(withCatalogManagedPartitions = true, gateway))
@@ -912,7 +927,7 @@ class FormatTablePartitionDdlPlanningTest extends
PaimonSparkTestWithRestCatalog
registeredPartitions(partitions.asScala.map(_.asScala.toMap).toSeq: _*)
}
- override def listPartitions(prefix: JMap[String, String]):
JList[Partition] =
+ override def listPartitions(prefix: JMap[String, String], filter:
Predicate): JList[Partition] =
Collections.emptyList()
}
@@ -944,8 +959,9 @@ class FormatTablePartitionDdlPlanningTest extends
PaimonSparkTestWithRestCatalog
override def listPartitionsByNames(partitions: JList[JMap[String,
String]]): JList[Partition] =
throw new AssertionError("ADD must not perform a client-side existence
lookup")
- override def listPartitions(prefix: JMap[String, String]):
JList[Partition] = synchronized {
- registeredPartitions(partitions.toSeq: _*)
- }
+ override def listPartitions(prefix: JMap[String, String], filter:
Predicate): JList[Partition] =
+ synchronized {
+ registeredPartitions(partitions.toSeq: _*)
+ }
}
}
diff --git
a/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/format/CatalogManagedPartitionLoadTest.scala
b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/format/CatalogManagedPartitionTest.scala
similarity index 62%
rename from
paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/format/CatalogManagedPartitionLoadTest.scala
rename to
paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/format/CatalogManagedPartitionTest.scala
index 9e39d19565..191fe2cb46 100644
---
a/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/format/CatalogManagedPartitionLoadTest.scala
+++
b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/format/CatalogManagedPartitionTest.scala
@@ -20,9 +20,10 @@ package org.apache.paimon.spark.format
import org.apache.paimon.spark.{PaimonSparkTestWithRestCatalogBase,
SparkCatalog}
+import org.apache.spark.sql.Row
import org.apache.spark.sql.connector.catalog.{Identifier => SparkIdentifier}
-class CatalogManagedPartitionLoadTest extends
PaimonSparkTestWithRestCatalogBase {
+class CatalogManagedPartitionTest extends PaimonSparkTestWithRestCatalogBase {
test(
"a Format Table with catalog-managed partitions loaded through
SparkCatalog carries its partition manager") {
@@ -43,4 +44,27 @@ class CatalogManagedPartitionLoadTest extends
PaimonSparkTestWithRestCatalogBase
assert(sparkTable.partitionManager != null)
}
}
+
+ test("catalog-managed Format Table sends partition predicate to REST
filtered listing") {
+ val tableName = "catalog_partition_filter_pushdown"
+ withTable(tableName) {
+ sql(s"""CREATE TABLE $tableName (id INT, payload STRING, year INT, month
INT)
+ |USING CSV
+ |PARTITIONED BY (year, month)
+ |TBLPROPERTIES (
+ | 'format-table.implementation' = 'paimon',
+ | 'metastore.partitioned-table' = 'true')
+ |""".stripMargin)
+ sql(s"""INSERT INTO $tableName VALUES
+ |(1, 'a', 2024, 12), (2, 'b', 2025, 9), (3, 'c', 2025, 11), (4,
'd', 2025, 12)
+ |""".stripMargin)
+
+ clearFilteredListingRequests()
+ checkAnswer(
+ sql(s"SELECT id FROM $tableName WHERE year = 2025 AND month > 10 ORDER
BY id"),
+ Seq(Row(3), Row(4)))
+
+ assert(filteredListingWasRequested, "the filtered listing endpoint was
not called")
+ }
+ }
}
diff --git
a/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/format/FormatTablePartitionManagementTest.scala
b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/format/FormatTablePartitionManagementTest.scala
index 3cb44485de..7c36fd09be 100644
---
a/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/format/FormatTablePartitionManagementTest.scala
+++
b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/format/FormatTablePartitionManagementTest.scala
@@ -23,6 +23,7 @@ import org.apache.paimon.fs.{FileIO, Path}
import org.apache.paimon.fs.local.LocalFileIO
import org.apache.paimon.options.Options
import org.apache.paimon.partition.Partition
+import org.apache.paimon.predicate.Predicate
import org.apache.paimon.table.FormatTable
import org.apache.paimon.table.format.FormatTablePartitionManager
import org.apache.paimon.types.DataTypes
@@ -61,7 +62,9 @@ class FormatTablePartitionManagementTest extends
SparkFunSuite {
partitions: JList[JMap[String, String]]): JList[Partition] =
Collections.emptyList()
- override def listPartitions(prefix: JMap[String, String]):
JList[Partition] =
+ override def listPartitions(
+ prefix: JMap[String, String],
+ filter: Predicate): JList[Partition] =
Collections.emptyList()
}
val sparkTable =
@@ -113,7 +116,9 @@ class FormatTablePartitionManagementTest extends
SparkFunSuite {
partitions: JList[JMap[String, String]]): JList[Partition] =
Collections.emptyList()
- override def listPartitions(prefix: JMap[String, String]):
JList[Partition] =
+ override def listPartitions(
+ prefix: JMap[String, String],
+ filter: Predicate): JList[Partition] =
Collections.emptyList()
}
@@ -144,7 +149,9 @@ class FormatTablePartitionManagementTest extends
SparkFunSuite {
partitions: JList[JMap[String, String]]): JList[Partition] =
Collections.emptyList()
- override def listPartitions(prefix: JMap[String, String]):
JList[Partition] =
+ override def listPartitions(
+ prefix: JMap[String, String],
+ filter: Predicate): JList[Partition] =
Collections.emptyList()
}
@@ -174,7 +181,9 @@ class FormatTablePartitionManagementTest extends
SparkFunSuite {
partitions: JList[JMap[String, String]]): JList[Partition] =
Collections.emptyList()
- override def listPartitions(prefix: JMap[String, String]):
JList[Partition] =
+ override def listPartitions(
+ prefix: JMap[String, String],
+ filter: Predicate): JList[Partition] =
Collections.emptyList()
}
@@ -245,7 +254,9 @@ class FormatTablePartitionManagementTest extends
SparkFunSuite {
partitions: JList[JMap[String, String]]): JList[Partition] =
Collections.emptyList()
- override def listPartitions(prefix: JMap[String, String]):
JList[Partition] =
+ override def listPartitions(
+ prefix: JMap[String, String],
+ filter: Predicate): JList[Partition] =
Collections.emptyList()
}
@@ -293,7 +304,9 @@ class FormatTablePartitionManagementTest extends
SparkFunSuite {
partitions: JList[JMap[String, String]]): JList[Partition] =
Collections.emptyList()
- override def listPartitions(prefix: JMap[String, String]):
JList[Partition] = {
+ override def listPartitions(
+ prefix: JMap[String, String],
+ filter: Predicate): JList[Partition] = {
listedPrefixes :+= prefix.asScala.toMap
registeredPartitions(first, second)
}
@@ -407,7 +420,9 @@ class FormatTablePartitionManagementTest extends
SparkFunSuite {
partitions: JList[JMap[String, String]]): JList[Partition] =
Collections.emptyList()
- override def listPartitions(prefix: JMap[String, String]):
JList[Partition] =
+ override def listPartitions(
+ prefix: JMap[String, String],
+ filter: Predicate): JList[Partition] =
registeredPartitions(matching)
}
@@ -446,7 +461,9 @@ class FormatTablePartitionManagementTest extends
SparkFunSuite {
partitions: JList[JMap[String, String]]): JList[Partition] =
Collections.emptyList()
- override def listPartitions(prefix: JMap[String, String]):
JList[Partition] =
+ override def listPartitions(
+ prefix: JMap[String, String],
+ filter: Predicate): JList[Partition] =
throw new TestPartitionListException
}
@@ -500,7 +517,9 @@ class FormatTablePartitionManagementTest extends
SparkFunSuite {
partitions: JList[JMap[String, String]]): JList[Partition] =
Collections.emptyList()
- override def listPartitions(prefix: JMap[String, String]):
JList[Partition] =
+ override def listPartitions(
+ prefix: JMap[String, String],
+ filter: Predicate): JList[Partition] =
registeredPartitions(registered.toSeq: _*)
}
@@ -600,7 +619,9 @@ class FormatTablePartitionManagementTest extends
SparkFunSuite {
partitions: JList[JMap[String, String]]): JList[Partition] =
Collections.emptyList()
- override def listPartitions(prefix: JMap[String, String]):
JList[Partition] =
+ override def listPartitions(
+ prefix: JMap[String, String],
+ filter: Predicate): JList[Partition] =
Collections.emptyList()
}
@@ -639,7 +660,7 @@ class FormatTablePartitionManagementTest extends
SparkFunSuite {
partitions.asScala.map(_.asScala.toMap).filter(storedPartitions.contains).toSeq:
_*)
}
- override def listPartitions(prefix: JMap[String, String]):
JList[Partition] =
+ override def listPartitions(prefix: JMap[String, String], filter:
Predicate): JList[Partition] =
throw new AssertionError("ADD must not enumerate catalog or filesystem
partitions")
}
@@ -662,7 +683,7 @@ class FormatTablePartitionManagementTest extends
SparkFunSuite {
override def listPartitionsByNames(partitions: JList[JMap[String,
String]]): JList[Partition] =
Collections.emptyList()
- override def listPartitions(prefix: JMap[String, String]):
JList[Partition] =
+ override def listPartitions(prefix: JMap[String, String], filter:
Predicate): JList[Partition] =
Collections.emptyList()
}
diff --git
a/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/CatalogManagedPartitionMsckRepairTest.scala
b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/CatalogManagedPartitionMsckRepairTest.scala
index 3997341b45..24e42b9c86 100644
---
a/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/CatalogManagedPartitionMsckRepairTest.scala
+++
b/paimon-spark/paimon-spark-ut/src/test/scala/org/apache/paimon/spark/sql/CatalogManagedPartitionMsckRepairTest.scala
@@ -21,6 +21,7 @@ package org.apache.paimon.spark.sql
import org.apache.paimon.catalog.Identifier
import org.apache.paimon.fs.Path
import org.apache.paimon.partition.Partition
+import org.apache.paimon.predicate.Predicate
import org.apache.paimon.spark.{PaimonSparkTestWithRestCatalogBase,
SparkCatalog}
import org.apache.paimon.spark.execution.PaimonRepairFormatTablePartitionsExec
import org.apache.paimon.spark.format.PaimonFormatTable
@@ -579,7 +580,7 @@ private[sql] class StatefulFaultCatalog(initial:
Set[Map[String, String]] = Set.
MsckTestFixtures.toPartitions(
partitions.asScala.map(_.asScala.toMap).filter(state.contains).toSeq)
- override def listPartitions(prefix: JMap[String, String]): JList[Partition]
= {
+ override def listPartitions(prefix: JMap[String, String], filter:
Predicate): JList[Partition] = {
if (failList) {
throw new IllegalStateException(MsckFaultInjection.LIST_FAILURE)
}
@@ -659,11 +660,11 @@ private[sql] class
FaultInjectingFormatTablePartitionManager(delegate: FormatTab
override def listPartitionsByNames(partitions: JList[JMap[String, String]]):
JList[Partition] =
delegate.listPartitionsByNames(partitions)
- override def listPartitions(prefix: JMap[String, String]): JList[Partition]
= {
+ override def listPartitions(prefix: JMap[String, String], filter:
Predicate): JList[Partition] = {
MsckFaultInjection.listCalls += 1
if (MsckFaultInjection.failList) {
throw new IllegalStateException(MsckFaultInjection.LIST_FAILURE)
}
- delegate.listPartitions(prefix)
+ delegate.listPartitions(prefix, filter)
}
}