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

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


The following commit(s) were added to refs/heads/main by this push:
     new 97720316a feat(catalog): add table lifecycle conformance tests and fix 
rename-to-existing errors (#2165)
97720316a is described below

commit 97720316a4a206c600b9357c18d158d8eda570be
Author: Richie Bachala <[email protected]>
AuthorDate: Sun Oct 11 11:19:06 2026 -0700

    feat(catalog): add table lifecycle conformance tests and fix 
rename-to-existing errors (#2165)
    
    * feat(catalog): add table lifecycle tests to the catalog conformance suite
    
    Add ListTables, DropTable, DropMissingTable, RenameTable,
    RenameTableToExisting and RenameMissingTable to catalogtest, gated by a
    new Config.SupportsRenameTable flag. SQL, REST and Hive opt in; Hadoop
    does not implement rename.
    
    Related to #1691
    
    * fix(catalog): return ErrTableAlreadyExists when renaming onto an existing 
table
    
    The new RenameTableToExisting conformance test found that REST and Hive
    returned untyped errors for this case. REST now maps 409 to
    ErrTableAlreadyExists, matching CreateTable. Hive checks the destination
    while holding the rename locks, matching its create path.
    
    Related to #1691
    
    * fix(hive): detect non-Iceberg tables at rename destination
    
    CheckTableExists only recognises Iceberg tables, so a non-Iceberg Hive
    table at the destination was invisible to the pre-check and AlterTable
    would return a raw metastore error.  Switch to a raw GetTable call so
    any table (Iceberg or not) at the destination is rejected with
    ErrTableAlreadyExists.
    
    Also strengthen the rename conformance test to assert that the table
    UUID survives the rename, catching backends that silently recreate
    instead of renaming.
    
    * test(catalog): verify unsupported rename and rename metadata preservation
    
    Address review feedback on the table lifecycle conformance tests:
    
    - When SupportsRenameTable is false, run RenameTableNotSupported instead
      of skipping the rename suite, asserting RenameTable returns
      iceberg.ErrNotImplemented and leaves the source table in place. The
      Hadoop catalog now wraps that sentinel. Reference #1097 in the field
      doc.
    - Assert that rename preserves the table metadata location alongside
      its UUID, catching backends that rename by recreating the table.
    
    Signed-off-by: Richie Bachala <[email protected]>
    
    ---------
    
    Signed-off-by: Richie Bachala <[email protected]>
---
 catalog/catalogtest/catalogtest.go |  17 ++++
 catalog/catalogtest/tables.go      | 198 +++++++++++++++++++++++++++++++++++++
 catalog/hadoop/hadoop.go           |   2 +-
 catalog/hadoop/hadoop_test.go      |   2 +-
 catalog/hive/conformance_test.go   |   1 +
 catalog/hive/hive.go               |  10 ++
 catalog/hive/hive_test.go          |  27 +++++
 catalog/rest/conformance_test.go   |   1 +
 catalog/rest/rest.go               |   2 +-
 catalog/rest/rest_test.go          |  23 +++++
 catalog/sql/conformance_test.go    |   1 +
 11 files changed, 281 insertions(+), 3 deletions(-)

diff --git a/catalog/catalogtest/catalogtest.go 
b/catalog/catalogtest/catalogtest.go
index 0521f24a2..79bf7a6e2 100644
--- a/catalog/catalogtest/catalogtest.go
+++ b/catalog/catalogtest/catalogtest.go
@@ -89,6 +89,12 @@ type Config struct {
        // are directories with nowhere to keep them. Loading is required of 
every
        // catalog and is tested either way.
        SupportsNamespaceProperties bool
+
+       // SupportsRenameTable reports whether the catalog can rename tables.
+       // When it is false, as it is for the Hadoop catalog until #1097 lands,
+       // the rename tests are replaced by one asserting that RenameTable 
returns
+       // iceberg.ErrNotImplemented and leaves the source table in place.
+       SupportsRenameTable bool
 }
 
 // RunCatalogTests runs the conformance suite against the catalog described by
@@ -102,6 +108,17 @@ func RunCatalogTests(t *testing.T, cfg Config) {
        t.Run("LoadMissingTable", func(t *testing.T) { testLoadMissingTable(t, 
cfg) })
        t.Run("LoadTableWithNonExistingNamespace", func(t *testing.T) { 
testLoadTableWithNonExistingNamespace(t, cfg) })
 
+       t.Run("ListTables", func(t *testing.T) { testListTables(t, cfg) })
+       t.Run("DropTable", func(t *testing.T) { testDropTable(t, cfg) })
+       t.Run("DropMissingTable", func(t *testing.T) { testDropMissingTable(t, 
cfg) })
+       if cfg.SupportsRenameTable {
+               t.Run("RenameTable", func(t *testing.T) { testRenameTable(t, 
cfg) })
+               t.Run("RenameTableToExisting", func(t *testing.T) { 
testRenameTableToExisting(t, cfg) })
+               t.Run("RenameMissingTable", func(t *testing.T) { 
testRenameMissingTable(t, cfg) })
+       } else {
+               t.Run("RenameTableNotSupported", func(t *testing.T) { 
testRenameTableNotSupported(t, cfg) })
+       }
+
        t.Run("CreateNamespace", func(t *testing.T) { testCreateNamespace(t, 
cfg) })
        t.Run("CreateNamespaceThatAlreadyExists", func(t *testing.T) { 
testCreateNamespaceThatAlreadyExists(t, cfg) })
        t.Run("DropNamespace", func(t *testing.T) { testDropNamespace(t, cfg) })
diff --git a/catalog/catalogtest/tables.go b/catalog/catalogtest/tables.go
new file mode 100644
index 000000000..bfc03cc75
--- /dev/null
+++ b/catalog/catalogtest/tables.go
@@ -0,0 +1,198 @@
+// 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 catalogtest
+
+import (
+       "context"
+       "testing"
+
+       "github.com/apache/iceberg-go"
+       "github.com/apache/iceberg-go/catalog"
+       "github.com/apache/iceberg-go/table"
+       "github.com/stretchr/testify/assert"
+       "github.com/stretchr/testify/require"
+)
+
+// listTables collects every table identifier the catalog reports for 
namespace.
+func listTables(t *testing.T, cat catalog.Catalog, namespace table.Identifier) 
[]table.Identifier {
+       t.Helper()
+
+       var out []table.Identifier
+       for ident, err := range cat.ListTables(context.Background(), namespace) 
{
+               require.NoError(t, err)
+               out = append(out, ident)
+       }
+
+       return out
+}
+
+// testListTables asserts that ListTables reflects creates and drops.
+func testListTables(t *testing.T, cfg Config) {
+       ctx := context.Background()
+       cat := cfg.NewCatalog(t)
+       namespace, ident := newIdentifiers()
+
+       require.NoError(t, cat.CreateNamespace(ctx, namespace, nil))
+       t.Cleanup(func() { _ = cat.DropNamespace(ctx, namespace) })
+
+       assert.Empty(t, listTables(t, cat, namespace), "new namespace should 
have no tables")
+
+       _, err := cat.CreateTable(ctx, ident, Schema)
+       require.NoError(t, err)
+       t.Cleanup(func() { _ = cat.DropTable(ctx, ident) })
+
+       assert.Equal(t, []table.Identifier{ident}, listTables(t, cat, 
namespace))
+
+       require.NoError(t, cat.DropTable(ctx, ident))
+       assert.Empty(t, listTables(t, cat, namespace), "dropped table should 
not be listed")
+}
+
+// testDropTable asserts that a dropped table is no longer visible.
+func testDropTable(t *testing.T, cfg Config) {
+       ctx := context.Background()
+       cat := cfg.NewCatalog(t)
+       namespace, ident := newIdentifiers()
+
+       require.NoError(t, cat.CreateNamespace(ctx, namespace, nil))
+       t.Cleanup(func() { _ = cat.DropNamespace(ctx, namespace) })
+
+       _, err := cat.CreateTable(ctx, ident, Schema)
+       require.NoError(t, err)
+
+       require.NoError(t, cat.DropTable(ctx, ident))
+
+       exists, err := cat.CheckTableExists(ctx, ident)
+       require.NoError(t, err)
+       assert.False(t, exists, "table should not exist after drop")
+
+       _, err = cat.LoadTable(ctx, ident)
+       assert.ErrorIs(t, err, catalog.ErrNoSuchTable)
+}
+
+// testDropMissingTable asserts that dropping a table that was never created
+// reports that the table does not exist.
+func testDropMissingTable(t *testing.T, cfg Config) {
+       ctx := context.Background()
+       cat := cfg.NewCatalog(t)
+       namespace, ident := newIdentifiers()
+
+       require.NoError(t, cat.CreateNamespace(ctx, namespace, nil))
+       t.Cleanup(func() { _ = cat.DropNamespace(ctx, namespace) })
+
+       assert.ErrorIs(t, cat.DropTable(ctx, ident), catalog.ErrNoSuchTable)
+}
+
+// testRenameTableNotSupported asserts that a catalog without rename support
+// reports it with iceberg.ErrNotImplemented and leaves the source table in
+// place, so an unsupported rename is a verified behavior rather than a skip.
+func testRenameTableNotSupported(t *testing.T, cfg Config) {
+       ctx := context.Background()
+       cat := cfg.NewCatalog(t)
+       namespace, from := newIdentifiers()
+       to := table.Identifier{namespace[0], "renamed"}
+
+       require.NoError(t, cat.CreateNamespace(ctx, namespace, nil))
+       t.Cleanup(func() { _ = cat.DropNamespace(ctx, namespace) })
+
+       _, err := cat.CreateTable(ctx, from, Schema)
+       require.NoError(t, err)
+       t.Cleanup(func() { _ = cat.DropTable(ctx, from) })
+
+       _, err = cat.RenameTable(ctx, from, to)
+       assert.ErrorIs(t, err, iceberg.ErrNotImplemented)
+
+       exists, err := cat.CheckTableExists(ctx, from)
+       require.NoError(t, err)
+       assert.True(t, exists, "source table should still exist after an 
unsupported rename")
+}
+
+// testRenameTable asserts that a renamed table is reachable only under its new
+// identifier and keeps its identity, metadata and schema.
+func testRenameTable(t *testing.T, cfg Config) {
+       ctx := context.Background()
+       cat := cfg.NewCatalog(t)
+       namespace, from := newIdentifiers()
+       to := table.Identifier{namespace[0], "renamed"}
+
+       require.NoError(t, cat.CreateNamespace(ctx, namespace, nil))
+       t.Cleanup(func() { _ = cat.DropNamespace(ctx, namespace) })
+
+       created, err := cat.CreateTable(ctx, from, Schema)
+       require.NoError(t, err)
+       t.Cleanup(func() { _ = cat.DropTable(ctx, from) })
+
+       originalUUID := created.Metadata().TableUUID()
+       originalMetadataLocation := created.MetadataLocation()
+
+       _, err = cat.RenameTable(ctx, from, to)
+       require.NoError(t, err)
+       t.Cleanup(func() { _ = cat.DropTable(ctx, to) })
+
+       exists, err := cat.CheckTableExists(ctx, from)
+       require.NoError(t, err)
+       assert.False(t, exists, "source table should not exist after rename")
+
+       tbl, err := cat.LoadTable(ctx, to)
+       require.NoError(t, err)
+       assert.Equal(t, to, tbl.Identifier())
+       assert.Equal(t, originalUUID, tbl.Metadata().TableUUID(), "rename must 
preserve table identity")
+       assert.Equal(t, originalMetadataLocation, tbl.MetadataLocation(), 
"rename should not rewrite table metadata")
+       assert.Equal(t, TableSchema.AsStruct(), tbl.Schema().AsStruct(), 
"rename should keep the schema")
+}
+
+// testRenameTableToExisting asserts that renaming onto an existing table is
+// rejected and leaves both tables in place.
+func testRenameTableToExisting(t *testing.T, cfg Config) {
+       ctx := context.Background()
+       cat := cfg.NewCatalog(t)
+       namespace, from := newIdentifiers()
+       to := table.Identifier{namespace[0], "other"}
+
+       require.NoError(t, cat.CreateNamespace(ctx, namespace, nil))
+       t.Cleanup(func() { _ = cat.DropNamespace(ctx, namespace) })
+
+       for _, ident := range []table.Identifier{from, to} {
+               _, err := cat.CreateTable(ctx, ident, Schema)
+               require.NoError(t, err)
+               t.Cleanup(func() { _ = cat.DropTable(ctx, ident) })
+       }
+
+       _, err := cat.RenameTable(ctx, from, to)
+       assert.ErrorIs(t, err, catalog.ErrTableAlreadyExists)
+
+       for _, ident := range []table.Identifier{from, to} {
+               exists, err := cat.CheckTableExists(ctx, ident)
+               require.NoError(t, err)
+               assert.True(t, exists, "%v should still exist after a rejected 
rename", ident)
+       }
+}
+
+// testRenameMissingTable asserts that renaming a table that was never created
+// reports that the table does not exist.
+func testRenameMissingTable(t *testing.T, cfg Config) {
+       ctx := context.Background()
+       cat := cfg.NewCatalog(t)
+       namespace, from := newIdentifiers()
+       to := table.Identifier{namespace[0], "renamed"}
+
+       require.NoError(t, cat.CreateNamespace(ctx, namespace, nil))
+       t.Cleanup(func() { _ = cat.DropNamespace(ctx, namespace) })
+
+       _, err := cat.RenameTable(ctx, from, to)
+       assert.ErrorIs(t, err, catalog.ErrNoSuchTable)
+}
diff --git a/catalog/hadoop/hadoop.go b/catalog/hadoop/hadoop.go
index ec25158f8..ecc718aa6 100644
--- a/catalog/hadoop/hadoop.go
+++ b/catalog/hadoop/hadoop.go
@@ -928,7 +928,7 @@ func (c *Catalog) PurgeTable(ctx context.Context, 
identifier table.Identifier) e
 }
 
 func (c *Catalog) RenameTable(_ context.Context, _, _ table.Identifier) 
(*table.Table, error) {
-       return nil, errors.New("hadoop catalog: rename table is not supported")
+       return nil, fmt.Errorf("%w: hadoop catalog: rename table is not 
supported", iceberg.ErrNotImplemented)
 }
 
 func (c *Catalog) CreateNamespace(_ context.Context, ns table.Identifier, 
props iceberg.Properties) error {
diff --git a/catalog/hadoop/hadoop_test.go b/catalog/hadoop/hadoop_test.go
index 2007ab402..1390a34df 100644
--- a/catalog/hadoop/hadoop_test.go
+++ b/catalog/hadoop/hadoop_test.go
@@ -2105,7 +2105,7 @@ func (s *HadoopCatalogTestSuite) 
TestDropTableNamespacePreserved() {
 
 func (s *HadoopCatalogTestSuite) TestRenameTableUnsupported() {
        _, err := s.cat.RenameTable(context.Background(), []string{"ns", 
"old"}, []string{"ns", "new"})
-       s.Require().Error(err)
+       s.Require().ErrorIs(err, iceberg.ErrNotImplemented)
        s.Contains(err.Error(), "not supported")
 }
 
diff --git a/catalog/hive/conformance_test.go b/catalog/hive/conformance_test.go
index 7aab243fe..46e4a2ae9 100644
--- a/catalog/hive/conformance_test.go
+++ b/catalog/hive/conformance_test.go
@@ -31,6 +31,7 @@ import (
 func TestHiveCatalogConformance(t *testing.T) {
        catalogtest.RunCatalogTests(t, catalogtest.Config{
                SupportsNamespaceProperties: true,
+               SupportsRenameTable:         true,
                NewCatalog: func(t *testing.T) catalog.Catalog {
                        cat, err := NewCatalog(iceberg.Properties{
                                URI:       getTestHiveURI(),
diff --git a/catalog/hive/hive.go b/catalog/hive/hive.go
index 30670bd96..2b00770f2 100644
--- a/catalog/hive/hive.go
+++ b/catalog/hive/hive.go
@@ -539,6 +539,16 @@ func (c *Catalog) RenameTable(ctx context.Context, from, 
to table.Identifier) (_
                        table.ErrCommitFailed, fromDB, fromTable, 
sourceMetadataLocation, lockedMetadataLocation)
        }
 
+       // Use a raw GetTable instead of CheckTableExists so that non-Iceberg
+       // Hive tables at the destination are also detected as conflicts.
+       _, err = c.client.GetTable(ctx, toDB, toTable)
+       if err == nil {
+               return nil, fmt.Errorf("%w: %s.%s", 
catalog.ErrTableAlreadyExists, toDB, toTable)
+       }
+       if !isNoSuchObjectError(err) {
+               return nil, fmt.Errorf("failed to check destination table 
%s.%s: %w", toDB, toTable, err)
+       }
+
        hiveTbl.TableName = toTable
        hiveTbl.DbName = toDB
 
diff --git a/catalog/hive/hive_test.go b/catalog/hive/hive_test.go
index 85ab555e7..d8c06a684 100644
--- a/catalog/hive/hive_test.go
+++ b/catalog/hive/hive_test.go
@@ -891,6 +891,33 @@ func TestHiveRenameTableRejectsChangedMetadataAfterLock(t 
*testing.T) {
        mockClient.AssertExpectations(t)
 }
 
+func TestHiveRenameTableToExistingTable(t *testing.T) {
+       mockClient := &mockHiveClient{}
+       destination := *testIcebergHiveTable1
+       destination.TableName = "renamed"
+       destination.DbName = "target_database"
+
+       mockClient.On("GetDatabase", mock.Anything, "target_database").
+               Return(&hive_metastore.Database{Name: "target_database"}, 
nil).Once()
+       mockClient.On("GetTable", mock.Anything, "test_database", "test_table").
+               Return(testIcebergHiveTable1, nil).Twice()
+       mockClient.On("Lock", mock.Anything, mock.Anything).
+               Return(&hive_metastore.LockResponse{Lockid: 2, State: 
hive_metastore.LockState_ACQUIRED}, nil).Once()
+       mockClient.On("GetTable", mock.Anything, "target_database", "renamed").
+               Return(&destination, nil).Once()
+       mockClient.On("Unlock", mock.Anything, int64(2)).Return(nil).Once()
+       hiveCatalog := NewCatalogWithClient(mockClient, iceberg.Properties{})
+
+       _, err := hiveCatalog.RenameTable(
+               context.Background(),
+               TableIdentifier("test_database", "test_table"),
+               TableIdentifier("target_database", "renamed"),
+       )
+       require.ErrorIs(t, err, catalog.ErrTableAlreadyExists)
+       mockClient.AssertNotCalled(t, "AlterTable", mock.Anything, 
mock.Anything, mock.Anything, mock.Anything)
+       mockClient.AssertExpectations(t)
+}
+
 func TestHivePurgeTable(t *testing.T) {
        assert := require.New(t)
        ctx := context.Background()
diff --git a/catalog/rest/conformance_test.go b/catalog/rest/conformance_test.go
index 3e8bda67e..15f4e17f5 100644
--- a/catalog/rest/conformance_test.go
+++ b/catalog/rest/conformance_test.go
@@ -33,6 +33,7 @@ import (
 func TestRestCatalogConformance(t *testing.T) {
        catalogtest.RunCatalogTests(t, catalogtest.Config{
                SupportsNamespaceProperties: true,
+               SupportsRenameTable:         true,
                NewCatalog: func(t *testing.T) catalog.Catalog {
                        cat, err := catalog.Load(context.Background(), "local", 
iceberg.Properties{
                                "type":               "rest",
diff --git a/catalog/rest/rest.go b/catalog/rest/rest.go
index 06bd3eaae..83c5d9855 100644
--- a/catalog/rest/rest.go
+++ b/catalog/rest/rest.go
@@ -1980,7 +1980,7 @@ func (r *Catalog) RenameTable(ctx context.Context, from, 
to table.Identifier) (*
        }
 
        _, err = doPost[payload, any](ctx, r.baseURI, path, payload{Source: 
src, Destination: dst}, r.cl,
-               map[int]error{http.StatusNotFound: catalog.ErrNoSuchTable}, 
allowNoContent())
+               map[int]error{http.StatusNotFound: catalog.ErrNoSuchTable, 
http.StatusConflict: catalog.ErrTableAlreadyExists}, allowNoContent())
        if err != nil {
                return nil, err
        }
diff --git a/catalog/rest/rest_test.go b/catalog/rest/rest_test.go
index 0655da244..8ace73360 100644
--- a/catalog/rest/rest_test.go
+++ b/catalog/rest/rest_test.go
@@ -1944,6 +1944,29 @@ func (r *RestCatalogSuite) 
TestLoadTableWithSnapshotModeAll() {
        r.EqualValues(3497810964824022504, tbl.CurrentSnapshot().SnapshotID)
 }
 
+func (r *RestCatalogSuite) TestRenameTable409() {
+       r.mux.HandleFunc("/v1/tables/rename", func(w http.ResponseWriter, req 
*http.Request) {
+               if !r.Equal(http.MethodPost, req.Method) {
+                       return
+               }
+
+               w.WriteHeader(http.StatusConflict)
+               json.NewEncoder(w).Encode(map[string]any{
+                       "error": map[string]any{
+                               "message": "Table already exists: 
fokko.destination",
+                               "type":    "AlreadyExistsException",
+                               "code":    409,
+                       },
+               })
+       })
+
+       cat, err := rest.NewCatalog(context.Background(), "rest", r.srv.URL, 
rest.WithOAuthToken(TestToken))
+       r.Require().NoError(err)
+
+       _, err = cat.RenameTable(context.Background(), 
catalog.ToIdentifier("fokko", "source"), catalog.ToIdentifier("fokko", 
"destination"))
+       r.ErrorIs(err, catalog.ErrTableAlreadyExists)
+}
+
 func (r *RestCatalogSuite) TestRenameTable204() {
        // Mock the rename table endpoint
        r.mux.HandleFunc("/v1/tables/rename", func(w http.ResponseWriter, req 
*http.Request) {
diff --git a/catalog/sql/conformance_test.go b/catalog/sql/conformance_test.go
index 2fca4fb89..2c4129041 100644
--- a/catalog/sql/conformance_test.go
+++ b/catalog/sql/conformance_test.go
@@ -33,6 +33,7 @@ import (
 func TestSqlCatalogConformance(t *testing.T) {
        catalogtest.RunCatalogTests(t, catalogtest.Config{
                SupportsNamespaceProperties: true,
+               SupportsRenameTable:         true,
                NewCatalog: func(t *testing.T) catalog.Catalog {
                        warehouse := t.TempDir()
 

Reply via email to