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 56e0fce4f7 [core][rest] Support catalog-managed Format Table
partitions (#8707)
56e0fce4f7 is described below
commit 56e0fce4f7166001b310920cd0ec5834c9d5118e
Author: Dapeng Sun(孙大鹏) <[email protected]>
AuthorDate: Fri Jul 17 17:31:42 2026 +0800
[core][rest] Support catalog-managed Format Table partitions (#8707)
Support catalog-managed partitions for Format Tables in REST Catalog.
This adds create/drop APIs; managed partition listing does not fall back
to filesystem discovery, and drops only unregister catalog metadata.
---
docs/static/rest-catalog-open-api.yaml | 145 ++++++++++++++++++++-
.../main/java/org/apache/paimon/rest/RESTApi.java | 35 +++++
.../java/org/apache/paimon/rest/ResourcePaths.java | 12 ++
.../rest/requests/CreatePartitionsRequest.java | 67 ++++++++++
.../rest/requests/DropPartitionsRequest.java | 67 ++++++++++
.../rest/responses/CreatePartitionsResponse.java | 61 +++++++++
.../rest/responses/DropPartitionsResponse.java | 61 +++++++++
.../paimon/rest/responses/ErrorResponse.java | 2 +
.../org/apache/paimon/catalog/CachingCatalog.java | 19 +++
.../java/org/apache/paimon/catalog/Catalog.java | 21 +++
.../org/apache/paimon/catalog/DelegateCatalog.java | 7 +
.../java/org/apache/paimon/rest/RESTCatalog.java | 81 +++++++++++-
.../apache/paimon/catalog/CachingCatalogTest.java | 34 +++++
.../apache/paimon/rest/MockRESTCatalogTest.java | 50 +++++++
.../org/apache/paimon/rest/RESTApiJsonTest.java | 87 +++++++++++++
.../org/apache/paimon/rest/RESTCatalogServer.java | 109 +++++++++++++++-
.../org/apache/paimon/rest/RESTCatalogTest.java | 110 ++++++++++++++++
17 files changed, 958 insertions(+), 10 deletions(-)
diff --git a/docs/static/rest-catalog-open-api.yaml
b/docs/static/rest-catalog-open-api.yaml
index e310f398f8..2754e720d1 100644
--- a/docs/static/rest-catalog-open-api.yaml
+++ b/docs/static/rest-catalog-open-api.yaml
@@ -983,6 +983,90 @@ paths:
$ref: '#/components/responses/TableNotExistErrorResponse'
"500":
$ref: '#/components/responses/ServerErrorResponse'
+ post:
+ tags:
+ - partition
+ summary: Create partitions
+ operationId: createPartitions
+ 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/CreatePartitionsRequest'
+ responses:
+ "200":
+ description: Partitions created or found to exist
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/CreatePartitionsResponse'
+ "401":
+ $ref: '#/components/responses/UnauthorizedErrorResponse'
+ "404":
+ $ref: '#/components/responses/TableNotExistErrorResponse'
+ "409":
+ $ref: '#/components/responses/ResourceAlreadyExistErrorResponse'
+ "500":
+ $ref: '#/components/responses/ServerErrorResponse'
+ /v1/{prefix}/databases/{database}/tables/{table}/partitions/drop:
+ post:
+ tags:
+ - partition
+ summary: Drop partitions
+ description: Unregisters partitions from the catalog. The server never
deletes data files; managed format table data deletion is performed by the
client afterwards.
+ operationId: dropPartitions
+ 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/DropPartitionsRequest'
+ responses:
+ "200":
+ description: Partitions dropped or found missing
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/DropPartitionsResponse'
+ "401":
+ $ref: '#/components/responses/UnauthorizedErrorResponse'
+ "404":
+ $ref: '#/components/responses/TableNotExistErrorResponse'
+ "500":
+ $ref: '#/components/responses/ServerErrorResponse'
/v1/{prefix}/databases/{database}/tables/{table}/partitions/mark:
post:
tags:
@@ -2352,11 +2436,68 @@ components:
type: string
DropPartitionsRequest:
type: object
+ required:
+ - partitionSpecs
+ properties:
+ partitionSpecs:
+ type: array
+ items:
+ type: object
+ additionalProperties:
+ type: string
+ ignoreIfNotExists:
+ type: boolean
+ default: true
+ DropPartitionsResponse:
+ type: object
+ required:
+ - dropped
+ - missing
properties:
- specs:
+ dropped:
type: array
items:
type: object
+ additionalProperties:
+ type: string
+ missing:
+ type: array
+ items:
+ type: object
+ additionalProperties:
+ type: string
+ CreatePartitionsRequest:
+ type: object
+ required:
+ - partitionSpecs
+ properties:
+ partitionSpecs:
+ type: array
+ items:
+ type: object
+ additionalProperties:
+ type: string
+ ignoreIfExists:
+ type: boolean
+ default: true
+ CreatePartitionsResponse:
+ type: object
+ required:
+ - created
+ - existed
+ properties:
+ created:
+ type: array
+ items:
+ type: object
+ additionalProperties:
+ type: string
+ existed:
+ type: array
+ items:
+ type: object
+ additionalProperties:
+ type: string
AlterTableRequest:
type: object
properties:
@@ -2398,7 +2539,7 @@ components:
resourceType:
type: string
nullable: true
- enum: [ "DATABASE", "TABLE", "COLUMN", "SNAPSHOT", "BRANCH", "TAG",
"VIEW", "DIALECT", "UNKNOWN" ]
+ enum: [ "DATABASE", "TABLE", "PARTITION", "COLUMN", "SNAPSHOT",
"BRANCH", "TAG", "VIEW", "DIALECT", "UNKNOWN" ]
resourceName:
type: string
nullable: true
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 263c4e2c06..4d321ed279 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
@@ -42,9 +42,11 @@ import org.apache.paimon.rest.requests.CommitTableRequest;
import org.apache.paimon.rest.requests.CreateBranchRequest;
import org.apache.paimon.rest.requests.CreateDatabaseRequest;
import org.apache.paimon.rest.requests.CreateFunctionRequest;
+import org.apache.paimon.rest.requests.CreatePartitionsRequest;
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.ForwardBranchRequest;
import org.apache.paimon.rest.requests.ListPartitionsByNamesRequest;
import org.apache.paimon.rest.requests.MarkDonePartitionsRequest;
@@ -58,6 +60,8 @@ import org.apache.paimon.rest.responses.AlterDatabaseResponse;
import org.apache.paimon.rest.responses.AuthTableQueryResponse;
import org.apache.paimon.rest.responses.CommitTableResponse;
import org.apache.paimon.rest.responses.ConfigResponse;
+import org.apache.paimon.rest.responses.CreatePartitionsResponse;
+import org.apache.paimon.rest.responses.DropPartitionsResponse;
import org.apache.paimon.rest.responses.ErrorResponse;
import org.apache.paimon.rest.responses.GetDatabaseResponse;
import org.apache.paimon.rest.responses.GetFunctionResponse;
@@ -855,6 +859,37 @@ public class RESTApi {
restAuthFunction);
}
+ /** Create partitions for table, ignoring partitions which already exist.
*/
+ public CreatePartitionsResponse createPartitions(
+ Identifier identifier, List<Map<String, String>> partitions) {
+ return createPartitions(identifier, partitions, true);
+ }
+
+ /** Create partitions for table. */
+ public CreatePartitionsResponse createPartitions(
+ Identifier identifier, List<Map<String, String>> partitions,
boolean ignoreIfExists) {
+ CreatePartitionsRequest request = new
CreatePartitionsRequest(partitions, ignoreIfExists);
+ return client.post(
+ resourcePaths.partitions(identifier.getDatabaseName(),
identifier.getObjectName()),
+ request,
+ CreatePartitionsResponse.class,
+ restAuthFunction);
+ }
+
+ /** Drop (unregister) partitions for table; the server never deletes data
files. */
+ public DropPartitionsResponse dropPartitions(
+ Identifier identifier,
+ List<Map<String, String>> partitions,
+ boolean ignoreIfNotExists) {
+ DropPartitionsRequest request = new DropPartitionsRequest(partitions,
ignoreIfNotExists);
+ return client.post(
+ resourcePaths.dropPartitions(
+ identifier.getDatabaseName(),
identifier.getObjectName()),
+ request,
+ DropPartitionsResponse.class,
+ restAuthFunction);
+ }
+
/**
* List partitions 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 28f79d0409..0ab36e348c 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
@@ -213,6 +213,18 @@ public class ResourcePaths {
PARTITIONS);
}
+ public String dropPartitions(String databaseName, String objectName) {
+ return SLASH.join(
+ V1,
+ prefix,
+ DATABASES,
+ encodeString(databaseName),
+ TABLES,
+ encodeString(objectName),
+ PARTITIONS,
+ "drop");
+ }
+
public String markDonePartitions(String databaseName, String objectName) {
return SLASH.join(
V1,
diff --git
a/paimon-api/src/main/java/org/apache/paimon/rest/requests/CreatePartitionsRequest.java
b/paimon-api/src/main/java/org/apache/paimon/rest/requests/CreatePartitionsRequest.java
new file mode 100644
index 0000000000..506c66406e
--- /dev/null
+++
b/paimon-api/src/main/java/org/apache/paimon/rest/requests/CreatePartitionsRequest.java
@@ -0,0 +1,67 @@
+/*
+ * 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.JsonProperty;
+
+import javax.annotation.Nullable;
+
+import java.util.List;
+import java.util.Map;
+
+/** Request for creating partitions. */
+@JsonIgnoreProperties(ignoreUnknown = true)
+public class CreatePartitionsRequest implements RESTRequest {
+
+ private static final String FIELD_PARTITION_SPECS = "partitionSpecs";
+ private static final String FIELD_IGNORE_IF_EXISTS = "ignoreIfExists";
+
+ @JsonProperty(FIELD_PARTITION_SPECS)
+ private final List<Map<String, String>> partitionSpecs;
+
+ @JsonProperty(FIELD_IGNORE_IF_EXISTS)
+ private final boolean ignoreIfExists;
+
+ public CreatePartitionsRequest(List<Map<String, String>> partitionSpecs) {
+ this(partitionSpecs, true);
+ }
+
+ @JsonCreator
+ public CreatePartitionsRequest(
+ @JsonProperty(FIELD_PARTITION_SPECS) List<Map<String, String>>
partitionSpecs,
+ @JsonProperty(FIELD_IGNORE_IF_EXISTS) @Nullable Boolean
ignoreIfExists) {
+ this.partitionSpecs = partitionSpecs;
+ this.ignoreIfExists = ignoreIfExists == null || ignoreIfExists;
+ }
+
+ @JsonGetter(FIELD_PARTITION_SPECS)
+ public List<Map<String, String>> getPartitionSpecs() {
+ return partitionSpecs;
+ }
+
+ @JsonGetter(FIELD_IGNORE_IF_EXISTS)
+ public boolean ignoreIfExists() {
+ return ignoreIfExists;
+ }
+}
diff --git
a/paimon-api/src/main/java/org/apache/paimon/rest/requests/DropPartitionsRequest.java
b/paimon-api/src/main/java/org/apache/paimon/rest/requests/DropPartitionsRequest.java
new file mode 100644
index 0000000000..cdc448034e
--- /dev/null
+++
b/paimon-api/src/main/java/org/apache/paimon/rest/requests/DropPartitionsRequest.java
@@ -0,0 +1,67 @@
+/*
+ * 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.JsonProperty;
+
+import javax.annotation.Nullable;
+
+import java.util.List;
+import java.util.Map;
+
+/** Request for dropping (unregistering) partitions. */
+@JsonIgnoreProperties(ignoreUnknown = true)
+public class DropPartitionsRequest implements RESTRequest {
+
+ private static final String FIELD_PARTITION_SPECS = "partitionSpecs";
+ private static final String FIELD_IGNORE_IF_NOT_EXISTS =
"ignoreIfNotExists";
+
+ @JsonProperty(FIELD_PARTITION_SPECS)
+ private final List<Map<String, String>> partitionSpecs;
+
+ @JsonProperty(FIELD_IGNORE_IF_NOT_EXISTS)
+ private final boolean ignoreIfNotExists;
+
+ public DropPartitionsRequest(List<Map<String, String>> partitionSpecs) {
+ this(partitionSpecs, true);
+ }
+
+ @JsonCreator
+ public DropPartitionsRequest(
+ @JsonProperty(FIELD_PARTITION_SPECS) List<Map<String, String>>
partitionSpecs,
+ @JsonProperty(FIELD_IGNORE_IF_NOT_EXISTS) @Nullable Boolean
ignoreIfNotExists) {
+ this.partitionSpecs = partitionSpecs;
+ this.ignoreIfNotExists = ignoreIfNotExists == null ||
ignoreIfNotExists;
+ }
+
+ @JsonGetter(FIELD_PARTITION_SPECS)
+ public List<Map<String, String>> getPartitionSpecs() {
+ return partitionSpecs;
+ }
+
+ @JsonGetter(FIELD_IGNORE_IF_NOT_EXISTS)
+ public boolean ignoreIfNotExists() {
+ return ignoreIfNotExists;
+ }
+}
diff --git
a/paimon-api/src/main/java/org/apache/paimon/rest/responses/CreatePartitionsResponse.java
b/paimon-api/src/main/java/org/apache/paimon/rest/responses/CreatePartitionsResponse.java
new file mode 100644
index 0000000000..03a923ed21
--- /dev/null
+++
b/paimon-api/src/main/java/org/apache/paimon/rest/responses/CreatePartitionsResponse.java
@@ -0,0 +1,61 @@
+/*
+ * 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.responses;
+
+import org.apache.paimon.rest.RESTResponse;
+
+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.JsonProperty;
+
+import java.util.List;
+import java.util.Map;
+
+/** Response for creating partitions. */
+@JsonIgnoreProperties(ignoreUnknown = true)
+public class CreatePartitionsResponse implements RESTResponse {
+
+ private static final String FIELD_CREATED = "created";
+ private static final String FIELD_EXISTED = "existed";
+
+ @JsonProperty(FIELD_CREATED)
+ private final List<Map<String, String>> created;
+
+ @JsonProperty(FIELD_EXISTED)
+ private final List<Map<String, String>> existed;
+
+ @JsonCreator
+ public CreatePartitionsResponse(
+ @JsonProperty(FIELD_CREATED) List<Map<String, String>> created,
+ @JsonProperty(FIELD_EXISTED) List<Map<String, String>> existed) {
+ this.created = created;
+ this.existed = existed;
+ }
+
+ @JsonGetter(FIELD_CREATED)
+ public List<Map<String, String>> getCreated() {
+ return created;
+ }
+
+ @JsonGetter(FIELD_EXISTED)
+ public List<Map<String, String>> getExisted() {
+ return existed;
+ }
+}
diff --git
a/paimon-api/src/main/java/org/apache/paimon/rest/responses/DropPartitionsResponse.java
b/paimon-api/src/main/java/org/apache/paimon/rest/responses/DropPartitionsResponse.java
new file mode 100644
index 0000000000..b250b6ddb9
--- /dev/null
+++
b/paimon-api/src/main/java/org/apache/paimon/rest/responses/DropPartitionsResponse.java
@@ -0,0 +1,61 @@
+/*
+ * 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.responses;
+
+import org.apache.paimon.rest.RESTResponse;
+
+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.JsonProperty;
+
+import java.util.List;
+import java.util.Map;
+
+/** Response for dropping (unregistering) partitions. */
+@JsonIgnoreProperties(ignoreUnknown = true)
+public class DropPartitionsResponse implements RESTResponse {
+
+ private static final String FIELD_DROPPED = "dropped";
+ private static final String FIELD_MISSING = "missing";
+
+ @JsonProperty(FIELD_DROPPED)
+ private final List<Map<String, String>> dropped;
+
+ @JsonProperty(FIELD_MISSING)
+ private final List<Map<String, String>> missing;
+
+ @JsonCreator
+ public DropPartitionsResponse(
+ @JsonProperty(FIELD_DROPPED) List<Map<String, String>> dropped,
+ @JsonProperty(FIELD_MISSING) List<Map<String, String>> missing) {
+ this.dropped = dropped;
+ this.missing = missing;
+ }
+
+ @JsonGetter(FIELD_DROPPED)
+ public List<Map<String, String>> getDropped() {
+ return dropped;
+ }
+
+ @JsonGetter(FIELD_MISSING)
+ public List<Map<String, String>> getMissing() {
+ return missing;
+ }
+}
diff --git
a/paimon-api/src/main/java/org/apache/paimon/rest/responses/ErrorResponse.java
b/paimon-api/src/main/java/org/apache/paimon/rest/responses/ErrorResponse.java
index d4f9662dfa..4eb52308dd 100644
---
a/paimon-api/src/main/java/org/apache/paimon/rest/responses/ErrorResponse.java
+++
b/paimon-api/src/main/java/org/apache/paimon/rest/responses/ErrorResponse.java
@@ -35,6 +35,8 @@ public class ErrorResponse implements RESTResponse {
public static final String RESOURCE_TYPE_TABLE = "TABLE";
+ public static final String RESOURCE_TYPE_PARTITION = "PARTITION";
+
public static final String RESOURCE_TYPE_COLUMN = "COLUMN";
public static final String RESOURCE_TYPE_SNAPSHOT = "SNAPSHOT";
diff --git
a/paimon-core/src/main/java/org/apache/paimon/catalog/CachingCatalog.java
b/paimon-core/src/main/java/org/apache/paimon/catalog/CachingCatalog.java
index afe4ed8ae6..01f04cbba3 100644
--- a/paimon-core/src/main/java/org/apache/paimon/catalog/CachingCatalog.java
+++ b/paimon-core/src/main/java/org/apache/paimon/catalog/CachingCatalog.java
@@ -335,6 +335,25 @@ public class CachingCatalog extends DelegateCatalog {
return result;
}
+ @Override
+ public void createPartitions(Identifier identifier, List<Map<String,
String>> partitions)
+ throws TableNotExistException {
+ wrapped.createPartitions(identifier, partitions);
+ if (partitionCache != null) {
+ partitionCache.invalidate(identifier);
+ }
+ }
+
+ @Override
+ public void createPartitions(
+ Identifier identifier, List<Map<String, String>> partitions,
boolean ignoreIfExists)
+ throws TableNotExistException {
+ wrapped.createPartitions(identifier, partitions, ignoreIfExists);
+ if (partitionCache != null) {
+ partitionCache.invalidate(identifier);
+ }
+ }
+
@Override
public void dropPartitions(Identifier identifier, List<Map<String,
String>> partitions)
throws TableNotExistException {
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 57fa040a2a..8e4ad8d518 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
@@ -1034,6 +1034,27 @@ public interface Catalog extends AutoCloseable {
default void createPartitions(Identifier identifier, List<Map<String,
String>> partitions)
throws TableNotExistException {}
+ /**
+ * Create partitions of the specify table with explicit existence
semantics.
+ *
+ * @param identifier path of the table to create partitions
+ * @param partitions partitions to be created
+ * @param ignoreIfExists if false, fail when any partition already exists
and apply none of the
+ * batch; if true, behave like {@link #createPartitions(Identifier,
List)}
+ * @throws TableNotExistException if the table does not exist
+ */
+ default void createPartitions(
+ Identifier identifier, List<Map<String, String>> partitions,
boolean ignoreIfExists)
+ throws TableNotExistException {
+ if (!ignoreIfExists) {
+ throw new UnsupportedOperationException(
+ String.format(
+ "Catalog %s does not support createPartitions
without ignoreIfExists.",
+ getClass().getName()));
+ }
+ createPartitions(identifier, partitions);
+ }
+
/**
* Drop partitions of the specify table. Ignore non-existent partitions.
*
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 0f18f7d045..290849024f 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
@@ -322,6 +322,13 @@ public abstract class DelegateCatalog implements Catalog {
wrapped.createPartitions(identifier, partitions);
}
+ @Override
+ public void createPartitions(
+ Identifier identifier, List<Map<String, String>> partitions,
boolean ignoreIfExists)
+ throws TableNotExistException {
+ wrapped.createPartitions(identifier, partitions, ignoreIfExists);
+ }
+
@Override
public void dropPartitions(Identifier identifier, List<Map<String,
String>> partitions)
throws TableNotExistException {
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 0a76fb5a78..c44378f6d0 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
@@ -59,9 +59,11 @@ import org.apache.paimon.schema.Schema;
import org.apache.paimon.schema.SchemaChange;
import org.apache.paimon.schema.SchemaManager;
import org.apache.paimon.schema.TableSchema;
+import org.apache.paimon.table.FormatTable;
import org.apache.paimon.table.Instant;
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.Pair;
import org.apache.paimon.utils.SnapshotNotExistException;
@@ -725,6 +727,59 @@ public class RESTCatalog implements Catalog {
}
}
+ @Override
+ public void createPartitions(Identifier identifier, List<Map<String,
String>> partitions)
+ throws TableNotExistException {
+ createPartitions(identifier, partitions, true);
+ }
+
+ @Override
+ public void createPartitions(
+ Identifier identifier, List<Map<String, String>> partitions,
boolean ignoreIfExists)
+ throws TableNotExistException {
+ try {
+ api.createPartitions(identifier, partitions, ignoreIfExists);
+ } catch (NoSuchResourceException e) {
+ throw new TableNotExistException(identifier);
+ } catch (ForbiddenException e) {
+ throw new TableNoPermissionException(identifier, e);
+ } catch (AlreadyExistsException e) {
+ // Server contract: with ignoreIfExists=false the whole batch is
rejected atomically.
+ throw new IllegalStateException(
+ String.format(
+ "Some partitions of table %s already exist: %s",
+ identifier, e.getMessage()));
+ } catch (BadRequestException e) {
+ throw new IllegalArgumentException(e.getMessage());
+ }
+ }
+
+ @Override
+ public void dropPartitions(Identifier identifier, List<Map<String,
String>> partitions)
+ throws TableNotExistException {
+ Table table = getTable(identifier);
+ if (isCatalogManagedFormatTable(table)) {
+ // Managed format table: metadata-only unregistration on the
server. Data deletion is
+ // the caller's responsibility (performed with the table FileIO
afterwards).
+ try {
+ api.dropPartitions(identifier, partitions, true);
+ } catch (NoSuchResourceException e) {
+ throw new TableNotExistException(identifier);
+ } catch (ForbiddenException e) {
+ throw new TableNoPermissionException(identifier, e);
+ } catch (BadRequestException e) {
+ throw new IllegalArgumentException(e.getMessage());
+ }
+ return;
+ }
+ // Non-managed tables keep the default truncate-based data semantics.
+ try (BatchTableCommit commit =
table.newBatchWriteBuilder().newCommit()) {
+ commit.truncatePartitions(partitions);
+ } catch (Exception e) {
+ throw new RuntimeException(e);
+ }
+ }
+
@Override
public List<Partition> listPartitions(Identifier identifier) throws
TableNotExistException {
try {
@@ -734,8 +789,11 @@ public class RESTCatalog implements Catalog {
} catch (ForbiddenException e) {
throw new TableNoPermissionException(identifier, e);
} catch (NotImplementedException e) {
- // not a metastore partitioned table
- return listPartitionsFromFileSystem(getTable(identifier));
+ Table table = getTable(identifier);
+ if (isCatalogManagedFormatTable(table)) {
+ throw e;
+ }
+ return listPartitionsFromFileSystem(table);
}
}
@@ -753,8 +811,11 @@ public class RESTCatalog implements Catalog {
} catch (ForbiddenException e) {
throw new TableNoPermissionException(identifier, e);
} catch (NotImplementedException e) {
- // not a metastore partitioned table
- return new
PagedList<>(listPartitionsFromFileSystem(getTable(identifier)), null);
+ Table table = getTable(identifier);
+ if (isCatalogManagedFormatTable(table)) {
+ throw e;
+ }
+ return new PagedList<>(listPartitionsFromFileSystem(table), null);
}
}
@@ -769,7 +830,11 @@ public class RESTCatalog implements Catalog {
} catch (ForbiddenException e) {
throw new TableNoPermissionException(identifier, e);
} catch (NotImplementedException e) {
- return listPartitionsFromFileSystem(getTable(identifier),
partitions);
+ Table table = getTable(identifier);
+ if (isCatalogManagedFormatTable(table)) {
+ throw e;
+ }
+ return listPartitionsFromFileSystem(table, partitions);
}
}
@@ -836,6 +901,7 @@ public class RESTCatalog implements Catalog {
@Override
public boolean supportsPartitionModification() {
+ // Paimon tables use commit-based partition maintenance.
return false;
}
@@ -1257,6 +1323,11 @@ public class RESTCatalog implements Catalog {
return api;
}
+ private static boolean isCatalogManagedFormatTable(Table table) {
+ return table instanceof FormatTable
+ && new
CoreOptions(table.options()).partitionedTableInMetastore();
+ }
+
private FileIO fileIOForData(Path path, Identifier identifier) {
return CachingFileIO.wrapWithCachingIfNeeded(
dataTokenEnabled
diff --git
a/paimon-core/src/test/java/org/apache/paimon/catalog/CachingCatalogTest.java
b/paimon-core/src/test/java/org/apache/paimon/catalog/CachingCatalogTest.java
index c28299b79e..0961050f11 100644
---
a/paimon-core/src/test/java/org/apache/paimon/catalog/CachingCatalogTest.java
+++
b/paimon-core/src/test/java/org/apache/paimon/catalog/CachingCatalogTest.java
@@ -56,6 +56,7 @@ import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
+import java.util.Map;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
@@ -65,6 +66,7 @@ import java.util.concurrent.atomic.AtomicInteger;
import static java.util.Collections.emptyList;
import static java.util.Collections.singletonList;
+import static java.util.Collections.singletonMap;
import static org.apache.paimon.data.BinaryString.fromString;
import static
org.apache.paimon.options.CatalogOptions.CACHE_EXPIRE_AFTER_ACCESS;
import static
org.apache.paimon.options.CatalogOptions.CACHE_MANIFEST_MAX_MEMORY;
@@ -334,6 +336,38 @@ class CachingCatalogTest extends CatalogTestBase {
assertThat(partitionEntryListFromCache).containsAll(partitionEntryList);
}
+ @Test
+ public void testCreatePartitionsInvalidatesPartitionCache() throws
Exception {
+ Catalog wrapped = Mockito.mock(Catalog.class);
+ TestableCachingCatalog catalog =
+ new TestableCachingCatalog(wrapped, EXPIRATION_TTL, ticker);
+ Identifier identifier = new Identifier("db", "tbl");
+ Map<String, String> spec = singletonMap("dt", "20260717");
+ Partition created = new Partition(spec, 0, 0, 0, 0, -1, false);
+ when(wrapped.listPartitions(identifier)).thenReturn(emptyList(),
singletonList(created));
+
+ assertThat(catalog.listPartitions(identifier)).isEmpty();
+ catalog.createPartitions(identifier, singletonList(spec));
+
+
assertThat(catalog.listPartitions(identifier)).containsExactly(created);
+ }
+
+ @Test
+ public void
testCreatePartitionsWithIgnoreIfExistsInvalidatesPartitionCache() throws
Exception {
+ Catalog wrapped = Mockito.mock(Catalog.class);
+ TestableCachingCatalog catalog =
+ new TestableCachingCatalog(wrapped, EXPIRATION_TTL, ticker);
+ Identifier identifier = new Identifier("db", "tbl");
+ Map<String, String> spec = singletonMap("dt", "20260717");
+ Partition created = new Partition(spec, 0, 0, 0, 0, -1, false);
+ when(wrapped.listPartitions(identifier)).thenReturn(emptyList(),
singletonList(created));
+
+ assertThat(catalog.listPartitions(identifier)).isEmpty();
+ catalog.createPartitions(identifier, singletonList(spec), false);
+
+
assertThat(catalog.listPartitions(identifier)).containsExactly(created);
+ }
+
@Test
public void testDeadlock() throws Exception {
Catalog underlyCatalog = this.catalog;
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 f5045a4360..b4af3194b7 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
@@ -43,6 +43,7 @@ import org.apache.paimon.rest.auth.DLFTokenLoader;
import org.apache.paimon.rest.auth.DLFTokenLoaderFactory;
import org.apache.paimon.rest.auth.RESTAuthParameter;
import org.apache.paimon.rest.exceptions.NotAuthorizedException;
+import org.apache.paimon.rest.exceptions.NotImplementedException;
import org.apache.paimon.rest.responses.ConfigResponse;
import org.apache.paimon.schema.Schema;
import org.apache.paimon.types.DataTypes;
@@ -214,6 +215,55 @@ class MockRESTCatalogTest extends RESTCatalogTest {
"default-value");
}
+ @Test
+ void testManagedFormatTablePagedPartitionListingDoesNotFallback() throws
Exception {
+ Identifier identifier = createManagedFormatTable();
+ restCatalogServer.setPartitionListingSupported(false);
+
+ assertThatThrownBy(() -> restCatalog.listPartitionsPaged(identifier,
null, null, null))
+ .isInstanceOf(NotImplementedException.class);
+ }
+
+ @Test
+ void testManagedFormatTablePartitionListingByNamesDoesNotFallback() throws
Exception {
+ Identifier identifier = createManagedFormatTable();
+ restCatalogServer.setPartitionListingSupported(false);
+
+ assertThatThrownBy(
+ () ->
+ restCatalog.listPartitionsByNames(
+ identifier,
+ Collections.singletonList(
+ Collections.singletonMap("dt",
"20260717"))))
+ .isInstanceOf(NotImplementedException.class);
+ }
+
+ @Test
+ void testManagedFormatTablePartitionListingDoesNotFallback() throws
Exception {
+ Identifier identifier = createManagedFormatTable();
+ restCatalogServer.setPartitionListingSupported(false);
+
+ assertThatThrownBy(() -> restCatalog.listPartitions(identifier))
+ .isInstanceOf(NotImplementedException.class);
+ }
+
+ private Identifier createManagedFormatTable() throws Exception {
+ Identifier identifier = Identifier.create("db1",
"managed_partition_table");
+ restCatalog.createDatabase(identifier.getDatabaseName(), true);
+ restCatalog.createTable(
+ identifier,
+ Schema.newBuilder()
+ .option(CoreOptions.TYPE.key(),
TableType.FORMAT_TABLE.toString())
+ .option(CoreOptions.METASTORE_PARTITIONED_TABLE.key(),
"true")
+ .option(CoreOptions.FILE_FORMAT.key(), "parquet")
+ .column("id", DataTypes.INT())
+ .column("dt", DataTypes.STRING())
+ .partitionKeys("dt")
+ .build(),
+ false);
+ return identifier;
+ }
+
@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 1b8b1f71d0..bdf2b5f82c 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
@@ -24,13 +24,17 @@ import org.apache.paimon.rest.requests.AlterTableRequest;
import org.apache.paimon.rest.requests.AlterViewRequest;
import org.apache.paimon.rest.requests.CreateDatabaseRequest;
import org.apache.paimon.rest.requests.CreateFunctionRequest;
+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.RenameTableRequest;
import org.apache.paimon.rest.requests.RollbackTableRequest;
import org.apache.paimon.rest.responses.AlterDatabaseResponse;
import org.apache.paimon.rest.responses.AuthTableQueryResponse;
import org.apache.paimon.rest.responses.ConfigResponse;
+import org.apache.paimon.rest.responses.CreatePartitionsResponse;
+import org.apache.paimon.rest.responses.DropPartitionsResponse;
import org.apache.paimon.rest.responses.ErrorResponse;
import org.apache.paimon.rest.responses.GetDatabaseResponse;
import org.apache.paimon.rest.responses.GetFunctionResponse;
@@ -50,10 +54,12 @@ import
org.apache.paimon.shade.jackson2.com.fasterxml.jackson.core.JsonProcessin
import org.junit.Test;
+import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
/** Test for {@link RESTApi} json. */
@@ -204,6 +210,87 @@ public class RESTApiJsonTest {
parseData.getPartitions().get(0).fileCount());
}
+ @Test
+ public void createPartitionsResponseParseTest() throws Exception {
+ Map<String, String> created = new HashMap<>();
+ created.put("dt", "20260714");
+ created.put("region", "cn");
+ Map<String, String> existed = new HashMap<>();
+ existed.put("dt", "20260713");
+ existed.put("region", "us");
+ CreatePartitionsResponse response =
+ new CreatePartitionsResponse(
+ Collections.singletonList(created),
Collections.singletonList(existed));
+ CreatePartitionsResponse parsed =
+ RESTApi.fromJson(RESTApi.toJson(response),
CreatePartitionsResponse.class);
+
+ assertEquals(Collections.singletonList(created), parsed.getCreated());
+ assertEquals(Collections.singletonList(existed), parsed.getExisted());
+ }
+
+ @Test
+ public void createPartitionsRequestParseTest() throws Exception {
+ String requestWithoutIgnoreIfExists =
"{\"partitionSpecs\":[{\"dt\":\"20260715\"}]}";
+ CreatePartitionsRequest defaultRequest =
+ RESTApi.fromJson(requestWithoutIgnoreIfExists,
CreatePartitionsRequest.class);
+
+ assertEquals(
+ Collections.singletonList(Collections.singletonMap("dt",
"20260715")),
+ defaultRequest.getPartitionSpecs());
+ assertTrue(defaultRequest.ignoreIfExists());
+
+ CreatePartitionsRequest explicitRequest =
+ new
CreatePartitionsRequest(defaultRequest.getPartitionSpecs(), false);
+ String explicitRequestJson = RESTApi.toJson(explicitRequest);
+ assertTrue(explicitRequestJson.contains("\"partitionSpecs\""));
+ assertFalse(explicitRequestJson.contains("\"specs\""));
+ assertTrue(explicitRequestJson.contains("\"ignoreIfExists\":false"));
+
+ CreatePartitionsRequest parsedExplicitRequest =
+ RESTApi.fromJson(explicitRequestJson,
CreatePartitionsRequest.class);
+ assertFalse(parsedExplicitRequest.ignoreIfExists());
+ }
+
+ @Test
+ public void dropPartitionsResponseParseTest() throws Exception {
+ Map<String, String> dropped = new HashMap<>();
+ dropped.put("dt", "20260714");
+ dropped.put("region", "cn");
+ Map<String, String> missing = new HashMap<>();
+ missing.put("dt", "20260713");
+ missing.put("region", "us");
+ DropPartitionsResponse response =
+ new DropPartitionsResponse(
+ Collections.singletonList(dropped),
Collections.singletonList(missing));
+ DropPartitionsResponse parsed =
+ RESTApi.fromJson(RESTApi.toJson(response),
DropPartitionsResponse.class);
+
+ assertEquals(Collections.singletonList(dropped), parsed.getDropped());
+ assertEquals(Collections.singletonList(missing), parsed.getMissing());
+ }
+
+ @Test
+ public void dropPartitionsRequestParseTest() throws Exception {
+ String requestWithoutIgnoreIfNotExists =
"{\"partitionSpecs\":[{\"dt\":\"20260715\"}]}";
+ DropPartitionsRequest defaultRequest =
+ RESTApi.fromJson(requestWithoutIgnoreIfNotExists,
DropPartitionsRequest.class);
+
+ assertEquals(
+ Collections.singletonList(Collections.singletonMap("dt",
"20260715")),
+ defaultRequest.getPartitionSpecs());
+ assertTrue(defaultRequest.ignoreIfNotExists());
+
+ DropPartitionsRequest explicitRequest =
+ new DropPartitionsRequest(defaultRequest.getPartitionSpecs(),
false);
+ String explicitRequestJson = RESTApi.toJson(explicitRequest);
+ assertTrue(explicitRequestJson.contains("\"partitionSpecs\""));
+
assertTrue(explicitRequestJson.contains("\"ignoreIfNotExists\":false"));
+
+ DropPartitionsRequest parsedExplicitRequest =
+ RESTApi.fromJson(explicitRequestJson,
DropPartitionsRequest.class);
+ assertFalse(parsedExplicitRequest.ignoreIfNotExists());
+ }
+
@Test
public void createViewRequestParseTest() throws Exception {
CreateViewRequest request = MockRESTMessage.createViewRequest("t1");
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 af5d94e3f6..3a0e1539ee 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
@@ -56,9 +56,11 @@ import org.apache.paimon.rest.requests.CommitTableRequest;
import org.apache.paimon.rest.requests.CreateBranchRequest;
import org.apache.paimon.rest.requests.CreateDatabaseRequest;
import org.apache.paimon.rest.requests.CreateFunctionRequest;
+import org.apache.paimon.rest.requests.CreatePartitionsRequest;
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.ListPartitionsByNamesRequest;
import org.apache.paimon.rest.requests.MarkDonePartitionsRequest;
import org.apache.paimon.rest.requests.RenameTableRequest;
@@ -70,6 +72,8 @@ import org.apache.paimon.rest.responses.AlterDatabaseResponse;
import org.apache.paimon.rest.responses.AuthTableQueryResponse;
import org.apache.paimon.rest.responses.CommitTableResponse;
import org.apache.paimon.rest.responses.ConfigResponse;
+import org.apache.paimon.rest.responses.CreatePartitionsResponse;
+import org.apache.paimon.rest.responses.DropPartitionsResponse;
import org.apache.paimon.rest.responses.ErrorResponse;
import org.apache.paimon.rest.responses.GetDatabaseResponse;
import org.apache.paimon.rest.responses.GetFunctionResponse;
@@ -207,6 +211,8 @@ public class RESTCatalogServer {
private final List<Map<String, String>> receivedHeaders = new
ArrayList<>();
+ private volatile boolean partitionListingSupported = true;
+
public RESTCatalogServer(
String dataPath, AuthProvider authProvider, ConfigResponse config,
String warehouse) {
this.warehouse = warehouse;
@@ -269,6 +275,10 @@ public class RESTCatalogServer {
DataTokenStore.removeDataToken(warehouse, identifier.getFullName());
}
+ public void setPartitionListingSupported(boolean
partitionListingSupported) {
+ this.partitionListingSupported = partitionListingSupported;
+ }
+
public void addNoPermissionDatabase(String database) {
noPermissionDatabases.add(database);
}
@@ -449,6 +459,11 @@ public class RESTCatalogServer {
&&
ResourcePaths.TABLES.equals(resources[1])
&& "partitions".equals(resources[3])
&&
"list-by-names".equals(resources[4]);
+ boolean isDropPartitions =
+ resources.length == 5
+ &&
ResourcePaths.TABLES.equals(resources[1])
+ && "partitions".equals(resources[3])
+ && "drop".equals(resources[4]);
boolean isBranches =
resources.length >= 4
@@ -479,7 +494,7 @@ public class RESTCatalogServer {
}
}
// validate partition
- if (isPartitions || isMarkDonePartitions) {
+ if (isPartitions || isMarkDonePartitions ||
isDropPartitions) {
String tableName =
RESTUtil.decodeString(resources[2]);
Optional<MockResponse> error =
checkTablePartitioned(
@@ -494,9 +509,18 @@ public class RESTCatalogServer {
catalog.markDonePartitions(
identifier,
markDonePartitionsRequest.getPartitionSpecs());
return new MockResponse().setResponseCode(200);
+ } else if (!partitionListingSupported
+ && ((isPartitions &&
"GET".equals(restAuthParameter.method()))
+ || isListPartitionsByNames)) {
+ return mockResponse(new ErrorResponse(null, null,
"", 501), 501);
+ } else if (isDropPartitions) {
+ return
dropPartitionsHandle(restAuthParameter.data(), identifier);
} else if (isPartitions) {
return partitionsApiHandle(
- restAuthParameter.method(), parameters,
identifier);
+ restAuthParameter.method(),
+ restAuthParameter.data(),
+ parameters,
+ identifier);
} else if (isListPartitionsByNames) {
ListPartitionsByNamesRequest
listPartitionsByNamesRequest =
RESTApi.fromJson(data,
ListPartitionsByNamesRequest.class);
@@ -1815,7 +1839,8 @@ public class RESTCatalogServer {
}
private MockResponse partitionsApiHandle(
- String method, Map<String, String> parameters, Identifier
tableIdentifier) {
+ String method, String data, Map<String, String> parameters,
Identifier tableIdentifier)
+ throws Exception {
String partitionNamePattern = parameters.get(PARTITION_NAME_PATTERN);
switch (method) {
case "GET":
@@ -1835,11 +1860,89 @@ public class RESTCatalogServer {
}
}
return generateFinalListPartitionsResponse(parameters,
partitions);
+ case "POST":
+ CreatePartitionsRequest request =
+ RESTApi.fromJson(data, CreatePartitionsRequest.class);
+ List<Partition> storedPartitions =
+ tablePartitionsStore.computeIfAbsent(
+ tableIdentifier.getFullName(), ignored -> new
ArrayList<>());
+ Set<Map<String, String>> existingSpecs =
+
storedPartitions.stream().map(Partition::spec).collect(Collectors.toSet());
+ if (!request.ignoreIfExists()) {
+ Set<Map<String, String>> seenSpecs = new
HashSet<>(existingSpecs);
+ Optional<Map<String, String>> conflictingSpec =
+ request.getPartitionSpecs().stream()
+ .filter(spec -> !seenSpecs.add(spec))
+ .findFirst();
+ if (conflictingSpec.isPresent()) {
+ String partitionName =
+
PartitionUtils.buildPartitionName(conflictingSpec.get());
+ ErrorResponse response =
+ new ErrorResponse(
+ ErrorResponse.RESOURCE_TYPE_PARTITION,
+ partitionName,
+ String.format(
+ "Partition %s already
exists.", partitionName),
+ 409);
+ return mockResponse(response, 409);
+ }
+ }
+ List<Map<String, String>> created = new ArrayList<>();
+ List<Map<String, String>> existed = new ArrayList<>();
+ for (Map<String, String> spec : request.getPartitionSpecs()) {
+ if (existingSpecs.add(spec)) {
+ storedPartitions.add(new Partition(spec, 0, 0, 0, 0,
-1, false));
+ created.add(spec);
+ } else {
+ existed.add(spec);
+ }
+ }
+ return mockResponse(new CreatePartitionsResponse(created,
existed), 200);
default:
return new MockResponse().setResponseCode(404);
}
}
+ private MockResponse dropPartitionsHandle(String data, Identifier
tableIdentifier)
+ throws Exception {
+ DropPartitionsRequest request = RESTApi.fromJson(data,
DropPartitionsRequest.class);
+ List<Partition> storedPartitions =
+ tablePartitionsStore.computeIfAbsent(
+ tableIdentifier.getFullName(), ignored -> new
ArrayList<>());
+ Set<Map<String, String>> existingSpecs =
+
storedPartitions.stream().map(Partition::spec).collect(Collectors.toSet());
+ List<Map<String, String>> missing = new ArrayList<>();
+ for (Map<String, String> spec : request.getPartitionSpecs()) {
+ if (!existingSpecs.contains(spec)) {
+ missing.add(spec);
+ }
+ }
+ if (!request.ignoreIfNotExists() && !missing.isEmpty()) {
+ List<String> missingNames =
+ missing.stream()
+ .map(PartitionUtils::buildPartitionName)
+ .collect(Collectors.toList());
+ ErrorResponse response =
+ new ErrorResponse(
+ ErrorResponse.RESOURCE_TYPE_PARTITION,
+ missingNames.get(0),
+ String.format("Partitions %s do not exist.",
missingNames),
+ 404);
+ return mockResponse(response, 404);
+ }
+ List<Map<String, String>> dropped = new ArrayList<>();
+ Set<Map<String, String>> toDrop = new
HashSet<>(request.getPartitionSpecs());
+ storedPartitions.removeIf(
+ partition -> {
+ if (toDrop.contains(partition.spec())) {
+ dropped.add(partition.spec());
+ return true;
+ }
+ return false;
+ });
+ return mockResponse(new DropPartitionsResponse(dropped, missing), 200);
+ }
+
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 4092271bbf..504dc6fab6 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
@@ -64,9 +64,13 @@ import org.apache.paimon.predicate.Transform;
import org.apache.paimon.predicate.UpperTransform;
import org.apache.paimon.reader.RecordReader;
import org.apache.paimon.rest.auth.DLFToken;
+import org.apache.paimon.rest.exceptions.AlreadyExistsException;
import org.apache.paimon.rest.exceptions.BadRequestException;
import org.apache.paimon.rest.exceptions.ForbiddenException;
+import org.apache.paimon.rest.exceptions.NoSuchResourceException;
import org.apache.paimon.rest.responses.ConfigResponse;
+import org.apache.paimon.rest.responses.CreatePartitionsResponse;
+import org.apache.paimon.rest.responses.DropPartitionsResponse;
import org.apache.paimon.rest.responses.GetTagResponse;
import org.apache.paimon.schema.Schema;
import org.apache.paimon.schema.SchemaChange;
@@ -106,6 +110,7 @@ import org.apache.commons.io.FileUtils;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
+import org.mockito.Mockito;
import java.io.File;
import java.io.IOException;
@@ -1529,6 +1534,111 @@ public abstract class RESTCatalogTest extends
CatalogTestBase {
assertEquals(0, result.size());
}
+ @Test
+ void testCreatePartitionsForManagedFormatTable() throws Exception {
+ Identifier identifier = Identifier.create("format_partition_db",
"managed_table");
+ catalog.createDatabase(identifier.getDatabaseName(), true);
+ catalog.createTable(
+ identifier,
+ Schema.newBuilder()
+ .option(CoreOptions.TYPE.key(),
TableType.FORMAT_TABLE.toString())
+ .option(METASTORE_PARTITIONED_TABLE.key(), "true")
+ .option(CoreOptions.FILE_FORMAT.key(), "parquet")
+ .column("id", DataTypes.INT())
+ .column("dt", DataTypes.STRING())
+ .partitionKeys("dt")
+ .build(),
+ false);
+
+ List<Map<String, String>> partitionSpecs =
+ Arrays.asList(singletonMap("dt", "20260714"),
singletonMap("dt", "20260715"));
+ CreatePartitionsResponse response =
+ restCatalog.api().createPartitions(identifier, partitionSpecs);
+
+
assertThat(response.getCreated()).containsExactlyInAnyOrderElementsOf(partitionSpecs);
+ assertThat(response.getExisted()).isEmpty();
+
+ catalog.createPartitions(identifier, partitionSpecs);
+ catalog.createPartitions(identifier, partitionSpecs);
+
+ List<Map<String, String>> conflictingSpecs =
+ Arrays.asList(partitionSpecs.get(0), singletonMap("dt",
"20260716"));
+ assertThatThrownBy(
+ () ->
+ restCatalog
+ .api()
+ .createPartitions(identifier,
conflictingSpecs, false))
+ .isInstanceOf(AlreadyExistsException.class)
+ .hasMessageContaining("dt=20260714");
+
+
assertThat(catalog.listPartitions(identifier).stream().map(Partition::spec))
+ .containsExactlyInAnyOrderElementsOf(partitionSpecs);
+ }
+
+ @Test
+ void testDropPartitionsForManagedFormatTable() throws Exception {
+ Identifier identifier = Identifier.create("format_partition_db",
"managed_drop_table");
+ catalog.createDatabase(identifier.getDatabaseName(), true);
+ catalog.createTable(
+ identifier,
+ Schema.newBuilder()
+ .option(CoreOptions.TYPE.key(),
TableType.FORMAT_TABLE.toString())
+ .option(METASTORE_PARTITIONED_TABLE.key(), "true")
+ .option(CoreOptions.FILE_FORMAT.key(), "parquet")
+ .column("id", DataTypes.INT())
+ .column("dt", DataTypes.STRING())
+ .partitionKeys("dt")
+ .build(),
+ false);
+
+ List<Map<String, String>> partitionSpecs =
+ Arrays.asList(singletonMap("dt", "20260714"),
singletonMap("dt", "20260715"));
+ catalog.createPartitions(identifier, partitionSpecs);
+
+ DropPartitionsResponse response =
+ restCatalog
+ .api()
+ .dropPartitions(
+ identifier,
+ Arrays.asList(
+ singletonMap("dt", "20260714"),
+ singletonMap("dt", "20260799")),
+ true);
+ assertThat(response.getDropped()).containsExactly(singletonMap("dt",
"20260714"));
+ assertThat(response.getMissing()).containsExactly(singletonMap("dt",
"20260799"));
+
assertThat(catalog.listPartitions(identifier).stream().map(Partition::spec))
+ .containsExactly(singletonMap("dt", "20260715"));
+
+ // Catalog contract: dropPartitions ignores non-existent partitions
and unregisters
+ // metadata only (no data deletion on the server).
+ catalog.dropPartitions(
+ identifier,
+ Arrays.asList(singletonMap("dt", "20260715"),
singletonMap("dt", "20260799")));
+ assertThat(catalog.listPartitions(identifier)).isEmpty();
+
+ assertThatThrownBy(
+ () ->
+ restCatalog
+ .api()
+ .dropPartitions(
+ identifier,
+
singletonList(singletonMap("dt", "20260799")),
+ false))
+ .isInstanceOf(NoSuchResourceException.class)
+ .hasMessageContaining("dt=20260799");
+ }
+
+ @Test
+ void testDropPartitionsLoadsNonManagedTableOnce() throws Exception {
+ Identifier identifier = Identifier.create("test_db",
"drop_partition_table");
+ createTable(identifier, emptyMap(), singletonList("col1"));
+ RESTCatalog catalogSpy = Mockito.spy(restCatalog);
+
+ catalogSpy.dropPartitions(identifier,
singletonList(singletonMap("col1", "20260717")));
+
+ Mockito.verify(catalogSpy, Mockito.times(1)).getTable(identifier);
+ }
+
@Test
void testListPartitions() throws Exception {
innerTestListPartitions(true);