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

danielcweeks pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/iceberg.git


The following commit(s) were added to refs/heads/main by this push:
     new ca670d771a Core: Implement unregister table in catalogs and RCKs 
(#18203)
ca670d771a is described below

commit ca670d771a41f98ef8c407d9b6fe46eeb0a82e0e
Author: Marco Kroll <[email protected]>
AuthorDate: Thu Oct 1 17:29:47 2026 +0200

    Core: Implement unregister table in catalogs and RCKs (#18203)
    
    * Add unregister table to RCK
    
    Co-authored-by: Alex Stephen <[email protected]>
    Co-authored-by: Alex Stephen <[email protected]>
    Co-authored-by: Daniel Weeks <[email protected]>
    Co-authored-by: Marco Kroll <[email protected]>
---
 .../java/org/apache/iceberg/catalog/Catalog.java   | 15 ++++
 .../org/apache/iceberg/catalog/SessionCatalog.java | 15 ++++
 .../java/org/apache/iceberg/CachingCatalog.java    |  7 ++
 .../apache/iceberg/catalog/BaseSessionCatalog.java |  5 ++
 .../apache/iceberg/inmemory/InMemoryCatalog.java   | 24 ++++++
 .../java/org/apache/iceberg/jdbc/JdbcCatalog.java  | 38 +++++++++
 .../java/org/apache/iceberg/jdbc/JdbcUtil.java     |  4 +
 .../org/apache/iceberg/rest/CatalogHandlers.java   | 16 ++++
 .../java/org/apache/iceberg/rest/Endpoint.java     |  2 +
 .../java/org/apache/iceberg/rest/RESTCatalog.java  |  5 ++
 .../org/apache/iceberg/rest/RESTSerializers.java   | 27 +++++++
 .../apache/iceberg/rest/RESTSessionCatalog.java    | 36 +++++++++
 .../org/apache/iceberg/rest/ResourcePaths.java     | 13 +++
 .../rest/responses/UnregisterTableResponse.java    | 43 ++++++++++
 .../responses/UnregisterTableResponseParser.java   | 78 ++++++++++++++++++
 .../org/apache/iceberg/catalog/CatalogTests.java   | 45 +++++++++++
 .../iceberg/inmemory/TestInMemoryCatalog.java      |  5 ++
 .../org/apache/iceberg/jdbc/TestJdbcCatalog.java   |  5 ++
 .../apache/iceberg/rest/RESTCatalogAdapter.java    | 10 +++
 .../test/java/org/apache/iceberg/rest/Route.java   |  6 ++
 .../TestUnregisterTableResponseParser.java         | 92 ++++++++++++++++++++++
 .../rest/RESTCompatibilityKitCatalogTests.java     | 59 ++++++++++++++
 22 files changed, 550 insertions(+)

diff --git a/api/src/main/java/org/apache/iceberg/catalog/Catalog.java 
b/api/src/main/java/org/apache/iceberg/catalog/Catalog.java
index fddae97125..231bc57087 100644
--- a/api/src/main/java/org/apache/iceberg/catalog/Catalog.java
+++ b/api/src/main/java/org/apache/iceberg/catalog/Catalog.java
@@ -382,6 +382,21 @@ public interface Catalog {
     throw new UnsupportedOperationException("Registering tables with overwrite 
is not supported");
   }
 
+  /**
+   * Unregister a table without deleting its data or metadata files.
+   *
+   * <p>The returned table is fixed at the last metadata file registered with 
the catalog and cannot
+   * be modified. Its metadata file location can be used to {@link 
#registerTable(TableIdentifier,
+   * String) register} the table again.
+   *
+   * @param identifier a table identifier
+   * @return a read-only table fixed at the metadata current when it was 
unregistered
+   * @throws NoSuchTableException if the table does not exist
+   */
+  default Table unregisterTable(TableIdentifier identifier) {
+    throw new UnsupportedOperationException("Unregistering tables is not 
supported");
+  }
+
   /**
    * Instantiate a builder to either create a table or start a create/replace 
transaction.
    *
diff --git a/api/src/main/java/org/apache/iceberg/catalog/SessionCatalog.java 
b/api/src/main/java/org/apache/iceberg/catalog/SessionCatalog.java
index a3e2696135..a9132e1d61 100644
--- a/api/src/main/java/org/apache/iceberg/catalog/SessionCatalog.java
+++ b/api/src/main/java/org/apache/iceberg/catalog/SessionCatalog.java
@@ -196,6 +196,21 @@ public interface SessionCatalog {
     throw new UnsupportedOperationException("Registering tables with overwrite 
is not supported");
   }
 
+  /**
+   * Unregister a table without deleting its data or metadata files.
+   *
+   * <p>The returned table is fixed at the last metadata file registered with 
the catalog and cannot
+   * be modified.
+   *
+   * @param context session context
+   * @param ident a table identifier
+   * @return a read-only table fixed at the metadata current when it was 
unregistered
+   * @throws NoSuchTableException if the table does not exist
+   */
+  default Table unregisterTable(SessionContext context, TableIdentifier ident) 
{
+    throw new UnsupportedOperationException("Unregistering tables is not 
supported");
+  }
+
   /**
    * Check whether table exists.
    *
diff --git a/core/src/main/java/org/apache/iceberg/CachingCatalog.java 
b/core/src/main/java/org/apache/iceberg/CachingCatalog.java
index 700cfc6cb5..4ee6d61c09 100644
--- a/core/src/main/java/org/apache/iceberg/CachingCatalog.java
+++ b/core/src/main/java/org/apache/iceberg/CachingCatalog.java
@@ -204,6 +204,13 @@ public class CachingCatalog implements Catalog {
     return table;
   }
 
+  @Override
+  public Table unregisterTable(TableIdentifier identifier) {
+    Table table = catalog.unregisterTable(identifier);
+    invalidateTable(identifier);
+    return table;
+  }
+
   private Iterable<TableIdentifier> metadataTableIdentifiers(TableIdentifier 
ident) {
     ImmutableList.Builder<TableIdentifier> builder = ImmutableList.builder();
 
diff --git 
a/core/src/main/java/org/apache/iceberg/catalog/BaseSessionCatalog.java 
b/core/src/main/java/org/apache/iceberg/catalog/BaseSessionCatalog.java
index 69e0990cbf..bf8bce4e4d 100644
--- a/core/src/main/java/org/apache/iceberg/catalog/BaseSessionCatalog.java
+++ b/core/src/main/java/org/apache/iceberg/catalog/BaseSessionCatalog.java
@@ -95,6 +95,11 @@ public abstract class BaseSessionCatalog implements 
SessionCatalog {
       return BaseSessionCatalog.this.registerTable(context, ident, 
metadataFileLocation, overwrite);
     }
 
+    @Override
+    public Table unregisterTable(TableIdentifier ident) {
+      return BaseSessionCatalog.this.unregisterTable(context, ident);
+    }
+
     @Override
     public boolean tableExists(TableIdentifier ident) {
       return BaseSessionCatalog.this.tableExists(context, ident);
diff --git 
a/core/src/main/java/org/apache/iceberg/inmemory/InMemoryCatalog.java 
b/core/src/main/java/org/apache/iceberg/inmemory/InMemoryCatalog.java
index 80297310e8..18b205ec24 100644
--- a/core/src/main/java/org/apache/iceberg/inmemory/InMemoryCatalog.java
+++ b/core/src/main/java/org/apache/iceberg/inmemory/InMemoryCatalog.java
@@ -29,8 +29,11 @@ import java.util.Set;
 import java.util.concurrent.ConcurrentMap;
 import java.util.stream.Collectors;
 import org.apache.iceberg.BaseMetastoreTableOperations;
+import org.apache.iceberg.BaseTable;
 import org.apache.iceberg.CatalogProperties;
 import org.apache.iceberg.CatalogUtil;
+import org.apache.iceberg.StaticTableOperations;
+import org.apache.iceberg.Table;
 import org.apache.iceberg.TableMetadata;
 import org.apache.iceberg.TableOperations;
 import org.apache.iceberg.catalog.Namespace;
@@ -125,6 +128,27 @@ public class InMemoryCatalog extends 
BaseMetastoreViewCatalog
     }
   }
 
+  @Override
+  public Table unregisterTable(TableIdentifier tableIdentifier) {
+    TableOperations ops = newTableOps(tableIdentifier);
+    TableMetadata metadata;
+
+    synchronized (this) {
+      metadata = ops.current();
+      if (metadata == null) {
+        throw new NoSuchTableException("Table does not exist: %s", 
tableIdentifier);
+      }
+
+      if (tables.remove(tableIdentifier) == null) {
+        throw new NoSuchTableException("Table does not exist: %s", 
tableIdentifier);
+      }
+    }
+
+    StaticTableOperations staticOps =
+        new StaticTableOperations(metadata, ops.io(), ops.locationProvider());
+    return new BaseTable(staticOps, tableIdentifier.name(), metricsReporter());
+  }
+
   @Override
   public boolean dropTable(TableIdentifier tableIdentifier, boolean purge) {
     TableOperations ops = newTableOps(tableIdentifier);
diff --git a/core/src/main/java/org/apache/iceberg/jdbc/JdbcCatalog.java 
b/core/src/main/java/org/apache/iceberg/jdbc/JdbcCatalog.java
index 2d24e5598a..0d98b53e72 100644
--- a/core/src/main/java/org/apache/iceberg/jdbc/JdbcCatalog.java
+++ b/core/src/main/java/org/apache/iceberg/jdbc/JdbcCatalog.java
@@ -39,9 +39,12 @@ import java.util.function.Function;
 import java.util.function.Predicate;
 import java.util.stream.Collectors;
 import java.util.stream.Stream;
+import org.apache.iceberg.BaseTable;
 import org.apache.iceberg.CatalogProperties;
 import org.apache.iceberg.CatalogUtil;
 import org.apache.iceberg.Schema;
+import org.apache.iceberg.StaticTableOperations;
+import org.apache.iceberg.Table;
 import org.apache.iceberg.TableMetadata;
 import org.apache.iceberg.TableOperations;
 import org.apache.iceberg.Transaction;
@@ -49,6 +52,7 @@ import org.apache.iceberg.catalog.Namespace;
 import org.apache.iceberg.catalog.SupportsNamespaces;
 import org.apache.iceberg.catalog.TableIdentifier;
 import org.apache.iceberg.exceptions.AlreadyExistsException;
+import org.apache.iceberg.exceptions.CommitFailedException;
 import org.apache.iceberg.exceptions.NamespaceNotEmptyException;
 import org.apache.iceberg.exceptions.NoSuchNamespaceException;
 import org.apache.iceberg.exceptions.NoSuchTableException;
@@ -297,6 +301,40 @@ public class JdbcCatalog extends BaseMetastoreViewCatalog
     return SLASH.join(defaultNamespaceLocation(table.namespace()), 
tableLocation);
   }
 
+  @Override
+  public Table unregisterTable(TableIdentifier identifier) {
+    Preconditions.checkArgument(
+        identifier != null && isValidIdentifier(identifier), "Invalid 
identifier: %s", identifier);
+    TableOperations ops = newTableOps(identifier);
+    TableMetadata metadata = ops.current();
+
+    if (metadata == null) {
+      throw new NoSuchTableException("Table does not exist: %s", identifier);
+    }
+
+    if (!dropTableIfMetadataMatches(identifier, 
metadata.metadataFileLocation())) {
+      throw new CommitFailedException(
+          "Cannot unregister table %s: metadata location has changed or table 
was dropped",
+          identifier);
+    }
+
+    StaticTableOperations staticOps =
+        new StaticTableOperations(metadata, ops.io(), ops.locationProvider());
+    return new BaseTable(staticOps, identifier.name(), metricsReporter());
+  }
+
+  private boolean dropTableIfMetadataMatches(TableIdentifier identifier, 
String metadataLocation) {
+    return execute(
+            (schemaVersion == JdbcUtil.SchemaVersion.V1)
+                ? JdbcUtil.V1_UNREGISTER_TABLE_SQL
+                : JdbcUtil.V0_UNREGISTER_TABLE_SQL,
+            catalogName,
+            JdbcUtil.namespaceToString(identifier.namespace()),
+            identifier.name(),
+            metadataLocation)
+        == 1;
+  }
+
   @Override
   public boolean dropTable(TableIdentifier identifier, boolean purge) {
     TableOperations ops = newTableOps(identifier);
diff --git a/core/src/main/java/org/apache/iceberg/jdbc/JdbcUtil.java 
b/core/src/main/java/org/apache/iceberg/jdbc/JdbcUtil.java
index 259bd78125..528eccdf16 100644
--- a/core/src/main/java/org/apache/iceberg/jdbc/JdbcUtil.java
+++ b/core/src/main/java/org/apache/iceberg/jdbc/JdbcUtil.java
@@ -321,6 +321,8 @@ final class JdbcUtil {
           + " OR "
           + RECORD_TYPE
           + " IS NULL)";
+  static final String V1_UNREGISTER_TABLE_SQL =
+      V1_DROP_TABLE_SQL + " AND " + 
BaseMetastoreTableOperations.METADATA_LOCATION_PROP + " = ?";
   static final String V0_DROP_TABLE_SQL =
       "DELETE FROM "
           + CATALOG_TABLE_VIEW_NAME
@@ -331,6 +333,8 @@ final class JdbcUtil {
           + "  = ? AND "
           + TABLE_NAME
           + " = ?";
+  static final String V0_UNREGISTER_TABLE_SQL =
+      V0_DROP_TABLE_SQL + " AND " + 
BaseMetastoreTableOperations.METADATA_LOCATION_PROP + " = ?";
   private static final String GET_NAMESPACE_SQL =
       "SELECT "
           + TABLE_NAMESPACE
diff --git a/core/src/main/java/org/apache/iceberg/rest/CatalogHandlers.java 
b/core/src/main/java/org/apache/iceberg/rest/CatalogHandlers.java
index 13089fc07d..4361da4e3e 100644
--- a/core/src/main/java/org/apache/iceberg/rest/CatalogHandlers.java
+++ b/core/src/main/java/org/apache/iceberg/rest/CatalogHandlers.java
@@ -95,11 +95,13 @@ import 
org.apache.iceberg.rest.responses.FetchPlanningResultResponse;
 import org.apache.iceberg.rest.responses.FetchScanTasksResponse;
 import org.apache.iceberg.rest.responses.GetNamespaceResponse;
 import org.apache.iceberg.rest.responses.ImmutableLoadViewResponse;
+import org.apache.iceberg.rest.responses.ImmutableUnregisterTableResponse;
 import org.apache.iceberg.rest.responses.ListNamespacesResponse;
 import org.apache.iceberg.rest.responses.ListTablesResponse;
 import org.apache.iceberg.rest.responses.LoadTableResponse;
 import org.apache.iceberg.rest.responses.LoadViewResponse;
 import org.apache.iceberg.rest.responses.PlanTableScanResponse;
+import org.apache.iceberg.rest.responses.UnregisterTableResponse;
 import org.apache.iceberg.rest.responses.UpdateNamespacePropertiesResponse;
 import org.apache.iceberg.util.Pair;
 import org.apache.iceberg.util.Tasks;
@@ -488,6 +490,20 @@ public class CatalogHandlers {
     }
   }
 
+  public static UnregisterTableResponse unregisterTable(Catalog catalog, 
TableIdentifier ident) {
+    Table table = catalog.unregisterTable(ident);
+    if (!(table instanceof BaseTable)) {
+      throw new IllegalStateException("Cannot wrap catalog that does not 
produce BaseTable");
+    }
+
+    TableMetadata metadata = ((BaseTable) table).operations().current();
+
+    return ImmutableUnregisterTableResponse.builder()
+        .metadataLocation(metadata.metadataFileLocation())
+        .metadata(metadata)
+        .build();
+  }
+
   public static void purgeTable(Catalog catalog, TableIdentifier ident) {
     boolean dropped = catalog.dropTable(ident, true);
     if (!dropped) {
diff --git a/core/src/main/java/org/apache/iceberg/rest/Endpoint.java 
b/core/src/main/java/org/apache/iceberg/rest/Endpoint.java
index d56a14d189..ef071c228e 100644
--- a/core/src/main/java/org/apache/iceberg/rest/Endpoint.java
+++ b/core/src/main/java/org/apache/iceberg/rest/Endpoint.java
@@ -62,6 +62,8 @@ public class Endpoint {
       Endpoint.create("POST", ResourcePaths.V1_TABLE_RENAME);
   public static final Endpoint V1_REGISTER_TABLE =
       Endpoint.create("POST", ResourcePaths.V1_TABLE_REGISTER);
+  public static final Endpoint V1_UNREGISTER_TABLE =
+      Endpoint.create("POST", ResourcePaths.V1_TABLE_UNREGISTER);
   public static final Endpoint V1_REPORT_METRICS =
       Endpoint.create("POST", ResourcePaths.V1_TABLE_METRICS);
   public static final Endpoint V1_TABLE_CREDENTIALS =
diff --git a/core/src/main/java/org/apache/iceberg/rest/RESTCatalog.java 
b/core/src/main/java/org/apache/iceberg/rest/RESTCatalog.java
index 02bceab4d2..dca43aa217 100644
--- a/core/src/main/java/org/apache/iceberg/rest/RESTCatalog.java
+++ b/core/src/main/java/org/apache/iceberg/rest/RESTCatalog.java
@@ -252,6 +252,11 @@ public class RESTCatalog
     return delegate.registerTable(ident, metadataFileLocation, overwrite);
   }
 
+  @Override
+  public Table unregisterTable(TableIdentifier ident) {
+    return delegate.unregisterTable(ident);
+  }
+
   @Override
   public void createNamespace(Namespace ns, Map<String, String> props) {
     nsDelegate.createNamespace(ns, props);
diff --git a/core/src/main/java/org/apache/iceberg/rest/RESTSerializers.java 
b/core/src/main/java/org/apache/iceberg/rest/RESTSerializers.java
index c97f4aa2e5..ad3aa936c5 100644
--- a/core/src/main/java/org/apache/iceberg/rest/RESTSerializers.java
+++ b/core/src/main/java/org/apache/iceberg/rest/RESTSerializers.java
@@ -80,6 +80,7 @@ import 
org.apache.iceberg.rest.responses.FetchScanTasksResponseParser;
 import org.apache.iceberg.rest.responses.ImmutableLoadCredentialsResponse;
 import org.apache.iceberg.rest.responses.ImmutableLoadViewResponse;
 import org.apache.iceberg.rest.responses.ImmutableRemoteSignResponse;
+import org.apache.iceberg.rest.responses.ImmutableUnregisterTableResponse;
 import org.apache.iceberg.rest.responses.LoadCredentialsResponse;
 import org.apache.iceberg.rest.responses.LoadCredentialsResponseParser;
 import org.apache.iceberg.rest.responses.LoadTableResponse;
@@ -91,6 +92,8 @@ import 
org.apache.iceberg.rest.responses.PlanTableScanResponse;
 import org.apache.iceberg.rest.responses.PlanTableScanResponseParser;
 import org.apache.iceberg.rest.responses.RemoteSignResponse;
 import org.apache.iceberg.rest.responses.RemoteSignResponseParser;
+import org.apache.iceberg.rest.responses.UnregisterTableResponse;
+import org.apache.iceberg.rest.responses.UnregisterTableResponseParser;
 import org.apache.iceberg.util.JsonUtil;
 
 public class RESTSerializers {
@@ -153,6 +156,12 @@ public class RESTSerializers {
         .addDeserializer(ConfigResponse.class, new 
ConfigResponseDeserializer<>())
         .addSerializer(LoadTableResponse.class, new 
LoadTableResponseSerializer<>())
         .addDeserializer(LoadTableResponse.class, new 
LoadTableResponseDeserializer<>())
+        .addSerializer(UnregisterTableResponse.class, new 
UnregisterTableResponseSerializer<>())
+        .addSerializer(
+            ImmutableUnregisterTableResponse.class, new 
UnregisterTableResponseSerializer<>())
+        .addDeserializer(UnregisterTableResponse.class, new 
UnregisterTableResponseDeserializer<>())
+        .addDeserializer(
+            ImmutableUnregisterTableResponse.class, new 
UnregisterTableResponseDeserializer<>())
         .addSerializer(PlanTableScanRequest.class, new 
PlanTableScanRequestSerializer<>())
         .addDeserializer(PlanTableScanRequest.class, new 
PlanTableScanRequestDeserializer<>())
         .addSerializer(FetchScanTasksRequest.class, new 
FetchScanTasksRequestSerializer<>())
@@ -532,6 +541,24 @@ public class RESTSerializers {
     }
   }
 
+  static class UnregisterTableResponseSerializer<T extends 
UnregisterTableResponse>
+      extends JsonSerializer<T> {
+    @Override
+    public void serialize(T response, JsonGenerator gen, SerializerProvider 
serializers)
+        throws IOException {
+      UnregisterTableResponseParser.toJson(response, gen);
+    }
+  }
+
+  static class UnregisterTableResponseDeserializer<T extends 
UnregisterTableResponse>
+      extends JsonDeserializer<T> {
+    @Override
+    public T deserialize(JsonParser p, DeserializationContext context) throws 
IOException {
+      JsonNode jsonNode = p.getCodec().readTree(p);
+      return (T) UnregisterTableResponseParser.fromJson(jsonNode);
+    }
+  }
+
   static class LoadTableResponseDeserializer<T extends LoadTableResponse>
       extends JsonDeserializer<T> {
     @Override
diff --git a/core/src/main/java/org/apache/iceberg/rest/RESTSessionCatalog.java 
b/core/src/main/java/org/apache/iceberg/rest/RESTSessionCatalog.java
index 663d28f6b8..d63f358390 100644
--- a/core/src/main/java/org/apache/iceberg/rest/RESTSessionCatalog.java
+++ b/core/src/main/java/org/apache/iceberg/rest/RESTSessionCatalog.java
@@ -43,6 +43,7 @@ import org.apache.iceberg.MetadataUpdate;
 import org.apache.iceberg.PartitionSpec;
 import org.apache.iceberg.Schema;
 import org.apache.iceberg.SortOrder;
+import org.apache.iceberg.StaticTableOperations;
 import org.apache.iceberg.Table;
 import org.apache.iceberg.TableMetadata;
 import org.apache.iceberg.TableOperations;
@@ -100,6 +101,7 @@ import 
org.apache.iceberg.rest.responses.ListNamespacesResponse;
 import org.apache.iceberg.rest.responses.ListTablesResponse;
 import org.apache.iceberg.rest.responses.LoadTableResponse;
 import org.apache.iceberg.rest.responses.LoadViewResponse;
+import org.apache.iceberg.rest.responses.UnregisterTableResponse;
 import org.apache.iceberg.rest.responses.UpdateNamespacePropertiesResponse;
 import org.apache.iceberg.util.EnvironmentUtil;
 import org.apache.iceberg.util.PropertyUtil;
@@ -810,6 +812,40 @@ public class RESTSessionCatalog extends 
BaseViewSessionCatalog
         response.labels());
   }
 
+  /**
+   * Unregister a table from the catalog without removing its data or metadata 
files.
+   *
+   * <p>This is the opposite of {@link #registerTable(SessionContext, 
TableIdentifier, String)}. On
+   * success, the table no longer exists in the catalog and the returned table 
is fixed at the last
+   * metadata registered with the catalog.
+   *
+   * @param context session context
+   * @param identifier a table identifier
+   * @return a read-only table fixed at the metadata current when it was 
unregistered
+   */
+  @Override
+  public Table unregisterTable(SessionContext context, TableIdentifier 
identifier) {
+    Endpoint.check(endpoints, Endpoint.V1_UNREGISTER_TABLE);
+    checkIdentifierIsValid(identifier);
+
+    try {
+      AuthSession contextualSession = authManager.contextualSession(context, 
catalogAuth);
+      UnregisterTableResponse response =
+          client
+              .withAuthSession(contextualSession)
+              .post(
+                  paths.unregister(identifier),
+                  null,
+                  UnregisterTableResponse.class,
+                  mutationHeaders,
+                  ErrorHandlers.tableErrorHandler());
+      StaticTableOperations ops = new 
StaticTableOperations(response.metadata(), io);
+      return new BaseTable(ops, identifier.name());
+    } finally {
+      invalidateTable(context, identifier);
+    }
+  }
+
   @Override
   public void createNamespace(
       SessionContext context, Namespace namespace, Map<String, String> 
metadata) {
diff --git a/core/src/main/java/org/apache/iceberg/rest/ResourcePaths.java 
b/core/src/main/java/org/apache/iceberg/rest/ResourcePaths.java
index 5757a6d176..970e29e0eb 100644
--- a/core/src/main/java/org/apache/iceberg/rest/ResourcePaths.java
+++ b/core/src/main/java/org/apache/iceberg/rest/ResourcePaths.java
@@ -38,6 +38,8 @@ public class ResourcePaths {
   public static final String V1_TABLE_REMOTE_SIGN =
       "/v1/{prefix}/namespaces/{namespace}/tables/{table}/sign";
   public static final String V1_TABLE_REGISTER = 
"/v1/{prefix}/namespaces/{namespace}/register";
+  public static final String V1_TABLE_UNREGISTER =
+      "/v1/{prefix}/namespaces/{namespace}/tables/{table}/unregister";
   public static final String V1_TABLE_METRICS =
       "/v1/{prefix}/namespaces/{namespace}/tables/{table}/metrics";
   public static final String V1_TABLE_RENAME = "/v1/{prefix}/tables/rename";
@@ -108,6 +110,17 @@ public class ResourcePaths {
     return SLASH.join("v1", prefix, "namespaces", pathEncode(ns), "register");
   }
 
+  public String unregister(TableIdentifier ident) {
+    return SLASH.join(
+        "v1",
+        prefix,
+        "namespaces",
+        pathEncode(ident.namespace()),
+        "tables",
+        RESTUtil.encodePathSegment(ident.name()),
+        "unregister");
+  }
+
   public String rename() {
     return SLASH.join("v1", prefix, "tables", "rename");
   }
diff --git 
a/core/src/main/java/org/apache/iceberg/rest/responses/UnregisterTableResponse.java
 
b/core/src/main/java/org/apache/iceberg/rest/responses/UnregisterTableResponse.java
new file mode 100644
index 0000000000..a555a69962
--- /dev/null
+++ 
b/core/src/main/java/org/apache/iceberg/rest/responses/UnregisterTableResponse.java
@@ -0,0 +1,43 @@
+/*
+ * 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.iceberg.rest.responses;
+
+import org.apache.iceberg.TableMetadata;
+import org.apache.iceberg.relocated.com.google.common.base.Preconditions;
+import org.apache.iceberg.rest.RESTResponse;
+import org.immutables.value.Value;
+
+/**
+ * Represents the response when a table is successfully unregistered from a 
catalog.
+ *
+ * <p>The response carries the table's last metadata location and the 
corresponding table metadata
+ * so that the underlying (still present) files can be registered in another 
catalog.
+ */
[email protected]
+public interface UnregisterTableResponse extends RESTResponse {
+  String metadataLocation();
+
+  TableMetadata metadata();
+
+  @Override
+  default void validate() {
+    Preconditions.checkArgument(metadataLocation() != null, "Invalid metadata 
location: null");
+    Preconditions.checkArgument(metadata() != null, "Invalid metadata: null");
+  }
+}
diff --git 
a/core/src/main/java/org/apache/iceberg/rest/responses/UnregisterTableResponseParser.java
 
b/core/src/main/java/org/apache/iceberg/rest/responses/UnregisterTableResponseParser.java
new file mode 100644
index 0000000000..95479fa018
--- /dev/null
+++ 
b/core/src/main/java/org/apache/iceberg/rest/responses/UnregisterTableResponseParser.java
@@ -0,0 +1,78 @@
+/*
+ * 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.iceberg.rest.responses;
+
+import com.fasterxml.jackson.core.JsonGenerator;
+import com.fasterxml.jackson.databind.JsonNode;
+import java.io.IOException;
+import org.apache.iceberg.TableMetadata;
+import org.apache.iceberg.TableMetadataParser;
+import org.apache.iceberg.relocated.com.google.common.base.Preconditions;
+import org.apache.iceberg.util.JsonUtil;
+
+public class UnregisterTableResponseParser {
+
+  private static final String METADATA_LOCATION = "metadata-location";
+  private static final String METADATA = "metadata";
+
+  private UnregisterTableResponseParser() {}
+
+  public static String toJson(UnregisterTableResponse response) {
+    return toJson(response, false);
+  }
+
+  public static String toJson(UnregisterTableResponse response, boolean 
pretty) {
+    return JsonUtil.generate(gen -> toJson(response, gen), pretty);
+  }
+
+  public static void toJson(UnregisterTableResponse response, JsonGenerator 
gen)
+      throws IOException {
+    Preconditions.checkArgument(null != response, "Invalid unregister table 
response: null");
+
+    gen.writeStartObject();
+
+    gen.writeStringField(METADATA_LOCATION, response.metadataLocation());
+
+    gen.writeFieldName(METADATA);
+    TableMetadataParser.toJson(response.metadata(), gen);
+
+    gen.writeEndObject();
+  }
+
+  public static UnregisterTableResponse fromJson(String json) {
+    return JsonUtil.parse(json, UnregisterTableResponseParser::fromJson);
+  }
+
+  public static UnregisterTableResponse fromJson(JsonNode json) {
+    Preconditions.checkArgument(
+        null != json, "Cannot parse unregister table response from null 
object");
+
+    String metadataLocation = JsonUtil.getString(METADATA_LOCATION, json);
+    TableMetadata metadata = 
TableMetadataParser.fromJson(JsonUtil.get(METADATA, json));
+
+    if (null == metadata.metadataFileLocation()) {
+      metadata = 
TableMetadata.buildFrom(metadata).withMetadataLocation(metadataLocation).build();
+    }
+
+    return ImmutableUnregisterTableResponse.builder()
+        .metadataLocation(metadataLocation)
+        .metadata(metadata)
+        .build();
+  }
+}
diff --git a/core/src/test/java/org/apache/iceberg/catalog/CatalogTests.java 
b/core/src/test/java/org/apache/iceberg/catalog/CatalogTests.java
index ff40527ebe..9e6ad888c6 100644
--- a/core/src/test/java/org/apache/iceberg/catalog/CatalogTests.java
+++ b/core/src/test/java/org/apache/iceberg/catalog/CatalogTests.java
@@ -208,6 +208,10 @@ public abstract class CatalogTests<C extends Catalog & 
SupportsNamespaces> {
     return false;
   }
 
+  protected boolean supportsUnregister() {
+    return false;
+  }
+
   protected String baseTableLocation(TableIdentifier identifier) {
     return BASE_TABLE_LOCATION + "/" + identifier.namespace() + "/" + 
identifier.name();
   }
@@ -3454,6 +3458,47 @@ public abstract class CatalogTests<C extends Catalog & 
SupportsNamespaces> {
     assertThat(catalog.dropTable(identifier)).isTrue();
   }
 
+  @Test
+  public void unregisterTable() {
+    assumeThat(supportsUnregister()).isTrue();
+
+    C catalog = catalog();
+    if (requiresNamespaceCreate()) {
+      catalog.createNamespace(TABLE.namespace());
+    }
+
+    Table original = catalog.buildTable(TABLE, 
SCHEMA).withPartitionSpec(SPEC).create();
+    original.newFastAppend().appendFile(FILE_A).commit();
+    String metadataLocation = TableUtil.metadataFileLocation(original);
+
+    Table unregistered = catalog.unregisterTable(TABLE);
+
+    assertThat(unregistered.name()).isEqualTo(TABLE.name());
+    
assertThat(TableUtil.metadataFileLocation(unregistered)).isEqualTo(metadataLocation);
+    
assertThat(unregistered.currentSnapshot()).isEqualTo(original.currentSnapshot());
+    assertThat(catalog.tableExists(TABLE)).isFalse();
+    
assertThat(unregistered.io().newInputFile(metadataLocation).exists()).isTrue();
+    assertThatThrownBy(() -> 
unregistered.updateProperties().set("unregistered", "true").commit())
+        .isInstanceOf(UnsupportedOperationException.class)
+        .hasMessageContaining("Cannot modify a static table");
+
+    TableIdentifier registeredIdentifier =
+        TableIdentifier.of(TABLE.namespace(), "registered-after-unregister");
+    Table registered = catalog.registerTable(registeredIdentifier, 
metadataLocation);
+    
assertThat(registered.currentSnapshot()).isEqualTo(original.currentSnapshot());
+    assertFiles(registered, FILE_A);
+    assertThat(catalog.dropTable(registeredIdentifier)).isTrue();
+  }
+
+  @Test
+  public void unregisterMissingTable() {
+    assumeThat(supportsUnregister()).isTrue();
+
+    assertThatThrownBy(() -> catalog().unregisterTable(TABLE))
+        .isInstanceOf(NoSuchTableException.class)
+        .hasMessageContaining("Table does not exist");
+  }
+
   @Test
   public void testCatalogWithCustomMetricsReporter() throws IOException {
     C catalogWithCustomReporter =
diff --git 
a/core/src/test/java/org/apache/iceberg/inmemory/TestInMemoryCatalog.java 
b/core/src/test/java/org/apache/iceberg/inmemory/TestInMemoryCatalog.java
index 827450d4a3..9cd361aa26 100644
--- a/core/src/test/java/org/apache/iceberg/inmemory/TestInMemoryCatalog.java
+++ b/core/src/test/java/org/apache/iceberg/inmemory/TestInMemoryCatalog.java
@@ -87,6 +87,11 @@ public class TestInMemoryCatalog extends 
CatalogTests<InMemoryCatalog> {
     return true;
   }
 
+  @Override
+  protected boolean supportsUnregister() {
+    return true;
+  }
+
   @Test
   @Override
   public void testLoadTableWithMissingMetadataFile(@TempDir Path tempDir) 
throws IOException {
diff --git a/core/src/test/java/org/apache/iceberg/jdbc/TestJdbcCatalog.java 
b/core/src/test/java/org/apache/iceberg/jdbc/TestJdbcCatalog.java
index ce22c8089b..225e6da20a 100644
--- a/core/src/test/java/org/apache/iceberg/jdbc/TestJdbcCatalog.java
+++ b/core/src/test/java/org/apache/iceberg/jdbc/TestJdbcCatalog.java
@@ -112,6 +112,11 @@ public class TestJdbcCatalog extends 
CatalogTests<JdbcCatalog> {
     return true;
   }
 
+  @Override
+  protected boolean supportsUnregister() {
+    return true;
+  }
+
   @Override
   protected boolean supportsNamesWithDot() {
     // namespaces with a dot are not supported
diff --git a/core/src/test/java/org/apache/iceberg/rest/RESTCatalogAdapter.java 
b/core/src/test/java/org/apache/iceberg/rest/RESTCatalogAdapter.java
index b99d7ffb63..e06f07d445 100644
--- a/core/src/test/java/org/apache/iceberg/rest/RESTCatalogAdapter.java
+++ b/core/src/test/java/org/apache/iceberg/rest/RESTCatalogAdapter.java
@@ -391,6 +391,16 @@ public class RESTCatalogAdapter extends BaseHTTPClient {
               });
         }
 
+      case UNREGISTER_TABLE:
+        {
+          return CatalogHandlers.withIdempotency(
+              httpRequest,
+              () ->
+                  castResponse(
+                      responseType,
+                      CatalogHandlers.unregisterTable(catalog, 
tableIdentFromPathVars(vars))));
+        }
+
       case UPDATE_TABLE:
         {
           return CatalogHandlers.withIdempotency(
diff --git a/core/src/test/java/org/apache/iceberg/rest/Route.java 
b/core/src/test/java/org/apache/iceberg/rest/Route.java
index 8680915bff..4a51fc256c 100644
--- a/core/src/test/java/org/apache/iceberg/rest/Route.java
+++ b/core/src/test/java/org/apache/iceberg/rest/Route.java
@@ -45,6 +45,7 @@ import org.apache.iceberg.rest.responses.LoadTableResponse;
 import org.apache.iceberg.rest.responses.LoadViewResponse;
 import org.apache.iceberg.rest.responses.OAuthTokenResponse;
 import org.apache.iceberg.rest.responses.PlanTableScanResponse;
+import org.apache.iceberg.rest.responses.UnregisterTableResponse;
 import org.apache.iceberg.rest.responses.UpdateNamespacePropertiesResponse;
 import org.apache.iceberg.util.Pair;
 
@@ -82,6 +83,11 @@ enum Route {
       ResourcePaths.V1_TABLE_REGISTER,
       RegisterTableRequest.class,
       LoadTableResponse.class),
+  UNREGISTER_TABLE(
+      HTTPRequest.HTTPMethod.POST,
+      ResourcePaths.V1_TABLE_UNREGISTER,
+      null,
+      UnregisterTableResponse.class),
   UPDATE_TABLE(
       HTTPRequest.HTTPMethod.POST,
       ResourcePaths.V1_TABLE,
diff --git 
a/core/src/test/java/org/apache/iceberg/rest/responses/TestUnregisterTableResponseParser.java
 
b/core/src/test/java/org/apache/iceberg/rest/responses/TestUnregisterTableResponseParser.java
new file mode 100644
index 0000000000..997be87e1d
--- /dev/null
+++ 
b/core/src/test/java/org/apache/iceberg/rest/responses/TestUnregisterTableResponseParser.java
@@ -0,0 +1,92 @@
+/*
+ * 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.iceberg.rest.responses;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import org.apache.iceberg.PartitionSpec;
+import org.apache.iceberg.Schema;
+import org.apache.iceberg.SortOrder;
+import org.apache.iceberg.TableMetadata;
+import org.apache.iceberg.TableMetadataParser;
+import org.apache.iceberg.types.Types;
+import org.junit.jupiter.api.Test;
+
+public class TestUnregisterTableResponseParser {
+
+  @Test
+  public void nullAndEmptyCheck() {
+    assertThatThrownBy(() -> UnregisterTableResponseParser.toJson(null))
+        .isInstanceOf(IllegalArgumentException.class)
+        .hasMessage("Invalid unregister table response: null");
+
+    assertThatThrownBy(() -> UnregisterTableResponseParser.fromJson((JsonNode) 
null))
+        .isInstanceOf(IllegalArgumentException.class)
+        .hasMessage("Cannot parse unregister table response from null object");
+
+    assertThatThrownBy(() -> UnregisterTableResponseParser.fromJson("{}"))
+        .isInstanceOf(IllegalArgumentException.class)
+        .hasMessage("Cannot parse missing string: metadata-location");
+  }
+
+  @Test
+  public void missingFields() {
+    assertThatThrownBy(
+            () ->
+                UnregisterTableResponseParser.fromJson(
+                    "{\"metadata-location\": \"custom-location\"}"))
+        .isInstanceOf(IllegalArgumentException.class)
+        .hasMessage("Cannot parse missing field: metadata");
+  }
+
+  @Test
+  public void roundTripSerde() {
+    String uuid = "386b9f01-002b-4d8c-b77f-42c3fd3b7c9b";
+    TableMetadata metadata =
+        TableMetadata.buildFromEmpty(1)
+            .assignUUID(uuid)
+            .setLocation("location")
+            .setCurrentSchema(
+                new Schema(Types.NestedField.required(1, "x", 
Types.LongType.get())), 1)
+            .addPartitionSpec(PartitionSpec.unpartitioned())
+            .addSortOrder(SortOrder.unsorted())
+            .discardChanges()
+            .withMetadataLocation("metadataTestLocation")
+            .build();
+
+    UnregisterTableResponse response =
+        ImmutableUnregisterTableResponse.builder()
+            .metadataLocation("metadataTestLocation")
+            .metadata(metadata)
+            .build();
+
+    String expectedJson =
+        String.format(
+            "{\"metadata-location\":\"metadataTestLocation\",\"metadata\":%s}",
+            TableMetadataParser.toJson(metadata));
+    String actualJson = UnregisterTableResponseParser.toJson(response);
+    assertThat(actualJson).isEqualTo(expectedJson);
+
+    UnregisterTableResponse parsed = 
UnregisterTableResponseParser.fromJson(actualJson);
+    
assertThat(parsed.metadata().metadataFileLocation()).isEqualTo("metadataTestLocation");
+    
assertThat(UnregisterTableResponseParser.toJson(parsed)).isEqualTo(expectedJson);
+  }
+}
diff --git 
a/open-api/src/test/java/org/apache/iceberg/rest/RESTCompatibilityKitCatalogTests.java
 
b/open-api/src/test/java/org/apache/iceberg/rest/RESTCompatibilityKitCatalogTests.java
index 611a6a20da..83343c4e42 100644
--- 
a/open-api/src/test/java/org/apache/iceberg/rest/RESTCompatibilityKitCatalogTests.java
+++ 
b/open-api/src/test/java/org/apache/iceberg/rest/RESTCompatibilityKitCatalogTests.java
@@ -19,9 +19,13 @@
 package org.apache.iceberg.rest;
 
 import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
 
 import java.util.Map;
+import org.apache.iceberg.Table;
+import org.apache.iceberg.TableUtil;
 import org.apache.iceberg.catalog.CatalogTests;
+import org.apache.iceberg.exceptions.NoSuchTableException;
 import org.apache.iceberg.util.PropertyUtil;
 import org.junit.jupiter.api.AfterAll;
 import org.junit.jupiter.api.BeforeAll;
@@ -115,6 +119,61 @@ public class RESTCompatibilityKitCatalogTests extends 
CatalogTests<RESTCatalog>
         restCatalog.properties(), 
RESTCompatibilityKitSuite.RCK_SUPPORTS_VARIANT, false);
   }
 
+  @Override
+  protected boolean supportsUnregister() {
+    return true;
+  }
+
+  @Test
+  public void testUnregisterTable() {
+    if (requiresNamespaceCreate()) {
+      restCatalog.createNamespace(TABLE.namespace());
+    }
+
+    Table original =
+        restCatalog
+            .buildTable(TABLE, SCHEMA)
+            .withPartitionSpec(SPEC)
+            .withSortOrder(WRITE_ORDER)
+            .create();
+    original.newFastAppend().appendFile(FILE_A).commit();
+    original.newFastAppend().appendFile(FILE_B).commit();
+
+    Table unregistered = restCatalog.unregisterTable(TABLE);
+
+    assertThat(unregistered.currentSnapshot())
+        .as("Current snapshot must match the unregistered table")
+        .isEqualTo(original.currentSnapshot());
+    assertFiles(unregistered, FILE_A, FILE_B);
+    assertThat(restCatalog.tableExists(TABLE))
+        .as("Table must not exist after being unregistered")
+        .isFalse();
+    assertThatThrownBy(() -> 
unregistered.updateProperties().set("unregistered", "true").commit())
+        .isInstanceOf(UnsupportedOperationException.class)
+        .hasMessageContaining("Cannot modify a static table");
+
+    // the underlying files are left in place, so the table can be registered 
again
+    Table registered =
+        restCatalog.registerTable(TABLE, 
TableUtil.metadataFileLocation(unregistered));
+    assertThat(registered.currentSnapshot())
+        .as("Current snapshot must match the unregistered table")
+        .isEqualTo(original.currentSnapshot());
+    assertFiles(registered, FILE_A, FILE_B);
+
+    assertThat(restCatalog.dropTable(TABLE)).isTrue();
+  }
+
+  @Test
+  public void testUnregisterMissingTable() {
+    if (requiresNamespaceCreate()) {
+      restCatalog.createNamespace(TABLE.namespace());
+    }
+
+    assertThatThrownBy(() -> restCatalog.unregisterTable(TABLE))
+        .isInstanceOf(NoSuchTableException.class)
+        .hasMessageContaining("Table does not exist");
+  }
+
   @Disabled("RESTServerExtension isn’t configurable per test")
   @Test
   public void createTableInUniqueLocation() {

Reply via email to