This is an automated email from the ASF dual-hosted git repository.
jerryshao pushed a commit to branch branch-1.3
in repository https://gitbox.apache.org/repos/asf/gravitino.git
The following commit(s) were added to refs/heads/branch-1.3 by this push:
new 7e78024546 [Cherry-pick to branch-1.3] [#13309][#13310] fix(core):
Keep column ids stable and pass the new schema on cross-schema table rename
(#13316) (#13320)
7e78024546 is described below
commit 7e78024546f3a5e9eb85b8f704473e647393e6f9
Author: Jerry Shao <[email protected]>
AuthorDate: Fri Sep 18 22:16:04 2026 +0800
[Cherry-pick to branch-1.3] [#13309][#13310] fix(core): Keep column ids
stable and pass the new schema on cross-schema table rename (#13316) (#13320)
> Backport of #13316 to `branch-1.3`.
### What changes were proposed in this pull request?
Backport of #13316 to `branch-1.3`, **except for the re-import fix**
(see below).
- **Cross-schema table move with a column change (#13309):**
`TableColumnMetaService.updateColumnPOsFromTableDiff` 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 later cascade drop
of the old schema deleted the unchanged columns and their relations.
- **Lance `VERSION_CHECK` schema refresh (#13309):**
`LanceTableOperations.replaceColumnsFromDataset` reuses the id, comment
and audit info of each column still in the dataset, so column tags and
comments survive a dataset version change.
- **Cross-schema table rename and authorization (#13310):**
`TableHookDispatcher.alterTable` passes the new schema from
`RenameTable.getNewSchemaName()` to the authorization plugins, through a
new `AuthorizationUtils.authorizationPluginRenamePrivileges` overload
that takes the new `NameIdentifier`.
- **Ranger (Hive) plugin on a cross-schema table rename (#13310):**
`RangerAuthorizationHadoopSQLPlugin` now skips the schema-level step of
a table rename, which would otherwise move every policy of the old
schema to the new one, and moves only the table's own policies to the
new schema and name.
**Not backported: keeping column ids when a table is re-imported** (e.g.
loaded after an out-of-band rename). On `branch-1.3`,
`insertTable(overwrite = true)` resets the table version and
`RelationalEntityStore.put(overwrite)` caches the incoming entity.
Reusing column ids there would also need cache and column-query changes,
which is too risky for this release. That fix stays on `main` only.
The Lance change is a clean cherry-pick. So is the authorization change.
The schema-move change is the corresponding part of the first commit of
#13316, ported by hand.
### 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.
- 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 were told the table
moved to `<old schema>.<new name>`.
Fix: #13309, #13310
Part of #13303
### Does this PR introduce _any_ user-facing change?
No API or configuration change. Column tags, owners and privileges
survive a table move with a column change and a Lance schema refresh.
Lance columns keep their comments. Authorization plugins get the correct
schema on a cross-schema table rename.
### How was this patch tested?
- **Unit tests** (H2 locally; each fails without its fix):
-
`TestTableColumnMetaService.testMoveTableWithColumnChangeMovesAllColumns`
-
`TestLanceTableOperations.testVersionCheckRefreshKeepsExistingColumnIdsAndComments`
- `TestTableHookDispatcher`:
`testRenameAcrossSchemasPassesNewSchemaToAuthorization`,
`testRenameTwiceKeepsSchemaFromEarlierRename`
-
`TestAuthorizationUtils.testRenameTableAcrossSchemasNotifiesAuthorizationPluginWithNewSchema`
- `TestRangerAuthorizationHadoopSQLPlugin`: a table rename across
schemas and within one schema
- **Integration tests**:
-
`CatalogGenericCatalogLanceIT.testVersionCheckRefreshKeepsColumnTagsAndComments`
passes locally.
- `TagIT.testMovedTableKeepsColumnTagsAfterOldSchemaIsDropped` needs
Docker.
- `./gradlew :core:test -PskipITs` and the `catalog-lakehouse-generic`
tests pass locally on this branch. 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 +++++++++++++++++++++
.../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 | 17 ++-
.../authorization/TestAuthorizationUtils.java | 36 +++++
.../gravitino/hook/TestTableHookDispatcher.java | 52 +++++++-
.../service/TestTableColumnMetaService.java | 59 +++++++++
12 files changed, 593 insertions(+), 24 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-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 fb2bfaba1f..86debc4f03 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
@@ -715,12 +715,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 f5b092fbf5..3545e2beda 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
@@ -331,6 +331,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 412bd6329a..9a04f0b2af 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
@@ -862,6 +862,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 11c380d669..0545baf3fa 100644
---
a/core/src/main/java/org/apache/gravitino/authorization/AuthorizationUtils.java
+++
b/core/src/main/java/org/apache/gravitino/authorization/AuthorizationUtils.java
@@ -367,18 +367,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..df79aa7513 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
@@ -187,15 +187,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;
}
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..7a38cd485e 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
@@ -243,6 +243,65 @@ 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());
+ }
+
+ 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);