This is an automated email from the ASF dual-hosted git repository.
yuqi1129 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 3c63c74ff1 [#14681] fix(tag): Normalize metadata object names for tag
operations (#14682)
3c63c74ff1 is described below
commit 3c63c74ff1d19d6592d617035b4b033687825341
Author: Qi Yu <[email protected]>
AuthorDate: Fri Nov 6 20:28:38 2026 +0800
[#14681] fix(tag): Normalize metadata object names for tag operations
(#14682)
### What changes were proposed in this pull request?
Normalize metadata object identifiers before TagManager reads or updates
tag relations. The rules live in one shared helper,
`CapabilityHelpers.normalizeMetadataIdentifier`, which TagManager and
the authorization ID lookup (`MetadataIdConverter`) both use:
- Schema and object names follow the catalog's naming capabilities.
- Tables and the parent table of a column resolve to their physical
names.
- A column keeps the requested name when folding changes it and the
stored table has a column with exactly that name. This keeps exact names
working for catalogs whose custom `normalizeName` folds columns that the
source stored with another case.
- Gravitino-owned Semantic Model names are kept.
Existence checks still use the original request so quoted names are not
normalized twice.
Each operation on a supported non-catalog object loads naming
capabilities after the existence check; table and column operations also
repeat physical table-name resolution. Catalog cache misses can reload
the connector; connectors whose resolver reads the source can add a
source read. A column read from the entity store happens only when
folding changes a column name. Resolution failures propagate before tag
relations are changed.
Authorization change: column authorization now resolves the parent
table's physical name, as table authorization already does, so it checks
the same object the operation changes. In this repository only JDBC
catalogs implement `SupportsTableNameResolution`, and its default
`resolveTableName` returns the name unchanged; a backend that overrides
it adds one call per column ID lookup. A failure while normalizing one
object (including a stored-column read) is remembered for its catalog
for the rest of that authorization request and denies later objects in
it; the `AuthorizationRequestContext` Javadoc now says so.
The separate policy-inheritance lookup in `EffectiveTagResolver`
(#14719) and owner lookups (#14720) are outside this change; both can
reuse the shared helper.
### Why are the changes needed?
An object can pass the catalog's normalized existence check but fail the
relation-store lookup because TagManager uses the original spelling. The
same mismatch affects tag association and disassociation, including
value assignments. The authorization path had its own copy of the rules,
which did not resolve the physical table name for columns.
Fixes #14681
### Does this PR introduce _any_ user-facing change?
Metadata-object tag operations accept equivalent object names according
to the catalog's existing naming rules. Case-sensitive object names,
exact stored column names, tag names, and tag values are preserved.
Column authorization on catalogs with physical table-name resolution now
targets the resolved table. No API, configuration, or storage-schema
changes.
### How was this patch tested?
After merging `main` at `61d5c84753`, all 282 targeted cases passed (122
core and 160 server-common; zero failures, errors, or skips). The
conflict was limited to imports in `TestCapabilityHelpers`; both the
tag-normalization tests and upstream existing-column-name tests are
retained.
- Regression tests exercise real H2 relation storage for
case-insensitive names, custom uppercase normalization, quoted names,
physical table-name resolution, Semantic Model naming, Glue-style rules
(schema and table fold, columns keep case), exact stored column names,
and all ten tag-supported metadata object types. A resolution failure
test checks that add, remove and value assignment fail and leave
existing tags unchanged.
- With the old TagManager, 13 runs of the 6 original regression methods
fail. Removing the exact-column match fails the new column tests in
TestTagManager and TestCapabilityHelpers. Restoring the old
`MetadataIdConverter` fails the new column physical-name test.
- `:core:coreUnitTest` (101 tests): TestTagManager, TestPolicyManager,
TestEffectiveTagResolver, TestTagHookDispatcher, TestCapabilityHelpers,
TestTableNormalizeDispatcher, TestMetadataObjectUtil.
- `:server-common:test` (160 tests): TestMetadataIdConverter (including
the exact stored column name through `normalizeMetadataObject`),
TestJcasbinAuthorizer, TestJcasbinMetadataIdCacheNormalization,
TestJcasbinModelCacheInvalidation, TestJcasbinAuthorizationLookups.
`:core:coreUnitTest` TestAuthorizationRequestContext (21 tests) also
passed.
- Spotless, compile and `javadoc` passed for core and server-common.
Tests use embedded H2; live connector and Docker-backed database tests
were not run.
---
.../authorization/AuthorizationRequestContext.java | 7 +-
.../gravitino/catalog/CapabilityHelpers.java | 76 ++++
.../java/org/apache/gravitino/tag/TagManager.java | 33 +-
.../gravitino/catalog/TestCapabilityHelpers.java | 87 ++++
.../apache/gravitino/policy/TestPolicyManager.java | 16 +-
.../org/apache/gravitino/tag/TestTagManager.java | 505 ++++++++++++++++++++-
.../server/authorization/MetadataIdConverter.java | 38 +-
.../authorization/TestMetadataIdConverter.java | 86 ++++
8 files changed, 804 insertions(+), 44 deletions(-)
diff --git
a/core/src/main/java/org/apache/gravitino/authorization/AuthorizationRequestContext.java
b/core/src/main/java/org/apache/gravitino/authorization/AuthorizationRequestContext.java
index 22c47cf23f..a7c139700f 100644
---
a/core/src/main/java/org/apache/gravitino/authorization/AuthorizationRequestContext.java
+++
b/core/src/main/java/org/apache/gravitino/authorization/AuthorizationRequestContext.java
@@ -284,10 +284,13 @@ public class AuthorizationRequestContext {
}
/**
- * Normalizes a raw name while remembering capability failures for its
catalog in this request.
+ * Normalizes a raw name while remembering normalization failures for its
catalog in this request.
*
* <p>Catalog lookups are serialized so concurrent objects cannot retry a
failed initialization.
- * Missing catalogs are not memoized; a fresh request can retry any
capability failure.
+ * Any failure except not found is remembered for the whole catalog,
including one that only
+ * concerns a single object, such as a physical table-name or stored-column
read. Later objects in
+ * the same catalog then fail in this request, which denies them. Missing
catalogs are not
+ * memoized; a fresh request retries every failure.
*
* @param key the raw metadata object's cache key
* @param catalogIdentifier the containing catalog, including its metalake
diff --git
a/core/src/main/java/org/apache/gravitino/catalog/CapabilityHelpers.java
b/core/src/main/java/org/apache/gravitino/catalog/CapabilityHelpers.java
index 51f898344e..a46bf623d2 100644
--- a/core/src/main/java/org/apache/gravitino/catalog/CapabilityHelpers.java
+++ b/core/src/main/java/org/apache/gravitino/catalog/CapabilityHelpers.java
@@ -23,7 +23,12 @@ import static
org.apache.gravitino.rel.expressions.transforms.Transforms.NAME_OF
import static
org.apache.gravitino.utils.NameIdentifierUtil.getCatalogIdentifier;
import com.google.common.base.Preconditions;
+import com.google.common.collect.ObjectArrays;
+import java.io.IOException;
import java.util.Arrays;
+import java.util.function.Supplier;
+import org.apache.gravitino.Entity;
+import org.apache.gravitino.EntityStore;
import org.apache.gravitino.NameIdentifier;
import org.apache.gravitino.Namespace;
import org.apache.gravitino.connector.CatalogOperations;
@@ -31,7 +36,9 @@ import
org.apache.gravitino.connector.SupportsTableNameResolution;
import org.apache.gravitino.connector.capability.Capability;
import org.apache.gravitino.connector.capability.CapabilityResult;
import org.apache.gravitino.exceptions.NoSuchCatalogException;
+import org.apache.gravitino.exceptions.NoSuchEntityException;
import org.apache.gravitino.file.FilesetChange;
+import org.apache.gravitino.meta.TableEntity;
import org.apache.gravitino.model.ModelChange;
import org.apache.gravitino.rel.Column;
import org.apache.gravitino.rel.TableChange;
@@ -108,6 +115,63 @@ public class CapabilityHelpers {
}
}
+ /**
+ * Normalizes the identifier of an existing catalog object to the identifier
stored by Gravitino.
+ * Tag and authorization lookups use it, so they find the same stored object.
+ *
+ * <ul>
+ * <li>Names follow the catalog's case rules for each scope.
+ * <li>A table, or the parent table of a column, is resolved to its
physical name by {@link
+ * #resolvePhysicalTableName}.
+ * <li>A column keeps the requested name when its table has a stored
column with exactly that
+ * name. A custom {@link Capability#normalizeName} can fold a name
that the source stored
+ * with another case, and an exact name must still find that column.
+ * <li>A Semantic Model name is owned by Gravitino, so only its schema is
normalized.
+ * </ul>
+ *
+ * @param ident the entity identifier of the object
+ * @param scope the capability scope of the object
+ * @param catalogManager the catalog manager used to load capabilities and
resolve table names
+ * @param entityStore supplies the entity store that holds the stored
columns of a table. It is
+ * called only for a column whose name the catalog rules change.
+ * @return the normalized identifier
+ * @throws NoSuchCatalogException if the catalog does not exist
+ * @throws RuntimeException if capabilities, the physical table name or the
stored table cannot be
+ * loaded
+ */
+ public static NameIdentifier normalizeMetadataIdentifier(
+ NameIdentifier ident,
+ Capability.Scope scope,
+ CatalogManager catalogManager,
+ Supplier<EntityStore> entityStore) {
+ Capability capability = getCapability(ident, catalogManager);
+ if (scope == Capability.Scope.COLUMN) {
+ NameIdentifier table =
+ resolvePhysicalTableName(
+ applyCaseSensitive(
+ NameIdentifier.of(ident.namespace().levels()),
+ Capability.Scope.TABLE,
+ capability),
+ catalogManager);
+ String column = applyCaseSensitiveOnName(scope, ident.name(),
capability);
+ if (!column.equals(ident.name()) && hasStoredColumn(table, ident.name(),
entityStore.get())) {
+ column = ident.name();
+ }
+ return NameIdentifier.of(
+ Namespace.of(ObjectArrays.concat(table.namespace().levels(),
table.name())), column);
+ }
+
+ if (scope == Capability.Scope.SEMANTIC_MODEL) {
+ return NameIdentifier.of(
+ applyCaseSensitive(ident.namespace(), scope, capability),
ident.name());
+ }
+
+ NameIdentifier normalized = applyCaseSensitive(ident, scope, capability);
+ return scope == Capability.Scope.TABLE
+ ? resolvePhysicalTableName(normalized, catalogManager)
+ : normalized;
+ }
+
public static Column[] applyCapabilities(Column[] columns, Capability
capabilities) {
return Arrays.stream(columns)
.map(c -> applyCapabilities(c, capabilities))
@@ -289,6 +353,18 @@ public class CapabilityHelpers {
return namespace;
}
+ private static boolean hasStoredColumn(
+ NameIdentifier table, String column, EntityStore entityStore) {
+ try {
+ return entityStore.get(table, Entity.EntityType.TABLE,
TableEntity.class).columns().stream()
+ .anyMatch(storedColumn -> storedColumn.name().equals(column));
+ } catch (NoSuchEntityException e) {
+ return false;
+ } catch (IOException e) {
+ throw new RuntimeException("Failed to load stored columns of table: " +
table, e);
+ }
+ }
+
private static boolean hasSchemaParent(Capability.Scope resourceScope) {
switch (resourceScope) {
case TABLE:
diff --git a/core/src/main/java/org/apache/gravitino/tag/TagManager.java
b/core/src/main/java/org/apache/gravitino/tag/TagManager.java
index 63611c1f7c..a8d9f54495 100644
--- a/core/src/main/java/org/apache/gravitino/tag/TagManager.java
+++ b/core/src/main/java/org/apache/gravitino/tag/TagManager.java
@@ -35,6 +35,7 @@ import javax.annotation.Nullable;
import org.apache.gravitino.Entity;
import org.apache.gravitino.EntityAlreadyExistsException;
import org.apache.gravitino.EntityStore;
+import org.apache.gravitino.GravitinoEnv;
import org.apache.gravitino.MetadataObject;
import org.apache.gravitino.NameIdentifier;
import org.apache.gravitino.RelationEdgeTarget;
@@ -42,6 +43,8 @@ import org.apache.gravitino.RelationQuery;
import org.apache.gravitino.RelationUpdate;
import org.apache.gravitino.RelationalEntity;
import org.apache.gravitino.SupportsRelationOperations;
+import org.apache.gravitino.catalog.CapabilityHelpers;
+import org.apache.gravitino.connector.capability.Capability;
import org.apache.gravitino.exceptions.NoSuchEntityException;
import org.apache.gravitino.exceptions.NoSuchMetadataObjectException;
import org.apache.gravitino.exceptions.NoSuchTagException;
@@ -328,11 +331,9 @@ public class TagManager implements TagDispatcher {
public Tag[] listTagsInfoForMetadataObject(String metalake, MetadataObject
metadataObject)
throws NoSuchMetadataObjectException {
- NameIdentifier entityIdent = MetadataObjectUtil.toEntityIdent(metalake,
metadataObject);
+ NameIdentifier entityIdent = checkAndNormalizeMetadataObject(metalake,
metadataObject);
Entity.EntityType entityType =
MetadataObjectUtil.toEntityType(metadataObject);
- MetadataObjectUtil.checkMetadataObject(metalake, metadataObject);
-
try {
checkMetalake(NameIdentifier.of(metalake), entityStore);
List<TagEntity> tags =
@@ -352,12 +353,10 @@ public class TagManager implements TagDispatcher {
public Tag getTagForMetadataObject(String metalake, MetadataObject
metadataObject, String name)
throws NoSuchMetadataObjectException {
- NameIdentifier entityIdent = MetadataObjectUtil.toEntityIdent(metalake,
metadataObject);
+ NameIdentifier entityIdent = checkAndNormalizeMetadataObject(metalake,
metadataObject);
Entity.EntityType entityType =
MetadataObjectUtil.toEntityType(metadataObject);
NameIdentifier tagIdent = NameIdentifierUtil.ofTag(metalake, name);
- MetadataObjectUtil.checkMetadataObject(metalake, metadataObject);
-
try {
checkMetalake(NameIdentifier.of(metalake), entityStore);
return entityStore
@@ -421,11 +420,9 @@ public class TagManager implements TagDispatcher {
validateTagValuesToAdd(tagsToAdd);
validateTagValuesToRemove(tagsToRemove);
- NameIdentifier entityIdent = MetadataObjectUtil.toEntityIdent(metalake,
metadataObject);
+ NameIdentifier entityIdent = checkAndNormalizeMetadataObject(metalake,
metadataObject);
Entity.EntityType entityType =
MetadataObjectUtil.toEntityType(metadataObject);
- MetadataObjectUtil.checkMetadataObject(metalake, metadataObject);
-
Set<TagValue> tagsToAddSet =
tagsToAdd == null ? new LinkedHashSet<>() : new
LinkedHashSet<>(Arrays.asList(tagsToAdd));
Set<TagValue> tagsToRemoveSet =
@@ -482,6 +479,24 @@ public class TagManager implements TagDispatcher {
}
}
+ private NameIdentifier checkAndNormalizeMetadataObject(
+ String metalake, MetadataObject metadataObject) {
+ NameIdentifier ident = MetadataObjectUtil.toEntityIdent(metalake,
metadataObject);
+ // Existence checks use normalizing dispatchers and must see the original
request. Applying
+ // normalization first would fold quoted names again after their quotes
have been removed.
+ MetadataObjectUtil.checkMetadataObject(metalake, metadataObject);
+ if
(!SUPPORTED_METADATA_OBJECT_TYPES_FOR_TAGS.contains(metadataObject.type())
+ || metadataObject.type() == MetadataObject.Type.CATALOG) {
+ return ident;
+ }
+
+ return CapabilityHelpers.normalizeMetadataIdentifier(
+ ident,
+ Capability.Scope.valueOf(metadataObject.type().name()),
+ GravitinoEnv.getInstance().catalogManager(),
+ () -> entityStore);
+ }
+
private static String[] allowedValuesForStorage(TagValueConstraint
valueConstraint) {
TagValueConstraint normalizedConstraint =
valueConstraint == null ? TagValueConstraint.anyValue() :
valueConstraint;
diff --git
a/core/src/test/java/org/apache/gravitino/catalog/TestCapabilityHelpers.java
b/core/src/test/java/org/apache/gravitino/catalog/TestCapabilityHelpers.java
index ec5db8b006..6cff282079 100644
--- a/core/src/test/java/org/apache/gravitino/catalog/TestCapabilityHelpers.java
+++ b/core/src/test/java/org/apache/gravitino/catalog/TestCapabilityHelpers.java
@@ -18,13 +18,20 @@
*/
package org.apache.gravitino.catalog;
+import java.io.IOException;
+import java.util.Collections;
import java.util.Locale;
+import org.apache.gravitino.Entity;
+import org.apache.gravitino.EntityStore;
import org.apache.gravitino.NameIdentifier;
import org.apache.gravitino.Namespace;
import org.apache.gravitino.connector.BaseCatalog;
import org.apache.gravitino.connector.capability.Capability;
import org.apache.gravitino.connector.capability.CapabilityResult;
import org.apache.gravitino.exceptions.NoSuchCatalogException;
+import org.apache.gravitino.exceptions.NoSuchEntityException;
+import org.apache.gravitino.meta.ColumnEntity;
+import org.apache.gravitino.meta.TableEntity;
import org.apache.gravitino.rel.TableChange;
import org.apache.gravitino.rel.expressions.literals.Literal;
import org.apache.gravitino.rel.expressions.literals.Literals;
@@ -265,4 +272,84 @@ public class TestCapabilityHelpers {
Assertions.assertTrue(
Capability.DEFAULT.managedStorage(Capability.Scope.SEMANTIC_MODEL).supported());
}
+
+ @Test
+ void testNormalizeMetadataIdentifierKeepsExactStoredColumnName() throws
IOException {
+ CatalogManager catalogManager = foldingCatalogManager();
+ EntityStore store = Mockito.mock(EntityStore.class);
+ NameIdentifier table = NameIdentifier.of("metalake", "catalog", "schema",
"orders");
+ TableEntity tableEntity = Mockito.mock(TableEntity.class);
+ ColumnEntity column = Mockito.mock(ColumnEntity.class);
+ Mockito.when(column.name()).thenReturn("MyCol");
+
Mockito.when(tableEntity.columns()).thenReturn(Collections.singletonList(column));
+ Mockito.when(store.get(table, Entity.EntityType.TABLE, TableEntity.class))
+ .thenReturn(tableEntity);
+
+ Assertions.assertEquals(
+ NameIdentifier.of("metalake", "catalog", "schema", "orders", "MyCol"),
+ CapabilityHelpers.normalizeMetadataIdentifier(
+ NameIdentifier.of("metalake", "catalog", "SCHEMA", "ORDERS",
"MyCol"),
+ Capability.Scope.COLUMN,
+ catalogManager,
+ () -> store));
+ Assertions.assertEquals(
+ NameIdentifier.of("metalake", "catalog", "schema", "orders",
"othercol"),
+ CapabilityHelpers.normalizeMetadataIdentifier(
+ NameIdentifier.of("metalake", "catalog", "SCHEMA", "ORDERS",
"OtherCol"),
+ Capability.Scope.COLUMN,
+ catalogManager,
+ () -> store));
+ Mockito.verify(store, Mockito.times(2)).get(table,
Entity.EntityType.TABLE, TableEntity.class);
+
+ // A name that folding does not change needs no stored-column read.
+ Assertions.assertEquals(
+ NameIdentifier.of("metalake", "catalog", "schema", "orders", "mycol"),
+ CapabilityHelpers.normalizeMetadataIdentifier(
+ NameIdentifier.of("metalake", "catalog", "SCHEMA", "ORDERS",
"mycol"),
+ Capability.Scope.COLUMN,
+ catalogManager,
+ () -> store));
+ Mockito.verify(store, Mockito.times(2)).get(table,
Entity.EntityType.TABLE, TableEntity.class);
+ }
+
+ @Test
+ void testNormalizeMetadataIdentifierHandlesMissingOrFailedStoredTable()
throws IOException {
+ CatalogManager catalogManager = foldingCatalogManager();
+ EntityStore store = Mockito.mock(EntityStore.class);
+ NameIdentifier table = NameIdentifier.of("metalake", "catalog", "schema",
"orders");
+ Mockito.when(store.get(table, Entity.EntityType.TABLE, TableEntity.class))
+ .thenThrow(new NoSuchEntityException("missing"));
+ NameIdentifier column = NameIdentifier.of("metalake", "catalog", "schema",
"ORDERS", "MyCol");
+ Assertions.assertEquals(
+ NameIdentifier.of("metalake", "catalog", "schema", "orders", "mycol"),
+ CapabilityHelpers.normalizeMetadataIdentifier(
+ column, Capability.Scope.COLUMN, catalogManager, () -> store));
+
+ IOException failure = new IOException("store down");
+ Mockito.reset(store);
+ Mockito.when(store.get(table, Entity.EntityType.TABLE,
TableEntity.class)).thenThrow(failure);
+ RuntimeException error =
+ Assertions.assertThrows(
+ RuntimeException.class,
+ () ->
+ CapabilityHelpers.normalizeMetadataIdentifier(
+ column, Capability.Scope.COLUMN, catalogManager, () ->
store));
+ Assertions.assertSame(failure, error.getCause());
+ Assertions.assertTrue(error.getMessage().contains(table.toString()));
+ }
+
+ private static CatalogManager foldingCatalogManager() {
+ CatalogManager catalogManager = Mockito.mock(CatalogManager.class);
+ BaseCatalog<?> catalog = Mockito.mock(BaseCatalog.class);
+ Mockito.when(catalog.capability())
+ .thenReturn(
+ new Capability() {
+ @Override
+ public CapabilityResult caseSensitiveOnName(Scope scope) {
+ return CapabilityResult.unsupported("case insensitive");
+ }
+ });
+ CatalogTestUtils.mockDoWithCatalog(catalogManager, catalog);
+ return catalogManager;
+ }
}
diff --git
a/core/src/test/java/org/apache/gravitino/policy/TestPolicyManager.java
b/core/src/test/java/org/apache/gravitino/policy/TestPolicyManager.java
index 45cbaf2b9c..0c8ed6d87d 100644
--- a/core/src/test/java/org/apache/gravitino/policy/TestPolicyManager.java
+++ b/core/src/test/java/org/apache/gravitino/policy/TestPolicyManager.java
@@ -73,11 +73,15 @@ import org.apache.gravitino.MetadataObject;
import org.apache.gravitino.NameIdentifier;
import org.apache.gravitino.Namespace;
import org.apache.gravitino.catalog.CatalogDispatcher;
+import org.apache.gravitino.catalog.CatalogManager;
+import org.apache.gravitino.catalog.CatalogTestUtils;
import org.apache.gravitino.catalog.FunctionDispatcher;
import org.apache.gravitino.catalog.SchemaDispatcher;
import org.apache.gravitino.catalog.TableDispatcher;
import org.apache.gravitino.catalog.TreeLockTestSupport;
import org.apache.gravitino.catalog.ViewDispatcher;
+import org.apache.gravitino.connector.BaseCatalog;
+import org.apache.gravitino.connector.capability.Capability;
import org.apache.gravitino.exceptions.NoSuchEntityException;
import org.apache.gravitino.exceptions.NoSuchMetalakeException;
import org.apache.gravitino.exceptions.NoSuchPolicyException;
@@ -139,6 +143,7 @@ public class TestPolicyManager {
private static EntityStore entityStore;
private static PolicyManager policyManager;
private static TagManager tagManager;
+ private static CatalogManager previousCatalogManager;
@BeforeAll
public static void setUp() throws IllegalAccessException, IOException {
@@ -148,6 +153,13 @@ public class TestPolicyManager {
entityStore.initialize(config);
policyManager = new PolicyManager(idGenerator, entityStore);
tagManager = new TagManager(idGenerator, entityStore);
+ previousCatalogManager =
+ (CatalogManager) FieldUtils.readField(GravitinoEnv.getInstance(),
"catalogManager", true);
+ CatalogManager catalogManager = mock(CatalogManager.class);
+ BaseCatalog<?> backendCatalog = mock(BaseCatalog.class);
+ CatalogTestUtils.mockDoWithCatalog(catalogManager, backendCatalog);
+ when(backendCatalog.capability()).thenReturn(Capability.DEFAULT);
+ FieldUtils.writeField(GravitinoEnv.getInstance(), "catalogManager",
catalogManager, true);
FieldUtils.writeField(GravitinoEnv.getInstance(), "lockManager", new
LockManager(config), true);
FieldUtils.writeField(
@@ -289,7 +301,9 @@ public class TestPolicyManager {
}
@AfterAll
- public static void tearDown() throws IOException {
+ public static void tearDown() throws IOException, IllegalAccessException {
+ FieldUtils.writeField(
+ GravitinoEnv.getInstance(), "catalogManager", previousCatalogManager,
true);
if (entityStore != null) {
entityStore.close();
entityStore = null;
diff --git a/core/src/test/java/org/apache/gravitino/tag/TestTagManager.java
b/core/src/test/java/org/apache/gravitino/tag/TestTagManager.java
index a57eb35e2e..e32d096ca1 100644
--- a/core/src/test/java/org/apache/gravitino/tag/TestTagManager.java
+++ b/core/src/test/java/org/apache/gravitino/tag/TestTagManager.java
@@ -54,6 +54,7 @@ import java.io.IOException;
import java.time.Instant;
import java.util.Arrays;
import java.util.List;
+import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
@@ -75,12 +76,23 @@ import org.apache.gravitino.NameIdentifier;
import org.apache.gravitino.Namespace;
import org.apache.gravitino.RelationalEntity;
import org.apache.gravitino.catalog.CatalogDispatcher;
+import org.apache.gravitino.catalog.CatalogManager;
+import org.apache.gravitino.catalog.CatalogTestUtils;
+import org.apache.gravitino.catalog.FilesetDispatcher;
import org.apache.gravitino.catalog.FunctionDispatcher;
+import org.apache.gravitino.catalog.ModelDispatcher;
import org.apache.gravitino.catalog.SchemaDispatcher;
import org.apache.gravitino.catalog.SemanticModelDispatcher;
import org.apache.gravitino.catalog.TableDispatcher;
+import org.apache.gravitino.catalog.TopicDispatcher;
import org.apache.gravitino.catalog.TreeLockTestSupport;
import org.apache.gravitino.catalog.ViewDispatcher;
+import org.apache.gravitino.connector.BaseCatalog;
+import org.apache.gravitino.connector.CatalogOperations;
+import org.apache.gravitino.connector.SupportsTableNameResolution;
+import org.apache.gravitino.connector.capability.Capability;
+import org.apache.gravitino.connector.capability.CapabilityResult;
+import org.apache.gravitino.exceptions.IllegalNamespaceException;
import org.apache.gravitino.exceptions.NoSuchMetadataObjectException;
import org.apache.gravitino.exceptions.NoSuchMetalakeException;
import org.apache.gravitino.exceptions.NoSuchTagException;
@@ -88,6 +100,7 @@ import org.apache.gravitino.exceptions.NotFoundException;
import org.apache.gravitino.exceptions.PolicyAlreadyAssociatedException;
import org.apache.gravitino.exceptions.TagAlreadyAssociatedException;
import org.apache.gravitino.exceptions.TagAlreadyExistsException;
+import org.apache.gravitino.file.Fileset;
import org.apache.gravitino.function.FunctionDefinition;
import org.apache.gravitino.function.FunctionDefinitions;
import org.apache.gravitino.function.FunctionImpl;
@@ -102,11 +115,15 @@ import org.apache.gravitino.meta.AuditInfo;
import org.apache.gravitino.meta.BaseMetalake;
import org.apache.gravitino.meta.CatalogEntity;
import org.apache.gravitino.meta.ColumnEntity;
+import org.apache.gravitino.meta.FilesetEntity;
import org.apache.gravitino.meta.FunctionEntity;
+import org.apache.gravitino.meta.ModelEntity;
import org.apache.gravitino.meta.PolicyEntity;
import org.apache.gravitino.meta.SchemaEntity;
import org.apache.gravitino.meta.SchemaVersion;
+import org.apache.gravitino.meta.SemanticModelEntity;
import org.apache.gravitino.meta.TableEntity;
+import org.apache.gravitino.meta.TopicEntity;
import org.apache.gravitino.meta.ViewEntity;
import org.apache.gravitino.metalake.MetalakeDispatcher;
import org.apache.gravitino.policy.AllValuesSelector;
@@ -117,6 +134,8 @@ import org.apache.gravitino.rel.Column;
import org.apache.gravitino.rel.Representation;
import org.apache.gravitino.rel.SQLRepresentation;
import org.apache.gravitino.rel.types.Types;
+import org.apache.gravitino.semantic.Dataset;
+import org.apache.gravitino.semantic.SemanticModelDefinition;
import org.apache.gravitino.storage.IdGenerator;
import org.apache.gravitino.storage.RandomIdGenerator;
import org.apache.gravitino.utils.NameIdentifierUtil;
@@ -126,6 +145,8 @@ import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.EnumSource;
import org.mockito.Mockito;
public class TestTagManager {
@@ -167,6 +188,18 @@ public class TestTagManager {
private static TagManager tagManager;
+ private static final CatalogManager catalogManager =
mock(CatalogManager.class);
+ private static final BaseCatalog<?> backendCatalog = mock(BaseCatalog.class);
+ private static CatalogManager previousCatalogManager;
+
+ private static final Capability CASE_INSENSITIVE_CAPABILITY =
+ new Capability() {
+ @Override
+ public CapabilityResult caseSensitiveOnName(Scope scope) {
+ return CapabilityResult.unsupported("case insensitive");
+ }
+ };
+
@BeforeAll
public static void setUp() throws IOException, IllegalAccessException {
idGenerator = new RandomIdGenerator();
@@ -301,6 +334,11 @@ public class TestTagManager {
entityStore.put(function, false /* overwritten */);
tagManager = new TagManager(idGenerator, entityStore);
+ previousCatalogManager =
+ (CatalogManager) FieldUtils.readField(GravitinoEnv.getInstance(),
"catalogManager", true);
+ FieldUtils.writeField(GravitinoEnv.getInstance(), "catalogManager",
catalogManager, true);
+ CatalogTestUtils.mockDoWithCatalog(catalogManager, backendCatalog);
+ when(backendCatalog.capability()).thenReturn(Capability.DEFAULT);
FieldUtils.writeField(
GravitinoEnv.getInstance(), "internalMetalakeDispatcher",
metalakeDispatcher, true);
@@ -329,7 +367,9 @@ public class TestTagManager {
}
@AfterAll
- public static void tearDown() throws IOException {
+ public static void tearDown() throws IOException, IllegalAccessException {
+ FieldUtils.writeField(
+ GravitinoEnv.getInstance(), "catalogManager", previousCatalogManager,
true);
if (entityStore != null) {
entityStore.close();
entityStore = null;
@@ -381,6 +421,382 @@ public class TestTagManager {
Arrays.stream(tagManager.listTags(METALAKE)).forEach(n ->
tagManager.deleteTag(METALAKE, n));
}
+ @ParameterizedTest
+ @EnumSource(
+ value = Entity.EntityType.class,
+ names = {"SCHEMA", "TABLE", "COLUMN", "VIEW", "FUNCTION"})
+ void testTagOperationsNormalizeCaseInsensitiveNames(Entity.EntityType type) {
+ MetadataObject canonical = tagTarget(type, false);
+ MetadataObject request = tagTarget(type, true);
+ when(backendCatalog.capability()).thenReturn(CASE_INSENSITIVE_CAPABILITY);
+ try {
+ assertTagRoundTrip(request, canonical, canonical);
+ } finally {
+ when(backendCatalog.capability()).thenReturn(Capability.DEFAULT);
+ }
+ }
+
+ @ParameterizedTest
+ @EnumSource(
+ value = Entity.EntityType.class,
+ names = {"FILESET", "TOPIC", "MODEL"})
+ void testTagOperationsNormalizeOtherCatalogObjects(Entity.EntityType type)
+ throws IOException, IllegalAccessException {
+ NameIdentifier ident = NameIdentifier.of(METALAKE, CATALOG, SCHEMA,
"normalization_object");
+ AuditInfo audit =
AuditInfo.builder().withCreator("test").withCreateTime(Instant.now()).build();
+ Object dispatcher;
+ String dispatcherField;
+ switch (type) {
+ case FILESET:
+ entityStore.put(
+ FilesetEntity.builder()
+ .withId(idGenerator.nextId())
+ .withName(ident.name())
+ .withNamespace(ident.namespace())
+ .withFilesetType(Fileset.Type.EXTERNAL)
+ .withStorageLocations(Map.of("default",
"file:/tmp/normalization_object"))
+ .withAuditInfo(audit)
+ .build(),
+ false);
+ FilesetDispatcher filesetDispatcher = mock(FilesetDispatcher.class);
+ when(filesetDispatcher.filesetExists(any())).thenReturn(true);
+ dispatcher = filesetDispatcher;
+ dispatcherField = "internalFilesetDispatcher";
+ break;
+ case TOPIC:
+ entityStore.put(
+ TopicEntity.builder()
+ .withId(idGenerator.nextId())
+ .withName(ident.name())
+ .withNamespace(ident.namespace())
+ .withAuditInfo(audit)
+ .build(),
+ false);
+ TopicDispatcher topicDispatcher = mock(TopicDispatcher.class);
+ when(topicDispatcher.topicExists(any())).thenReturn(true);
+ dispatcher = topicDispatcher;
+ dispatcherField = "internalTopicDispatcher";
+ break;
+ case MODEL:
+ entityStore.put(
+ ModelEntity.builder()
+ .withId(idGenerator.nextId())
+ .withName(ident.name())
+ .withNamespace(ident.namespace())
+ .withLatestVersion(0)
+ .withAuditInfo(audit)
+ .build(),
+ false);
+ ModelDispatcher modelDispatcher = mock(ModelDispatcher.class);
+ when(modelDispatcher.modelExists(any())).thenReturn(true);
+ dispatcher = modelDispatcher;
+ dispatcherField = "internalModelDispatcher";
+ break;
+ default:
+ throw new IllegalArgumentException("Unsupported test target: " + type);
+ }
+ Object previousDispatcher =
+ FieldUtils.readField(GravitinoEnv.getInstance(), dispatcherField,
true);
+ FieldUtils.writeField(GravitinoEnv.getInstance(), dispatcherField,
dispatcher, true);
+ when(backendCatalog.capability()).thenReturn(CASE_INSENSITIVE_CAPABILITY);
+ try {
+ MetadataObject request =
+ NameIdentifierUtil.toMetadataObject(
+ NameIdentifier.of(
+ METALAKE,
+ CATALOG,
+ SCHEMA.toUpperCase(Locale.ROOT),
+ ident.name().toUpperCase(Locale.ROOT)),
+ type);
+ MetadataObject canonical = NameIdentifierUtil.toMetadataObject(ident,
type);
+ assertTagRoundTrip(request, canonical, canonical);
+ } finally {
+ when(backendCatalog.capability()).thenReturn(Capability.DEFAULT);
+ FieldUtils.writeField(GravitinoEnv.getInstance(), dispatcherField,
previousDispatcher, true);
+ entityStore.delete(ident, type);
+ }
+ }
+
+ @Test
+ void testCatalogAndMetalakeTagOperationsDoNotLoadCapabilities() {
+ Mockito.clearInvocations(catalogManager);
+ MetadataObject catalogObject =
+ NameIdentifierUtil.toMetadataObject(
+ NameIdentifier.of(METALAKE, CATALOG), Entity.EntityType.CATALOG);
+ assertTagRoundTrip(catalogObject, catalogObject, catalogObject);
+ MetadataObject metalakeObject =
+ NameIdentifierUtil.toMetadataObject(
+ NameIdentifier.of(METALAKE), Entity.EntityType.METALAKE);
+ // Metalake tags are unsupported: preserve the existing store failure
without loading a catalog.
+ Assertions.assertThrows(
+ IllegalNamespaceException.class,
+ () -> tagManager.listTagsForMetadataObject(METALAKE, metalakeObject));
+ Assertions.assertThrows(
+ IllegalArgumentException.class,
+ () ->
+ tagManager.associateTagsForMetadataObject(
+ METALAKE, metalakeObject, new String[] {"normalization_tag"},
null));
+ Mockito.verifyNoInteractions(catalogManager);
+ }
+
+ @Test
+ void testTagOperationsPreserveCaseSensitiveNames() {
+ MetadataObject canonical = tagTarget(Entity.EntityType.TABLE, false);
+ MetadataObject differentlyCased = tagTarget(Entity.EntityType.TABLE, true);
+ Tag tag = tagManager.createTag(METALAKE, "case_sensitive_tag", null, null);
+ tagManager.associateTagsForMetadataObject(METALAKE, canonical, new
String[] {tag.name()}, null);
+
+ Assertions.assertThrows(
+ NoSuchMetadataObjectException.class,
+ () -> tagManager.listTagsForMetadataObject(METALAKE,
differentlyCased));
+ Assertions.assertThrows(
+ NoSuchMetadataObjectException.class,
+ () -> tagManager.getTagForMetadataObject(METALAKE, differentlyCased,
tag.name()));
+ Assertions.assertThrows(
+ NoSuchMetadataObjectException.class,
+ () ->
+ tagManager.associateTagsForMetadataObject(
+ METALAKE, differentlyCased, null, new String[] {tag.name()}));
+ Assertions.assertArrayEquals(
+ new String[] {tag.name()},
tagManager.listTagsForMetadataObject(METALAKE, canonical));
+ }
+
+ @Test
+ void testTagOperationsPreserveQuotedNamesAndOriginalExistenceCheck() {
+ Capability quoteAware =
+ new Capability() {
+ @Override
+ public String normalizeName(Scope scope, String name) {
+ return name.startsWith("\"") && name.endsWith("\"")
+ ? name.substring(1, name.length() - 1)
+ : name.toUpperCase(Locale.ROOT);
+ }
+ };
+ NameIdentifier quotedTable =
+ NameIdentifier.of(METALAKE, CATALOG, "\"" + SCHEMA + "\"", "\"" +
TABLE + "\"");
+ MetadataObject request =
+ NameIdentifierUtil.toMetadataObject(quotedTable,
Entity.EntityType.TABLE);
+ when(backendCatalog.capability()).thenReturn(quoteAware);
+ when(tableDispatcher.tableExists(any()))
+ .thenAnswer(invocation ->
quotedTable.equals(invocation.getArgument(0)));
+ try {
+ assertTagRoundTrip(request, request, tagTarget(Entity.EntityType.TABLE,
false));
+ } finally {
+ when(backendCatalog.capability()).thenReturn(Capability.DEFAULT);
+ when(tableDispatcher.tableExists(any())).thenReturn(true);
+ }
+ }
+
+ @ParameterizedTest
+ @EnumSource(
+ value = Entity.EntityType.class,
+ names = {"TABLE", "COLUMN"})
+ void testTagOperationsResolvePhysicalTableName(Entity.EntityType type) {
+ CatalogOperations ops =
+ mock(
+ CatalogOperations.class,
+
Mockito.withSettings().extraInterfaces(SupportsTableNameResolution.class));
+ Mockito.doReturn(ops).when(backendCatalog).ops();
+ when(((SupportsTableNameResolution) ops).resolveTableName(any()))
+ .thenAnswer(
+ invocation -> {
+ NameIdentifier ident = invocation.getArgument(0);
+ return ident.name().equals("table_alias")
+ ? NameIdentifier.of(ident.namespace(), TABLE)
+ : ident;
+ });
+ when(backendCatalog.capability()).thenReturn(CASE_INSENSITIVE_CAPABILITY);
+ NameIdentifier alias =
+ type == Entity.EntityType.TABLE
+ ? NameIdentifier.of(METALAKE, CATALOG,
SCHEMA.toUpperCase(Locale.ROOT), "TABLE_ALIAS")
+ : NameIdentifier.of(
+ METALAKE,
+ CATALOG,
+ SCHEMA.toUpperCase(Locale.ROOT),
+ "TABLE_ALIAS",
+ COLUMN.toUpperCase(Locale.ROOT));
+ MetadataObject canonical = tagTarget(type, false);
+ try {
+ assertTagRoundTrip(NameIdentifierUtil.toMetadataObject(alias, type),
canonical, canonical);
+ } finally {
+ when(backendCatalog.capability()).thenReturn(Capability.DEFAULT);
+ Mockito.doReturn(null).when(backendCatalog).ops();
+ }
+ }
+
+ @Test
+ void testTagOperationsFollowGlueStyleColumnCase() throws IOException {
+ // Glue folds schema and table names but keeps the case of column names.
+ NameIdentifier table = putTableWithColumn("glue_table", "MixedCol");
+ Capability glueStyle =
+ new Capability() {
+ @Override
+ public CapabilityResult caseSensitiveOnName(Scope scope) {
+ return scope == Scope.SCHEMA || scope == Scope.TABLE
+ ? CapabilityResult.unsupported("case insensitive")
+ : CapabilityResult.SUPPORTED;
+ }
+ };
+ when(backendCatalog.capability()).thenReturn(glueStyle);
+ try {
+ MetadataObject request =
+ NameIdentifierUtil.toMetadataObject(
+ NameIdentifier.of(
+ METALAKE, CATALOG, SCHEMA.toUpperCase(Locale.ROOT),
"GLUE_TABLE", "MixedCol"),
+ Entity.EntityType.COLUMN);
+ MetadataObject canonical =
+ NameIdentifierUtil.toMetadataObject(
+ NameIdentifier.of(METALAKE, CATALOG, SCHEMA, table.name(),
"MixedCol"),
+ Entity.EntityType.COLUMN);
+ assertTagRoundTrip(request, canonical, canonical);
+ } finally {
+ when(backendCatalog.capability()).thenReturn(Capability.DEFAULT);
+ entityStore.delete(table, Entity.EntityType.TABLE);
+ }
+ }
+
+ @Test
+ void testTagOperationsKeepExactStoredColumnName() throws IOException {
+ // A capability can fold column names that the source stored with another
case. The exact
+ // stored name must still find the column.
+ NameIdentifier table = putTableWithColumn("exact_table", "MyCol");
+ when(backendCatalog.capability()).thenReturn(CASE_INSENSITIVE_CAPABILITY);
+ try {
+ MetadataObject canonical =
+ NameIdentifierUtil.toMetadataObject(
+ NameIdentifier.of(METALAKE, CATALOG, SCHEMA, table.name(),
"MyCol"),
+ Entity.EntityType.COLUMN);
+ MetadataObject request =
+ NameIdentifierUtil.toMetadataObject(
+ NameIdentifier.of(
+ METALAKE, CATALOG, SCHEMA.toUpperCase(Locale.ROOT),
"EXACT_TABLE", "MyCol"),
+ Entity.EntityType.COLUMN);
+ assertTagRoundTrip(request, canonical, canonical);
+ } finally {
+ when(backendCatalog.capability()).thenReturn(Capability.DEFAULT);
+ entityStore.delete(table, Entity.EntityType.TABLE);
+ }
+ }
+
+ @ParameterizedTest
+ @EnumSource(
+ value = Entity.EntityType.class,
+ names = {"TABLE", "COLUMN"})
+ void testFailedTableNameResolutionLeavesTagsUnchanged(Entity.EntityType
type) {
+ MetadataObject canonical = tagTarget(type, false);
+ Tag kept = tagManager.createTag(METALAKE, "kept_tag", null, null);
+ Tag added = tagManager.createTag(METALAKE, "added_tag", null, null);
+ tagManager.associateTagsForMetadataObject(
+ METALAKE, canonical, new String[] {kept.name()}, null);
+
+ CatalogOperations ops =
+ mock(
+ CatalogOperations.class,
+
Mockito.withSettings().extraInterfaces(SupportsTableNameResolution.class));
+ when(((SupportsTableNameResolution) ops).resolveTableName(any()))
+ .thenThrow(new IllegalStateException("source unavailable"));
+ Mockito.doReturn(ops).when(backendCatalog).ops();
+ try {
+ RuntimeException addError =
+ Assertions.assertThrows(
+ RuntimeException.class,
+ () ->
+ tagManager.associateTagsForMetadataObject(
+ METALAKE, canonical, new String[] {added.name()}, null));
+ Assertions.assertInstanceOf(IllegalStateException.class,
addError.getCause());
+ Assertions.assertThrows(
+ RuntimeException.class,
+ () ->
+ tagManager.associateTagsForMetadataObject(
+ METALAKE, canonical, null, new String[] {kept.name()}));
+ Assertions.assertThrows(
+ RuntimeException.class,
+ () ->
+ tagManager.associateTagValuesForMetadataObject(
+ METALAKE, canonical, new TagValue[]
{TagValue.of(added.name(), "v")}, null));
+ } finally {
+ Mockito.doReturn(null).when(backendCatalog).ops();
+ }
+ Assertions.assertArrayEquals(
+ new String[] {kept.name()},
tagManager.listTagsForMetadataObject(METALAKE, canonical));
+ }
+
+ @Test
+ void testTagOperationsUseCustomNormalization() throws IOException {
+ NameIdentifier uppercase = NameIdentifier.of(METALAKE, CATALOG, SCHEMA,
"CUSTOM_TABLE");
+ AuditInfo audit =
AuditInfo.builder().withCreator("test").withCreateTime(Instant.now()).build();
+ entityStore.put(
+ TableEntity.builder()
+ .withId(idGenerator.nextId())
+ .withName(uppercase.name())
+ .withNamespace(uppercase.namespace())
+ .withColumns(Lists.newArrayList())
+ .withAuditInfo(audit)
+ .build(),
+ false);
+ Capability uppercaseTables =
+ new Capability() {
+ @Override
+ public String normalizeName(Scope scope, String name) {
+ return scope == Scope.TABLE ? name.toUpperCase(Locale.ROOT) : name;
+ }
+ };
+ when(backendCatalog.capability()).thenReturn(uppercaseTables);
+ try {
+ MetadataObject request =
+ NameIdentifierUtil.toMetadataObject(
+ NameIdentifier.of(uppercase.namespace(), "custom_table"),
Entity.EntityType.TABLE);
+ MetadataObject canonical =
+ NameIdentifierUtil.toMetadataObject(uppercase,
Entity.EntityType.TABLE);
+ assertTagRoundTrip(request, canonical, canonical);
+ } finally {
+ when(backendCatalog.capability()).thenReturn(Capability.DEFAULT);
+ entityStore.delete(uppercase, Entity.EntityType.TABLE);
+ }
+ }
+
+ @Test
+ void testTagOperationsPreserveSemanticModelName() throws IOException {
+ NameIdentifier ident = NameIdentifier.of(METALAKE, CATALOG, SCHEMA,
"SalesModel");
+ AuditInfo audit =
AuditInfo.builder().withCreator("test").withCreateTime(Instant.now()).build();
+ Dataset dataset =
+ Dataset.builder()
+ .withName("sales")
+ .withSource(NameIdentifier.of(CATALOG, SCHEMA, TABLE))
+ .build();
+ entityStore.put(
+ SemanticModelEntity.builder()
+ .withId(idGenerator.nextId())
+ .withName(ident.name())
+ .withNamespace(ident.namespace())
+ .withAuditInfo(audit)
+ .withDefinition(
+ SemanticModelDefinition.builder().withDatasets(new Dataset[]
{dataset}).build())
+ .build(),
+ false);
+ when(backendCatalog.capability()).thenReturn(CASE_INSENSITIVE_CAPABILITY);
+ when(semanticModelDispatcher.semanticModelExists(any())).thenReturn(true);
+ try {
+ MetadataObject request =
+ NameIdentifierUtil.toMetadataObject(
+ NameIdentifier.of(METALAKE, CATALOG,
SCHEMA.toUpperCase(Locale.ROOT), ident.name()),
+ Entity.EntityType.SEMANTIC_MODEL);
+ MetadataObject canonical =
+ NameIdentifierUtil.toMetadataObject(ident,
Entity.EntityType.SEMANTIC_MODEL);
+ assertTagRoundTrip(request, canonical, canonical);
+ MetadataObject wrongCase =
+ NameIdentifierUtil.toMetadataObject(
+ NameIdentifier.of(ident.namespace(), "salesmodel"),
Entity.EntityType.SEMANTIC_MODEL);
+ Assertions.assertThrows(
+ NoSuchMetadataObjectException.class,
+ () -> tagManager.listTagsForMetadataObject(METALAKE, wrongCase));
+ } finally {
+ when(backendCatalog.capability()).thenReturn(Capability.DEFAULT);
+
when(semanticModelDispatcher.semanticModelExists(any())).thenReturn(false);
+ entityStore.delete(ident, Entity.EntityType.SEMANTIC_MODEL);
+ }
+ }
+
@Test
public void testCreateAndGetTag() {
Tag tag = tagManager.createTag(METALAKE, "tag1", null, null);
@@ -1431,4 +1847,91 @@ public class TestTagManager {
Assertions.assertTrue(tag.assignment().isPresent());
Assertions.assertArrayEquals(expectedValues,
tag.assignment().get().values());
}
+
+ private static NameIdentifier putTableWithColumn(String tableName, String
columnName)
+ throws IOException {
+ AuditInfo audit =
AuditInfo.builder().withCreator("test").withCreateTime(Instant.now()).build();
+ NameIdentifier ident = NameIdentifier.of(METALAKE, CATALOG, SCHEMA,
tableName);
+ entityStore.put(
+ TableEntity.builder()
+ .withId(idGenerator.nextId())
+ .withName(tableName)
+ .withNamespace(ident.namespace())
+ .withColumns(
+ Lists.newArrayList(
+ ColumnEntity.builder()
+ .withId(idGenerator.nextId())
+ .withName(columnName)
+ .withPosition(0)
+ .withDataType(Types.IntegerType.get())
+ .withNullable(true)
+ .withAutoIncrement(false)
+ .withAuditInfo(audit)
+ .build()))
+ .withAuditInfo(audit)
+ .build(),
+ false);
+ return ident;
+ }
+
+ private static MetadataObject tagTarget(Entity.EntityType type, boolean
upperCase) {
+ String schema = upperCase ? SCHEMA.toUpperCase(Locale.ROOT) : SCHEMA;
+ String table = upperCase ? TABLE.toUpperCase(Locale.ROOT) : TABLE;
+ NameIdentifier ident;
+ switch (type) {
+ case SCHEMA:
+ ident = NameIdentifier.of(METALAKE, CATALOG, schema);
+ break;
+ case TABLE:
+ ident = NameIdentifier.of(METALAKE, CATALOG, schema, table);
+ break;
+ case COLUMN:
+ String column = upperCase ? COLUMN.toUpperCase(Locale.ROOT) : COLUMN;
+ ident = NameIdentifier.of(METALAKE, CATALOG, schema, table, column);
+ break;
+ case VIEW:
+ case FUNCTION:
+ String name = type == Entity.EntityType.VIEW ? VIEW : FUNCTION;
+ name = upperCase ? name.toUpperCase(Locale.ROOT) : name;
+ ident = NameIdentifier.of(METALAKE, CATALOG, schema, name);
+ break;
+ default:
+ throw new IllegalArgumentException("Unsupported test target: " + type);
+ }
+ return NameIdentifierUtil.toMetadataObject(ident, type);
+ }
+
+ private static void assertTagRoundTrip(
+ MetadataObject request, MetadataObject alias, MetadataObject
storedObject) {
+ Tag tag = tagManager.createTag(METALAKE, "normalization_tag", null, null);
+ String[] names = {tag.name()};
+ tagManager.associateTagsForMetadataObject(METALAKE, alias, names, null);
+ Assertions.assertArrayEquals(names,
tagManager.listTagsForMetadataObject(METALAKE, request));
+ Assertions.assertEquals(
+ tag.name(), tagManager.listTagsInfoForMetadataObject(METALAKE,
request)[0].name());
+ Assertions.assertEquals(
+ tag.name(), tagManager.getTagForMetadataObject(METALAKE, request,
tag.name()).name());
+ Assertions.assertArrayEquals(
+ new MetadataObject[] {storedObject},
+ tagManager.listMetadataObjectsForTag(METALAKE, tag.name()));
+
+ Assertions.assertEquals(
+ 0, tagManager.associateTagsForMetadataObject(METALAKE, request, null,
names).length);
+ Assertions.assertArrayEquals(
+ names, tagManager.associateTagsForMetadataObject(METALAKE, request,
names, null));
+ tagManager.associateTagsForMetadataObject(METALAKE, alias, null, names);
+ TagValue[] values = {TagValue.of(tag.name(), "MixedCaseValue")};
+ Assertions.assertArrayEquals(
+ names, tagManager.associateTagValuesForMetadataObject(METALAKE,
request, values, null));
+ Assertions.assertArrayEquals(
+ new String[] {"MixedCaseValue"},
+ tagManager
+ .getTagForMetadataObject(METALAKE, alias, tag.name())
+ .assignment()
+ .get()
+ .values());
+ Assertions.assertEquals(
+ 0, tagManager.associateTagValuesForMetadataObject(METALAKE, request,
null, values).length);
+ Assertions.assertEquals(0,
tagManager.listTagsInfoForMetadataObject(METALAKE, alias).length);
+ }
}
diff --git
a/server-common/src/main/java/org/apache/gravitino/server/authorization/MetadataIdConverter.java
b/server-common/src/main/java/org/apache/gravitino/server/authorization/MetadataIdConverter.java
index 5a4f44bb59..7040a2ccfc 100644
---
a/server-common/src/main/java/org/apache/gravitino/server/authorization/MetadataIdConverter.java
+++
b/server-common/src/main/java/org/apache/gravitino/server/authorization/MetadataIdConverter.java
@@ -22,7 +22,6 @@ package org.apache.gravitino.server.authorization;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Preconditions;
import com.google.common.collect.ImmutableMap;
-import com.google.common.collect.ObjectArrays;
import java.io.IOException;
import java.util.Map;
import java.util.Optional;
@@ -33,7 +32,6 @@ import org.apache.gravitino.GravitinoEnv;
import org.apache.gravitino.HasIdentifier;
import org.apache.gravitino.MetadataObject;
import org.apache.gravitino.NameIdentifier;
-import org.apache.gravitino.Namespace;
import org.apache.gravitino.catalog.CapabilityHelpers;
import org.apache.gravitino.catalog.CatalogManager;
import org.apache.gravitino.connector.capability.Capability;
@@ -106,8 +104,10 @@ public class MetadataIdConverter {
/**
* Normalizes a metadata object's name using the same catalog rules as ID
resolution.
*
- * <p>Types without a catalog capability scope retain their names. Semantic
model leaves remain
- * case sensitive, while column names and their table/schema parents follow
their own scopes.
+ * <p>Types without a catalog capability scope retain their names. The rules
come from {@link
+ * CapabilityHelpers#normalizeMetadataIdentifier}, which tag operations also
use: Semantic Model
+ * leaves keep their case, tables and column parents resolve to physical
names, and a column keeps
+ * a name that exactly matches a stored column.
*
* @param metadataObject the object whose name will be normalized
* @param metalake the metalake name
@@ -152,33 +152,9 @@ public class MetadataIdConverter {
if (scope == null) {
return ident;
}
-
- Capability capability = CapabilityHelpers.getCapability(ident,
catalogManager);
- if (scope == Capability.Scope.COLUMN) {
- // The NameIdentifier overload applies SCHEMA to the namespace and TABLE
to the leaf.
- NameIdentifier table =
- CapabilityHelpers.applyCaseSensitive(
- NameIdentifier.of(ident.namespace().levels()),
Capability.Scope.TABLE, capability);
- return NameIdentifier.of(
- Namespace.of(ObjectArrays.concat(table.namespace().levels(),
table.name())),
- CapabilityHelpers.applyCaseSensitiveOnName(scope, ident.name(),
capability));
- }
-
- if (scope == Capability.Scope.SEMANTIC_MODEL) {
- return NameIdentifier.of(
- CapabilityHelpers.applyCaseSensitive(ident.namespace(), scope,
capability), ident.name());
- }
-
- NameIdentifier normalized = CapabilityHelpers.applyCaseSensitive(ident,
scope, capability);
-
- // For a table, resolve the normalized name to the physically stored name
through the same
- // shared resolver the table dispatcher uses, so the identifier authorized
here is the
- // identifier the operation later runs on. For catalogs that do not
implement the resolution
- // capability (the vast majority) this is a pure identity call with no
extra source access.
- if (scope == Capability.Scope.TABLE) {
- return CapabilityHelpers.resolvePhysicalTableName(normalized,
catalogManager);
- }
- return normalized;
+ // Tag operations use the same helper, so authorization checks the object
they change.
+ return CapabilityHelpers.normalizeMetadataIdentifier(
+ ident, scope, catalogManager, () ->
GravitinoEnv.getInstance().entityStore());
}
private static Optional<Long> loadId(MetadataObject metadataObject,
NameIdentifier ident) {
diff --git
a/server-common/src/test/java/org/apache/gravitino/server/authorization/TestMetadataIdConverter.java
b/server-common/src/test/java/org/apache/gravitino/server/authorization/TestMetadataIdConverter.java
index c6225e1be1..fe7bfe4f87 100644
---
a/server-common/src/test/java/org/apache/gravitino/server/authorization/TestMetadataIdConverter.java
+++
b/server-common/src/test/java/org/apache/gravitino/server/authorization/TestMetadataIdConverter.java
@@ -32,6 +32,7 @@ import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
import java.io.IOException;
import java.time.Instant;
+import java.util.Collections;
import java.util.Optional;
import org.apache.commons.lang3.reflect.FieldUtils;
import org.apache.gravitino.Catalog;
@@ -344,6 +345,91 @@ public class TestMetadataIdConverter {
Assertions.assertEquals(physical, authorized);
}
+ @Test
+ void testColumnAuthorizationResolvesParentPhysicalName() {
+ // Tag operations and authorization share one helper, so a column's parent
table resolves to
+ // the same physical name as a table does.
+ NameIdentifier physical = NameIdentifier.of("metalake", "catalog",
"schema", "t_phys");
+ CatalogOperations resolvingOps =
+ mock(
+ CatalogOperations.class,
+ withSettings().extraInterfaces(SupportsTableNameResolution.class));
+ when(((SupportsTableNameResolution) resolvingOps).resolveTableName(any()))
+ .thenAnswer(
+ invocation -> {
+ NameIdentifier norm = invocation.getArgument(0);
+ return "T_NORM".equals(norm.name()) ? physical : norm;
+ });
+ BaseCatalog<?> mockCatalog = mock(BaseCatalog.class);
+ when(mockCatalog.capability()).thenReturn(new Capability() {});
+ when(mockCatalog.ops()).thenReturn(resolvingOps);
+ CatalogManager mockCatalogManager = mock(CatalogManager.class);
+ doAnswer(
+ invocation -> {
+ ThrowableFunction<BaseCatalog<?>, Object> fn =
invocation.getArgument(1);
+ return fn.apply(mockCatalog);
+ })
+ .when(mockCatalogManager)
+ .doWithCatalog(any(), any());
+
+ Assertions.assertEquals(
+ NameIdentifier.of("metalake", "catalog", "schema", "t_phys", "col"),
+ MetadataIdConverter.normalizeCaseSensitive(
+ NameIdentifier.of("metalake", "catalog", "schema", "T_NORM",
"col"),
+ Capability.Scope.COLUMN,
+ mockCatalogManager));
+ }
+
+ @Test
+ void testColumnAuthorizationKeepsExactStoredColumnName() throws Exception {
+ CatalogManager catalogs = mock(CatalogManager.class);
+ BaseCatalog<?> catalog = mock(BaseCatalog.class);
+ when(catalog.capability())
+ .thenReturn(
+ new Capability() {
+ @Override
+ public CapabilityResult caseSensitiveOnName(Scope scope) {
+ return CapabilityResult.unsupported("lowercase");
+ }
+ });
+ Mockito.doAnswer(
+ invocation -> {
+ ThrowableFunction<BaseCatalog<?>, Object> operation =
invocation.getArgument(1);
+ return operation.apply(catalog);
+ })
+ .when(catalogs)
+ .doWithCatalog(any(), any());
+ EntityStore store = mock(EntityStore.class);
+ TableEntity table = mock(TableEntity.class);
+ ColumnEntity column = mock(ColumnEntity.class);
+ when(column.name()).thenReturn("MyCol");
+ when(table.columns()).thenReturn(Collections.singletonList(column));
+ when(store.get(
+ NameIdentifier.of("metalake", "catalog", "schema", "orders"),
+ Entity.EntityType.TABLE,
+ TableEntity.class))
+ .thenReturn(table);
+ GravitinoEnv env = mock(GravitinoEnv.class);
+ when(env.catalogManager()).thenReturn(catalogs);
+ when(env.entityStore()).thenReturn(store);
+ try (MockedStatic<GravitinoEnv> mocked = mockStatic(GravitinoEnv.class)) {
+ mocked.when(GravitinoEnv::getInstance).thenReturn(env);
+ Assertions.assertEquals(
+ "catalog.schema.orders.MyCol",
+ MetadataIdConverter.normalizeMetadataObject(
+ MetadataObjects.parse("catalog.SCHEMA.ORDERS.MyCol",
MetadataObject.Type.COLUMN),
+ "metalake")
+ .fullName());
+ Assertions.assertEquals(
+ "catalog.schema.orders.othercol",
+ MetadataIdConverter.normalizeMetadataObject(
+ MetadataObjects.parse(
+ "catalog.SCHEMA.ORDERS.OtherCol",
MetadataObject.Type.COLUMN),
+ "metalake")
+ .fullName());
+ }
+ }
+
private void initTestNameIdentifier() {
ident1 = NameIdentifier.of("metalake");
ident2 = NameIdentifier.of("metalake", "catalog");