This is an automated email from the ASF dual-hosted git repository.
Gabriel39 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/doris.git
The following commit(s) were added to refs/heads/master by this push:
new 57e41e92c28 [fix](fe) Allow table access through Iceberg JDBC V0
catalogs (#69452)
57e41e92c28 is described below
commit 57e41e92c28d0699b0d777cb173da716f601f70d
Author: Gabriel <[email protected]>
AuthorDate: Fri Oct 23 06:47:22 2026 +0800
[fix](fe) Allow table access through Iceberg JDBC V0 catalogs (#69452)
### What problem does this PR solve?
Issue Number: N/A
Problem Summary:
Iceberg's `JdbcCatalog` implements `ViewCatalog` even when its JDBC
metadata schema is V0, which cannot store views. The SDK throws
`UnsupportedOperationException` for V0 `listViews` and `viewExists`.
Doris calls these methods when listing tables and resolving whether an
ordinary table is a view, so an existing V0 catalog can fail table
discovery and queries despite containing no views.
Treat unsupported JDBC view probes as an empty view list or `false`, and
reuse the same probe path when filtering table names. Preserve working
JDBC view support, explicit view-operation errors, failures from other
catalog types, and SQL/authorization errors. Reading V0 does not require
migrating the catalog to V1.
Add six unit tests using the actual SDK V0 guards and negative coverage.
Extend the JDBC regression suite with an isolated PostgreSQL V0 database
and a SELECT-only reader, covering namespace/table listing, schema
inspection, scans, snapshots, refresh, and time travel. The fixture
database is separate because the SDK inspects columns across PostgreSQL
schemas.
### Release note
Fix table discovery and access through Iceberg JDBC catalogs that use
the V0 metadata schema without view support.
### Check List (For Author)
- Test
- [x] Unit Test: Iceberg connector reactor build and tests: 1,493 tests,
zero failures/errors, one existing live REST connectivity test skipped.
FE Checkstyle passes. Before the fix, the new tests reproduce two
failures from the actual SDK V0 guards.
- [x] Regression test: `test_iceberg_jdbc_catalog`, including the new V0
reader and existing PostgreSQL/MySQL cases. Local runtime uses existing
FE/BE binaries with the modified connector class compiled from master,
PostgreSQL 16, MySQL 8, and MinIO.
- [x] Manual test: verify the V0 table has no `iceberg_type` column and
the reader has SELECT but lacks INSERT, UPDATE, and schema CREATE
privileges.
- Behavior changed:
- [x] Yes. Unsupported JDBC view probes no longer prevent ordinary 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
---
.../doris/connector/iceberg/IcebergCatalogOps.java | 28 ++--
.../iceberg/IcebergJdbcViewProbeTest.java | 143 +++++++++++++++++++++
.../iceberg/test_iceberg_jdbc_catalog.groovy | 83 ++++++++++++
3 files changed, 245 insertions(+), 9 deletions(-)
diff --git
a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergCatalogOps.java
b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergCatalogOps.java
index 4968a141ab2..0e60d18ab41 100644
---
a/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergCatalogOps.java
+++
b/fe/fe-connector/fe-connector-iceberg/src/main/java/org/apache/doris/connector/iceberg/IcebergCatalogOps.java
@@ -45,6 +45,7 @@ import org.apache.iceberg.exceptions.NoSuchNamespaceException;
import org.apache.iceberg.expressions.Expressions;
import org.apache.iceberg.expressions.Term;
import org.apache.iceberg.hadoop.HadoopCatalog;
+import org.apache.iceberg.jdbc.JdbcCatalog;
import org.apache.iceberg.types.Type;
import org.apache.iceberg.types.Types;
import org.apache.iceberg.view.View;
@@ -60,6 +61,7 @@ import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.function.Function;
+import java.util.function.Supplier;
import java.util.stream.Collectors;
import java.util.stream.Stream;
@@ -376,12 +378,7 @@ public interface IcebergCatalogOps {
.collect(Collectors.toList());
// iceberg's listTables also returns views, so subtract the view
names when the catalog is a
// (view-enabled) ViewCatalog — mirrors legacy
IcebergMetadataOps.listTableNames.
- if (!isViewCatalogEnabled()) {
- return tableNames;
- }
- List<String> views = resolveViewCatalog().listViews(ns).stream()
- .map(TableIdentifier::name)
- .collect(Collectors.toList());
+ List<String> views = listViewNames(dbName);
if (views.isEmpty()) {
return tableNames;
}
@@ -398,9 +395,9 @@ public interface IcebergCatalogOps {
if (!isViewCatalogEnabled()) {
return Collections.emptyList();
}
- return resolveViewCatalog().listViews(toNamespace(dbName)).stream()
+ return probeViews(() ->
resolveViewCatalog().listViews(toNamespace(dbName)).stream()
.map(TableIdentifier::name)
- .collect(Collectors.toList());
+ .collect(Collectors.toList()), Collections.emptyList());
}
@Override
@@ -415,7 +412,7 @@ public interface IcebergCatalogOps {
if (!isViewCatalogEnabled()) {
return false;
}
- return resolveViewCatalog().viewExists(toTableIdentifier(dbName,
viewName));
+ return probeViews(() ->
resolveViewCatalog().viewExists(toTableIdentifier(dbName, viewName)), false);
}
@Override
@@ -878,6 +875,19 @@ public interface IcebergCatalogOps {
return (SupportsNamespaces) catalog;
}
+ private <T> T probeViews(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 (resolveViewCatalog() instanceof JdbcCatalog) {
+ return noViews;
+ }
+ throw e;
+ }
+ }
+
/** View filtering is on iff a view catalog is resolvable and (for
REST) views are enabled. */
private boolean isViewCatalogEnabled() {
if (resolveViewCatalog() == null) {
diff --git
a/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergJdbcViewProbeTest.java
b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergJdbcViewProbeTest.java
new file mode 100644
index 00000000000..854b09dff81
--- /dev/null
+++
b/fe/fe-connector/fe-connector-iceberg/src/test/java/org/apache/doris/connector/iceberg/IcebergJdbcViewProbeTest.java
@@ -0,0 +1,143 @@
+// 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.connector.iceberg;
+
+import
org.apache.doris.connector.iceberg.IcebergCatalogOps.CatalogBackedIcebergCatalogOps;
+
+import org.apache.iceberg.catalog.Catalog;
+import org.apache.iceberg.catalog.Namespace;
+import org.apache.iceberg.catalog.TableIdentifier;
+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 java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Optional;
+
+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 IcebergCatalogOps ops(Catalog catalog) {
+ return new CatalogBackedIcebergCatalogOps(catalog, false, false, true,
Optional.empty());
+ }
+
+ @Test
+ public void v0ListsTablesWithoutViewSupport() {
+ Assertions.assertEquals(Collections.singletonList("table"), ops(new
V0JdbcCatalog()).listTableNames("db"));
+ }
+
+ @Test
+ public void v0HasNoViews() {
+ IcebergCatalogOps 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 explicitV0ViewOperationsStillFail() {
+ IcebergCatalogOps ops = ops(new V0JdbcCatalog());
+ Assertions.assertThrows(UnsupportedOperationException.class, () ->
ops.loadView("db", "view"));
+ Assertions.assertThrows(UnsupportedOperationException.class, () ->
ops.dropView("db", "view"));
+ }
+
+ @Test
+ public void jdbcViewSupportIsNotDisabledByCatalogType() {
+ IcebergCatalogOps 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"))) {
+ IcebergCatalogOps 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(failure.getClass(),
+ () -> ops.viewExists("db", "view")));
+ }
+ }
+
+ @Test
+ public void unsupportedViewsInOtherCatalogsAreNotHidden() {
+ UnsupportedOperationException failure = new
UnsupportedOperationException("Unsupported view operation");
+ FakeIcebergViewCatalog catalog = new FakeIcebergViewCatalog() {
+ @Override
+ public List<TableIdentifier> listViews(Namespace namespace) {
+ throw failure;
+ }
+
+ @Override
+ public boolean viewExists(TableIdentifier identifier) {
+ throw failure;
+ }
+ };
+ IcebergCatalogOps 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(UnsupportedOperationException.class,
+ () -> ops.viewExists("db", "view")));
+ }
+
+ // 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 10ab5bc8cb5..729b82428f0 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
@@ -290,7 +290,88 @@ suite("test_iceberg_jdbc_catalog", "p0,external") {
check("restored_namespace", "SELECT * FROM jdbc_ctas_renamed ORDER BY
id")
}
+ 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)"""
+ // 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}"""
@@ -540,6 +621,8 @@ suite("test_iceberg_jdbc_catalog", "p0,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: [email protected]
For additional commands, e-mail: [email protected]