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");

Reply via email to