You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@doris.apache.org by yi...@apache.org on 2026/10/23 06:13:52 UTC

(doris) branch branch-4.2 updated: [fix](fe) Allow table access through Iceberg JDBC V0 catalogs (branch-4.2) (#69485)

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

yiguolei pushed a commit to branch branch-4.2
in repository https://gitbox.apache.org/repos/asf/doris.git


The following commit(s) were added to refs/heads/branch-4.2 by this push:
     new 8e5b079051b [fix](fe) Allow table access through Iceberg JDBC V0 catalogs (branch-4.2) (#69485)
8e5b079051b is described below

commit 8e5b079051bea5b4bff40857eb65b96c96995c3f
Author: Gabriel <li...@selectdb.com>
AuthorDate: Fri Oct 23 14:13:41 2026 +0800

    [fix](fe) Allow table access through Iceberg JDBC V0 catalogs (branch-4.2) (#69485)
    
    ### What problem does this PR solve?
    
    Issue Number: N/A
    
    Related PR: #69452
    
    Problem Summary:
    
    Backport #69452 to branch-4.2. Iceberg's JDBC catalog implements
    `ViewCatalog` even when its V0 metadata schema cannot store views. Treat
    unsupported JDBC view probes as no views so ordinary table listing and
    reads work without requiring a V1 migration. Preserve V1 view support,
    REST view routing, explicit view-operation errors, and real
    SQL/authorization failures.
    
    Preserve the requested DROP kind and lifetime before invoking the legacy
    metadata API, which accepts none of these flags and can purge an
    ordinary table. Reject `DROP VIEW` and `DROP MATERIALIZED VIEW` on a
    table, including `IF EXISTS`. An external permanent table cannot satisfy
    `DROP TEMPORARY TABLE`: return without mutation for `IF EXISTS`,
    otherwise report an unknown table. Reject `DROP TABLE` on a view while
    preserving matching table/view drops and missing-object `IF EXISTS`
    behavior.
    
    Add eight drop-dispatch tests using the actual JDBC V0 view guards and a
    recording writable catalog. Assert that rejected/no-op statements
    preserve table state and rows without purge or delete journaling. Extend
    the PostgreSQL V0 regression with all six wrong-kind SQL forms and check
    table listing and data after every statement.
    
    ### Release note
    
    Fix table access through Iceberg JDBC V0 catalogs. Prevent wrong-kind
    external DROP statements from deleting existing table data.
    
    ### Check List (For Author)
    
    - Test
    - [x] Unit Test: 29 targeted tests passed, zero failures/errors/skips.
    Includes eight new drop-kind tests, six JDBC view-probe tests, and
    existing metadata-operation tests. Before the fix, five drop-kind tests
    reproduced the unsafe mutation behavior on each branch.
        - [x] FE Checkstyle: zero violations.
    - [ ] Regression test: SELECT-only V0 scenarios and six writable V0
    wrong-kind DROP checks added; release-branch end-to-end SQL execution
    not performed locally. Regression Groovy syntax checked.
    - Behavior changed:
    - [x] Yes. V0 view probes no longer block table access; mismatched DROP
    statements cannot reach destructive table operations.
    - Does this need documentation?
        - [x] No.
    
    ### Check List (For Reviewer who merge this PR)
    
    - [ ] Confirm the release note
    - [ ] Confirm test cases
    - [ ] Confirm document
    - [ ] Add branch pick label
---
 .../apache/doris/datasource/ExternalCatalog.java   |  20 ++
 .../datasource/iceberg/IcebergMetadataOps.java     |  25 ++-
 .../datasource/ExternalCatalogDropKindTest.java    | 221 +++++++++++++++++++++
 .../iceberg/IcebergJdbcViewProbeTest.java          | 144 ++++++++++++++
 .../iceberg/test_iceberg_jdbc_catalog.groovy       | 101 ++++++++++
 5 files changed, 507 insertions(+), 4 deletions(-)

diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/ExternalCatalog.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/ExternalCatalog.java
index 0501f8c603d..3c83b18f181 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/datasource/ExternalCatalog.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/datasource/ExternalCatalog.java
@@ -29,6 +29,8 @@ import org.apache.doris.catalog.TableIf;
 import org.apache.doris.cluster.ClusterNamespace;
 import org.apache.doris.common.Config;
 import org.apache.doris.common.DdlException;
+import org.apache.doris.common.ErrorCode;
+import org.apache.doris.common.ErrorReport;
 import org.apache.doris.common.FeConstants;
 import org.apache.doris.common.Pair;
 import org.apache.doris.common.ThreadPoolManager;
@@ -1303,6 +1305,24 @@ public abstract class ExternalCatalog
             }
             throw new DdlException("Failed to get table: '" + tableName + "' in database: " + dbName);
         }
+        // The metadata API receives no kind/lifetime flags and may purge an ordinary table.
+        // External catalogs have no temporary tables or MTMVs; reject mismatches before dispatch.
+        if (mustTemporary) {
+            if (ifExists) {
+                return;
+            }
+            ErrorReport.reportDdlException(ErrorCode.ERR_UNKNOWN_TABLE, tableName, dbName);
+        }
+        if (isMtmv) {
+            ErrorReport.reportDdlException(ErrorCode.ERR_WRONG_OBJECT, dbName, tableName, "MTMV",
+                    "External catalogs do not support materialized views");
+        }
+        boolean actualIsView = dorisTable.isView();
+        if (isView != actualIsView) {
+            // IF EXISTS must not relax the type check for an existing object.
+            ErrorReport.reportDdlException(ErrorCode.ERR_WRONG_OBJECT, dbName, tableName, isView ? "VIEW" : "TABLE",
+                    "Use DROP " + (actualIsView ? "VIEW" : "TABLE") + " instead");
+        }
         try {
             metadataOps.dropTable(dorisTable, ifExists);
             DropInfo info = new DropInfo(getName(), db.getFullName(), dorisTable.getName(), db.getFullName());
diff --git a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergMetadataOps.java b/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergMetadataOps.java
index 536ff56c891..723cdab4441 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergMetadataOps.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/datasource/iceberg/IcebergMetadataOps.java
@@ -77,6 +77,7 @@ import org.apache.iceberg.exceptions.NoSuchNamespaceException;
 import org.apache.iceberg.expressions.Expressions;
 import org.apache.iceberg.expressions.Literal;
 import org.apache.iceberg.expressions.Term;
+import org.apache.iceberg.jdbc.JdbcCatalog;
 import org.apache.iceberg.rest.RESTSessionCatalog;
 import org.apache.iceberg.types.Type;
 import org.apache.iceberg.types.TypeUtil;
@@ -100,6 +101,7 @@ import java.util.Set;
 import java.util.TreeSet;
 import java.util.concurrent.Callable;
 import java.util.concurrent.ThreadPoolExecutor;
+import java.util.function.Supplier;
 import java.util.stream.Collectors;
 import java.util.stream.Stream;
 
@@ -2061,6 +2063,19 @@ public class IcebergMetadataOps implements ExternalMetadataOps {
         return userSessionCatalog != null && userSessionCatalog.isNestedNamespaceEnabled();
     }
 
+    private <T> T probeViews(ViewCatalog viewCatalog, Supplier<T> probe, T noViews) {
+        try {
+            return probe.get();
+        } catch (UnsupportedOperationException e) {
+            // JdbcCatalog implements ViewCatalog even for V0, which cannot store views. The SDK
+            // rejects these probes before SQL; do not require a V1 migration merely to read tables.
+            if (viewCatalog instanceof JdbcCatalog) {
+                return noViews;
+            }
+            throw e;
+        }
+    }
+
     private boolean isViewCatalogEnabled() {
         return viewCatalogEnabled;
     }
@@ -2141,8 +2156,8 @@ public class IcebergMetadataOps implements ExternalMetadataOps {
     }
 
     private boolean viewExistsInternal(SessionContext ctx, String remoteDbName, String remoteViewName) {
-        return viewCatalog(ctx).map(activeCatalog ->
-                activeCatalog.viewExists(getTableIdentifier(remoteDbName, remoteViewName))).orElse(false);
+        return viewCatalog(ctx).map(activeCatalog -> probeViews(activeCatalog,
+                () -> activeCatalog.viewExists(getTableIdentifier(remoteDbName, remoteViewName)), false)).orElse(false);
     }
 
     private View loadViewInternal(String dbName, String tblName) {
@@ -2159,8 +2174,10 @@ public class IcebergMetadataOps implements ExternalMetadataOps {
     }
 
     private List<String> listViewNamesInternal(SessionContext ctx, String dbName) {
-        return viewCatalog(ctx).map(activeCatalog -> activeCatalog.listViews(getNamespace(dbName))
-                .stream().map(TableIdentifier::name).collect(Collectors.toList())).orElseGet(Collections::emptyList);
+        return viewCatalog(ctx).map(activeCatalog -> probeViews(activeCatalog,
+                () -> activeCatalog.listViews(getNamespace(dbName)).stream()
+                        .map(TableIdentifier::name).collect(Collectors.toList()), Collections.<String>emptyList()))
+                .orElseGet(Collections::emptyList);
     }
 
     private void performDropView(SessionContext ctx, String remoteDbName, String remoteViewName) throws DdlException {
diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/ExternalCatalogDropKindTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/ExternalCatalogDropKindTest.java
new file mode 100644
index 00000000000..0bacc0fd0c3
--- /dev/null
+++ b/fe/fe-core/src/test/java/org/apache/doris/datasource/ExternalCatalogDropKindTest.java
@@ -0,0 +1,221 @@
+// 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.doris.datasource;
+
+import org.apache.doris.catalog.Env;
+import org.apache.doris.common.DdlException;
+import org.apache.doris.common.security.authentication.ExecutionAuthenticator;
+import org.apache.doris.datasource.iceberg.IcebergMetadataOps;
+import org.apache.doris.persist.EditLog;
+
+import mockit.Mock;
+import mockit.MockUp;
+import org.apache.iceberg.catalog.TableIdentifier;
+import org.apache.iceberg.jdbc.JdbcCatalog;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+public class ExternalCatalogDropKindTest {
+    @Test
+    public void dropViewCannotDeleteV0Table() {
+        for (boolean ifExists : new boolean[] {false, true}) {
+            DropCatalog catalog = new DropCatalog(false);
+            Assertions.assertThrows(DdlException.class,
+                    () -> catalog.dropTable("db", "t", true, false, ifExists, false, false));
+            catalog.assertTableUnchanged();
+        }
+    }
+
+    @Test
+    public void dropMaterializedViewCannotDeleteV0Table() {
+        for (boolean ifExists : new boolean[] {false, true}) {
+            DropCatalog catalog = new DropCatalog(false);
+            Assertions.assertThrows(DdlException.class,
+                    () -> catalog.dropTable("db", "t", false, true, ifExists, false, false));
+            catalog.assertTableUnchanged();
+        }
+    }
+
+    @Test
+    public void dropTemporaryTableCannotDeleteV0Table() {
+        DropCatalog catalog = new DropCatalog(false);
+        Assertions.assertThrows(DdlException.class,
+                () -> catalog.dropTable("db", "t", false, false, false, true, false));
+        catalog.assertTableUnchanged();
+    }
+
+    @Test
+    public void dropTemporaryTableIfExistsDoesNotDeleteV0Table() throws Exception {
+        DropCatalog catalog = new DropCatalog(false);
+        catalog.dropTable("db", "t", false, false, true, true, false);
+        catalog.assertTableUnchanged();
+    }
+
+    @Test
+    public void dropTableCannotDeleteView() {
+        for (boolean ifExists : new boolean[] {false, true}) {
+            DropCatalog catalog = new DropCatalog(true);
+            Assertions.assertThrows(DdlException.class,
+                    () -> catalog.dropTable("db", "t", false, false, ifExists, false, false));
+            Assertions.assertEquals(0, catalog.sdk.viewDrops);
+            Assertions.assertTrue(catalog.sdk.present);
+            Mockito.verifyNoInteractions(catalog.editLog);
+        }
+    }
+
+    @Test
+    public void matchingV0TableDropStillWorks() throws Exception {
+        DropCatalog catalog = new DropCatalog(false);
+        catalog.dropTable("db", "t", false, false, false, false, false);
+        Assertions.assertFalse(catalog.sdk.present);
+        Assertions.assertEquals(1, catalog.sdk.tableDrops);
+        Assertions.assertTrue(catalog.sdk.purge);
+        Mockito.verify(catalog.editLog).logDropTable(Mockito.any());
+    }
+
+    @Test
+    public void matchingViewDropStillWorks() throws Exception {
+        DropCatalog catalog = new DropCatalog(true);
+        catalog.dropTable("db", "t", true, false, false, false, false);
+        Assertions.assertFalse(catalog.sdk.present);
+        Assertions.assertEquals(1, catalog.sdk.viewDrops);
+        Assertions.assertEquals(0, catalog.sdk.tableDrops);
+        Mockito.verify(catalog.editLog).logDropTable(Mockito.any());
+    }
+
+    @Test
+    public void missingObjectIfExistsStillDoesNotMutate() throws Exception {
+        for (boolean view : new boolean[] {false, true}) {
+            DropCatalog catalog = new DropCatalog(false);
+            Mockito.when(catalog.database.getTableNullable("t")).thenReturn(null);
+            catalog.dropTable("db", "t", view, false, true, false, false);
+            catalog.assertTableUnchanged();
+        }
+    }
+
+    private static class DropCatalog extends ExternalCatalog {
+        private final RecordingJdbcCatalog sdk;
+        private final ExternalDatabase<ExternalTable> database;
+        private final EditLog editLog = Mockito.mock(EditLog.class);
+
+        private DropCatalog(boolean view) {
+            super(1L, "drop_kind_test", InitCatalogLog.Type.TEST, "");
+            initialized = true;
+            catalogProperty = new CatalogProperty(null, Collections.emptyMap());
+            executionAuthenticator = new ExecutionAuthenticator() {
+            };
+            sdk = new RecordingJdbcCatalog(view);
+            metadataOps = new IcebergMetadataOps(this, sdk) {
+                @Override
+                public void afterDropTable(String dbName, String tableName) {
+                    // Cache retirement is outside this test's drop-kind and mutation boundary.
+                }
+            };
+            database = Mockito.mock(ExternalDatabase.class);
+            ExternalTable table = Mockito.mock(ExternalTable.class);
+            Mockito.when(database.getTableNullable("t")).thenReturn(table);
+            Mockito.when(database.getFullName()).thenReturn("db");
+            Mockito.when(table.getName()).thenReturn("t");
+            Mockito.when(table.getRemoteName()).thenReturn("t");
+            Mockito.when(table.getDbName()).thenReturn("db");
+            Mockito.when(table.getRemoteDbName()).thenReturn("db");
+            Mockito.when(table.isView()).thenReturn(view);
+            Env env = Mockito.mock(Env.class);
+            Mockito.when(env.getEditLog()).thenReturn(editLog);
+            new MockUp<Env>() {
+                @Mock
+                Env getCurrentEnv() {
+                    return env;
+                }
+            };
+        }
+
+        private void assertTableUnchanged() {
+            Assertions.assertTrue(sdk.present);
+            Assertions.assertEquals(Arrays.asList(1, 2), sdk.rows);
+            Assertions.assertEquals(0, sdk.tableDrops);
+            Assertions.assertEquals(0, sdk.viewDrops);
+            Mockito.verifyNoInteractions(editLog);
+        }
+
+        @Override
+        protected void initLocalObjectsImpl() {
+        }
+
+        @Override
+        public ExternalDatabase<ExternalTable> getDbNullable(String dbName) {
+            return database;
+        }
+
+        @Override
+        protected List<String> listTableNamesFromRemote(SessionContext ctx, String dbName) {
+            return Collections.singletonList("t");
+        }
+
+        @Override
+        public boolean tableExist(SessionContext ctx, String dbName, String tblName) {
+            return sdk.present;
+        }
+    }
+
+    // Retain the real SDK's V0 view guard while recording the destructive table operation it used to block.
+    private static class RecordingJdbcCatalog extends JdbcCatalog {
+        private final boolean view;
+        private final List<Integer> rows = new ArrayList<>(Arrays.asList(1, 2));
+        private boolean present = true;
+        private boolean purge;
+        private int tableDrops;
+        private int viewDrops;
+
+        private RecordingJdbcCatalog(boolean view) {
+            this.view = view;
+        }
+
+        @Override
+        public boolean viewExists(TableIdentifier identifier) {
+            return view ? present : super.viewExists(identifier);
+        }
+
+        @Override
+        public boolean tableExists(TableIdentifier identifier) {
+            return present;
+        }
+
+        @Override
+        public boolean dropTable(TableIdentifier identifier, boolean purge) {
+            this.purge = purge;
+            tableDrops++;
+            present = false;
+            rows.clear();
+            return true;
+        }
+
+        @Override
+        public boolean dropView(TableIdentifier identifier) {
+            viewDrops++;
+            present = false;
+            return true;
+        }
+    }
+}
diff --git a/fe/fe-core/src/test/java/org/apache/doris/datasource/iceberg/IcebergJdbcViewProbeTest.java b/fe/fe-core/src/test/java/org/apache/doris/datasource/iceberg/IcebergJdbcViewProbeTest.java
new file mode 100644
index 00000000000..4796697531a
--- /dev/null
+++ b/fe/fe-core/src/test/java/org/apache/doris/datasource/iceberg/IcebergJdbcViewProbeTest.java
@@ -0,0 +1,144 @@
+// 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.doris.datasource.iceberg;
+
+import org.apache.doris.common.security.authentication.ExecutionAuthenticator;
+import org.apache.doris.datasource.ExternalCatalog;
+
+import org.apache.iceberg.catalog.Catalog;
+import org.apache.iceberg.catalog.Namespace;
+import org.apache.iceberg.catalog.SupportsNamespaces;
+import org.apache.iceberg.catalog.TableIdentifier;
+import org.apache.iceberg.catalog.ViewCatalog;
+import org.apache.iceberg.exceptions.ForbiddenException;
+import org.apache.iceberg.jdbc.JdbcCatalog;
+import org.apache.iceberg.jdbc.UncheckedSQLException;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+public class IcebergJdbcViewProbeTest {
+    private static final Namespace NAMESPACE = Namespace.of("db");
+    private static final TableIdentifier TABLE = TableIdentifier.of(NAMESPACE, "table");
+    private static final TableIdentifier VIEW = TableIdentifier.of(NAMESPACE, "view");
+
+    private IcebergMetadataOps ops(Catalog catalog) {
+        ExternalCatalog dorisCatalog = Mockito.mock(ExternalCatalog.class);
+        Mockito.when(dorisCatalog.getProperties()).thenReturn(Collections.emptyMap());
+        Mockito.when(dorisCatalog.getExecutionAuthenticator()).thenReturn(new ExecutionAuthenticator() {
+        });
+        return new IcebergMetadataOps(dorisCatalog, catalog);
+    }
+
+    @Test
+    public void v0ListsTablesWithoutViewSupport() {
+        Assertions.assertEquals(Collections.singletonList("table"), ops(new V0JdbcCatalog()).listTableNames("db"));
+    }
+
+    @Test
+    public void v0HasNoViews() {
+        IcebergMetadataOps ops = ops(new V0JdbcCatalog());
+        Assertions.assertEquals(Collections.emptyList(), ops.listViewNames("db"));
+        Assertions.assertFalse(ops.viewExists("db", "table"));
+        Assertions.assertFalse(ops.viewExists("db", "missing"));
+    }
+
+    @Test
+    public void explicitV0ViewLoadStillFails() {
+        IcebergMetadataOps ops = ops(new V0JdbcCatalog());
+        RuntimeException failure = Assertions.assertThrows(RuntimeException.class, () -> ops.loadView("db", "view"));
+        Assertions.assertInstanceOf(UnsupportedOperationException.class, failure.getCause());
+    }
+
+    @Test
+    public void jdbcViewSupportIsNotDisabledByCatalogType() {
+        IcebergMetadataOps ops = ops(new V0JdbcCatalog() {
+            @Override
+            public List<TableIdentifier> listTables(Namespace namespace) {
+                return Arrays.asList(TABLE, VIEW);
+            }
+
+            @Override
+            public List<TableIdentifier> listViews(Namespace namespace) {
+                return Collections.singletonList(VIEW);
+            }
+
+            @Override
+            public boolean viewExists(TableIdentifier identifier) {
+                return VIEW.equals(identifier);
+            }
+        });
+        Assertions.assertEquals(Collections.singletonList("table"), ops.listTableNames("db"));
+        Assertions.assertEquals(Collections.singletonList("view"), ops.listViewNames("db"));
+        Assertions.assertTrue(ops.viewExists("db", "view"));
+        Assertions.assertFalse(ops.viewExists("db", "table"));
+    }
+
+    @Test
+    public void jdbcProbeFailuresAreNotHidden() {
+        for (RuntimeException failure : Arrays.asList(
+                new ForbiddenException("Access denied"), new UncheckedSQLException("Connection failed"))) {
+            IcebergMetadataOps ops = ops(new V0JdbcCatalog() {
+                @Override
+                public List<TableIdentifier> listViews(Namespace namespace) {
+                    throw failure;
+                }
+
+                @Override
+                public boolean viewExists(TableIdentifier identifier) {
+                    throw failure;
+                }
+            });
+            Assertions.assertSame(failure, Assertions.assertThrows(failure.getClass(),
+                    () -> ops.listTableNames("db")));
+            Assertions.assertSame(failure, Assertions.assertThrows(failure.getClass(),
+                    () -> ops.listViewNames("db")));
+            Assertions.assertSame(failure, Assertions.assertThrows(RuntimeException.class,
+                    () -> ops.viewExists("db", "view")).getCause());
+        }
+    }
+
+    @Test
+    public void unsupportedViewsInOtherCatalogsAreNotHidden() {
+        UnsupportedOperationException failure = new UnsupportedOperationException("Unsupported view operation");
+        Catalog catalog = Mockito.mock(Catalog.class,
+                Mockito.withSettings().extraInterfaces(SupportsNamespaces.class, ViewCatalog.class));
+        Mockito.when(catalog.listTables(NAMESPACE)).thenReturn(Collections.singletonList(TABLE));
+        Mockito.when(((ViewCatalog) catalog).listViews(NAMESPACE)).thenThrow(failure);
+        Mockito.when(((ViewCatalog) catalog).viewExists(VIEW)).thenThrow(failure);
+        IcebergMetadataOps ops = ops(catalog);
+        Assertions.assertSame(failure, Assertions.assertThrows(UnsupportedOperationException.class,
+                () -> ops.listTableNames("db")));
+        Assertions.assertSame(failure, Assertions.assertThrows(UnsupportedOperationException.class,
+                () -> ops.listViewNames("db")));
+        Assertions.assertSame(failure, Assertions.assertThrows(RuntimeException.class,
+                () -> ops.viewExists("db", "view")).getCause());
+    }
+
+    // Keep the SDK's actual V0 listViews/viewExists guards; only table listing avoids a live JDBC connection.
+    private static class V0JdbcCatalog extends JdbcCatalog {
+        @Override
+        public List<TableIdentifier> listTables(Namespace namespace) {
+            return Collections.singletonList(TABLE);
+        }
+    }
+}
diff --git a/regression-test/suites/external_table_p0/iceberg/test_iceberg_jdbc_catalog.groovy b/regression-test/suites/external_table_p0/iceberg/test_iceberg_jdbc_catalog.groovy
index 2ed8af6c586..368fccc008b 100644
--- a/regression-test/suites/external_table_p0/iceberg/test_iceberg_jdbc_catalog.groovy
+++ b/regression-test/suites/external_table_p0/iceberg/test_iceberg_jdbc_catalog.groovy
@@ -119,7 +119,106 @@ suite("test_iceberg_jdbc_catalog", "p0,external,iceberg,external_docker,external
         }
     }
     
+    String v0_writer = "test_iceberg_jdbc_v0_writer"
+    String v0_reader = "test_iceberg_jdbc_v0_reader"
+    String v0_database = "iceberg_jdbc_v0_test"
+    String v0_uri = "jdbc:postgresql://${externalEnvIp}:${jdbc_port}/${v0_database}"
+    def driverLoader = new URLClassLoader([new File(local_driver_path).toURI().toURL()] as URL[],
+            this.class.classLoader)
+    def pgDriver = driverLoader.loadClass("org.postgresql.Driver").getDeclaredConstructor().newInstance()
+    def pgProperties = new Properties()
+    pgProperties.setProperty("user", "postgres")
+    pgProperties.setProperty("password", "123456")
+    def pgConnection = pgDriver.connect("jdbc:postgresql://${externalEnvIp}:${jdbc_port}/${pg_db}", pgProperties)
     try {
+        def statement = pgConnection.createStatement()
+        try {
+            // JDBC schema detection scans all PostgreSQL schemas, so isolate V0 from the V1 database.
+            statement.execute("DROP DATABASE IF EXISTS ${v0_database} WITH (FORCE)")
+            statement.execute("CREATE DATABASE ${v0_database}")
+            statement.execute("DROP ROLE IF EXISTS iceberg_jdbc_v0_reader")
+            statement.execute("CREATE ROLE iceberg_jdbc_v0_reader LOGIN PASSWORD '123456'")
+        } finally {
+            statement.close()
+        }
+        pgConnection.close()
+        pgConnection = pgDriver.connect(v0_uri, pgProperties)
+        statement = pgConnection.createStatement()
+        try {
+            // Use the PyIceberg-compatible V0 layout, which has no iceberg_type column.
+            statement.execute("""CREATE TABLE iceberg_tables (
+                catalog_name VARCHAR(255) NOT NULL, table_namespace VARCHAR(255) NOT NULL,
+                table_name VARCHAR(255) NOT NULL, metadata_location VARCHAR(1000),
+                previous_metadata_location VARCHAR(1000),
+                PRIMARY KEY (catalog_name, table_namespace, table_name))""")
+            statement.execute("""CREATE TABLE iceberg_namespace_properties (
+                catalog_name VARCHAR(255) NOT NULL, namespace VARCHAR(255) NOT NULL,
+                property_key VARCHAR(255), property_value VARCHAR(1000),
+                PRIMARY KEY (catalog_name, namespace, property_key))""")
+            statement.execute("GRANT CONNECT ON DATABASE ${v0_database} TO iceberg_jdbc_v0_reader")
+            statement.execute("GRANT USAGE ON SCHEMA public TO iceberg_jdbc_v0_reader")
+            statement.execute("GRANT SELECT ON ALL TABLES IN SCHEMA public TO iceberg_jdbc_v0_reader")
+        } finally {
+            statement.close()
+        }
+    } finally {
+        pgConnection.close()
+        driverLoader.close()
+    }
+
+    def createV0Catalog = { String name, String uri, String user ->
+        sql """DROP CATALOG IF EXISTS ${name}"""
+        sql """CREATE CATALOG ${name} PROPERTIES (
+            'type' = 'iceberg', 'iceberg.catalog.type' = 'jdbc',
+            'uri' = '${uri}', 'warehouse' = 's3://warehouse/jdbc_v0_wh/',
+            'iceberg.jdbc.driver_url' = '${driver_name}',
+            'iceberg.jdbc.driver_class' = 'org.postgresql.Driver',
+            'iceberg.jdbc.user' = '${user}', 'iceberg.jdbc.password' = '123456',
+            'iceberg.jdbc.catalog_name' = 'jdbc_v0_catalog',
+            'iceberg.jdbc.init-catalog-tables' = 'false', 'iceberg.jdbc.schema-version' = 'V0',
+            's3.endpoint' = 'http://${externalEnvIp}:${minio_port}',
+            's3.access_key' = 'admin', 's3.secret_key' = 'password',
+            's3.region' = 'us-east-1', 'use_path_style' = 'true')"""
+    }
+
+    try {
+        createV0Catalog(v0_writer, v0_uri, "postgres")
+        sql """CREATE DATABASE IF NOT EXISTS ${v0_writer}.v0_db"""
+        sql """CREATE TABLE ${v0_writer}.v0_db.v0_table (id INT) PROPERTIES ('format-version' = '2')"""
+        sql """INSERT INTO ${v0_writer}.v0_db.v0_table VALUES (1), (2)"""
+        // Wrong-kind DROP statements must never purge a permanent V0 table, even with IF EXISTS.
+        for (def dropKind : [[statement: "DROP VIEW", error: "is not VIEW"],
+                            [statement: "DROP MATERIALIZED VIEW", error: "is not MTMV"],
+                            [statement: "DROP TEMPORARY TABLE", error: "Unknown table"]]) {
+            for (boolean ifExists : [false, true]) {
+                String dropSql = "${dropKind.statement} ${ifExists ? 'IF EXISTS ' : ''}${v0_writer}.v0_db.v0_table"
+                if (dropKind.statement == "DROP TEMPORARY TABLE" && ifExists) {
+                    sql dropSql
+                } else {
+                    test {
+                        sql dropSql
+                        exception dropKind.error
+                    }
+                }
+                assertEquals([["v0_table"]], sql("SHOW TABLES FROM ${v0_writer}.v0_db"))
+                assertEquals([[1], [2]], sql("SELECT * FROM ${v0_writer}.v0_db.v0_table ORDER BY id"))
+            }
+        }
+        // PostgreSQL enforces read-only transactions, so accidental catalog migration or writes fail.
+        createV0Catalog(v0_reader, v0_uri + "?options=-c%20default_transaction_read_only%3Don",
+                "iceberg_jdbc_v0_reader")
+        assertTrue(sql("SHOW DATABASES FROM ${v0_reader}").collect { it[0] }.contains("v0_db"))
+        assertEquals([["v0_table"]], sql("SHOW TABLES FROM ${v0_reader}.v0_db"))
+        assertEquals("id", sql("DESC ${v0_reader}.v0_db.v0_table")[0][0])
+        assertEquals([[1], [2]], sql("SELECT * FROM ${v0_reader}.v0_db.v0_table ORDER BY id"))
+        def v0_snapshot = sql("SELECT snapshot_id FROM ${v0_reader}.v0_db.v0_table\$snapshots")[0][0]
+        assertEquals([[1], [2]], sql("""SELECT * FROM ${v0_reader}.v0_db.v0_table
+            FOR VERSION AS OF ${v0_snapshot} ORDER BY id"""))
+        sql """INSERT INTO ${v0_writer}.v0_db.v0_table VALUES (3)"""
+        sql """REFRESH CATALOG ${v0_reader}"""
+        assertEquals([[3L]], sql("SELECT COUNT(*) FROM ${v0_reader}.v0_db.v0_table"))
+        assertEquals([[2L]], sql("""SELECT COUNT(*) FROM ${v0_reader}.v0_db.v0_table
+            FOR VERSION AS OF ${v0_snapshot}"""))
         // Clean up existing catalog
         sql """DROP CATALOG IF EXISTS ${catalog_name}"""
 
@@ -363,6 +462,8 @@ suite("test_iceberg_jdbc_catalog", "p0,external,iceberg,external_docker,external
         try {
             sql """SWITCH internal"""
             sql """DROP CATALOG IF EXISTS ${catalog_name}"""
+            sql """DROP CATALOG IF EXISTS ${v0_reader}"""
+            sql """DROP CATALOG IF EXISTS ${v0_writer}"""
         } catch (Exception e) {
             logger.warn("Failed to cleanup catalog: ${e.message}")
         }


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@doris.apache.org
For additional commands, e-mail: commits-help@doris.apache.org