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

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


The following commit(s) were added to refs/heads/main by this push:
     new 44846562db [#13309][#13310] fix(core): Keep column ids stable and pass 
the new schema on cross-schema table rename (#13316)
44846562db is described below

commit 44846562dbd28319ccdfe19ed32b1d549d901b4f
Author: Jerry Shao <[email protected]>
AuthorDate: Fri Sep 18 22:15:44 2026 +0800

    [#13309][#13310] fix(core): Keep column ids stable and pass the new schema 
on cross-schema table rename (#13316)
    
    ### What changes were proposed in this pull request?
    
    Tags, owners and privileges are attached to a column by its id. This PR
    fixes three paths that gave existing columns new ids or left column rows
    behind, so column-level metadata was lost. It also passes the right
    schema to authorization plugins on a cross-schema table rename.
    
    **Cross-schema table move with a column change (#13309)**
    - `TableColumnMetaService.updateColumnPOsFromTableDiff` now moves all of
    the table's column rows to the new `schema_id` whenever the table moves.
    Before, it did this only when no column changed, so a move plus a column
    change left the unchanged columns under the old schema, and a later
    cascade drop of that schema deleted them.
    
    **Re-import (#13309)**
    - `TableMetaService.insertTable(overwrite = true)` reuses the stored
    column ids by name, but only when the upsert kept the same table id and
    that table already existed. This is the case when a table is
    re-imported, e.g. loaded after it was renamed outside Gravitino.
    - A stored id is never given to a second column. If legacy data holds
    two live columns with one name, the most recently written one keeps its
    id.
    - The tag and owner relations of stored columns that are gone are
    removed in the same transaction.
    - When the upsert resolves to another table's row, which MySQL/H2 can do
    through the `(schema_id, table_name)` key, no ids are reused, so that
    table's columns are not inherited.
    
    **Lance `VERSION_CHECK` schema refresh (#13309)**
    - `LanceTableOperations.replaceColumnsFromDataset` reuses the id,
    comment and audit info of each column that is still in the dataset.
    Type, nullability and position come from the dataset.
    
    **Cross-schema table rename and authorization (#13310)**
    - `TableHookDispatcher.alterTable` builds the new identifier from
    `RenameTable.getNewSchemaName()`. If the request has several renames, it
    takes the last one that sets a schema.
    - `AuthorizationUtils` gets a new `authorizationPluginRenamePrivileges`
    overload that takes the new `NameIdentifier`. The existing `String`
    overload delegates to it and behaves as before.
    - `RangerAuthorizationHadoopSQLPlugin` renamed a table level by level,
    starting with the schema. Once the plugin gets the new schema, that
    schema step would match every policy of the old schema and move them
    all. It now skips the schema step for a table rename and moves only the
    table's own policies (table and column levels) to the new schema and
    name.
    
    ### Why are the changes needed?
    
    - Moving a table to another schema and changing a column in one
    `alterTable`, then dropping the old schema, deleted the unchanged
    columns and their tags, owners and privileges.
    - Re-importing a table, e.g. after an out-of-band rename, gave every
    column a new id, so all column tags were lost.
    - On a Lance catalog with `lance.schema-refresh-mode=VERSION_CHECK`,
    every dataset version change gave all columns new ids and cleared their
    comments.
    - On a cross-schema rename, authorization plugins such as Ranger were
    told the table moved to `<old schema>.<new name>`, which doesn't exist.
    
    Fix: #13309, #13310
    
    Part of #13303
    
    ### Does this PR introduce _any_ user-facing change?
    
    No API or configuration change. Behaviour changes:
    - Column tags, owners and privileges survive a table move with a column
    change, a re-import and a Lance schema refresh.
    - Lance columns keep their comments across a schema refresh.
    - Authorization plugins get the correct schema on a cross-schema table
    rename.
    
    Not changed here (tracked in #13303): when the MySQL/H2 upsert resolves
    to a stale registration with the same name, that registration's column
    relations are still orphaned.
    
    ### How was this patch tested?
    
    - **Unit tests** (H2 locally; each one fails without its fix):
      - `TestTableColumnMetaService`:
        - `testMoveTableWithColumnChangeMovesAllColumns`
        - `testOverwriteSameTableKeepsColumnIds`
        - `testOverwriteNeverGivesOneStoredIdToTwoColumns`
        - `testOverwriteWithoutColumnsRemovesAllColumnRelations`
        - `testOverwriteReusesNewestIdOfDuplicateLegacyColumns`
    - `testOverwriteOfAnotherTableDoesNotInheritColumnIds` (MySQL/H2 only)
    -
    
`TestLanceTableOperations.testVersionCheckRefreshKeepsExistingColumnIdsAndComments`
    - `TestTableHookDispatcher`:
    `testRenameAcrossSchemasPassesNewSchemaToAuthorization`,
    `testRenameTwiceKeepsSchemaFromEarlierRename`
    -
    
`TestAuthorizationUtils.testRenameTableAcrossSchemasNotifiesAuthorizationPluginWithNewSchema`
    - `TestRangerAuthorizationHadoopSQLPlugin`: a table rename across
    schemas and within one schema, against an in-memory Ranger
    - **Integration tests**:
    -
    
`CatalogGenericCatalogLanceIT.testVersionCheckRefreshKeepsColumnTagsAndComments`:
    passes locally and fails without the fix.
    - `CatalogHive2IT.testOutOfBandRenameKeepsColumnTags` (also runs in
    `CatalogHive3IT`): renames the table directly in HMS, then checks that
    the column keeps its tag. Needs Docker.
    - `TagIT.testMovedTableKeepsColumnTagsAfterOldSchemaIsDropped`: needs
    Docker.
    - `./gradlew :core:test -PskipITs` and the `catalog-lakehouse-generic`
    unit tests pass locally. The MySQL/PostgreSQL backends and the
    Docker-based ITs still need to run in CI.
    
    🤖 Generated with [Claude Code](https://claude.com/claude-code)
    
    ---------
    
    Co-authored-by: Claude Opus 5 <[email protected]>
---
 .../ranger/RangerAuthorizationHadoopSQLPlugin.java |  27 +-
 .../TestRangerAuthorizationHadoopSQLPlugin.java    | 147 +++++++++++
 .../hive/integration/test/CatalogHive2IT.java      |  61 +++++
 .../lakehouse/lance/LanceTableOperations.java      |  30 ++-
 .../lakehouse/lance/TestLanceTableOperations.java  |  72 +++++
 .../test/CatalogGenericCatalogLanceIT.java         |  90 +++++++
 .../gravitino/client/integration/test/TagIT.java   |  48 ++++
 .../authorization/AuthorizationUtils.java          |  24 +-
 .../apache/gravitino/hook/TableHookDispatcher.java |  15 +-
 .../relational/service/TableColumnMetaService.java |  91 ++++++-
 .../relational/service/TableMetaService.java       |  26 +-
 .../authorization/TestAuthorizationUtils.java      |  36 +++
 .../gravitino/hook/TestTableHookDispatcher.java    |  52 +++-
 .../service/TestTableColumnMetaService.java        | 292 +++++++++++++++++++++
 14 files changed, 980 insertions(+), 31 deletions(-)

diff --git 
a/authorizations/authorization-ranger/src/main/java/org/apache/gravitino/authorization/ranger/RangerAuthorizationHadoopSQLPlugin.java
 
b/authorizations/authorization-ranger/src/main/java/org/apache/gravitino/authorization/ranger/RangerAuthorizationHadoopSQLPlugin.java
index 3f1ca387f5..35296e4611 100644
--- 
a/authorizations/authorization-ranger/src/main/java/org/apache/gravitino/authorization/ranger/RangerAuthorizationHadoopSQLPlugin.java
+++ 
b/authorizations/authorization-ranger/src/main/java/org/apache/gravitino/authorization/ranger/RangerAuthorizationHadoopSQLPlugin.java
@@ -170,6 +170,12 @@ public class RangerAuthorizationHadoopSQLPlugin extends 
RangerAuthorizationPlugi
     for (int index = 0; index < mappingOldAndNewMetadata.size(); index++) {
       oldMetadataNames.add(mappingOldAndNewMetadata.get(index).getKey());
       newMetadataNames.add(mappingOldAndNewMetadata.get(index).getValue());
+      // A table rename never changes the schema-level policies. When the 
table also moves to
+      // another schema, updating them would move every policy of the old 
schema, so the table's
+      // own policies are updated from the table level down instead.
+      if (index == 0 && newAuthzMetadataObject.type().equals(TABLE)) {
+        continue;
+      }
 
       AuthorizationMetadataObject.Type type;
       if (index == 0) {
@@ -235,16 +241,21 @@ public class RangerAuthorizationHadoopSQLPlugin extends 
RangerAuthorizationPlugi
                     // Doesn't need to rename the policy `*`
                     return;
                   }
-                  policyNames.set(index, 
newAuthzMetaObject.names().get(index));
+                  // Every level up to the renamed one takes its new name, 
because a renamed
+                  // table may also have moved to another schema.
+                  for (int i = 0; i <= index; i++) {
+                    policyNames.set(i, newAuthzMetaObject.names().get(i));
+                  }
                   
policy.setName(AuthorizationSecurableObject.DOT_JOINER.join(policyNames));
                 }
-                // Update the policy resource name to new name
-                policy
-                    .getResources()
-                    .put(
-                        policyResourceDefinesRule().get(index),
-                        new RangerPolicy.RangerPolicyResource(
-                            newAuthzMetaObject.names().get(index)));
+                // Update the policy resource names, up to the renamed level, 
to the new names
+                for (int i = 0; i <= index; i++) {
+                  policy
+                      .getResources()
+                      .put(
+                          policyResourceDefinesRule().get(i),
+                          new 
RangerPolicy.RangerPolicyResource(newAuthzMetaObject.names().get(i)));
+                }
 
                 boolean alreadyExist =
                     existNewPolicies.stream()
diff --git 
a/authorizations/authorization-ranger/src/test/java/org/apache/gravitino/authorization/ranger/TestRangerAuthorizationHadoopSQLPlugin.java
 
b/authorizations/authorization-ranger/src/test/java/org/apache/gravitino/authorization/ranger/TestRangerAuthorizationHadoopSQLPlugin.java
new file mode 100644
index 0000000000..5b268bd12d
--- /dev/null
+++ 
b/authorizations/authorization-ranger/src/test/java/org/apache/gravitino/authorization/ranger/TestRangerAuthorizationHadoopSQLPlugin.java
@@ -0,0 +1,147 @@
+/*
+ * 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.gravitino.authorization.ranger;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyLong;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.stream.Collectors;
+import org.apache.gravitino.MetadataObject;
+import org.apache.gravitino.MetadataObjects;
+import org.apache.gravitino.authorization.MetadataObjectChange;
+import 
org.apache.gravitino.authorization.ranger.reference.RangerDefines.PolicyResource;
+import org.apache.ranger.plugin.model.RangerPolicy;
+import org.apache.ranger.plugin.util.SearchFilter;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
+
+public class TestRangerAuthorizationHadoopSQLPlugin {
+
+  private final List<RangerPolicy> policies = new ArrayList<>();
+  private RangerAuthorizationHadoopSQLPlugin plugin;
+
+  @BeforeEach
+  public void setUp() throws Exception {
+    policies.clear();
+    // The constructor connects to Ranger, so only the tested methods run for 
real.
+    plugin = Mockito.mock(RangerAuthorizationHadoopSQLPlugin.class, 
Mockito.CALLS_REAL_METHODS);
+    RangerClientExtension rangerClient = 
Mockito.mock(RangerClientExtension.class);
+    // An in-memory Ranger: a search returns the policies whose resources 
match every filter.
+    Mockito.when(rangerClient.findPolicies(any()))
+        .thenAnswer(invocation -> findPolicies(invocation.getArgument(0)));
+    Mockito.when(rangerClient.updatePolicy(anyLong(), any()))
+        .thenAnswer(invocation -> invocation.getArgument(1));
+    plugin.setRangerClient(rangerClient);
+  }
+
+  @Test
+  public void testRenameTableAcrossSchemasMovesOnlyThatTablesPolicies() {
+    RangerPolicy schemaPolicy = addPolicy(1, "s1", "s1", null, null);
+    RangerPolicy tablePolicy = addPolicy(2, "s1.t1", "s1", "t1", null);
+    RangerPolicy columnPolicy = addPolicy(3, "s1.t1.*", "s1", "t1", "*");
+    RangerPolicy otherTablePolicy = addPolicy(4, "s1.t9", "s1", "t9", null);
+
+    plugin.onMetadataUpdated(
+        MetadataObjectChange.rename(
+            MetadataObjects.parse("catalog.s1.t1", MetadataObject.Type.TABLE),
+            MetadataObjects.parse("catalog.s2.t2", MetadataObject.Type.TABLE),
+            null));
+
+    // The renamed table's policies follow it to the new schema and name.
+    Assertions.assertEquals("s2.t2", tablePolicy.getName());
+    Assertions.assertEquals(resources("s2", "t2", null), 
resourcesOf(tablePolicy));
+    Assertions.assertEquals(resources("s2", "t2", "*"), 
resourcesOf(columnPolicy));
+    // The old schema and its other tables keep their policies.
+    Assertions.assertEquals("s1", schemaPolicy.getName());
+    Assertions.assertEquals(resources("s1", null, null), 
resourcesOf(schemaPolicy));
+    Assertions.assertEquals("s1.t9", otherTablePolicy.getName());
+    Assertions.assertEquals(resources("s1", "t9", null), 
resourcesOf(otherTablePolicy));
+  }
+
+  @Test
+  public void testRenameTableInSameSchemaRenamesOnlyThatTablesPolicies() {
+    RangerPolicy schemaPolicy = addPolicy(1, "s1", "s1", null, null);
+    RangerPolicy tablePolicy = addPolicy(2, "s1.t1", "s1", "t1", null);
+    RangerPolicy otherTablePolicy = addPolicy(3, "s1.t9", "s1", "t9", null);
+
+    plugin.onMetadataUpdated(
+        MetadataObjectChange.rename(
+            MetadataObjects.parse("catalog.s1.t1", MetadataObject.Type.TABLE),
+            MetadataObjects.parse("catalog.s1.t2", MetadataObject.Type.TABLE),
+            null));
+
+    Assertions.assertEquals("s1.t2", tablePolicy.getName());
+    Assertions.assertEquals(resources("s1", "t2", null), 
resourcesOf(tablePolicy));
+    Assertions.assertEquals(resources("s1", null, null), 
resourcesOf(schemaPolicy));
+    Assertions.assertEquals(resources("s1", "t9", null), 
resourcesOf(otherTablePolicy));
+  }
+
+  private RangerPolicy addPolicy(long id, String name, String db, String 
table, String column) {
+    RangerPolicy policy = new RangerPolicy();
+    policy.setId(id);
+    policy.setName(name);
+    Map<String, RangerPolicy.RangerPolicyResource> policyResources = new 
HashMap<>();
+    resources(db, table, column)
+        .forEach((k, v) -> policyResources.put(k, new 
RangerPolicy.RangerPolicyResource(v)));
+    policy.setResources(policyResources);
+    policies.add(policy);
+    return policy;
+  }
+
+  private List<RangerPolicy> findPolicies(Map<String, String> filters) {
+    return policies.stream()
+        .filter(
+            policy ->
+                filters.entrySet().stream()
+                    .filter(e -> 
e.getKey().startsWith(SearchFilter.RESOURCE_PREFIX))
+                    .allMatch(
+                        e ->
+                            Objects.equals(
+                                resourcesOf(policy)
+                                    .get(
+                                        e.getKey()
+                                            
.substring(SearchFilter.RESOURCE_PREFIX.length())),
+                                e.getValue())))
+        .collect(Collectors.toList());
+  }
+
+  private static Map<String, String> resources(String db, String table, String 
column) {
+    Map<String, String> resources = new HashMap<>();
+    resources.put(PolicyResource.DATABASE.getName(), db);
+    if (table != null) {
+      resources.put(PolicyResource.TABLE.getName(), table);
+    }
+    if (column != null) {
+      resources.put(PolicyResource.COLUMN.getName(), column);
+    }
+    return resources;
+  }
+
+  private static Map<String, String> resourcesOf(RangerPolicy policy) {
+    return policy.getResources().entrySet().stream()
+        .collect(Collectors.toMap(Map.Entry::getKey, e -> 
e.getValue().getValues().get(0)));
+  }
+}
diff --git 
a/catalogs/catalog-hive/src/test/java/org/apache/gravitino/catalog/hive/integration/test/CatalogHive2IT.java
 
b/catalogs/catalog-hive/src/test/java/org/apache/gravitino/catalog/hive/integration/test/CatalogHive2IT.java
index d296b19878..6ec869c7f9 100644
--- 
a/catalogs/catalog-hive/src/test/java/org/apache/gravitino/catalog/hive/integration/test/CatalogHive2IT.java
+++ 
b/catalogs/catalog-hive/src/test/java/org/apache/gravitino/catalog/hive/integration/test/CatalogHive2IT.java
@@ -52,6 +52,7 @@ import java.util.Set;
 import java.util.stream.Collectors;
 import org.apache.gravitino.Catalog;
 import org.apache.gravitino.CatalogChange;
+import org.apache.gravitino.MetadataObject;
 import org.apache.gravitino.MetalakeChange;
 import org.apache.gravitino.NameIdentifier;
 import org.apache.gravitino.Namespace;
@@ -1401,6 +1402,59 @@ public class CatalogHive2IT extends BaseIT {
                 "please ensure that the type of the new column position is 
compatible with the old one"));
   }
 
+  @Test
+  public void testOutOfBandRenameKeepsColumnTags() throws InterruptedException 
{
+    // Hive stores names in lower case, and tag operations resolve a column by 
its stored name.
+    String schema = schemaName.toLowerCase(Locale.ROOT);
+    NameIdentifier ident =
+        NameIdentifier.of(schema, 
GravitinoITUtils.genRandomName("hive_oob_rename_table"));
+    catalog
+        .asTableCatalog()
+        .createTable(
+            ident, createColumns(), TABLE_COMMENT, createProperties(), 
Transforms.EMPTY_TRANSFORM);
+    String tagName = GravitinoITUtils.genRandomName("hive_oob_rename_tag");
+    metalake.createTag(tagName, "comment", Collections.emptyMap());
+    try {
+      loadColumn(ident, HIVE_COL_NAME1).supportsTags().associateTags(new 
String[] {tagName}, null);
+
+      // Rename the table directly in the Hive Metastore. It keeps its 
Gravitino id in its table
+      // parameters, so loading it under the new name imports it again over 
the same id.
+      String newName = 
GravitinoITUtils.genRandomName("hive_oob_renamed_table");
+      HiveTable hiveTable = loadHiveTable(schema, ident.name());
+      HiveTable.Builder renamed =
+          HiveTable.builder()
+              .withName(newName)
+              .withColumns(hiveTable.columns())
+              .withProperties(hiveTable.properties())
+              .withAuditInfo(hiveTable.auditInfo())
+              .withDistribution(hiveTable.distribution())
+              .withSortOrders(hiveTable.sortOrder())
+              .withPartitioning(hiveTable.partitioning())
+              .withCatalogName(hiveTable.catalogName())
+              .withDatabaseName(hiveTable.databaseName());
+      if (hiveTable.comment() != null) {
+        renamed.withComment(hiveTable.comment());
+      }
+      hiveClientPool.run(
+          client -> {
+            client.alterTable(hmsCatalog, schema, ident.name(), 
renamed.build());
+            return null;
+          });
+
+      // The column keeps its id, so the tag follows the table to its new name.
+      NameIdentifier newIdent = NameIdentifier.of(schema, newName);
+      Column column = loadColumn(newIdent, HIVE_COL_NAME1);
+      Assertions.assertArrayEquals(new String[] {tagName}, 
column.supportsTags().listTags());
+      MetadataObject[] objects = 
metalake.getTag(tagName).associatedObjects().objects();
+      Assertions.assertEquals(1, objects.length);
+      Assertions.assertEquals(
+          String.join(".", catalogName, schema, newName, 
HIVE_COL_NAME1).toLowerCase(Locale.ROOT),
+          objects[0].fullName().toLowerCase(Locale.ROOT));
+    } finally {
+      metalake.deleteTag(tagName);
+    }
+  }
+
   @Test
   public void testCrossSchemaTableRename() throws TException, 
InterruptedException {
     // Create a second schema to serve as the rename destination.
@@ -1946,4 +2000,11 @@ public class CatalogHive2IT extends BaseIT {
             schemaName.toLowerCase()));
     return properties;
   }
+
+  private Column loadColumn(NameIdentifier tableIdent, String columnName) {
+    return 
Arrays.stream(catalog.asTableCatalog().loadTable(tableIdent).columns())
+        .filter(c -> c.name().equals(columnName))
+        .findFirst()
+        .get();
+  }
 }
diff --git 
a/catalogs/catalog-lakehouse-generic/src/main/java/org/apache/gravitino/catalog/lakehouse/lance/LanceTableOperations.java
 
b/catalogs/catalog-lakehouse-generic/src/main/java/org/apache/gravitino/catalog/lakehouse/lance/LanceTableOperations.java
index c4e31e196e..a4d2a697e2 100644
--- 
a/catalogs/catalog-lakehouse-generic/src/main/java/org/apache/gravitino/catalog/lakehouse/lance/LanceTableOperations.java
+++ 
b/catalogs/catalog-lakehouse-generic/src/main/java/org/apache/gravitino/catalog/lakehouse/lance/LanceTableOperations.java
@@ -722,12 +722,36 @@ public class LanceTableOperations extends 
ManagedTableOperations {
             .withCreator(PrincipalUtils.getCurrentPrincipal().getName())
             .withCreateTime(Instant.now())
             .build();
+    // Tags, owners and privileges are attached to a column by id, so a column 
that is still in the
+    // dataset keeps its id. It also keeps its comment and audit info, which 
the dataset doesn't
+    // carry.
+    Map<String, ColumnEntity> existingColumns =
+        tableEntity.columns().stream()
+            .collect(
+                Collectors.toMap(
+                    ColumnEntity::name, Function.identity(), (first, second) 
-> first));
     List<ColumnEntity> columnEntities =
         IntStream.range(0, columns.length)
             .mapToObj(
-                i ->
-                    ColumnEntity.toColumnEntity(
-                        columns[i], i, idGenerator.nextId(), columnAuditInfo))
+                i -> {
+                  ColumnEntity existing = 
existingColumns.get(columns[i].name());
+                  if (existing == null) {
+                    return ColumnEntity.toColumnEntity(
+                        columns[i], i, idGenerator.nextId(), columnAuditInfo);
+                  }
+                  return ColumnEntity.builder()
+                      .withId(existing.id())
+                      .withName(columns[i].name())
+                      .withPosition(i)
+                      .withDataType(columns[i].dataType())
+                      .withComment(
+                          columns[i].comment() != null ? columns[i].comment() 
: existing.comment())
+                      .withNullable(columns[i].nullable())
+                      .withAutoIncrement(columns[i].autoIncrement())
+                      .withDefaultValue(columns[i].defaultValue())
+                      .withAuditInfo((AuditInfo) existing.auditInfo())
+                      .build();
+                })
             .collect(Collectors.toList());
 
     return TableEntity.builder()
diff --git 
a/catalogs/catalog-lakehouse-generic/src/test/java/org/apache/gravitino/catalog/lakehouse/lance/TestLanceTableOperations.java
 
b/catalogs/catalog-lakehouse-generic/src/test/java/org/apache/gravitino/catalog/lakehouse/lance/TestLanceTableOperations.java
index 53e5c2d919..3556337d15 100644
--- 
a/catalogs/catalog-lakehouse-generic/src/test/java/org/apache/gravitino/catalog/lakehouse/lance/TestLanceTableOperations.java
+++ 
b/catalogs/catalog-lakehouse-generic/src/test/java/org/apache/gravitino/catalog/lakehouse/lance/TestLanceTableOperations.java
@@ -476,6 +476,78 @@ public class TestLanceTableOperations {
     Assertions.assertEquals("9", 
loadedTable.properties().get(LANCE_TABLE_VERSION));
   }
 
+  @Test
+  public void testVersionCheckRefreshKeepsExistingColumnIdsAndComments() 
throws Exception {
+    lanceTableOps.setCatalogProperties(Map.of(LANCE_SCHEMA_REFRESH_MODE, 
"version-check"));
+    NameIdentifier ident = NameIdentifier.of("schema", "table");
+    String location = tempDir.resolve("version-check-keep-ids").toString();
+    AuditInfo columnAudit =
+        
AuditInfo.builder().withCreator("column_creator").withCreateTime(Instant.EPOCH).build();
+    TableEntity tableEntity =
+        tableEntity(
+            ident,
+            List.of(
+                ColumnEntity.builder()
+                    .withId(10L)
+                    .withName("id")
+                    .withComment("the id")
+                    .withDataType(Types.IntegerType.get())
+                    .withPosition(0)
+                    .withAuditInfo(columnAudit)
+                    .build(),
+                ColumnEntity.builder()
+                    .withId(11L)
+                    .withName("dropped")
+                    .withDataType(Types.StringType.get())
+                    .withPosition(1)
+                    .withAuditInfo(columnAudit)
+                    .build()),
+            Map.of(Table.PROPERTY_LOCATION, location, LANCE_TABLE_VERSION, 
"8"));
+    when(store.get(eq(ident), eq(Entity.EntityType.TABLE), 
eq(TableEntity.class)))
+        .thenReturn(tableEntity);
+    when(idGenerator.nextId()).thenReturn(12L);
+    AtomicReference<TableEntity> updated = new AtomicReference<>();
+    when(store.update(eq(ident), eq(TableEntity.class), 
eq(Entity.EntityType.TABLE), any()))
+        .thenAnswer(
+            invocation -> {
+              @SuppressWarnings("unchecked")
+              Function<TableEntity, TableEntity> updater = 
invocation.getArgument(3);
+              updated.set(updater.apply(tableEntity));
+              return updated.get();
+            });
+
+    // The dataset moved to a new version: "id" is still there, "dropped" is 
gone and "name" is new.
+    Dataset dataset = mock(Dataset.class);
+    when(dataset.getSchema())
+        .thenReturn(
+            new Schema(
+                List.of(
+                    Field.nullable("name", new ArrowType.Utf8()),
+                    Field.nullable("id", new ArrowType.Int(32, true)))));
+    when(dataset.version()).thenReturn(9L);
+    Mockito.doReturn(dataset).when(lanceTableOps).openDataset(location, 
Map.of());
+
+    Table loadedTable =
+        PrincipalUtils.doAs(new UserPrincipal("tester"), () -> 
lanceTableOps.loadTable(ident));
+
+    Assertions.assertEquals("9", 
loadedTable.properties().get(LANCE_TABLE_VERSION));
+    List<ColumnEntity> columns = updated.get().columns();
+    Assertions.assertEquals(2, columns.size());
+
+    ColumnEntity name = columns.get(0);
+    Assertions.assertEquals("name", name.name());
+    Assertions.assertEquals(12L, name.id());
+    Assertions.assertEquals(0, name.position());
+
+    ColumnEntity id = columns.get(1);
+    Assertions.assertEquals("id", id.name());
+    Assertions.assertEquals(10L, id.id());
+    Assertions.assertEquals(1, id.position());
+    Assertions.assertEquals("the id", id.comment());
+    Assertions.assertEquals(columnAudit, id.auditInfo());
+    Assertions.assertEquals("the id", loadedTable.columns()[1].comment());
+  }
+
   @Test
   public void testVersionCheckSkipsRefreshWhenVersionIsCurrent() throws 
Exception {
     lanceTableOps.setCatalogProperties(Map.of(LANCE_SCHEMA_REFRESH_MODE, 
"version-check"));
diff --git 
a/catalogs/catalog-lakehouse-generic/src/test/java/org/apache/gravitino/catalog/lakehouse/lance/integration/test/CatalogGenericCatalogLanceIT.java
 
b/catalogs/catalog-lakehouse-generic/src/test/java/org/apache/gravitino/catalog/lakehouse/lance/integration/test/CatalogGenericCatalogLanceIT.java
index 2654bec7a5..277ed1ec6b 100644
--- 
a/catalogs/catalog-lakehouse-generic/src/test/java/org/apache/gravitino/catalog/lakehouse/lance/integration/test/CatalogGenericCatalogLanceIT.java
+++ 
b/catalogs/catalog-lakehouse-generic/src/test/java/org/apache/gravitino/catalog/lakehouse/lance/integration/test/CatalogGenericCatalogLanceIT.java
@@ -19,6 +19,7 @@
 package org.apache.gravitino.catalog.lakehouse.lance.integration.test;
 
 import static 
org.apache.gravitino.lance.common.utils.LanceConstants.LANCE_CREATION_MODE;
+import static 
org.apache.gravitino.lance.common.utils.LanceConstants.LANCE_SCHEMA_REFRESH_MODE;
 import static 
org.apache.gravitino.lance.common.utils.LanceConstants.LANCE_TABLE_DECLARED;
 import static 
org.apache.gravitino.lance.common.utils.LanceConstants.LANCE_TABLE_FORMAT;
 import static 
org.apache.gravitino.lance.common.utils.LanceConstants.LANCE_TABLE_REGISTER;
@@ -48,6 +49,7 @@ import org.apache.arrow.vector.types.pojo.ArrowType;
 import org.apache.arrow.vector.types.pojo.Field;
 import org.apache.commons.io.FileUtils;
 import org.apache.gravitino.Catalog;
+import org.apache.gravitino.MetadataObject;
 import org.apache.gravitino.NameIdentifier;
 import org.apache.gravitino.Schema;
 import org.apache.gravitino.client.GravitinoMetalake;
@@ -56,6 +58,7 @@ import org.apache.gravitino.integration.test.util.BaseIT;
 import org.apache.gravitino.integration.test.util.GravitinoITUtils;
 import org.apache.gravitino.rel.Column;
 import org.apache.gravitino.rel.Table;
+import org.apache.gravitino.rel.TableCatalog;
 import org.apache.gravitino.rel.TableChange;
 import org.apache.gravitino.rel.expressions.NamedReference;
 import org.apache.gravitino.rel.expressions.distributions.Distribution;
@@ -419,6 +422,86 @@ public class CatalogGenericCatalogLanceIT extends BaseIT {
         RuntimeException.class, () -> 
catalog.asTableCatalog().loadTable(newNameIdentifier));
   }
 
+  @Test
+  void testVersionCheckRefreshKeepsColumnTagsAndComments() {
+    String refreshCatalogName = 
GravitinoITUtils.genRandomName("lance_version_check_catalog");
+    Catalog refreshCatalog =
+        metalake.createCatalog(
+            refreshCatalogName,
+            Catalog.Type.RELATIONAL,
+            provider,
+            "comment",
+            ImmutableMap.of(LANCE_SCHEMA_REFRESH_MODE, "VERSION_CHECK"));
+    String refreshSchemaName = GravitinoITUtils.genRandomName(SCHEMA_PREFIX);
+    String tagName = GravitinoITUtils.genRandomName("lance_version_check_tag");
+    try {
+      refreshCatalog
+          .asSchemas()
+          .createSchema(refreshSchemaName, "comment", 
createSchemaProperties());
+      NameIdentifier ident =
+          NameIdentifier.of(refreshSchemaName, 
GravitinoITUtils.genRandomName(TABLE_PREFIX));
+      Map<String, String> properties = createProperties();
+      properties.put(Table.PROPERTY_TABLE_FORMAT, LANCE_TABLE_FORMAT);
+      properties.put(Table.PROPERTY_LOCATION, tempDirectory + "/" + 
ident.name());
+      TableCatalog tableCatalog = refreshCatalog.asTableCatalog();
+      Table created =
+          tableCatalog.createTable(
+              ident,
+              new Column[] {
+                Column.of(LANCE_COL_NAME1, Types.IntegerType.get(), 
"col_1_comment"),
+                Column.of(LANCE_COL_NAME2, Types.StringType.get(), 
"col_2_comment")
+              },
+              TABLE_COMMENT,
+              properties,
+              Transforms.EMPTY_TRANSFORM,
+              Distributions.NONE,
+              new SortOrder[0]);
+
+      metalake.createTag(tagName, "comment", Collections.emptyMap());
+      findColumn(tableCatalog.loadTable(ident), LANCE_COL_NAME1)
+          .supportsTags()
+          .associateTags(new String[] {tagName}, null);
+
+      // Change the dataset outside Gravitino: keep column 1, drop column 2 
and add column 3. This
+      // writes a new dataset version, so the next load refreshes the columns 
from the dataset.
+      org.apache.arrow.vector.types.pojo.Schema newSchema =
+          new org.apache.arrow.vector.types.pojo.Schema(
+              Arrays.asList(
+                  Field.nullable(LANCE_COL_NAME1, new ArrowType.Int(32, true)),
+                  Field.nullable(LANCE_COL_NAME3, new ArrowType.Utf8())));
+      try (RootAllocator allocator = new RootAllocator();
+          Dataset ignored =
+              Dataset.write()
+                  .allocator(allocator)
+                  .schema(newSchema)
+                  .uri(created.properties().get(Table.PROPERTY_LOCATION))
+                  .mode(WriteParams.WriteMode.OVERWRITE)
+                  .execute()) {
+        // The new dataset version is written.
+      }
+
+      Table refreshed = tableCatalog.loadTable(ident);
+      Assertions.assertArrayEquals(
+          new String[] {LANCE_COL_NAME1, LANCE_COL_NAME3},
+          
Arrays.stream(refreshed.columns()).map(Column::name).toArray(String[]::new));
+
+      // Column 1 keeps its id, so it keeps its tag and its comment.
+      Column column1 = findColumn(refreshed, LANCE_COL_NAME1);
+      Assertions.assertEquals("col_1_comment", column1.comment());
+      Assertions.assertArrayEquals(new String[] {tagName}, 
column1.supportsTags().listTags());
+      MetadataObject[] objects = 
metalake.getTag(tagName).associatedObjects().objects();
+      Assertions.assertEquals(1, objects.length);
+      Assertions.assertEquals(
+          String.join(".", refreshCatalogName, refreshSchemaName, 
ident.name(), LANCE_COL_NAME1),
+          objects[0].fullName());
+      Assertions.assertEquals(
+          0, findColumn(refreshed, 
LANCE_COL_NAME3).supportsTags().listTags().length);
+    } finally {
+      metalake.deleteTag(tagName);
+      metalake.dropCatalog(refreshCatalogName, true);
+    }
+  }
+
   @Test
   void testLanceTableFormat() {
     String tableName = GravitinoITUtils.genRandomName(TABLE_PREFIX);
@@ -1193,4 +1276,11 @@ public class CatalogGenericCatalogLanceIT extends BaseIT 
{
       LOG.warn("Failed to delete external table directory: {}", 
externalTableLocation, e);
     }
   }
+
+  private static Column findColumn(Table table, String columnName) {
+    return Arrays.stream(table.columns())
+        .filter(c -> c.name().equals(columnName))
+        .findFirst()
+        .get();
+  }
 }
diff --git 
a/clients/client-java/src/test/java/org/apache/gravitino/client/integration/test/TagIT.java
 
b/clients/client-java/src/test/java/org/apache/gravitino/client/integration/test/TagIT.java
index c5e0689469..80a6018ab0 100644
--- 
a/clients/client-java/src/test/java/org/apache/gravitino/client/integration/test/TagIT.java
+++ 
b/clients/client-java/src/test/java/org/apache/gravitino/client/integration/test/TagIT.java
@@ -912,6 +912,54 @@ public class TagIT extends BaseIT {
     }
   }
 
+  @Test
+  public void testMovedTableKeepsColumnTagsAfterOldSchemaIsDropped() {
+    String sourceSchema = 
GravitinoITUtils.genRandomName("tag_it_move_source_schema");
+    relationalCatalog.asSchemas().createSchema(sourceSchema, "comment", 
Collections.emptyMap());
+    NameIdentifier sourceIdent =
+        NameIdentifier.of(sourceSchema, 
GravitinoITUtils.genRandomName("tag_it_move_table"));
+    relationalCatalog
+        .asTableCatalog()
+        .createTable(
+            sourceIdent,
+            new Column[] {
+              Column.of("c1", Types.IntegerType.get()), Column.of("c2", 
Types.StringType.get())
+            },
+            "comment",
+            Collections.emptyMap());
+    NameIdentifier movedIdent = NameIdentifier.of(schema.name(), 
sourceIdent.name());
+    try {
+      Tag tag =
+          metalake.createTag(
+              GravitinoITUtils.genRandomName("tag_it_move_table_tag"),
+              "comment",
+              Collections.emptyMap());
+      loadColumn(sourceIdent, "c1").supportsTags().associateTags(new String[] 
{tag.name()}, null);
+
+      // Move the table to another schema and change another column in the 
same request.
+      relationalCatalog
+          .asTableCatalog()
+          .alterTable(
+              sourceIdent,
+              TableChange.rename(sourceIdent.name(), schema.name()),
+              TableChange.updateColumnComment(new String[] {"c2"}, "new 
comment"));
+
+      // Dropping the old schema must not take the moved table's unchanged 
column with it.
+      
Assertions.assertTrue(relationalCatalog.asSchemas().dropSchema(sourceSchema, 
true));
+
+      Column c1 = loadColumn(movedIdent, "c1");
+      Assertions.assertArrayEquals(new String[] {tag.name()}, 
c1.supportsTags().listTags());
+      MetadataObject[] objects = 
metalake.getTag(tag.name()).associatedObjects().objects();
+      Assertions.assertEquals(1, objects.length);
+      Assertions.assertEquals(
+          String.join(".", relationalCatalog.name(), schema.name(), 
movedIdent.name(), "c1"),
+          objects[0].fullName());
+    } finally {
+      relationalCatalog.asTableCatalog().dropTable(movedIdent);
+      relationalCatalog.asSchemas().dropSchema(sourceSchema, true);
+    }
+  }
+
   private NameIdentifier createColumnTestTable(String prefix) {
     NameIdentifier tableIdent =
         NameIdentifier.of(schema.name(), 
GravitinoITUtils.genRandomName(prefix));
diff --git 
a/core/src/main/java/org/apache/gravitino/authorization/AuthorizationUtils.java 
b/core/src/main/java/org/apache/gravitino/authorization/AuthorizationUtils.java
index 43f99539ad..42eb56ba0b 100644
--- 
a/core/src/main/java/org/apache/gravitino/authorization/AuthorizationUtils.java
+++ 
b/core/src/main/java/org/apache/gravitino/authorization/AuthorizationUtils.java
@@ -370,18 +370,36 @@ public class AuthorizationUtils {
 
   public static void authorizationPluginRenamePrivileges(
       NameIdentifier ident, Entity.EntityType type, String newName, 
List<String> locations) {
+    authorizationPluginRenamePrivileges(
+        ident, type, NameIdentifier.of(ident.namespace(), newName), locations);
+  }
+
+  /**
+   * Renames the privileges of an entity in the authorization plugins, when 
the new identifier may
+   * be under a different parent, e.g. a table moved to another schema.
+   *
+   * @param ident the identifier of the entity before the rename
+   * @param type the entity type
+   * @param newIdent the identifier of the entity after the rename
+   * @param locations the storage locations of the entity, or {@code null}
+   */
+  public static void authorizationPluginRenamePrivileges(
+      NameIdentifier ident,
+      Entity.EntityType type,
+      NameIdentifier newIdent,
+      List<String> locations) {
     // If we enable authorization, we should rename the privileges about the 
entity in the
     // authorization plugin.
     if (GravitinoEnv.getInstance().internalAccessControlDispatcher() != null) {
       notifyEntityNameIdMappingChange(ident, type);
       MetadataObject oldMetadataObject = 
NameIdentifierUtil.toMetadataObject(ident, type);
-      MetadataObject newMetadataObject =
-          
NameIdentifierUtil.toMetadataObject(NameIdentifier.of(ident.namespace(), 
newName), type);
+      MetadataObject newMetadataObject = 
NameIdentifierUtil.toMetadataObject(newIdent, type);
 
       MetadataObjectChange renameChange =
           MetadataObjectChange.rename(oldMetadataObject, newMetadataObject, 
locations);
 
-      String metalake = type == Entity.EntityType.METALAKE ? newName : 
ident.namespace().level(0);
+      String metalake =
+          type == Entity.EntityType.METALAKE ? newIdent.name() : 
ident.namespace().level(0);
 
       // For a renamed catalog, we should pass the new name catalog, otherwise 
we can't find the
       // catalog in the entity store
diff --git 
a/core/src/main/java/org/apache/gravitino/hook/TableHookDispatcher.java 
b/core/src/main/java/org/apache/gravitino/hook/TableHookDispatcher.java
index 768c1e5410..76dc96d2ed 100644
--- a/core/src/main/java/org/apache/gravitino/hook/TableHookDispatcher.java
+++ b/core/src/main/java/org/apache/gravitino/hook/TableHookDispatcher.java
@@ -104,10 +104,15 @@ public class TableHookDispatcher implements 
TableDispatcher {
   public Table alterTable(NameIdentifier ident, TableChange... changes)
       throws NoSuchTableException, IllegalArgumentException {
     TableChange.RenameTable lastRenameChange = null;
+    // Renames apply in order, so the last one that sets a schema decides the 
table's schema.
+    String newSchemaName = ident.namespace().level(2);
     List<String> locations = null;
     for (TableChange change : changes) {
       if (change instanceof TableChange.RenameTable) {
         lastRenameChange = (TableChange.RenameTable) change;
+        if (lastRenameChange.getNewSchemaName().isPresent()) {
+          newSchemaName = lastRenameChange.getNewSchemaName().get();
+        }
       }
     }
     if (lastRenameChange != null) {
@@ -116,9 +121,15 @@ public class TableHookDispatcher implements 
TableDispatcher {
     Table alteredTable = dispatcher.alterTable(ident, changes);
 
     if (lastRenameChange != null) {
-      // todo: support rename across different schemas
+      // The rename may also move the table to another schema.
+      NameIdentifier newIdent =
+          NameIdentifierUtil.ofTable(
+              ident.namespace().level(0),
+              ident.namespace().level(1),
+              newSchemaName,
+              lastRenameChange.getNewName());
       AuthorizationUtils.authorizationPluginRenamePrivileges(
-          ident, Entity.EntityType.TABLE, lastRenameChange.getNewName(), 
locations);
+          ident, Entity.EntityType.TABLE, newIdent, locations);
     }
 
     return alteredTable;
diff --git 
a/core/src/main/java/org/apache/gravitino/storage/relational/service/TableColumnMetaService.java
 
b/core/src/main/java/org/apache/gravitino/storage/relational/service/TableColumnMetaService.java
index 62653e23f7..fd64a42b80 100644
--- 
a/core/src/main/java/org/apache/gravitino/storage/relational/service/TableColumnMetaService.java
+++ 
b/core/src/main/java/org/apache/gravitino/storage/relational/service/TableColumnMetaService.java
@@ -22,14 +22,18 @@ import static 
org.apache.gravitino.metrics.source.MetricsSource.GRAVITINO_RELATI
 
 import com.google.common.collect.Lists;
 import java.util.Collections;
+import java.util.Comparator;
+import java.util.HashMap;
 import java.util.List;
 import java.util.Locale;
 import java.util.Map;
+import java.util.Set;
 import java.util.function.Function;
 import java.util.stream.Collectors;
 import org.apache.gravitino.Entity;
 import org.apache.gravitino.MetadataObject;
 import org.apache.gravitino.exceptions.NoSuchEntityException;
+import org.apache.gravitino.meta.AuditInfo;
 import org.apache.gravitino.meta.ColumnEntity;
 import org.apache.gravitino.meta.TableEntity;
 import org.apache.gravitino.metrics.Monitored;
@@ -115,6 +119,62 @@ public class TableColumnMetaService {
     insertColumnPOsInBatches(columnPOs);
   }
 
+  /**
+   * Gives each column the id of the stored live column with the same name, if 
there is one, and
+   * removes the relations of the stored columns that are gone.
+   *
+   * <p>Tags, owners and privileges are attached to a column by id, so a table 
that is written again
+   * as a whole, e.g. re-imported, must keep the ids of the columns it still 
has. Columns with no
+   * stored counterpart keep the id they were given, and a stored id is never 
given to a second
+   * column. Names are matched exactly, as the catalog reports them. A stored 
column whose id is not
+   * kept is dropped, so its relations are removed in the same transaction, as 
for a column dropped
+   * by an alter.
+   *
+   * @param tableId the id of the stored table
+   * @param tableVersion the table version whose columns are reused
+   * @param columns the columns to write
+   * @return the columns, with the stored ids where the names match
+   */
+  List<ColumnEntity> reuseStoredColumnIds(
+      Long tableId, Long tableVersion, List<ColumnEntity> columns) {
+    List<ColumnPO> storedColumns = getColumnsByTableIdAndVersion(tableId, 
tableVersion);
+    if (storedColumns.isEmpty()) {
+      return columns;
+    }
+
+    // A stored id that one of the columns already carries stays with that 
column, so it is never
+    // handed to a second column.
+    Set<Long> carriedIds = 
columns.stream().map(ColumnEntity::id).collect(Collectors.toSet());
+    Map<String, Long> reusableIdsByName = new HashMap<>();
+    // Legacy data may hold two live columns with one name. Visit the most 
recently written one
+    // first, so the same id is reused whatever order the database returns the 
rows in.
+    List<ColumnPO> newestFirst = Lists.newArrayList(storedColumns);
+    newestFirst.sort(
+        Comparator.comparing(ColumnPO::getTableVersion)
+            .thenComparing(ColumnPO::getColumnId)
+            .reversed());
+    for (ColumnPO storedColumn : newestFirst) {
+      if (!carriedIds.contains(storedColumn.getColumnId())) {
+        reusableIdsByName.putIfAbsent(storedColumn.getColumnName(), 
storedColumn.getColumnId());
+      }
+    }
+
+    List<ColumnEntity> result = Lists.newArrayListWithCapacity(columns.size());
+    for (ColumnEntity column : columns) {
+      Long storedId = reusableIdsByName.remove(column.name());
+      result.add(storedId == null ? column : withId(column, storedId));
+    }
+
+    Set<Long> keptIds = 
result.stream().map(ColumnEntity::id).collect(Collectors.toSet());
+    deleteColumnRelations(
+        storedColumns.stream()
+            .map(ColumnPO::getColumnId)
+            .filter(id -> !keptIds.contains(id))
+            .distinct()
+            .collect(Collectors.toList()));
+    return result;
+  }
+
   @Monitored(
       metricsSource = GRAVITINO_RELATIONAL_STORE_METRIC_NAME,
       baseMetricName = "deleteColumnsByTableId")
@@ -187,15 +247,18 @@ public class TableColumnMetaService {
       }
     }
 
+    // If the table moved to another schema, move all of its existing column 
rows as well, whether
+    // or not any column changed. Only the changed columns get new rows below, 
so otherwise the
+    // unchanged ones would stay under the old schema and be dropped with it.
+    if (!newTable.namespace().equals(oldTable.namespace())) {
+      SessionUtils.doWithoutCommit(
+          TableColumnMapper.class,
+          mapper ->
+              mapper.updateSchemaIdByTableId(newTablePO.getTableId(), 
newTablePO.getSchemaId()));
+    }
+
     // If there is no change, directly return
     if (columnPOsToInsert.isEmpty()) {
-      // If namespace is changed, just update the schema_id of the columns.
-      if (!newTable.namespace().equals(oldTable.namespace())) {
-        SessionUtils.doWithoutCommit(
-            TableColumnMapper.class,
-            mapper ->
-                mapper.updateSchemaIdByTableId(newTablePO.getTableId(), 
newTablePO.getSchemaId()));
-      }
       return;
     }
 
@@ -227,6 +290,20 @@ public class TableColumnMetaService {
             });
   }
 
+  private static ColumnEntity withId(ColumnEntity column, Long id) {
+    return ColumnEntity.builder()
+        .withId(id)
+        .withName(column.name())
+        .withPosition(column.position())
+        .withDataType(column.dataType())
+        .withComment(column.comment())
+        .withNullable(column.nullable())
+        .withAutoIncrement(column.autoIncrement())
+        .withDefaultValue(column.defaultValue())
+        .withAuditInfo((AuditInfo) column.auditInfo())
+        .build();
+  }
+
   private void insertColumnPOsInBatches(List<ColumnPO> columnPOs) {
     // Column inserts run inside the table transaction, so no batch commits 
independently.
     Lists.partition(columnPOs, COLUMN_INSERT_BATCH_SIZE)
diff --git 
a/core/src/main/java/org/apache/gravitino/storage/relational/service/TableMetaService.java
 
b/core/src/main/java/org/apache/gravitino/storage/relational/service/TableMetaService.java
index 5ad3569fd0..7ace373f1d 100644
--- 
a/core/src/main/java/org/apache/gravitino/storage/relational/service/TableMetaService.java
+++ 
b/core/src/main/java/org/apache/gravitino/storage/relational/service/TableMetaService.java
@@ -33,6 +33,7 @@ import org.apache.gravitino.MetadataObject;
 import org.apache.gravitino.NameIdentifier;
 import org.apache.gravitino.Namespace;
 import org.apache.gravitino.exceptions.NoSuchEntityException;
+import org.apache.gravitino.meta.ColumnEntity;
 import org.apache.gravitino.meta.NamespacedEntityId;
 import org.apache.gravitino.meta.TableEntity;
 import org.apache.gravitino.metrics.Monitored;
@@ -168,16 +169,27 @@ public class TableMetaService {
                     }
                   }),
           () -> {
+            List<ColumnEntity> columns = tableEntity.columns();
             // We need to delete the columns first if we want to overwrite the 
table.
             if (overwrite) {
-              TableColumnMetaService.getInstance()
-                  .deleteColumnsByTableId(persistedPO.get().getTableId());
+              TablePO storedPO = persistedPO.get();
+              // Overwriting the same table, e.g. re-importing it after an 
out-of-band rename, keeps
+              // the stored ids of the columns that still exist, so their 
tags, owners and
+              // privileges stay attached. When the upsert resolved to another 
table's row, the
+              // ids differ and that table's columns are not inherited.
+              if (columns != null
+                  && po.getTableId().equals(storedPO.getTableId())
+                  && storedPO.getCurrentVersion() > POConverters.INIT_VERSION) 
{
+                columns =
+                    TableColumnMetaService.getInstance()
+                        .reuseStoredColumnIds(
+                            storedPO.getTableId(), 
storedPO.getCurrentVersion(), columns);
+              }
+              
TableColumnMetaService.getInstance().deleteColumnsByTableId(storedPO.getTableId());
             }
-          },
-          () -> {
-            if (tableEntity.columns() != null && 
!tableEntity.columns().isEmpty()) {
-              TableColumnMetaService.getInstance()
-                  .insertColumnPOs(persistedPO.get(), tableEntity.columns());
+
+            if (columns != null && !columns.isEmpty()) {
+              
TableColumnMetaService.getInstance().insertColumnPOs(persistedPO.get(), 
columns);
             }
           });
 
diff --git 
a/core/src/test/java/org/apache/gravitino/authorization/TestAuthorizationUtils.java
 
b/core/src/test/java/org/apache/gravitino/authorization/TestAuthorizationUtils.java
index bef17095d0..0049a36ec2 100644
--- 
a/core/src/test/java/org/apache/gravitino/authorization/TestAuthorizationUtils.java
+++ 
b/core/src/test/java/org/apache/gravitino/authorization/TestAuthorizationUtils.java
@@ -425,6 +425,42 @@ class TestAuthorizationUtils {
     Assertions.assertEquals(locations, renameChange.locations());
   }
 
+  @Test
+  void testRenameTableAcrossSchemasNotifiesAuthorizationPluginWithNewSchema() {
+    NameIdentifier ident = NameIdentifier.of("metalake", "catalog", "schema", 
"table");
+    NameIdentifier newIdent = NameIdentifier.of("metalake", "catalog", 
"new_schema", "new_table");
+    List<String> locations = Lists.newArrayList("/warehouse/schema/table");
+
+    AccessControlDispatcher accessControlDispatcher = 
Mockito.mock(AccessControlDispatcher.class);
+    CatalogManager catalogManager = Mockito.mock(CatalogManager.class);
+    BaseCatalog<?> baseCatalog = Mockito.mock(BaseCatalog.class);
+    AuthorizationPlugin authorizationPlugin = 
Mockito.mock(AuthorizationPlugin.class);
+    CatalogTestUtils.mockDoWithCatalog(catalogManager, baseCatalog);
+    
Mockito.when(baseCatalog.getAuthorizationPlugin()).thenReturn(authorizationPlugin);
+
+    GravitinoEnv envMock = Mockito.mock(GravitinoEnv.class);
+    
Mockito.when(envMock.internalAccessControlDispatcher()).thenReturn(accessControlDispatcher);
+    Mockito.when(envMock.catalogManager()).thenReturn(catalogManager);
+
+    try (MockedStatic<GravitinoEnv> envStatic = 
Mockito.mockStatic(GravitinoEnv.class)) {
+      envStatic.when(GravitinoEnv::getInstance).thenReturn(envMock);
+
+      AuthorizationUtils.authorizationPluginRenamePrivileges(
+          ident, Entity.EntityType.TABLE, newIdent, locations);
+    }
+
+    ArgumentCaptor<MetadataObjectChange[]> changesCaptor =
+        ArgumentCaptor.forClass(MetadataObjectChange[].class);
+    
Mockito.verify(authorizationPlugin).onMetadataUpdated(changesCaptor.capture());
+    MetadataObjectChange.RenameMetadataObject renameChange =
+        Assertions.assertInstanceOf(
+            MetadataObjectChange.RenameMetadataObject.class, 
changesCaptor.getValue()[0]);
+    Assertions.assertEquals("catalog.schema.table", 
renameChange.metadataObject().fullName());
+    Assertions.assertEquals(
+        "catalog.new_schema.new_table", 
renameChange.newMetadataObject().fullName());
+    Assertions.assertEquals(locations, renameChange.locations());
+  }
+
   @Test
   void 
testRemoveTablePrivilegesNotifiesAuthorizationPluginWithExpectedChange() {
     NameIdentifier ident = NameIdentifier.of("metalake", "catalog", "schema", 
"table");
diff --git 
a/core/src/test/java/org/apache/gravitino/hook/TestTableHookDispatcher.java 
b/core/src/test/java/org/apache/gravitino/hook/TestTableHookDispatcher.java
index befd3a9dd4..6aff59c0e7 100644
--- a/core/src/test/java/org/apache/gravitino/hook/TestTableHookDispatcher.java
+++ b/core/src/test/java/org/apache/gravitino/hook/TestTableHookDispatcher.java
@@ -222,10 +222,60 @@ public class TestTableHookDispatcher {
           .when(() -> AuthorizationUtils.getMetadataObjectLocation(ident, 
Entity.EntityType.TABLE))
           .thenReturn(locations);
       assertSame(alteredTable, hook.alterTable(ident, renameChange));
+      NameIdentifier newIdent = NameIdentifier.of(METALAKE, CATALOG, "schema", 
"newName");
       authorizationUtils.verify(
           () ->
               AuthorizationUtils.authorizationPluginRenamePrivileges(
-                  ident, Entity.EntityType.TABLE, "newName", locations));
+                  ident, Entity.EntityType.TABLE, newIdent, locations));
+    }
+  }
+
+  @Test
+  public void testRenameAcrossSchemasPassesNewSchemaToAuthorization() {
+    TableDispatcher dispatcher = Mockito.mock(TableDispatcher.class);
+    TableHookDispatcher hook = new TableHookDispatcher(dispatcher, () -> null);
+    NameIdentifier ident = NameIdentifier.of(METALAKE, CATALOG, "schema", 
"table");
+    Table alteredTable = Mockito.mock(Table.class);
+    TableChange renameChange = TableChange.rename("newName", "newSchema");
+    List<String> locations = ImmutableList.of("/test");
+    Mockito.when(dispatcher.alterTable(ident, 
renameChange)).thenReturn(alteredTable);
+
+    try (MockedStatic<AuthorizationUtils> authorizationUtils =
+        Mockito.mockStatic(AuthorizationUtils.class)) {
+      authorizationUtils
+          .when(() -> AuthorizationUtils.getMetadataObjectLocation(ident, 
Entity.EntityType.TABLE))
+          .thenReturn(locations);
+      assertSame(alteredTable, hook.alterTable(ident, renameChange));
+      NameIdentifier newIdent = NameIdentifier.of(METALAKE, CATALOG, 
"newSchema", "newName");
+      authorizationUtils.verify(
+          () ->
+              AuthorizationUtils.authorizationPluginRenamePrivileges(
+                  ident, Entity.EntityType.TABLE, newIdent, locations));
+    }
+  }
+
+  @Test
+  public void testRenameTwiceKeepsSchemaFromEarlierRename() {
+    TableDispatcher dispatcher = Mockito.mock(TableDispatcher.class);
+    TableHookDispatcher hook = new TableHookDispatcher(dispatcher, () -> null);
+    NameIdentifier ident = NameIdentifier.of(METALAKE, CATALOG, "schema", 
"table");
+    Table alteredTable = Mockito.mock(Table.class);
+    // The table dispatcher moves the table to the last schema set by any 
rename.
+    TableChange moveChange = TableChange.rename("t2", "newSchema");
+    TableChange renameChange = TableChange.rename("t3");
+    Mockito.when(dispatcher.alterTable(ident, moveChange, 
renameChange)).thenReturn(alteredTable);
+
+    try (MockedStatic<AuthorizationUtils> authorizationUtils =
+        Mockito.mockStatic(AuthorizationUtils.class)) {
+      authorizationUtils
+          .when(() -> AuthorizationUtils.getMetadataObjectLocation(ident, 
Entity.EntityType.TABLE))
+          .thenReturn(ImmutableList.of());
+      assertSame(alteredTable, hook.alterTable(ident, moveChange, 
renameChange));
+      NameIdentifier newIdent = NameIdentifier.of(METALAKE, CATALOG, 
"newSchema", "t3");
+      authorizationUtils.verify(
+          () ->
+              AuthorizationUtils.authorizationPluginRenamePrivileges(
+                  ident, Entity.EntityType.TABLE, newIdent, 
ImmutableList.of()));
     }
   }
 
diff --git 
a/core/src/test/java/org/apache/gravitino/storage/relational/service/TestTableColumnMetaService.java
 
b/core/src/test/java/org/apache/gravitino/storage/relational/service/TestTableColumnMetaService.java
index 377cbf8801..f53defa175 100644
--- 
a/core/src/test/java/org/apache/gravitino/storage/relational/service/TestTableColumnMetaService.java
+++ 
b/core/src/test/java/org/apache/gravitino/storage/relational/service/TestTableColumnMetaService.java
@@ -45,6 +45,7 @@ import 
org.apache.gravitino.storage.relational.session.SqlSessionFactoryHelper;
 import org.apache.gravitino.storage.relational.session.SqlSessions;
 import org.apache.ibatis.session.SqlSession;
 import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Assumptions;
 import org.junit.jupiter.api.TestTemplate;
 
 public class TestTableColumnMetaService extends TestJDBCBackend {
@@ -243,6 +244,297 @@ public class TestTableColumnMetaService extends 
TestJDBCBackend {
     Assertions.assertEquals(2, countActiveColumnRelations(kept));
   }
 
+  @TestTemplate
+  public void testMoveTableWithColumnChangeMovesAllColumns() throws Exception {
+    String catalogName = "catalog1";
+    String oldSchemaName = "schema1";
+    String newSchemaName = "schema2";
+    createParentEntities(METALAKE_NAME, catalogName, oldSchemaName, 
AUDIT_INFO);
+    createAndInsertSchema(METALAKE_NAME, catalogName, newSchemaName);
+
+    ColumnEntity unchanged = newIntColumn("unchanged", 0, "comment");
+    ColumnEntity changed = newIntColumn("changed", 1, "comment");
+    TableEntity table =
+        TableEntity.builder()
+            .withId(RandomIdGenerator.INSTANCE.nextId())
+            .withName("table_move")
+            .withNamespace(Namespace.of(METALAKE_NAME, catalogName, 
oldSchemaName))
+            .withColumns(Lists.newArrayList(unchanged, changed))
+            .withAuditInfo(AUDIT_INFO)
+            .build();
+    TableMetaService.getInstance().insertTable(table, false);
+
+    // Move the table to the other schema and change one column in the same 
update.
+    ColumnEntity changedColumn = newIntColumn("changed", 1, "new comment", 
changed.id());
+    TableEntity moved =
+        TableEntity.builder()
+            .withId(table.id())
+            .withName(table.name())
+            .withNamespace(Namespace.of(METALAKE_NAME, catalogName, 
newSchemaName))
+            .withColumns(Lists.newArrayList(unchanged, changedColumn))
+            .withAuditInfo(AUDIT_INFO)
+            .build();
+    TableMetaService.getInstance().updateTable(table.nameIdentifier(), 
(TableEntity old) -> moved);
+
+    // Dropping the old schema must not take any of the moved table's columns 
with it.
+    Assertions.assertTrue(
+        SchemaMetaService.getInstance()
+            .deleteSchema(NameIdentifier.of(METALAKE_NAME, catalogName, 
oldSchemaName), true));
+
+    TableEntity retrieved =
+        
TableMetaService.getInstance().getTableByIdentifier(moved.nameIdentifier());
+    compareTwoColumns(moved.columns(), retrieved.columns());
+  }
+
+  @TestTemplate
+  public void testOverwriteSameTableKeepsColumnIds() throws Exception {
+    String catalogName = "catalog1";
+    String schemaName = "schema1";
+    createParentEntities(METALAKE_NAME, catalogName, schemaName, AUDIT_INFO);
+
+    ColumnEntity kept = newIntColumn("kept", 0, "comment");
+    ColumnEntity dropped = newIntColumn("dropped", 1, "comment");
+    TableEntity table =
+        TableEntity.builder()
+            .withId(RandomIdGenerator.INSTANCE.nextId())
+            .withName("table_before_rename")
+            .withNamespace(Namespace.of(METALAKE_NAME, catalogName, 
schemaName))
+            .withColumns(Lists.newArrayList(kept, dropped))
+            .withAuditInfo(AUDIT_INFO)
+            .build();
+    TableMetaService.getInstance().insertTable(table, false);
+    insertColumnRelations(kept.id());
+    insertColumnRelations(dropped.id());
+
+    // Re-import the same table under a new name, e.g. after an out-of-band 
rename. The importer
+    // gives every column a fresh id and doesn't know the stored ones.
+    ColumnEntity reimportedKept = newIntColumn("kept", 0, "comment");
+    ColumnEntity added = newIntColumn("added", 1, "comment");
+    TableEntity reimported =
+        TableEntity.builder()
+            .withId(table.id())
+            .withName("table_after_rename")
+            .withNamespace(table.namespace())
+            .withColumns(Lists.newArrayList(reimportedKept, added))
+            .withAuditInfo(AUDIT_INFO)
+            .build();
+    TableMetaService.getInstance().insertTable(reimported, true);
+
+    Assertions.assertThrows(
+        NoSuchEntityException.class,
+        () -> 
TableMetaService.getInstance().getTableByIdentifier(table.nameIdentifier()));
+    Map<String, Long> columnIds =
+        TableMetaService.getInstance()
+            .getTableByIdentifier(reimported.nameIdentifier())
+            .columns()
+            .stream()
+            .collect(Collectors.toMap(ColumnEntity::name, ColumnEntity::id));
+    Assertions.assertEquals(2, columnIds.size());
+    Assertions.assertEquals(kept.id(), columnIds.get("kept"));
+    Assertions.assertEquals(added.id(), columnIds.get("added"));
+    Assertions.assertEquals(2, countActiveColumnRelations(kept.id()));
+    // The column that is gone from the table loses its relations with it.
+    Assertions.assertEquals(0, countActiveColumnRelations(dropped.id()));
+
+    // Overwriting again works too: the rows written by the first overwrite 
are retired first.
+    TableMetaService.getInstance().insertTable(reimported, true);
+    TableEntity retrieved =
+        
TableMetaService.getInstance().getTableByIdentifier(reimported.nameIdentifier());
+    Assertions.assertEquals(
+        kept.id(),
+        retrieved.columns().stream().filter(c -> 
c.name().equals("kept")).findFirst().get().id());
+    Assertions.assertEquals(2, retrieved.columns().size());
+  }
+
+  @TestTemplate
+  public void testOverwriteNeverGivesOneStoredIdToTwoColumns() throws 
Exception {
+    String catalogName = "catalog1";
+    String schemaName = "schema1";
+    createParentEntities(METALAKE_NAME, catalogName, schemaName, AUDIT_INFO);
+
+    ColumnEntity original = newIntColumn("b", 0, "comment");
+    TableEntity table =
+        TableEntity.builder()
+            .withId(RandomIdGenerator.INSTANCE.nextId())
+            .withName("table_carried_id")
+            .withNamespace(Namespace.of(METALAKE_NAME, catalogName, 
schemaName))
+            .withColumns(Lists.newArrayList(original))
+            .withAuditInfo(AUDIT_INFO)
+            .build();
+    TableMetaService.getInstance().insertTable(table, false);
+    insertColumnRelations(original.id());
+
+    // The stored column already travels under a new name, and a new column 
takes its old name. The
+    // stored id stays with the column that carries it.
+    ColumnEntity renamed = newIntColumn("c", 0, "comment", original.id());
+    ColumnEntity newColumn = newIntColumn("b", 1, "comment");
+    TableEntity overwritten =
+        TableEntity.builder()
+            .withId(table.id())
+            .withName(table.name())
+            .withNamespace(table.namespace())
+            .withColumns(Lists.newArrayList(renamed, newColumn))
+            .withAuditInfo(AUDIT_INFO)
+            .build();
+    TableMetaService.getInstance().insertTable(overwritten, true);
+
+    compareTwoColumns(
+        overwritten.columns(),
+        
TableMetaService.getInstance().getTableByIdentifier(table.nameIdentifier()).columns());
+    Assertions.assertEquals(2, countActiveColumnRelations(original.id()));
+  }
+
+  @TestTemplate
+  public void testOverwriteReusesNewestIdOfDuplicateLegacyColumns() throws 
Exception {
+    String catalogName = "catalog1";
+    String schemaName = "schema1";
+    createParentEntities(METALAKE_NAME, catalogName, schemaName, AUDIT_INFO);
+
+    ColumnEntity older = newIntColumn("a", 0, "comment");
+    TableEntity table =
+        TableEntity.builder()
+            .withId(RandomIdGenerator.INSTANCE.nextId())
+            .withName("table_duplicate_names")
+            .withNamespace(Namespace.of(METALAKE_NAME, catalogName, 
schemaName))
+            .withColumns(Lists.newArrayList(older))
+            .withAuditInfo(AUDIT_INFO)
+            .build();
+    TableMetaService.getInstance().insertTable(table, false);
+    ColumnEntity newer = newIntColumn("b", 1, "comment");
+    TableEntity withNewer =
+        TableEntity.builder()
+            .withId(table.id())
+            .withName(table.name())
+            .withNamespace(table.namespace())
+            .withColumns(Lists.newArrayList(older, newer))
+            .withAuditInfo(AUDIT_INFO)
+            .build();
+    TableMetaService.getInstance()
+        .updateTable(table.nameIdentifier(), (TableEntity old) -> withNewer);
+    insertColumnRelations(older.id());
+    insertColumnRelations(newer.id());
+
+    // Legacy data: two live columns share one name. The newer one was written 
at a later version.
+    try (SqlSession session =
+            
SqlSessionFactoryHelper.getInstance().getSqlSessionFactory().openSession(true);
+        Connection connection = session.getConnection();
+        Statement statement = connection.createStatement()) {
+      statement.executeUpdate(
+          "UPDATE "
+              + TableColumnMapper.COLUMN_TABLE_NAME
+              + " SET column_name = 'a' WHERE column_id = "
+              + newer.id());
+    }
+
+    ColumnEntity reimported = newIntColumn("a", 0, "comment");
+    TableEntity overwritten =
+        TableEntity.builder()
+            .withId(table.id())
+            .withName(table.name())
+            .withNamespace(table.namespace())
+            .withColumns(Lists.newArrayList(reimported))
+            .withAuditInfo(AUDIT_INFO)
+            .build();
+    TableMetaService.getInstance().insertTable(overwritten, true);
+
+    List<ColumnEntity> columns =
+        
TableMetaService.getInstance().getTableByIdentifier(table.nameIdentifier()).columns();
+    Assertions.assertEquals(1, columns.size());
+    Assertions.assertEquals(newer.id(), columns.get(0).id());
+    Assertions.assertEquals(2, countActiveColumnRelations(newer.id()));
+    Assertions.assertEquals(0, countActiveColumnRelations(older.id()));
+  }
+
+  @TestTemplate
+  public void testOverwriteWithoutColumnsRemovesAllColumnRelations() throws 
Exception {
+    String catalogName = "catalog1";
+    String schemaName = "schema1";
+    createParentEntities(METALAKE_NAME, catalogName, schemaName, AUDIT_INFO);
+
+    ColumnEntity column = newIntColumn("column", 0, "comment");
+    TableEntity table =
+        TableEntity.builder()
+            .withId(RandomIdGenerator.INSTANCE.nextId())
+            .withName("table_without_columns")
+            .withNamespace(Namespace.of(METALAKE_NAME, catalogName, 
schemaName))
+            .withColumns(Lists.newArrayList(column))
+            .withAuditInfo(AUDIT_INFO)
+            .build();
+    TableMetaService.getInstance().insertTable(table, false);
+    insertColumnRelations(column.id());
+
+    TableEntity overwritten =
+        TableEntity.builder()
+            .withId(table.id())
+            .withName(table.name())
+            .withNamespace(table.namespace())
+            .withColumns(Lists.newArrayList())
+            .withAuditInfo(AUDIT_INFO)
+            .build();
+    TableMetaService.getInstance().insertTable(overwritten, true);
+
+    Assertions.assertTrue(
+        TableMetaService.getInstance()
+            .getTableByIdentifier(table.nameIdentifier())
+            .columns()
+            .isEmpty());
+    Assertions.assertEquals(0, countActiveColumnRelations(column.id()));
+  }
+
+  @TestTemplate
+  public void testOverwriteOfAnotherTableDoesNotInheritColumnIds() throws 
Exception {
+    // Only MySQL/H2 can resolve the upsert to another table's row through the 
natural key.
+    // PostgreSQL's upsert targets table_id and rejects the insert instead.
+    Assumptions.assumeFalse("postgresql".equalsIgnoreCase(backendType));
+    String catalogName = "catalog1";
+    String schemaName = "schema1";
+    createParentEntities(METALAKE_NAME, catalogName, schemaName, AUDIT_INFO);
+
+    ColumnEntity storedColumn = newIntColumn("column", 0, "comment");
+    TableEntity stale =
+        TableEntity.builder()
+            .withId(RandomIdGenerator.INSTANCE.nextId())
+            .withName("table_same_name")
+            .withNamespace(Namespace.of(METALAKE_NAME, catalogName, 
schemaName))
+            .withColumns(Lists.newArrayList(storedColumn))
+            .withAuditInfo(AUDIT_INFO)
+            .build();
+    TableMetaService.getInstance().insertTable(stale, false);
+
+    ColumnEntity newColumn = newIntColumn("column", 0, "comment");
+    TableEntity other =
+        TableEntity.builder()
+            .withId(RandomIdGenerator.INSTANCE.nextId())
+            .withName(stale.name())
+            .withNamespace(stale.namespace())
+            .withColumns(Lists.newArrayList(newColumn))
+            .withAuditInfo(AUDIT_INFO)
+            .build();
+    TableMetaService.getInstance().insertTable(other, true);
+
+    TableEntity stored =
+        
TableMetaService.getInstance().getTableByIdentifier(other.nameIdentifier());
+    Assertions.assertEquals(1, stored.columns().size());
+    Assertions.assertEquals(newColumn.id(), stored.columns().get(0).id());
+  }
+
+  private ColumnEntity newIntColumn(String name, int position, String comment) 
{
+    return newIntColumn(name, position, comment, 
RandomIdGenerator.INSTANCE.nextId());
+  }
+
+  private ColumnEntity newIntColumn(String name, int position, String comment, 
long id) {
+    return ColumnEntity.builder()
+        .withId(id)
+        .withName(name)
+        .withPosition(position)
+        .withComment(comment)
+        .withDataType(Types.IntegerType.get())
+        .withNullable(true)
+        .withAutoIncrement(false)
+        .withAuditInfo(AUDIT_INFO)
+        .build();
+  }
+
   private void insertColumnRelations(long columnId) throws SQLException {
     try (SqlSession session =
             
SqlSessionFactoryHelper.getInstance().getSqlSessionFactory().openSession(true);

Reply via email to