From 981d8e6259a81b4cd23ae7397d61177fd6274cd3 Mon Sep 17 00:00:00 2001 From: Stefan Bischof Date: Fri, 16 Oct 2026 16:47:46 +0200 Subject: [PATCH] fix(jdbc): read column metadata left to right Oracle returns COLUMN_DEF as a LONG stream; reading a later column first fails with ORA-17027. Signed-off-by: Stefan Bischof --- .../sql/jdbc/impl/DatabaseServiceImpl.java | 16 +-- ...atabaseServiceReadsColumnsInOrderTest.java | 104 ++++++++++++++++++ 2 files changed, 113 insertions(+), 7 deletions(-) create mode 100644 jdbc/impl/src/test/java/org/eclipse/daanse/sql/jdbc/impl/DatabaseServiceReadsColumnsInOrderTest.java diff --git a/jdbc/impl/src/main/java/org/eclipse/daanse/sql/jdbc/impl/DatabaseServiceImpl.java b/jdbc/impl/src/main/java/org/eclipse/daanse/sql/jdbc/impl/DatabaseServiceImpl.java index dad4499..a98820f 100644 --- a/jdbc/impl/src/main/java/org/eclipse/daanse/sql/jdbc/impl/DatabaseServiceImpl.java +++ b/jdbc/impl/src/main/java/org/eclipse/daanse/sql/jdbc/impl/DatabaseServiceImpl.java @@ -685,11 +685,15 @@ private List getColumnDefinitions(DatabaseMetaData databaseMet try (ResultSet rs = databaseMetaData.getColumns(catalog, schemaPattern, tableNamePattern, columnNamePattern);) { while (rs.next()) { + // Left to right, in the column order of getColumns, as JDBC asks of a portable reader: Oracle + // returns COLUMN_DEF (13) as a LONG stream that a read of any later column consumes, and reading + // it afterwards fails with ORA-17027 "Stream has already been closed". final Optional oCatalogName = Optional.ofNullable(rs.getString("TABLE_CAT")); final Optional oSchemaName = Optional.ofNullable(rs.getString("TABLE_SCHEM")); final String tableName = rs.getString("TABLE_NAME"); final String columName = rs.getString("COLUMN_NAME"); + final int dataType = rs.getInt("DATA_TYPE"); final String typeName = rs.getString("TYPE_NAME"); OptionalInt oColumnSize = OptionalInt.of(rs.getInt("COLUMN_SIZE")); @@ -713,17 +717,15 @@ private List getColumnDefinitions(DatabaseMetaData databaseMet oNullable = OptionalInt.empty(); } - OptionalInt oCharOctetLength = OptionalInt.of(rs.getInt("CHAR_OCTET_LENGTH")); - if (rs.wasNull()) { - oCharOctetLength = OptionalInt.empty(); - } - - final int dataType = rs.getInt("DATA_TYPE"); - final Optional remarks = Optional.ofNullable(rs.getString("REMARKS")); // Additional fields from JDBC spec final Optional columnDefault = Optional.ofNullable(rs.getString("COLUMN_DEF")); + + OptionalInt oCharOctetLength = OptionalInt.of(rs.getInt("CHAR_OCTET_LENGTH")); + if (rs.wasNull()) { + oCharOctetLength = OptionalInt.empty(); + } final ColumnMetaData.Nullability nullability = oNullable.isPresent() ? ColumnMetaData.Nullability.of(oNullable.getAsInt()) : ColumnMetaData.Nullability.UNKNOWN; diff --git a/jdbc/impl/src/test/java/org/eclipse/daanse/sql/jdbc/impl/DatabaseServiceReadsColumnsInOrderTest.java b/jdbc/impl/src/test/java/org/eclipse/daanse/sql/jdbc/impl/DatabaseServiceReadsColumnsInOrderTest.java new file mode 100644 index 0000000..94078db --- /dev/null +++ b/jdbc/impl/src/test/java/org/eclipse/daanse/sql/jdbc/impl/DatabaseServiceReadsColumnsInOrderTest.java @@ -0,0 +1,104 @@ +/* +* Copyright (c) 2026 Contributors to the Eclipse Foundation. +* +* This program and the accompanying materials are made +* available under the terms of the Eclipse Public License 2.0 +* which is available at https://www.eclipse.org/legal/epl-2.0/ +* +* SPDX-License-Identifier: EPL-2.0 +* +* Contributors: +* Stefan Bischof (bipolis.org) - initial +*/ +package org.eclipse.daanse.sql.jdbc.impl; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Proxy; +import java.sql.Connection; +import java.sql.DatabaseMetaData; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.UUID; + +import javax.sql.DataSource; + +import org.eclipse.daanse.sql.jdbc.api.meta.MetaInfo; +import org.eclipse.daanse.sql.model.schema.ColumnDefinition; +import org.h2.jdbcx.JdbcDataSource; +import org.junit.jupiter.api.Test; + +/** + * The column metadata is read left to right, as JDBC asks of a portable reader. Oracle returns + * {@code COLUMN_DEF} (column 13 of {@code getColumns}) as a LONG stream, which a read of any later column of the row + * consumes: reading it afterwards fails with "ORA-17027: Stream has already been closed". H2 stands in here behind a + * result set that keeps that rule. + */ +class DatabaseServiceReadsColumnsInOrderTest { + + @Test + void theColumnDefaultIsReadBeforeTheColumnsAfterIt() throws SQLException { + JdbcDataSource h2 = new JdbcDataSource(); + h2.setURL("jdbc:h2:mem:" + UUID.randomUUID() + ";DB_CLOSE_DELAY=-1"); + try (Connection c = h2.getConnection(); Statement s = c.createStatement()) { + s.execute("CREATE TABLE T (ID INTEGER NOT NULL, LABEL VARCHAR(20) DEFAULT 'none')"); + } + + MetaInfo metaInfo = new DatabaseServiceImpl().createMetaInfo(columnDefaultAsLong(h2)); + + assertThat(metaInfo.structureInfo().columns()) + .filteredOn(column -> "LABEL".equals(column.column().name())) + .singleElement() + .extracting(ColumnDefinition::columnMetaData) + .satisfies(meta -> assertThat(meta.columnDefault()).hasValueSatisfying( + value -> assertThat(value).contains("none"))); + } + + /** The data source, its getColumns result sets keeping Oracle's rule for the LONG {@code COLUMN_DEF}. */ + private static DataSource columnDefaultAsLong(DataSource dataSource) { + return proxy(DataSource.class, dataSource, (method, result) -> "getConnection".equals(method) + ? proxy(Connection.class, (Connection) result, (m, r) -> "getMetaData".equals(m) + ? proxy(DatabaseMetaData.class, (DatabaseMetaData) r, + (mm, rr) -> "getColumns".equals(mm) ? longColumnDefault((ResultSet) rr) : rr) + : r) + : result); + } + + private static ResultSet longColumnDefault(ResultSet rows) throws SQLException { + int columnDefault = rows.findColumn("COLUMN_DEF"); + int[] furthest = { 0 }; + return (ResultSet) Proxy.newProxyInstance(ResultSet.class.getClassLoader(), new Class[] { ResultSet.class }, + (proxy, method, args) -> { + if ("next".equals(method.getName())) { + furthest[0] = 0; + } else if (method.getName().startsWith("get") && args != null && args.length == 1 + && (args[0] instanceof String || args[0] instanceof Integer)) { + int column = args[0] instanceof String label ? rows.findColumn(label) : (Integer) args[0]; + if (column == columnDefault && furthest[0] > columnDefault) { + throw new SQLException("ORA-17027: Stream has already been closed"); + } + furthest[0] = Math.max(furthest[0], column); + } + return invoke(rows, method, args); + }); + } + + private interface Wrap { + Object apply(String method, Object result) throws SQLException; + } + + private static T proxy(Class type, T target, Wrap wrap) { + return type.cast(Proxy.newProxyInstance(type.getClassLoader(), new Class[] { type }, + (proxy, method, args) -> wrap.apply(method.getName(), invoke(target, method, args)))); + } + + private static Object invoke(Object target, java.lang.reflect.Method method, Object[] args) throws Throwable { + try { + return method.invoke(target, args); + } catch (InvocationTargetException e) { + throw e.getCause(); + } + } +}