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 df73f5431846e9..68685cdfa99fe6 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.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; @@ -1290,6 +1292,24 @@ public void dropTable(String dbName, String tableName, boolean isView, boolean i } 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 0e605f9790d71f..2d131f31f12eea 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 @@ -76,6 +76,7 @@ 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.types.Type; import org.apache.iceberg.types.TypeUtil; import org.apache.iceberg.types.Types; @@ -98,6 +99,7 @@ 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; @@ -1954,6 +1956,19 @@ private boolean determineNestedNamespaceEnabled() { return false; } + private T probeViews(ViewCatalog viewCatalog, Supplier 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; } @@ -2026,8 +2041,8 @@ private List listTableNamesInternal(String dbName) { } private boolean viewExistsInternal(String remoteDbName, String remoteViewName) { - return isViewCatalogEnabled() - && ((ViewCatalog) catalog).viewExists(getTableIdentifier(remoteDbName, remoteViewName)); + return isViewCatalogEnabled() && probeViews((ViewCatalog) catalog, + () -> ((ViewCatalog) catalog).viewExists(getTableIdentifier(remoteDbName, remoteViewName)), false); } private View loadViewInternal(String dbName, String tblName) { @@ -2041,8 +2056,8 @@ private List listViewNamesInternal(String dbName) { if (!isViewCatalogEnabled()) { return Collections.emptyList(); } - return ((ViewCatalog) catalog).listViews(getNamespace(dbName)) - .stream().map(TableIdentifier::name).collect(Collectors.toList()); + return probeViews((ViewCatalog) catalog, () -> ((ViewCatalog) catalog).listViews(getNamespace(dbName)) + .stream().map(TableIdentifier::name).collect(Collectors.toList()), Collections.emptyList()); } private void performDropView(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 00000000000000..0bacc0fd0c38af --- /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 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() { + @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 getDbNullable(String dbName) { + return database; + } + + @Override + protected List 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 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 00000000000000..4796697531aeef --- /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 listTables(Namespace namespace) { + return Arrays.asList(TABLE, VIEW); + } + + @Override + public List 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 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 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 2ed8af6c5861f3..368fccc008b1f2 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}") }