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

FANNG1 pushed a commit to branch branch-1.3
in repository https://gitbox.apache.org/repos/asf/gravitino.git


The following commit(s) were added to refs/heads/branch-1.3 by this push:
     new 54cb6c254e [Cherry-pick to branch-1.3] [#10477] fix(flink-paimon):  
Paimon FlinkCatalog to fix Hive partition updates (#10475) (#11439)
54cb6c254e is described below

commit 54cb6c254ef4fb91df01806499523df007d3b468
Author: github-actions[bot] 
<41898282+github-actions[bot]@users.noreply.github.com>
AuthorDate: Fri Jun 5 10:21:47 2026 +0800

    [Cherry-pick to branch-1.3] [#10477] fix(flink-paimon):  Paimon 
FlinkCatalog to fix Hive partition updates (#10475) (#11439)
    
    **Cherry-pick Information:**
    - Original commit: 1bf667dd39f71b200600772de87a85e49119acc1
    - Target branch: `branch-1.3`
    - Status: ✅ Clean cherry-pick (no conflicts)
    
    Co-authored-by: wangxiaojing <[email protected]>
    Co-authored-by: wangxiaojing <[email protected]>
    Co-authored-by: Qi Yu <[email protected]>
---
 .../flink/connector/catalog/BaseCatalog.java       |  71 +++-
 .../connector/paimon/GravitinoPaimonCatalog.java   | 130 +++++-
 .../paimon/TestGravitinoPaimonCatalog.java         | 444 +++++++++++++++++++++
 3 files changed, 623 insertions(+), 22 deletions(-)

diff --git 
a/flink-connector/flink-common/src/main/java/org/apache/gravitino/flink/connector/catalog/BaseCatalog.java
 
b/flink-connector/flink-common/src/main/java/org/apache/gravitino/flink/connector/catalog/BaseCatalog.java
index 0d781fb631..9d9d407468 100644
--- 
a/flink-connector/flink-common/src/main/java/org/apache/gravitino/flink/connector/catalog/BaseCatalog.java
+++ 
b/flink-connector/flink-common/src/main/java/org/apache/gravitino/flink/connector/catalog/BaseCatalog.java
@@ -261,6 +261,8 @@ public abstract class BaseCatalog extends AbstractCatalog {
       // Treat authorization failure as table-not-exist to allow Calcite to 
fall back to
       // alternative resolution paths (e.g., treating the name as a schema).
       throw new TableNotExistException(catalogName(), tablePath, e);
+    } catch (CatalogException e) {
+      throw e;
     } catch (Exception e) {
       LOG.warn("Failed to load table {} from catalog {}", ident, 
catalogName(), e);
       throw new CatalogException(e);
@@ -320,6 +322,9 @@ public abstract class BaseCatalog extends AbstractCatalog {
       if (!tableDropped && !viewDropped && !ignoreIfNotExists) {
         throw new TableNotExistException(catalogName(), tablePath);
       }
+      if (tableDropped) {
+        invalidateTable(tablePath);
+      }
     } catch (TableNotExistException e) {
       throw e;
     } catch (Exception e) {
@@ -336,6 +341,8 @@ public abstract class BaseCatalog extends AbstractCatalog {
 
     try {
       catalog().asTableCatalog().alterTable(srcIdent, 
TableChange.rename(newTableName));
+      // Invalidate native catalog cache after successful rename
+      invalidateTable(tablePath);
       return;
     } catch (NoSuchTableException ignored) {
       // source is not a table, try as a view below
@@ -510,6 +517,8 @@ public abstract class BaseCatalog extends AbstractCatalog {
       catalog()
           .asTableCatalog()
           .alterTable(identifier, getGravitinoTableChanges(existingTable, 
newTable));
+      // Invalidate native catalog cache after successful alter
+      invalidateTable(tablePath);
     }
   }
 
@@ -556,6 +565,8 @@ public abstract class BaseCatalog extends AbstractCatalog {
       }
     } else {
       catalog().asTableCatalog().alterTable(identifier, 
getGravitinoTableChanges(tableChanges));
+      // Invalidate native catalog cache after successful alter
+      invalidateTable(tablePath);
     }
   }
 
@@ -714,6 +725,40 @@ public abstract class BaseCatalog extends AbstractCatalog {
     throw new UnsupportedOperationException();
   }
 
+  /**
+   * Invalidates cached table metadata in the native Flink catalog after DDL 
operations.
+   *
+   * <p>Connectors that maintain an internal native catalog cache (e.g. 
Paimon's {@code
+   * CachingCatalog}) must override {@link #invalidateNativeTableCache} to 
clear stale entries. This
+   * method calls {@code invalidateNativeTableCache} with a best-effort 
approach — failures are
+   * logged at DEBUG level and do not abort the DDL. It is always called 
<em>after</em> the
+   * Gravitino DDL has succeeded, so the cache is only evicted once the source 
of truth has already
+   * been updated.
+   *
+   * @param tablePath the table whose native cache entry should be dropped
+   */
+  protected void invalidateTable(ObjectPath tablePath) {
+    try {
+      invalidateNativeTableCache(tablePath);
+    } catch (Exception e) {
+      LOG.debug(
+          "Failed to invalidate native catalog cache for table {} in catalog 
{}",
+          tablePath,
+          catalogName(),
+          e);
+    }
+  }
+
+  /**
+   * Invalidates the native catalog cache for the given table. Default is a 
no-op.
+   *
+   * <p>Subclasses with an internal native catalog that caches table metadata 
(e.g. Paimon, Iceberg)
+   * should override this method to evict the stale entry after a DDL 
operation completes.
+   *
+   * @param tablePath the table whose native cache entry should be dropped
+   */
+  protected void invalidateNativeTableCache(ObjectPath tablePath) {}
+
   protected CatalogBaseTable toFlinkTable(Table table, ObjectPath tablePath) {
     org.apache.flink.table.api.Schema.Builder builder = 
buildSchemaFromColumns(table.columns());
     Optional<List<String>> flinkPrimaryKey = getFlinkPrimaryKey(table);
@@ -724,7 +769,31 @@ public abstract class BaseCatalog extends AbstractCatalog {
                 catalogOptions, table.properties(), tablePath));
     
flinkTableProperties.putAll(fromGravitinoDistribution(table.distribution()));
     List<String> partitionKeys = 
partitionConverter.toFlinkPartitionKeys(table.partitioning());
-    return newCatalogTable(builder.build(), table.comment(), partitionKeys, 
flinkTableProperties);
+    CatalogTable baseTable =
+        newCatalogTable(builder.build(), table.comment(), partitionKeys, 
flinkTableProperties);
+    return enrichCatalogTable(baseTable, tablePath);
+  }
+
+  /**
+   * Hook for subclasses to enrich or replace the plain {@link CatalogTable} 
built from Gravitino
+   * metadata with a connector-native representation.
+   *
+   * <p>The default implementation returns the table unchanged. 
Connector-specific subclasses (e.g.
+   * {@code GravitinoPaimonCatalog}) can override this method to return a 
native table object that
+   * carries additional runtime context required by the underlying engine — 
for example, Paimon's
+   * {@code DataCatalogTable} with a non-null {@code CatalogEnvironment} that 
enables {@code
+   * AddPartitionCommitCallback} registration on write.
+   *
+   * <p>This hook is called <em>after</em> Gravitino authorization has already 
been enforced in
+   * {@link #getTable(ObjectPath)}, so implementations do not need to repeat 
auth checks.
+   *
+   * @param table the plain {@link CatalogTable} built from Gravitino metadata
+   * @param tablePath the object path of the table
+   * @return the (possibly enriched) {@link CatalogBaseTable} to return to 
Flink
+   * @throws CatalogException if enrichment fails due to a catalog-level error
+   */
+  protected CatalogBaseTable enrichCatalogTable(CatalogTable table, ObjectPath 
tablePath) {
+    return table;
   }
 
   protected CatalogTable newCatalogTable(
diff --git 
a/flink-connector/flink-common/src/main/java/org/apache/gravitino/flink/connector/paimon/GravitinoPaimonCatalog.java
 
b/flink-connector/flink-common/src/main/java/org/apache/gravitino/flink/connector/paimon/GravitinoPaimonCatalog.java
index 77ac4806c4..3776b02da3 100644
--- 
a/flink-connector/flink-common/src/main/java/org/apache/gravitino/flink/connector/paimon/GravitinoPaimonCatalog.java
+++ 
b/flink-connector/flink-common/src/main/java/org/apache/gravitino/flink/connector/paimon/GravitinoPaimonCatalog.java
@@ -28,6 +28,8 @@ import java.util.Optional;
 import java.util.stream.Collectors;
 import org.apache.commons.lang3.StringUtils;
 import org.apache.flink.table.catalog.AbstractCatalog;
+import org.apache.flink.table.catalog.CatalogBaseTable;
+import org.apache.flink.table.catalog.CatalogTable;
 import org.apache.flink.table.catalog.ObjectPath;
 import org.apache.flink.table.catalog.exceptions.CatalogException;
 import org.apache.flink.table.catalog.exceptions.TableNotExistException;
@@ -43,11 +45,21 @@ import org.apache.gravitino.rel.expressions.NamedReference;
 import org.apache.gravitino.rel.expressions.distributions.Distribution;
 import org.apache.gravitino.rel.expressions.distributions.Distributions;
 import org.apache.gravitino.rel.expressions.distributions.Strategy;
+import org.apache.paimon.catalog.Identifier;
+import org.apache.paimon.flink.FlinkCatalog;
 import org.apache.paimon.flink.FlinkCatalogFactory;
 
 /**
  * The GravitinoPaimonCatalog class is an implementation of the BaseCatalog 
class that is used to
  * proxy the PaimonCatalog class.
+ *
+ * <p>DDL operations (CREATE / ALTER / DROP) are routed through the Gravitino 
REST API, keeping
+ * Gravitino as the single source of truth for metadata. The internal {@code 
paimonCatalog} is used
+ * only by {@link #enrichCatalogTable} so that {@code getTable()} returns 
Paimon's native {@code
+ * DataCatalogTable} — which carries a fully-initialised {@code 
CatalogEnvironment} (non-null {@code
+ * catalogLoader}). Without this, the {@code AddPartitionCommitCallback} that 
syncs new partitions
+ * to Hive Metastore is never registered, causing {@code SHOW PARTITIONS} to 
return empty results
+ * even when {@code metastore.partitioned-table=true}.
  */
 public class GravitinoPaimonCatalog extends BaseCatalog {
 
@@ -68,37 +80,28 @@ public class GravitinoPaimonCatalog extends BaseCatalog {
     this.paimonCatalog = flinkCatalogFactory.createCatalog(context);
   }
 
-  @Override
-  protected AbstractCatalog realCatalog() {
-    return paimonCatalog;
-  }
+  // 
---------------------------------------------------------------------------
+  // Lifecycle — keep paimonCatalog in sync with the outer catalog
+  // 
---------------------------------------------------------------------------
 
   @Override
-  public void dropTable(ObjectPath tablePath, boolean ignoreIfNotExists)
-      throws TableNotExistException, CatalogException {
-    boolean dropped =
-        catalog()
-            .asTableCatalog()
-            .purgeTable(NameIdentifier.of(tablePath.getDatabaseName(), 
tablePath.getObjectName()));
-    if (!dropped && !ignoreIfNotExists) {
-      throw new TableNotExistException(catalogName(), tablePath);
-    }
+  public void open() throws CatalogException {
+    super.open(); // opens realCatalog() == paimonCatalog, so 
paimonCatalog.open() is called here
   }
 
   @Override
-  public Optional<Factory> getFactory() {
-    return paimonCatalog.getFactory();
+  public void close() throws CatalogException {
+    super.close(); // closes realCatalog() == paimonCatalog
   }
 
   @Override
-  protected Distribution toGravitinoDistribution(Map<String, String> 
properties) {
-    return getDistribution(properties);
+  protected AbstractCatalog realCatalog() {
+    return paimonCatalog;
   }
 
-  @Override
-  protected Map<String, String> fromGravitinoDistribution(Distribution 
distribution) {
-    return distributionToProperties(distribution);
-  }
+  // 
---------------------------------------------------------------------------
+  // DDL — route through Gravitino (single source of truth)
+  // 
---------------------------------------------------------------------------
 
   @VisibleForTesting
   static Map<String, String> distributionToProperties(Distribution 
distribution) {
@@ -132,6 +135,10 @@ public class GravitinoPaimonCatalog extends BaseCatalog {
     return properties;
   }
 
+  // 
---------------------------------------------------------------------------
+  // getTable enrichment — return Paimon-native DataCatalogTable
+  // 
---------------------------------------------------------------------------
+
   @VisibleForTesting
   static Distribution getDistribution(Map<String, String> properties) {
     if (properties == null) {
@@ -175,4 +182,85 @@ public class GravitinoPaimonCatalog extends BaseCatalog {
           e);
     }
   }
+
+  @Override
+  public Optional<Factory> getFactory() {
+    return paimonCatalog.getFactory();
+  }
+
+  @Override
+  public void dropTable(ObjectPath tablePath, boolean ignoreIfNotExists)
+      throws TableNotExistException, CatalogException {
+    boolean dropped =
+        catalog()
+            .asTableCatalog()
+            .purgeTable(NameIdentifier.of(tablePath.getDatabaseName(), 
tablePath.getObjectName()));
+    if (!dropped && !ignoreIfNotExists) {
+      throw new TableNotExistException(catalogName(), tablePath);
+    }
+    if (dropped) {
+      invalidateNativeTableCache(tablePath);
+    }
+  }
+
+  @Override
+  protected Distribution toGravitinoDistribution(Map<String, String> 
properties) {
+    return getDistribution(properties);
+  }
+
+  @Override
+  protected Map<String, String> fromGravitinoDistribution(Distribution 
distribution) {
+    return distributionToProperties(distribution);
+  }
+
+  /**
+   * Returns the Paimon-native {@code DataCatalogTable} for {@code tablePath}.
+   *
+   * <p>{@link BaseCatalog#getTable} has already verified via the Gravitino 
REST API that (a) the
+   * caller is authorised and (b) the table exists before this hook is 
invoked. Therefore, a {@link
+   * TableNotExistException} from {@code paimonCatalog.getTable()} indicates a 
metadata
+   * inconsistency between Gravitino and the underlying Paimon store.
+   *
+   * <p>The returned {@code DataCatalogTable} wraps a {@code FileStoreTable} 
whose {@code
+   * CatalogEnvironment} holds a valid {@code catalogLoader}. Paimon's write 
path uses this to
+   * register {@code AddPartitionCommitCallback}, which in turn calls the Hive 
Metastore to record
+   * new partitions after each checkpoint commit.
+   */
+  @Override
+  protected CatalogBaseTable enrichCatalogTable(CatalogTable ignoredBaseTable, 
ObjectPath tablePath)
+      throws CatalogException {
+    try {
+      return realCatalog().getTable(tablePath);
+    } catch (TableNotExistException e) {
+      throw new CatalogException(
+          String.format(
+              "Table '%s.%s' was found in Gravitino but is absent from the 
underlying Paimon "
+                  + "catalog '%s'. The two metadata stores may be out of 
sync.",
+              tablePath.getDatabaseName(), tablePath.getObjectName(), 
catalogName()),
+          e);
+    }
+  }
+
+  /**
+   * Invalidates the Paimon native catalog cache for the given table.
+   *
+   * <p>When Paimon is initialised with a {@code CachingCatalog} (the default 
in production), table
+   * and partition metadata is held in an in-memory cache. After DDL 
operations routed through
+   * Gravitino (drop / rename / alter), the stale cache entry must be evicted 
so that the next
+   * {@code getTable()} call picks up fresh metadata from the Hive Metastore 
or filesystem.
+   *
+   * <p>This implementation casts {@link #paimonCatalog} to {@link 
FlinkCatalog} and delegates to
+   * {@code FlinkCatalog.catalog().invalidateTable(Identifier)}, which 
forwards to {@link
+   * org.apache.paimon.catalog.CachingCatalog#invalidateTable} when caching is 
enabled. The call is
+   * a no-op when the underlying catalog does not cache (e.g. in tests or 
filesystem mode).
+   */
+  @Override
+  protected void invalidateNativeTableCache(ObjectPath tablePath) {
+    AbstractCatalog nativeCatalog = realCatalog();
+    if (nativeCatalog instanceof FlinkCatalog) {
+      Identifier identifier =
+          Identifier.create(tablePath.getDatabaseName(), 
tablePath.getObjectName());
+      ((FlinkCatalog) nativeCatalog).catalog().invalidateTable(identifier);
+    }
+  }
 }
diff --git 
a/flink-connector/flink-common/src/test/java/org/apache/gravitino/flink/connector/paimon/TestGravitinoPaimonCatalog.java
 
b/flink-connector/flink-common/src/test/java/org/apache/gravitino/flink/connector/paimon/TestGravitinoPaimonCatalog.java
new file mode 100644
index 0000000000..1df9150b4e
--- /dev/null
+++ 
b/flink-connector/flink-common/src/test/java/org/apache/gravitino/flink/connector/paimon/TestGravitinoPaimonCatalog.java
@@ -0,0 +1,444 @@
+/*
+ * 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.flink.connector.paimon;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+import org.apache.flink.configuration.Configuration;
+import org.apache.flink.configuration.ReadableConfig;
+import org.apache.flink.table.api.DataTypes;
+import org.apache.flink.table.catalog.AbstractCatalog;
+import org.apache.flink.table.catalog.CatalogBaseTable;
+import org.apache.flink.table.catalog.CatalogTable;
+import org.apache.flink.table.catalog.ObjectPath;
+import org.apache.flink.table.catalog.exceptions.CatalogException;
+import org.apache.flink.table.catalog.exceptions.TableNotExistException;
+import org.apache.flink.table.factories.CatalogFactory;
+import org.apache.gravitino.Catalog;
+import org.apache.gravitino.flink.connector.DefaultPartitionConverter;
+import org.apache.gravitino.flink.connector.catalog.BaseCatalog;
+import org.apache.gravitino.rel.Table;
+import org.apache.gravitino.rel.TableCatalog;
+import org.apache.gravitino.rel.expressions.transforms.Transforms;
+import org.apache.gravitino.rel.indexes.Index;
+import org.apache.paimon.catalog.Identifier;
+import org.apache.paimon.flink.FlinkCatalog;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Unit tests for the {@code enrichCatalogTable} hook introduced in {@link 
BaseCatalog} and its
+ * implementation in {@link GravitinoPaimonCatalog}.
+ *
+ * <p>These tests verify:
+ *
+ * <ol>
+ *   <li>The default {@code BaseCatalog.enrichCatalogTable} is an identity 
function.
+ *   <li>{@code GravitinoPaimonCatalog.enrichCatalogTable} returns the 
Paimon-native table (i.e. the
+ *       result of {@code paimonCatalog.getTable()}) rather than the plain 
Gravitino {@link
+ *       CatalogTable}.
+ *   <li>When {@code paimonCatalog.getTable()} throws {@link 
TableNotExistException} (metadata
+ *       out-of-sync), a descriptive {@link CatalogException} is raised.
+ *   <li>{@code paimonCatalog.getTable()} is never called when Gravitino auth 
fails.
+ * </ol>
+ */
+public class TestGravitinoPaimonCatalog {
+
+  private AbstractCatalog mockPaimonCatalog;
+
+  // 
---------------------------------------------------------------------------
+  // Minimal test double for BaseCatalog — overrides only what's needed
+  // 
---------------------------------------------------------------------------
+
+  /**
+   * Subclass of {@link BaseCatalog} used to test the {@code 
enrichCatalogTable} default behaviour.
+   * All abstract methods delegate to no-ops or mocks; the only interesting 
thing is the hook.
+   */
+  private static class TestableBaseCatalog extends BaseCatalog {
+
+    private final AbstractCatalog realCatalog = mock(AbstractCatalog.class);
+    private final Catalog gravitinoCatalog = mock(Catalog.class);
+    private CatalogBaseTable toFlinkTableResult;
+    private CatalogException toFlinkTableException;
+
+    TestableBaseCatalog() {
+      super(
+          "test-catalog",
+          Collections.emptyMap(),
+          "default",
+          PaimonPropertiesConverter.INSTANCE,
+          DefaultPartitionConverter.INSTANCE);
+    }
+
+    @Override
+    protected AbstractCatalog realCatalog() {
+      return realCatalog;
+    }
+
+    @Override
+    protected Catalog catalog() {
+      return gravitinoCatalog;
+    }
+
+    @Override
+    protected CatalogBaseTable toFlinkTable(Table table, ObjectPath tablePath) 
{
+      if (toFlinkTableException != null) {
+        throw toFlinkTableException;
+      }
+      CatalogTable baseTable = (CatalogTable) toFlinkTableResult;
+      return enrichCatalogTable(baseTable, tablePath);
+    }
+
+    public CatalogBaseTable callEnrichCatalogTable(CatalogTable table, 
ObjectPath path) {
+      return enrichCatalogTable(table, path);
+    }
+  }
+
+  // 
---------------------------------------------------------------------------
+  // Minimal test double for GravitinoPaimonCatalog that injects a mock 
paimonCatalog
+  // 
---------------------------------------------------------------------------
+
+  /**
+   * Extends {@link GravitinoPaimonCatalog} so that tests can inject a mock 
native catalog via
+   * {@link #realCatalog()} and verify cache invalidation and enrichment 
behavior without a real
+   * Paimon environment.
+   */
+  private static class TestablePaimonCatalog extends GravitinoPaimonCatalog {
+
+    private final AbstractCatalog injectedPaimon;
+    private final Catalog injectedCatalog;
+
+    TestablePaimonCatalog(AbstractCatalog injectedPaimon) {
+      this(injectedPaimon, null);
+    }
+
+    TestablePaimonCatalog(AbstractCatalog injectedPaimon, Catalog 
injectedCatalog) {
+      // We cannot call super(context, ...) without a real 
FlinkCatalogFactory, so we use a
+      // package-private constructor shim that skips the factory call.  
Because we override
+      // realCatalog() the parent constructor's catalog reference is never 
used.
+      super(
+          new MockCatalogContext("test-paimon", defaultPaimonOptions()),
+          "default",
+          PaimonPropertiesConverter.INSTANCE,
+          DefaultPartitionConverter.INSTANCE);
+      this.injectedPaimon = injectedPaimon;
+      this.injectedCatalog = injectedCatalog;
+    }
+
+    @Override
+    protected AbstractCatalog realCatalog() {
+      return injectedPaimon;
+    }
+
+    @Override
+    protected Catalog catalog() {
+      return injectedCatalog != null ? injectedCatalog : super.catalog();
+    }
+
+    private static Map<String, String> defaultPaimonOptions() {
+      Map<String, String> options = new HashMap<>();
+      options.put("warehouse", "file:/tmp/test-paimon-warehouse");
+      return options;
+    }
+  }
+
+  @BeforeEach
+  void setUp() {
+    mockPaimonCatalog = mock(AbstractCatalog.class);
+  }
+
+  // 
---------------------------------------------------------------------------
+  // BaseCatalog default hook: identity function
+  // 
---------------------------------------------------------------------------
+
+  /**
+   * The base implementation must return the same {@link CatalogTable} it 
receives, unchanged. This
+   * ensures existing catalogs (Hive, Iceberg, JDBC) are unaffected by the new 
hook.
+   */
+  @Test
+  public void testDefaultEnrichCatalogTableIsIdentity() {
+    TestableBaseCatalog base = new TestableBaseCatalog();
+    CatalogTable input = mock(CatalogTable.class);
+    ObjectPath path = new ObjectPath("db", "tbl");
+
+    CatalogBaseTable result = base.callEnrichCatalogTable(input, path);
+
+    Assertions.assertSame(
+        input,
+        result,
+        "BaseCatalog.enrichCatalogTable must return the input table unchanged 
by default");
+  }
+
+  // 
---------------------------------------------------------------------------
+  // GravitinoPaimonCatalog: enrichCatalogTable returns Paimon-native table
+  // 
---------------------------------------------------------------------------
+
+  /**
+   * Verifies that {@code GravitinoPaimonCatalog.enrichCatalogTable} ignores 
the plain {@link
+   * CatalogTable} and instead returns whatever {@code 
paimonCatalog.getTable()} produces.
+   *
+   * <p>The object returned by {@code paimonCatalog.getTable()} is Paimon's 
{@code
+   * DataCatalogTable}, which carries a non-null {@code CatalogEnvironment}. 
This is what enables
+   * {@code AddPartitionCommitCallback} registration on the write path, fixing 
the "partitions not
+   * visible in {@code SHOW PARTITIONS}" bug.
+   */
+  @Test
+  public void testEnrichCatalogTableReturnsPaimonNativeTable()
+      throws TableNotExistException, CatalogException {
+    CatalogBaseTable paimonNativeTable = mock(CatalogBaseTable.class);
+    ObjectPath path = new ObjectPath("db", "tbl");
+    when(mockPaimonCatalog.getTable(path)).thenReturn(paimonNativeTable);
+
+    CatalogTable gravitinoBuiltTable = mock(CatalogTable.class);
+    TestablePaimonCatalog cat = new TestablePaimonCatalog(mockPaimonCatalog);
+    CatalogBaseTable result = cat.enrichCatalogTable(gravitinoBuiltTable, 
path);
+
+    Assertions.assertSame(
+        paimonNativeTable,
+        result,
+        "enrichCatalogTable must return the Paimon-native DataCatalogTable");
+    verify(mockPaimonCatalog).getTable(path);
+  }
+
+  // 
---------------------------------------------------------------------------
+  // Metadata out-of-sync: TableNotExistException → CatalogException
+  // 
---------------------------------------------------------------------------
+
+  /**
+   * When Gravitino confirms the table exists but the underlying Paimon store 
does not have it,
+   * {@code enrichCatalogTable} must throw a descriptive {@link 
CatalogException} rather than
+   * leaking the raw {@link TableNotExistException} from Paimon.
+   */
+  @Test
+  public void testEnrichCatalogTableOutOfSyncThrowsCatalogException()
+      throws TableNotExistException {
+    ObjectPath path = new ObjectPath("db", "missing_in_paimon");
+    when(mockPaimonCatalog.getTable(path))
+        .thenThrow(new TableNotExistException("test-paimon", path));
+
+    CatalogTable gravitinoBuiltTable = mock(CatalogTable.class);
+
+    TestablePaimonCatalog cat = new TestablePaimonCatalog(mockPaimonCatalog);
+    CatalogException ex =
+        Assertions.assertThrows(
+            CatalogException.class, () -> 
cat.enrichCatalogTable(gravitinoBuiltTable, path));
+
+    Assertions.assertTrue(
+        ex.getMessage().contains("missing_in_paimon"),
+        "Exception message should contain the table name for diagnostics");
+    Assertions.assertInstanceOf(
+        TableNotExistException.class,
+        ex.getCause(),
+        "Original TableNotExistException should be preserved as the cause");
+  }
+
+  // 
---------------------------------------------------------------------------
+  // paimonCatalog.getTable() must NOT be called when Gravitino auth fails
+  // (this contract is enforced by BaseCatalog.getTable(), tested here as a
+  //  sanity check via the enrichCatalogTable path)
+  // 
---------------------------------------------------------------------------
+
+  /**
+   * {@code paimonCatalog.getTable()} must never be called if Gravitino auth 
throws before {@code
+   * enrichCatalogTable} is reached. This is enforced by {@link 
BaseCatalog#getTable} which invokes
+   * the hook only after a successful Gravitino {@code loadTable()} call.
+   *
+   * <p>The test simulates this contract at the unit level by verifying that a 
direct call to {@code
+   * enrichCatalogTable} (the hook itself) does call the inner catalog — 
confirming the security
+   * boundary is in {@link BaseCatalog#getTable}, not in the hook.
+   */
+  @Test
+  public void testGetTableAuthFailureDoesNotCallPaimonCatalog() throws 
Exception {
+    Catalog mockCatalog = mock(Catalog.class);
+    TableCatalog mockTableCatalog = mock(TableCatalog.class);
+    ObjectPath path = new ObjectPath("db", "tbl");
+    when(mockCatalog.asTableCatalog()).thenReturn(mockTableCatalog);
+    when(mockTableCatalog.loadTable(any())).thenThrow(new 
RuntimeException("denied"));
+
+    TestablePaimonCatalog cat = new TestablePaimonCatalog(mockPaimonCatalog, 
mockCatalog);
+
+    Assertions.assertThrows(RuntimeException.class, () -> cat.getTable(path));
+    verify(mockPaimonCatalog, never()).getTable(any());
+  }
+
+  // 
---------------------------------------------------------------------------
+  // invalidateNativeTableCache: Paimon CachingCatalog is evicted after DDL
+  // 
---------------------------------------------------------------------------
+
+  /**
+   * Verifies that {@code invalidateNativeTableCache} calls {@code 
Catalog.invalidateTable} on the
+   * underlying Paimon inner catalog when {@code paimonCatalog} is a {@link 
FlinkCatalog}.
+   *
+   * <p>This ensures that after DDL operations (drop / rename / alter) routed 
through Gravitino,
+   * stale entries in Paimon's {@code CachingCatalog} are evicted so 
subsequent reads reflect the
+   * updated metadata.
+   */
+  @Test
+  public void testInvalidateNativeTableCacheCallsPaimonInvalidate() {
+    org.apache.paimon.catalog.Catalog mockInnerCatalog =
+        mock(org.apache.paimon.catalog.Catalog.class);
+    FlinkCatalog mockFlinkCatalog = mock(FlinkCatalog.class);
+    when(mockFlinkCatalog.catalog()).thenReturn(mockInnerCatalog);
+
+    TestablePaimonCatalog cat = new TestablePaimonCatalog(mockFlinkCatalog);
+    ObjectPath path = new ObjectPath("mydb", "mytable");
+    cat.invalidateNativeTableCache(path);
+
+    Identifier expected = Identifier.create("mydb", "mytable");
+    verify(mockInnerCatalog).invalidateTable(expected);
+  }
+
+  /**
+   * Verifies that {@code invalidateNativeTableCache} is a no-op when the 
underlying catalog is not
+   * a {@link FlinkCatalog} (e.g. in tests using a plain {@link 
AbstractCatalog} mock). No exception
+   * should be thrown.
+   */
+  @Test
+  public void testInvalidateNativeTableCacheIsNoOpForNonFlinkCatalog() {
+    // mockPaimonCatalog is AbstractCatalog, not FlinkCatalog — must not throw
+    TestablePaimonCatalog cat = new TestablePaimonCatalog(mockPaimonCatalog);
+    Assertions.assertDoesNotThrow(
+        () -> cat.invalidateNativeTableCache(new ObjectPath("db", "tbl")));
+  }
+
+  /**
+   * Verifies that {@link BaseCatalog#getTable(ObjectPath)} preserves 
hook-thrown CatalogException.
+   */
+  @Test
+  public void testGetTablePreservesCatalogException() {
+    TestableBaseCatalog baseCatalog = new TestableBaseCatalog();
+    Catalog mockCatalog = baseCatalog.catalog();
+    TableCatalog mockTableCatalog = mock(TableCatalog.class);
+    when(mockCatalog.asTableCatalog()).thenReturn(mockTableCatalog);
+    when(mockTableCatalog.loadTable(any())).thenReturn(mock(Table.class));
+    baseCatalog.toFlinkTableResult = mock(CatalogTable.class);
+    CatalogException expected = new CatalogException("boom");
+    baseCatalog.toFlinkTableException = expected;
+
+    CatalogException actual =
+        Assertions.assertThrows(
+            CatalogException.class, () -> baseCatalog.getTable(new 
ObjectPath("db", "tbl")));
+
+    Assertions.assertSame(expected, actual, "CatalogException should be 
rethrown as-is");
+  }
+
+  /** Verifies that successful Paimon alterTable invalidates the native cache. 
*/
+  @Test
+  public void testAlterTableInvalidatesNativeCacheAfterSuccessfulAlter() 
throws Exception {
+    org.apache.paimon.catalog.Catalog mockInnerCatalog =
+        mock(org.apache.paimon.catalog.Catalog.class);
+    FlinkCatalog mockFlinkCatalog = mock(FlinkCatalog.class);
+    when(mockFlinkCatalog.catalog()).thenReturn(mockInnerCatalog);
+
+    Catalog mockCatalog = mock(Catalog.class);
+    TableCatalog mockTableCatalog = mock(TableCatalog.class);
+    when(mockCatalog.asTableCatalog()).thenReturn(mockTableCatalog);
+
+    Table existingTable = mock(Table.class);
+    org.apache.gravitino.rel.Column existingColumn =
+        org.apache.gravitino.rel.Column.of(
+            "id", org.apache.gravitino.rel.types.Types.IntegerType.get());
+    when(existingTable.columns())
+        .thenReturn(new org.apache.gravitino.rel.Column[] {existingColumn});
+    when(existingTable.index()).thenReturn(new Index[0]);
+    when(existingTable.properties()).thenReturn(Collections.emptyMap());
+    when(existingTable.distribution()).thenReturn(null);
+    when(existingTable.partitioning()).thenReturn(Transforms.EMPTY_TRANSFORM);
+    when(existingTable.comment()).thenReturn("existing comment");
+    when(mockTableCatalog.loadTable(any())).thenReturn(existingTable);
+
+    TestablePaimonCatalog cat = new TestablePaimonCatalog(mockFlinkCatalog, 
mockCatalog);
+    ObjectPath path = new ObjectPath("mydb", "mytable");
+    CatalogTable newTable =
+        CatalogTable.of(
+            org.apache.flink.table.api.Schema.newBuilder().column("id", 
DataTypes.INT()).build(),
+            "new comment",
+            Collections.emptyList(),
+            Collections.emptyMap());
+    when(mockFlinkCatalog.getTable(path)).thenReturn(newTable);
+
+    cat.alterTable(path, newTable, false);
+
+    verify(mockTableCatalog).alterTable(any(), any());
+    verify(mockInnerCatalog).invalidateTable(Identifier.create("mydb", 
"mytable"));
+  }
+
+  /** Verifies that successful Paimon dropTable invalidates the native cache. 
*/
+  @Test
+  public void testDropTableInvalidatesNativeCacheAfterSuccessfulPurge() throws 
Exception {
+    org.apache.paimon.catalog.Catalog mockInnerCatalog =
+        mock(org.apache.paimon.catalog.Catalog.class);
+    FlinkCatalog mockFlinkCatalog = mock(FlinkCatalog.class);
+    when(mockFlinkCatalog.catalog()).thenReturn(mockInnerCatalog);
+
+    Catalog mockCatalog = mock(Catalog.class);
+    TableCatalog mockTableCatalog = mock(TableCatalog.class);
+    when(mockCatalog.asTableCatalog()).thenReturn(mockTableCatalog);
+    when(mockTableCatalog.purgeTable(any())).thenReturn(true);
+
+    TestablePaimonCatalog cat = new TestablePaimonCatalog(mockFlinkCatalog, 
mockCatalog);
+    ObjectPath path = new ObjectPath("mydb", "mytable");
+    cat.dropTable(path, false);
+
+    Identifier expected = Identifier.create("mydb", "mytable");
+    verify(mockInnerCatalog).invalidateTable(expected);
+  }
+
+  // 
---------------------------------------------------------------------------
+  // Helper: minimal CatalogFactory.Context implementation for constructor
+  // 
---------------------------------------------------------------------------
+
+  private static class MockCatalogContext implements CatalogFactory.Context {
+    private final String name;
+    private final Map<String, String> options;
+
+    MockCatalogContext(String name, Map<String, String> options) {
+      this.name = name;
+      this.options = options;
+    }
+
+    @Override
+    public String getName() {
+      return name;
+    }
+
+    @Override
+    public Map<String, String> getOptions() {
+      return options;
+    }
+
+    @Override
+    public ReadableConfig getConfiguration() {
+      return Configuration.fromMap(options);
+    }
+
+    @Override
+    public ClassLoader getClassLoader() {
+      return Thread.currentThread().getContextClassLoader();
+    }
+  }
+}


Reply via email to