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 4718fc54db [#13480] improvement(core): add a connector SPI to resolve
physical table names (#13481)
4718fc54db is described below
commit 4718fc54db190a85431a9fdde82716c899daae04
Author: geyanggang <[email protected]>
AuthorDate: Sat Oct 10 15:56:41 2026 +0800
[#13480] improvement(core): add a connector SPI to resolve physical table
names (#13481)
### What changes were proposed in this pull request?
Adds an opt-in, server-internal connector SPI that maps a normalized
table identifier back to the name the underlying source physically
stores, for backends whose name normalization is not reversible.
- core (api-connector) SupportsTableNameResolution: a new
CatalogOperations mixin in org.apache.gravitino.connector with
resolveTableName(NameIdentifier requestedIdent, NameIdentifier
normalizedIdent). It is server-internal and never exposed to clients;
the user-facing TableCatalog API is unchanged.
- core TableNormalizeDispatcher: loadTable / alterTable / dropTable /
purgeTable / tableExists resolve the identifier through the hook after
normalization and before delegating. Resolution runs above
TableHookDispatcher and TableOperationDispatcher, so the resolved
identifier drives the authorization hooks, the catalog call, and the
entity store key consistently. It takes no lock: resolution is
best-effort, and the locked downstream operation re-checks existence, so
a concurrent rename/recreate surfaces as the normal NoSuchTableException
rather than an action on a different object. Typed exceptions from the
catalog access (e.g. NoSuchCatalogException) propagate unchanged.
- catalog-jdbc-common: TableOperation gains a default identity
resolveTableName(databaseName, requestedName, normalizedName);
JdbcCatalogOperations implements the SPI by delegating to it and
preserving the namespace.
For every catalog that does not implement the SPI, resolution is a pure
identity call — no I/O and no behavior change.
### Why are the changes needed?
A name that listTables returns should be usable as-is by
loadTable/alterTable/dropTable. When a catalog's normalization is not
reversible, the normalized-name lookup can miss an object that
listTables surfaces under a different stored case. This SPI gives such
catalogs a single, server-side place to map the normalized name back to
the stored one, so the round-trip works for the SDK, REST, and query
engines at once. Resolving above the hook layer (rather than inside the
operation dispatcher) ensures an authorization plugin cleans up/renames
privileges for the real stored object rather than the folded name, and
the entity store key stays consistent with the catalog call.
The resolver receives both the requested and normalized identifiers and
follows a strict contract: prefer an exact match on the requested name,
then an exact match on the normalized name, then a unique
case-insensitive match; otherwise keep the normalized name. An ambiguous
name is never resolved to an arbitrary object, and the resolver never
throws when the table is absent, so tableExists/dropTable keep their
boolean not-found semantics.
Part of: #13480
### Does this PR introduce _any_ user-facing change?
No. A new server-internal connector SPI is added; the user-facing
TableCatalog API is unchanged, and catalogs that do not implement the
SPI behave exactly as before.
### How was this patch tested?
- TestTableNormalizeDispatcher: all of load/alter/drop/purge/exists
dispatch the resolved identifier; the resolver receives both the
requested (raw) and normalized names; the ambiguous/absent case keeps
the normalized name and drop/exists stay boolean (never throw); a
catalog without the capability is a pure identity no-op;
NoSuchCatalogException propagates.
- TestJdbcCatalogOperations: JdbcCatalogOperations.resolveTableName
delegates to TableOperation.resolveTableName and preserves the
namespace.
- Existing core catalog suite and catalog-jdbc-common suite pass
unchanged. spotlessJavaCheck and javadoc pass for the touched modules.
The real-container PostgreSQL and MySQL table-operation tests pass
(default identity resolver, no regression).
---
.../catalog/jdbc/JdbcCatalogOperations.java | 20 ++-
.../catalog/jdbc/operation/TableOperation.java | 25 +++
.../catalog/jdbc/TestJdbcCatalogOperations.java | 32 ++++
.../gravitino/catalog/CapabilityHelpers.java | 44 ++++++
.../catalog/TableNormalizeDispatcher.java | 16 +-
.../connector/SupportsTableNameResolution.java | 80 ++++++++++
.../catalog/TestTableNormalizeDispatcher.java | 174 +++++++++++++++++++++
.../server/authorization/MetadataIdConverter.java | 13 +-
.../authorization/TestMetadataIdConverter.java | 53 +++++++
9 files changed, 450 insertions(+), 7 deletions(-)
diff --git
a/catalogs/catalog-jdbc-common/src/main/java/org/apache/gravitino/catalog/jdbc/JdbcCatalogOperations.java
b/catalogs/catalog-jdbc-common/src/main/java/org/apache/gravitino/catalog/jdbc/JdbcCatalogOperations.java
index e8c3feeb60..7807b68134 100644
---
a/catalogs/catalog-jdbc-common/src/main/java/org/apache/gravitino/catalog/jdbc/JdbcCatalogOperations.java
+++
b/catalogs/catalog-jdbc-common/src/main/java/org/apache/gravitino/catalog/jdbc/JdbcCatalogOperations.java
@@ -57,6 +57,7 @@ import org.apache.gravitino.connector.CatalogInfo;
import org.apache.gravitino.connector.CatalogOperations;
import org.apache.gravitino.connector.HasPropertyMetadata;
import org.apache.gravitino.connector.SupportsSchemas;
+import org.apache.gravitino.connector.SupportsTableNameResolution;
import org.apache.gravitino.exceptions.ConnectionFailedException;
import org.apache.gravitino.exceptions.GravitinoRuntimeException;
import org.apache.gravitino.exceptions.NoSuchCatalogException;
@@ -82,7 +83,8 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/** Operations for interacting with the Jdbc catalog in Apache Gravitino. */
-public class JdbcCatalogOperations implements CatalogOperations,
SupportsSchemas, TableCatalog {
+public class JdbcCatalogOperations
+ implements CatalogOperations, SupportsSchemas, TableCatalog,
SupportsTableNameResolution {
private static final String GRAVITINO_ATTRIBUTE_DOES_NOT_EXIST_MSG =
"The Gravitino id attribute does not exist in properties";
@@ -399,6 +401,22 @@ public class JdbcCatalogOperations implements
CatalogOperations, SupportsSchemas
.build();
}
+ /**
+ * {@inheritDoc}
+ *
+ * <p>Delegates to {@link TableOperation#resolveTableName(String, String)};
the default backend
+ * implementation returns the normalized name unchanged, so this is a no-op
unless a backend
+ * overrides it.
+ */
+ @Override
+ public NameIdentifier resolveTableName(NameIdentifier normalizedIdent) {
+ String databaseName =
NameIdentifier.of(normalizedIdent.namespace().levels()).name();
+ String resolved = tableOperation.resolveTableName(databaseName,
normalizedIdent.name());
+ return resolved.equals(normalizedIdent.name())
+ ? normalizedIdent
+ : NameIdentifier.of(normalizedIdent.namespace(), resolved);
+ }
+
/**
* Apply the {@link TableChange change} to an existing Jdbc table.
*
diff --git
a/catalogs/catalog-jdbc-common/src/main/java/org/apache/gravitino/catalog/jdbc/operation/TableOperation.java
b/catalogs/catalog-jdbc-common/src/main/java/org/apache/gravitino/catalog/jdbc/operation/TableOperation.java
index 479a9baab5..7b7254910b 100644
---
a/catalogs/catalog-jdbc-common/src/main/java/org/apache/gravitino/catalog/jdbc/operation/TableOperation.java
+++
b/catalogs/catalog-jdbc-common/src/main/java/org/apache/gravitino/catalog/jdbc/operation/TableOperation.java
@@ -148,4 +148,29 @@ public interface TableOperation {
default JdbcTablePartitionOperations
createJdbcTablePartitionOperations(JdbcTable loadedTable) {
throw new UnsupportedOperationException("Table partition operation is not
supported yet");
}
+
+ /**
+ * Maps a table name to the name under which the table is physically stored,
when the two can
+ * differ for this backend.
+ *
+ * <p>The default returns {@code normalizedName} unchanged: most backends
store a table under
+ * exactly the normalized name. A backend whose name normalization is not
reversible (for example
+ * one that folds unquoted names to a fixed case while also preserving
case-sensitive names) may
+ * override this so a name returned by {@link #listTables(String)}
round-trips through
+ * load/alter/drop. See {@link
+ *
org.apache.gravitino.connector.SupportsTableNameResolution#resolveTableName}
for the resolution
+ * contract (exact match on the normalized name, then a unique
case-insensitive match; otherwise
+ * keep the normalized name; never throw when absent).
+ *
+ * <p>Implementations must be side-effect free and reuse the operation's
existing data source
+ * rather than opening new connections.
+ *
+ * @param databaseName The name of the database (schema).
+ * @param normalizedName The table name after Gravitino's case normalization.
+ * @return The physically stored table name; {@code normalizedName}
unchanged when no unambiguous
+ * mapping applies.
+ */
+ default String resolveTableName(String databaseName, String normalizedName) {
+ return normalizedName;
+ }
}
diff --git
a/catalogs/catalog-jdbc-common/src/test/java/org/apache/gravitino/catalog/jdbc/TestJdbcCatalogOperations.java
b/catalogs/catalog-jdbc-common/src/test/java/org/apache/gravitino/catalog/jdbc/TestJdbcCatalogOperations.java
index abfe47cf0a..4a888ef3e8 100644
---
a/catalogs/catalog-jdbc-common/src/test/java/org/apache/gravitino/catalog/jdbc/TestJdbcCatalogOperations.java
+++
b/catalogs/catalog-jdbc-common/src/test/java/org/apache/gravitino/catalog/jdbc/TestJdbcCatalogOperations.java
@@ -184,4 +184,36 @@ public class TestJdbcCatalogOperations {
Assertions.assertDoesNotThrow(catalogOperations::close);
}
+
+ @Test
+ public void
testResolveTableNameDelegatesToTableOperationAndPreservesNamespace() {
+ // A backend TableOperation that maps the normalized name to a
differently-cased stored name.
+ SqliteTableOperations resolvingTableOps =
+ new SqliteTableOperations() {
+ @Override
+ public String resolveTableName(String databaseName, String
normalizedName) {
+ Assertions.assertEquals("db", databaseName);
+ return "PHYSICAL_NAME".equals(normalizedName) ? "physical_Name" :
normalizedName;
+ }
+ };
+ try (JdbcCatalogOperations catalogOperations =
+ new JdbcCatalogOperations(
+ new SqliteExceptionConverter(),
+ new SqliteTypeConverter(),
+ new SqliteDatabaseOperations("/illegal/path"),
+ resolvingTableOps,
+ new SqliteColumnDefaultValueConverter())) {
+ NameIdentifier normalized = NameIdentifier.of("metalake", "catalog",
"db", "PHYSICAL_NAME");
+
+ NameIdentifier resolved = catalogOperations.resolveTableName(normalized);
+
+ // Delegates the leaf name to TableOperation and keeps the original
namespace.
+ Assertions.assertEquals("physical_Name", resolved.name());
+ Assertions.assertEquals(normalized.namespace(), resolved.namespace());
+
+ // When the backend returns the name unchanged, the same normalized
identifier is returned.
+ NameIdentifier unchanged = NameIdentifier.of("metalake", "catalog",
"db", "ALREADY_PHYSICAL");
+ Assertions.assertSame(unchanged,
catalogOperations.resolveTableName(unchanged));
+ }
+ }
}
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 e6323ab984..b6e784259b 100644
--- a/core/src/main/java/org/apache/gravitino/catalog/CapabilityHelpers.java
+++ b/core/src/main/java/org/apache/gravitino/catalog/CapabilityHelpers.java
@@ -26,6 +26,8 @@ import com.google.common.base.Preconditions;
import java.util.Arrays;
import org.apache.gravitino.NameIdentifier;
import org.apache.gravitino.Namespace;
+import org.apache.gravitino.connector.CatalogOperations;
+import org.apache.gravitino.connector.SupportsTableNameResolution;
import org.apache.gravitino.connector.capability.Capability;
import org.apache.gravitino.exceptions.NoSuchCatalogException;
import org.apache.gravitino.file.FilesetChange;
@@ -52,6 +54,48 @@ import org.apache.gravitino.rel.partitions.RangePartition;
public class CapabilityHelpers {
+ /**
+ * Maps a normalized table identifier to the identifier under which the
table is physically stored
+ * by the catalog's backend, when the catalog implements the connector-side
{@link
+ * SupportsTableNameResolution} capability; otherwise returns {@code
normalizedIdent} unchanged.
+ *
+ * <p>This is the single resolution path shared by the authorization layer
(so a request is
+ * authorized against the physical name) and the table dispatcher (so the
operation runs on the
+ * same physical name), which keeps the authorized identifier and the
operated-on identifier
+ * identical.
+ *
+ * <p>For the vast majority of catalogs — those that do not implement the
capability — this is a
+ * pure identity call: no differently-cased object is consulted, no extra
source access is made
+ * beyond the single {@code doWithCatalog} used to reach the catalog's
operations. Resolution is
+ * best-effort and takes no lock; a concurrent rename/recreate surfaces as
the normal {@code
+ * NoSuchTableException} from the subsequent locked operation.
+ *
+ * @param normalizedIdent the case-normalized table identifier
+ * @param catalogManager the catalog manager used to reach the catalog
operations
+ * @return the identifier under which the table is physically stored, or
{@code normalizedIdent}
+ * unchanged when the catalog does not opt in or no unambiguous mapping
applies
+ */
+ public static NameIdentifier resolvePhysicalTableName(
+ NameIdentifier normalizedIdent, CatalogManager catalogManager) {
+ NameIdentifier catalogIdent = getCatalogIdentifier(normalizedIdent);
+ try {
+ return catalogManager.doWithCatalog(
+ catalogIdent,
+ catalog -> {
+ CatalogOperations catalogOps = catalog.ops();
+ if (catalogOps instanceof SupportsTableNameResolution) {
+ return ((SupportsTableNameResolution)
catalogOps).resolveTableName(normalizedIdent);
+ }
+ return normalizedIdent;
+ });
+ } catch (NoSuchCatalogException e) {
+ throw e;
+ } catch (RuntimeException e) {
+ throw new RuntimeException(
+ "Failed to resolve physical table name for: " + normalizedIdent, e);
+ }
+ }
+
public static Capability getCapability(NameIdentifier ident, CatalogManager
catalogManager) {
NameIdentifier catalogIdent = getCatalogIdentifier(ident);
try {
diff --git
a/core/src/main/java/org/apache/gravitino/catalog/TableNormalizeDispatcher.java
b/core/src/main/java/org/apache/gravitino/catalog/TableNormalizeDispatcher.java
index 68544ba3cf..aeac691a37 100644
---
a/core/src/main/java/org/apache/gravitino/catalog/TableNormalizeDispatcher.java
+++
b/core/src/main/java/org/apache/gravitino/catalog/TableNormalizeDispatcher.java
@@ -21,6 +21,7 @@ package org.apache.gravitino.catalog;
import static org.apache.gravitino.catalog.CapabilityHelpers.applyCapabilities;
import static
org.apache.gravitino.catalog.CapabilityHelpers.applyCaseSensitive;
import static org.apache.gravitino.catalog.CapabilityHelpers.getCapability;
+import static
org.apache.gravitino.catalog.CapabilityHelpers.resolvePhysicalTableName;
import java.util.Map;
import org.apache.gravitino.NameIdentifier;
@@ -62,7 +63,8 @@ public class TableNormalizeDispatcher implements
TableDispatcher {
public Table loadTable(NameIdentifier ident) throws NoSuchTableException {
// The constraints of the name spec may be more strict than underlying
catalog,
// and for compatibility reasons, we only apply case-sensitive
capabilities here.
- return dispatcher.loadTable(normalizeCaseSensitive(ident));
+ return dispatcher.loadTable(
+ resolvePhysicalTableName(normalizeCaseSensitive(ident),
catalogManager));
}
@Override
@@ -95,24 +97,28 @@ public class TableNormalizeDispatcher implements
TableDispatcher {
return dispatcher.alterTable(
// The constraints of the name spec may be more strict than underlying
catalog,
// and for compatibility reasons, we only apply case-sensitive
capabilities here.
- normalizeCaseSensitive(ident), applyCapabilities(capability, changes));
+ resolvePhysicalTableName(normalizeCaseSensitive(ident),
catalogManager),
+ applyCapabilities(capability, changes));
}
@Override
public boolean dropTable(NameIdentifier ident) {
- return dispatcher.dropTable(normalizeNameIdentifier(ident));
+ return dispatcher.dropTable(
+ resolvePhysicalTableName(normalizeNameIdentifier(ident),
catalogManager));
}
@Override
public boolean purgeTable(NameIdentifier ident) throws
UnsupportedOperationException {
- return dispatcher.purgeTable(normalizeNameIdentifier(ident));
+ return dispatcher.purgeTable(
+ resolvePhysicalTableName(normalizeNameIdentifier(ident),
catalogManager));
}
@Override
public boolean tableExists(NameIdentifier ident) {
// The constraints of the name spec may be more strict than underlying
catalog,
// and for compatibility reasons, we only apply case-sensitive
capabilities here.
- return dispatcher.tableExists(normalizeCaseSensitive(ident));
+ return dispatcher.tableExists(
+ resolvePhysicalTableName(normalizeCaseSensitive(ident),
catalogManager));
}
private Namespace normalizeCaseSensitive(Namespace namespace) {
diff --git
a/core/src/main/java/org/apache/gravitino/connector/SupportsTableNameResolution.java
b/core/src/main/java/org/apache/gravitino/connector/SupportsTableNameResolution.java
new file mode 100644
index 0000000000..285a557acd
--- /dev/null
+++
b/core/src/main/java/org/apache/gravitino/connector/SupportsTableNameResolution.java
@@ -0,0 +1,80 @@
+/*
+ * 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.connector;
+
+import org.apache.gravitino.NameIdentifier;
+import org.apache.gravitino.annotation.Evolving;
+import org.apache.gravitino.rel.TableCatalog;
+
+/**
+ * A server-internal, connector-side capability that maps a normalized table
identifier to the
+ * identifier under which the table is physically stored by the underlying
source, for backends
+ * whose name normalization is not reversible.
+ *
+ * <p>Most catalogs store a table under exactly the name Gravitino normalized
it to, so they do not
+ * implement this. A catalog whose {@link
org.apache.gravitino.connector.capability.Capability}
+ * folds an unquoted name to a fixed case while the source also keeps
case-sensitive names created
+ * with a different case (so a name returned by {@link
TableCatalog#listTables} may not equal the
+ * normalized name) may implement this so that a name returned by list
round-trips through
+ * load/alter/drop.
+ *
+ * <p>This is a {@link CatalogOperations} mixin, not part of the user-facing
{@link TableCatalog}
+ * API: it is only consulted by the server. It is applied both when
authorizing a request and when
+ * running the operation, through the same shared resolution path, so the
identifier that is
+ * authorized is the identifier that is operated on.
+ *
+ * <p><b>Resolution contract.</b> Given the normalized identifier, an
implementation must:
+ *
+ * <ul>
+ * <li>return {@code normalizedIdent} unchanged if a table with that exact
stored name exists (the
+ * common case; no differently-cased object is consulted);
+ * <li>otherwise, if exactly one stored name matches {@code normalizedIdent}
case-insensitively,
+ * return that stored name;
+ * <li>otherwise — no match, or several case-insensitive matches with no
exact match — return
+ * {@code normalizedIdent} unchanged, so the operation proceeds against
the normalized name
+ * and the usual not-found behavior surfaces. An ambiguous name must
never be resolved to an
+ * arbitrary object.
+ * </ul>
+ *
+ * <p>The identifier passed in is the normalized one, not the caller's
original spelling, because a
+ * case-folding capability already encodes the caller's case intent in the
normalized name: an
+ * unquoted {@code foo} normalizes to the folded form (say {@code FOO}) and a
quoted {@code "foo"}
+ * normalizes to {@code foo}. Matching an unquoted request case-sensitively
would contradict the
+ * capability's own folding and could authorize one object while operating on
another.
+ *
+ * <p>Implementations must be side-effect free, must not open connections
beyond what the catalog
+ * already holds, and must not throw when the table is absent (they return
{@code normalizedIdent}
+ * so callers such as {@code tableExists} and {@code dropTable} keep their
boolean not-found
+ * semantics). Resolution is best-effort: it runs before the server acquires
its per-table lock, so
+ * a concurrent rename/recreate simply surfaces as the normal {@code
NoSuchTableException} from the
+ * subsequent locked operation, never as an action on a different object.
+ */
+@Evolving
+public interface SupportsTableNameResolution {
+
+ /**
+ * Resolves a normalized table identifier to the identifier under which the
table is physically
+ * stored.
+ *
+ * @param normalizedIdent The table identifier after Gravitino's case
normalization.
+ * @return The identifier under which the table is physically stored, or
{@code normalizedIdent}
+ * unchanged when no unambiguous mapping applies.
+ */
+ NameIdentifier resolveTableName(NameIdentifier normalizedIdent);
+}
diff --git
a/core/src/test/java/org/apache/gravitino/catalog/TestTableNormalizeDispatcher.java
b/core/src/test/java/org/apache/gravitino/catalog/TestTableNormalizeDispatcher.java
index ae4685e351..08f5a6c5fe 100644
---
a/core/src/test/java/org/apache/gravitino/catalog/TestTableNormalizeDispatcher.java
+++
b/core/src/test/java/org/apache/gravitino/catalog/TestTableNormalizeDispatcher.java
@@ -29,6 +29,9 @@ import org.apache.gravitino.NameIdentifier;
import org.apache.gravitino.Namespace;
import org.apache.gravitino.TestColumn;
import org.apache.gravitino.connector.BaseCatalog;
+import org.apache.gravitino.connector.CatalogOperations;
+import org.apache.gravitino.connector.SupportsTableNameResolution;
+import org.apache.gravitino.exceptions.NoSuchCatalogException;
import org.apache.gravitino.exceptions.TableAlreadyExistsException;
import org.apache.gravitino.rel.Column;
import org.apache.gravitino.rel.Table;
@@ -295,4 +298,175 @@ public class TestTableNormalizeDispatcher extends
TestOperationDispatcher {
Assertions.assertEquals(
expectedColumns[0].name().toLowerCase(),
table.index()[0].fieldNames()[0][0].toLowerCase());
}
+
+ @Test
+ public void testPhysicalNameResolutionDrivesDownstreamIdentifier() throws
Exception {
+ // When the catalog ops implements SupportsTableNameResolution and maps a
normalized name to a
+ // differently-stored physical name, TableNormalizeDispatcher must hand
that resolved name to
+ // the downstream dispatcher for load/alter/drop/purge/exists. Resolving
above the hook and
+ // operation layers means the same resolved identifier drives the
authorization hooks, the
+ // catalog call and the entity store key.
+ Namespace tableNs = Namespace.of(metalake, catalog, "schema");
+ // An unquoted request folds to uppercase PHYSICAL_NAME; the resolver maps
it to the stored
+ // case-sensitive "physical_Name".
+ NameIdentifier requested = NameIdentifier.of(tableNs, "physical_name");
+ NameIdentifier resolved = NameIdentifier.of(tableNs, "physical_Name");
+
+ TableDispatcher mockDispatcher = Mockito.mock(TableDispatcher.class);
+
Mockito.when(mockDispatcher.loadTable(Mockito.any())).thenReturn(Mockito.mock(Table.class));
+
Mockito.when(mockDispatcher.alterTable(Mockito.any())).thenReturn(Mockito.mock(Table.class));
+ Mockito.when(mockDispatcher.dropTable(Mockito.any())).thenReturn(true);
+ Mockito.when(mockDispatcher.purgeTable(Mockito.any())).thenReturn(true);
+ Mockito.when(mockDispatcher.tableExists(Mockito.any())).thenReturn(true);
+
+ // Resolver maps normalized "PHYSICAL_NAME" -> stored "physical_Name";
identity otherwise.
+ SupportsTableNameResolution resolver =
+ normalizedIdent ->
+ "PHYSICAL_NAME".equals(normalizedIdent.name()) ? resolved :
normalizedIdent;
+ TableNormalizeDispatcher dispatcher =
+ newResolvingDispatcher(mockDispatcher, resolvingCatalogOps(resolver));
+
+ dispatcher.loadTable(requested);
+ dispatcher.alterTable(requested, TableChange.rename("x"));
+ dispatcher.dropTable(requested);
+ dispatcher.purgeTable(requested);
+ dispatcher.tableExists(requested);
+
+ assertDispatchedName(mockDispatcher, "physical_Name");
+ }
+
+ @Test
+ public void testPhysicalNameResolutionReceivesNormalizedName() throws
Exception {
+ // The resolver receives the normalized identifier (a quoted "MixedCase"
normalizes to the
+ // unquoted, case-preserved MixedCase), which already encodes the caller's
case intent.
+ Namespace tableNs = Namespace.of(metalake, catalog, "schema");
+ NameIdentifier requested = NameIdentifier.of(tableNs, "\"MixedCase\"");
+
+ TableDispatcher mockDispatcher = Mockito.mock(TableDispatcher.class);
+
Mockito.when(mockDispatcher.loadTable(Mockito.any())).thenReturn(Mockito.mock(Table.class));
+
+ SupportsTableNameResolution resolver =
+ normalizedIdent -> {
+ Assertions.assertEquals("MixedCase", normalizedIdent.name());
+ return normalizedIdent;
+ };
+ TableNormalizeDispatcher dispatcher =
+ newResolvingDispatcher(mockDispatcher, resolvingCatalogOps(resolver));
+
+ dispatcher.loadTable(requested);
+ ArgumentCaptor<NameIdentifier> captor =
ArgumentCaptor.forClass(NameIdentifier.class);
+ Mockito.verify(mockDispatcher).loadTable(captor.capture());
+ Assertions.assertEquals("MixedCase", captor.getValue().name());
+ }
+
+ @Test
+ public void testPhysicalNameResolutionAmbiguousKeepsNormalized() throws
Exception {
+ // A resolver that cannot disambiguate returns the normalized name; the
dispatcher passes it
+ // through unchanged so the usual not-found behavior surfaces.
+ Namespace tableNs = Namespace.of(metalake, catalog, "schema");
+ NameIdentifier requested = NameIdentifier.of(tableNs, "\"ambiguous\"");
+
+ TableDispatcher mockDispatcher = Mockito.mock(TableDispatcher.class);
+ Mockito.when(mockDispatcher.dropTable(Mockito.any())).thenReturn(false);
+ Mockito.when(mockDispatcher.tableExists(Mockito.any())).thenReturn(false);
+
+ SupportsTableNameResolution resolver =
+ normalizedIdent -> normalizedIdent; // keep normalized
(ambiguous/absent)
+ TableNormalizeDispatcher dispatcher =
+ newResolvingDispatcher(mockDispatcher, resolvingCatalogOps(resolver));
+
+ // Not-found keeps boolean semantics (never throws from resolution).
+ Assertions.assertFalse(dispatcher.dropTable(requested));
+ Assertions.assertFalse(dispatcher.tableExists(requested));
+ ArgumentCaptor<NameIdentifier> captor =
ArgumentCaptor.forClass(NameIdentifier.class);
+ Mockito.verify(mockDispatcher).dropTable(captor.capture());
+ Assertions.assertEquals("ambiguous", captor.getValue().name());
+ }
+
+ @Test
+ public void testPhysicalNameResolutionNoCapabilityIsIdentity() throws
Exception {
+ // A catalog whose ops does not implement SupportsTableNameResolution
leaves the normalized
+ // identifier unchanged -- no behavior change for the vast majority of
catalogs.
+ Namespace tableNs = Namespace.of(metalake, catalog, "schema");
+ NameIdentifier requested = NameIdentifier.of(tableNs, "\"MixedCase\"");
+
+ TableDispatcher mockDispatcher = Mockito.mock(TableDispatcher.class);
+
Mockito.when(mockDispatcher.loadTable(Mockito.any())).thenReturn(Mockito.mock(Table.class));
+
+ // ops() returns a plain CatalogOperations that is NOT a
SupportsTableNameResolution.
+ TableNormalizeDispatcher dispatcher =
+ newResolvingDispatcher(mockDispatcher,
Mockito.mock(CatalogOperations.class));
+
+ dispatcher.loadTable(requested);
+ ArgumentCaptor<NameIdentifier> captor =
ArgumentCaptor.forClass(NameIdentifier.class);
+ Mockito.verify(mockDispatcher).loadTable(captor.capture());
+ // Quote-aware capability unquotes to the preserved "MixedCase";
resolution is a no-op.
+ Assertions.assertEquals("MixedCase", captor.getValue().name());
+ }
+
+ @Test
+ public void testPhysicalNameResolutionPropagatesNoSuchCatalog() {
+ Namespace tableNs = Namespace.of(metalake, catalog, "schema");
+ NameIdentifier requested = NameIdentifier.of(tableNs, "t");
+
+ TableDispatcher mockDispatcher = Mockito.mock(TableDispatcher.class);
+ CatalogManager mockCatalogManager = Mockito.mock(CatalogManager.class);
+ Mockito.when(mockCatalogManager.doWithCatalog(Mockito.any(),
Mockito.any()))
+ .thenThrow(new NoSuchCatalogException("no catalog"));
+
+ TableNormalizeDispatcher dispatcher =
+ new TableNormalizeDispatcher(mockDispatcher, mockCatalogManager);
+
+ Assertions.assertThrows(NoSuchCatalogException.class, () ->
dispatcher.loadTable(requested));
+ }
+
+ private void assertDispatchedName(TableDispatcher mockDispatcher, String
expected)
+ throws Exception {
+ ArgumentCaptor<NameIdentifier> loadCaptor =
ArgumentCaptor.forClass(NameIdentifier.class);
+ Mockito.verify(mockDispatcher).loadTable(loadCaptor.capture());
+ Assertions.assertEquals(expected, loadCaptor.getValue().name());
+
+ ArgumentCaptor<NameIdentifier> alterCaptor =
ArgumentCaptor.forClass(NameIdentifier.class);
+ Mockito.verify(mockDispatcher).alterTable(alterCaptor.capture(),
Mockito.any());
+ Assertions.assertEquals(expected, alterCaptor.getValue().name());
+
+ ArgumentCaptor<NameIdentifier> dropCaptor =
ArgumentCaptor.forClass(NameIdentifier.class);
+ Mockito.verify(mockDispatcher).dropTable(dropCaptor.capture());
+ Assertions.assertEquals(expected, dropCaptor.getValue().name());
+
+ ArgumentCaptor<NameIdentifier> purgeCaptor =
ArgumentCaptor.forClass(NameIdentifier.class);
+ Mockito.verify(mockDispatcher).purgeTable(purgeCaptor.capture());
+ Assertions.assertEquals(expected, purgeCaptor.getValue().name());
+
+ ArgumentCaptor<NameIdentifier> existsCaptor =
ArgumentCaptor.forClass(NameIdentifier.class);
+ Mockito.verify(mockDispatcher).tableExists(existsCaptor.capture());
+ Assertions.assertEquals(expected, existsCaptor.getValue().name());
+ }
+
+ /**
+ * Builds a TableNormalizeDispatcher whose (mocked) catalog uses a
quote-aware capability and
+ * whose ops is the given CatalogOperations.
+ */
+ private TableNormalizeDispatcher newResolvingDispatcher(
+ TableDispatcher downstream, CatalogOperations ops) {
+ CatalogManager mockCatalogManager = Mockito.mock(CatalogManager.class);
+ BaseCatalog<?> mockCatalog = Mockito.mock(BaseCatalog.class);
+
Mockito.when(mockCatalog.capability()).thenReturn(TestCapabilityHelpers.QUOTE_AWARE_CAPABILITY);
+ Mockito.when(mockCatalog.ops()).thenReturn(ops);
+ CatalogTestUtils.mockDoWithCatalog(mockCatalogManager, mockCatalog);
+ return new TableNormalizeDispatcher(downstream, mockCatalogManager);
+ }
+
+ /**
+ * A CatalogOperations that also implements SupportsTableNameResolution with
the given resolver.
+ */
+ private CatalogOperations resolvingCatalogOps(SupportsTableNameResolution
resolver) {
+ CatalogOperations ops =
+ Mockito.mock(
+ CatalogOperations.class,
+
Mockito.withSettings().extraInterfaces(SupportsTableNameResolution.class));
+ Mockito.when(((SupportsTableNameResolution)
ops).resolveTableName(Mockito.any()))
+ .thenAnswer(invocation ->
resolver.resolveTableName(invocation.getArgument(0)));
+ return ops;
+ }
}
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 eb280baca5..aace2c1063 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
@@ -103,11 +103,22 @@ public class MetadataIdConverter {
}
Capability capability = CapabilityHelpers.getCapability(ident,
catalogManager);
+
if (scope == Capability.Scope.SEMANTIC_MODEL) {
return NameIdentifier.of(
CapabilityHelpers.applyCaseSensitive(ident.namespace(), scope,
capability), ident.name());
}
- return CapabilityHelpers.applyCaseSensitive(ident, scope, capability);
+
+ 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;
}
private static Long extractIdFromEntity(Entity entity) {
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 7348bd29a6..c6225e1be1 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
@@ -22,9 +22,11 @@ package org.apache.gravitino.server.authorization;
import static org.mockito.Answers.CALLS_REAL_METHODS;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.when;
+import static org.mockito.Mockito.withSettings;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
@@ -42,6 +44,8 @@ import org.apache.gravitino.NameIdentifier;
import org.apache.gravitino.Namespace;
import org.apache.gravitino.catalog.CatalogManager;
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.NoSuchCatalogException;
@@ -291,6 +295,55 @@ public class TestMetadataIdConverter {
}
}
+ @Test
+ void testTableAuthorizationResolvesPhysicalNameSameAsDispatcher() {
+ // The authorization path must resolve a table to the same physical name
the dispatcher operates
+ // on. Here the catalog's ops implements SupportsTableNameResolution and
maps the normalized
+ // name T_NORM to the stored physical name t_phys; normalizeCaseSensitive
(used by getID for
+ // authorization) must return t_phys, so authorization and the operation
target the same table.
+ NameIdentifier tableIdent = NameIdentifier.of("metalake", "catalog",
"schema", "T_NORM");
+ NameIdentifier physical = NameIdentifier.of("metalake", "catalog",
"schema", "t_phys");
+
+ CatalogManager mockCatalogManager = mock(CatalogManager.class);
+
+ // A capability that leaves the name unchanged, to isolate the resolution
step.
+ Capability identityCapability = new Capability() {};
+ 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(identityCapability);
+ when(mockCatalog.ops()).thenReturn(resolvingOps);
+
+ // doWithCatalog runs the callback with the mock catalog (used by both
getCapability and the
+ // shared resolver inside normalizeCaseSensitive).
+ doAnswer(
+ invocation -> {
+ org.apache.gravitino.utils.ThrowableFunction<BaseCatalog,
Object> fn =
+ invocation.getArgument(1);
+ return fn.apply(mockCatalog);
+ })
+ .when(mockCatalogManager)
+ .doWithCatalog(any(), any());
+
+ NameIdentifier authorized =
+ MetadataIdConverter.normalizeCaseSensitive(
+ tableIdent, Capability.Scope.TABLE, mockCatalogManager);
+
+ Assertions.assertEquals(
+ "t_phys",
+ authorized.name(),
+ "authorization must resolve to the physical name the dispatcher
operates on");
+ Assertions.assertEquals(physical, authorized);
+ }
+
private void initTestNameIdentifier() {
ident1 = NameIdentifier.of("metalake");
ident2 = NameIdentifier.of("metalake", "catalog");