From 3e9c51f3fe3f45bbb9e3e2fedaf73b2e08753a51 Mon Sep 17 00:00:00 2001 From: Moritz Kaufmann Date: Fri, 24 Apr 2026 21:04:44 +0200 Subject: [PATCH 01/24] refactor: unify ResultSet implementations on Arrow-backed path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Collapse the two ResultSet families (streaming Arrow + row-based metadata) into a single Arrow-backed implementation so there is one accessor pipeline, one set of type semantics, and one place to fix bugs. Changes: - ArrowStreamReaderCursor becomes source-agnostic: a BatchLoader drives a VectorSchemaRoot, whether sourced from an ArrowStreamReader or a pre-populated in-memory batch. The cursor also owns an AutoCloseable so it is responsible for releasing the allocator + reader on close — the old ArrowStreamReader.close() would only tear down vectors and leak the 100 MB RootAllocator. - QueryResultArrowStream.toArrowStreamReader returns a Result holder that pairs the reader with the allocator and closes both in the right order so Arrow's accounting invariants hold. - StreamingResultSet gains ofInMemory(root, owned, queryId, zone, cols) so metadata results funnel through the same result set. A columns override preserves the JDBC-spec typeName labels (e.g. TEXT) that would otherwise be lost when deriving from the Arrow schema. - MetadataArrowBuilder materialises List> metadata rows into a populated VectorSchemaRoot using the existing HyperTypeToArrow mapping; MetadataResultSets is the factory callers use. - QueryMetadataUtil and DataCloudDatabaseMetadata route getTables, getColumns, getSchemas, getTypeInfo and empty metadata results through the Arrow-backed StreamingResultSet. - DataCloudMetadataResultSet, SimpleResultSet, and ColumnAccessor are removed now that no caller depends on them. - StreamingResultSet.getObject(int, Class) gains an isInstance-based fallback so callers can retrieve String-typed VARCHAR columns without each accessor having to implement typed getObject. - Tests moved to the unified path; integer-accessor-only assertions in DataCloudDatabaseMetadataTest updated to reflect stricter Arrow accessor semantics. --- .../jdbc/core/ArrowStreamReaderCursor.java | 75 ++- .../jdbc/core/DataCloudDatabaseMetadata.java | 23 +- .../jdbc/core/DataCloudMetadataResultSet.java | 164 ------- .../jdbc/core/QueryMetadataUtil.java | 4 +- .../core/SQLExceptionQueryResultIterator.java | 13 +- .../jdbc/core/StreamingResultSet.java | 125 ++++- .../core/metadata/MetadataArrowBuilder.java | 184 +++++++ .../core/metadata/MetadataResultSets.java | 76 +++ .../jdbc/core/resultset/ColumnAccessor.java | 61 --- .../jdbc/core/resultset/SimpleResultSet.java | 379 -------------- .../jdbc/protocol/QueryResultArrowStream.java | 31 +- .../core/ArrowStreamReaderCursorTest.java | 32 +- .../core/DataCloudDatabaseMetadataTest.java | 30 +- .../core/DataCloudMetadataResultSetTest.java | 59 ++- .../core/StreamingResultSetMethodTest.java | 17 +- .../DataCloudResultSetMetaDataTest.java | 5 +- .../core/resultset/ColumnAccessorTest.java | 31 -- .../core/resultset/SimpleResultSetTest.java | 462 ------------------ .../protocol/QueryResultArrowStreamTest.java | 6 +- 19 files changed, 560 insertions(+), 1217 deletions(-) delete mode 100644 jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/DataCloudMetadataResultSet.java create mode 100644 jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/metadata/MetadataArrowBuilder.java create mode 100644 jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/metadata/MetadataResultSets.java delete mode 100644 jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/resultset/ColumnAccessor.java delete mode 100644 jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/resultset/SimpleResultSet.java delete mode 100644 jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/core/resultset/ColumnAccessorTest.java delete mode 100644 jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/core/resultset/SimpleResultSetTest.java diff --git a/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/ArrowStreamReaderCursor.java b/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/ArrowStreamReaderCursor.java index ac143ae1..725e327c 100644 --- a/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/ArrowStreamReaderCursor.java +++ b/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/ArrowStreamReaderCursor.java @@ -8,7 +8,6 @@ import com.salesforce.datacloud.jdbc.core.accessor.QueryJDBCAccessor; import com.salesforce.datacloud.jdbc.core.accessor.QueryJDBCAccessorFactory; -import java.io.IOException; import java.sql.SQLException; import java.time.ZoneId; import java.util.List; @@ -21,12 +20,27 @@ import org.apache.arrow.vector.VectorSchemaRoot; import org.apache.arrow.vector.ipc.ArrowStreamReader; +/** + * Row cursor over a {@link VectorSchemaRoot} that drives the {@link StreamingResultSet}. + * + *

The cursor is source-agnostic: a {@link BatchLoader} loads the next batch into the vector + * schema root, whether that comes from an Arrow IPC stream or a pre-populated in-memory batch. + * This is the single codepath that the driver exposes to JDBC callers — streaming query results + * and materialised metadata results both funnel through here. + * + *

The cursor owns an optional {@link AutoCloseable} holding the resources that back the + * vector schema root (allocator, underlying reader, etc.). Closing the cursor closes that holder, + * guaranteeing root-allocator hygiene without requiring each call site to manage the allocator + * separately. + */ @Slf4j class ArrowStreamReaderCursor implements AutoCloseable { private static final int INIT_ROW_NUMBER = -1; - private final ArrowStreamReader reader; + private final VectorSchemaRoot root; + private final BatchLoader batchLoader; + private final AutoCloseable ownedResources; private final ZoneId sessionZone; @lombok.Getter @@ -34,18 +48,51 @@ class ArrowStreamReaderCursor implements AutoCloseable { private final AtomicInteger currentIndex = new AtomicInteger(INIT_ROW_NUMBER); - ArrowStreamReaderCursor(ArrowStreamReader reader, ZoneId sessionZone) { - this.reader = reader; - this.sessionZone = sessionZone; + /** + * Loads the next batch of rows into the vector schema root. + * + *

Implementations should return {@code true} if the vector schema root now holds rows from + * a newly-loaded batch, and {@code false} if the source has no more data. + */ + @FunctionalInterface + interface BatchLoader { + boolean loadNextBatch() throws Exception; } + /** + * Create a cursor that pulls batches from an {@link ArrowStreamReader}. The reader (and the + * allocator it was constructed with) are owned by the cursor — closing the cursor closes the + * supplied {@code ownedResources}. + */ @SneakyThrows - private VectorSchemaRoot getSchemaRoot() { - return reader.getVectorSchemaRoot(); + static ArrowStreamReaderCursor streaming( + ArrowStreamReader reader, AutoCloseable ownedResources, ZoneId sessionZone) { + val root = reader.getVectorSchemaRoot(); + BatchLoader loader = reader::loadNextBatch; + return new ArrowStreamReaderCursor(root, loader, ownedResources, sessionZone); + } + + /** + * Create a cursor over a single pre-populated {@link VectorSchemaRoot}. The root (and any + * backing allocator wrapped in {@code ownedResources}) are owned by the cursor — closing the + * cursor closes the supplied {@code ownedResources}. + */ + static ArrowStreamReaderCursor inMemory(VectorSchemaRoot root, AutoCloseable ownedResources, ZoneId sessionZone) { + // The VSR is already populated, so there is nothing more to load — the cursor walks the + // row count until exhausted and then reports end-of-stream. + return new ArrowStreamReaderCursor(root, () -> false, ownedResources, sessionZone); + } + + private ArrowStreamReaderCursor( + VectorSchemaRoot root, BatchLoader batchLoader, AutoCloseable ownedResources, ZoneId sessionZone) { + this.root = root; + this.batchLoader = batchLoader; + this.ownedResources = ownedResources; + this.sessionZone = sessionZone; } List createAccessors() { - return getSchemaRoot().getFieldVectors().stream() + return root.getFieldVectors().stream() .map(rethrowFunction(this::createAccessor)) .collect(Collectors.toList()); } @@ -57,9 +104,9 @@ private QueryJDBCAccessor createAccessor(FieldVector vector) throws SQLException /** * Load the next batch that has at least one row, skipping any zero-row batches in between. */ - private boolean loadNextNonEmptyBatch() throws IOException { - while (reader.loadNextBatch()) { - if (getSchemaRoot().getRowCount() > 0) { + private boolean loadNextNonEmptyBatch() throws Exception { + while (batchLoader.loadNextBatch()) { + if (root.getRowCount() > 0) { currentIndex.set(0); return true; } @@ -70,7 +117,7 @@ private boolean loadNextNonEmptyBatch() throws IOException { @SneakyThrows public boolean next() { val current = currentIndex.incrementAndGet(); - val total = getSchemaRoot().getRowCount(); + val total = root.getRowCount(); try { val next = current < total || loadNextNonEmptyBatch(); @@ -91,6 +138,8 @@ public boolean next() { @SneakyThrows @Override public void close() { - reader.close(); + if (ownedResources != null) { + ownedResources.close(); + } } } diff --git a/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/DataCloudDatabaseMetadata.java b/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/DataCloudDatabaseMetadata.java index 1dcfb65e..9adc8600 100644 --- a/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/DataCloudDatabaseMetadata.java +++ b/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/DataCloudDatabaseMetadata.java @@ -13,7 +13,7 @@ import com.google.common.collect.ImmutableList; import com.salesforce.datacloud.jdbc.config.DriverVersion; -import com.salesforce.datacloud.jdbc.core.metadata.DataCloudResultSetMetaData; +import com.salesforce.datacloud.jdbc.core.metadata.MetadataResultSets; import com.salesforce.datacloud.jdbc.core.types.HyperTypes; import com.salesforce.datacloud.jdbc.util.JdbcURL; import com.salesforce.datacloud.jdbc.util.ThrowingJdbcSupplier; @@ -706,39 +706,39 @@ public ResultSet getColumns(String catalog, String schemaPattern, String tableNa @Override public ResultSet getColumnPrivileges(String catalog, String schema, String table, String columnNamePattern) throws SQLException { - return DataCloudMetadataResultSet.empty(); + return MetadataResultSets.emptyNoColumns(); } @Override public ResultSet getTablePrivileges(String catalog, String schemaPattern, String tableNamePattern) throws SQLException { - return DataCloudMetadataResultSet.empty(); + return MetadataResultSets.emptyNoColumns(); } @Override public ResultSet getBestRowIdentifier(String catalog, String schema, String table, int scope, boolean nullable) throws SQLException { - return DataCloudMetadataResultSet.empty(); + return MetadataResultSets.emptyNoColumns(); } @Override public ResultSet getVersionColumns(String catalog, String schema, String table) throws SQLException { - return DataCloudMetadataResultSet.empty(); + return MetadataResultSets.emptyNoColumns(); } @Override public ResultSet getPrimaryKeys(String catalog, String schema, String table) throws SQLException { - return DataCloudMetadataResultSet.empty(); + return MetadataResultSets.emptyNoColumns(); } @Override public ResultSet getImportedKeys(String catalog, String schema, String table) throws SQLException { - return DataCloudMetadataResultSet.empty(); + return MetadataResultSets.emptyNoColumns(); } @Override public ResultSet getExportedKeys(String catalog, String schema, String table) throws SQLException { - return DataCloudMetadataResultSet.empty(); + return MetadataResultSets.emptyNoColumns(); } @Override @@ -750,19 +750,18 @@ public ResultSet getCrossReference( String foreignSchema, String foreignTable) throws SQLException { - return DataCloudMetadataResultSet.empty(); + return MetadataResultSets.emptyNoColumns(); } @Override public ResultSet getTypeInfo() throws SQLException { - return DataCloudMetadataResultSet.of( - new DataCloudResultSetMetaData(MetadataSchemas.TYPE_INFO), HyperTypes.typeInfoRows()); + return MetadataResultSets.ofRawRows(MetadataSchemas.TYPE_INFO, HyperTypes.typeInfoRows()); } @Override public ResultSet getIndexInfo(String catalog, String schema, String table, boolean unique, boolean approximate) throws SQLException { - return DataCloudMetadataResultSet.empty(); + return MetadataResultSets.emptyNoColumns(); } @Override diff --git a/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/DataCloudMetadataResultSet.java b/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/DataCloudMetadataResultSet.java deleted file mode 100644 index 1c6ee1d9..00000000 --- a/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/DataCloudMetadataResultSet.java +++ /dev/null @@ -1,164 +0,0 @@ -/** - * This file is part of https://github.com/forcedotcom/datacloud-jdbc which is released under the - * Apache 2.0 license. See https://github.com/forcedotcom/datacloud-jdbc/blob/main/LICENSE.txt - */ -package com.salesforce.datacloud.jdbc.core; - -import com.salesforce.datacloud.jdbc.core.metadata.DataCloudResultSetMetaData; -import com.salesforce.datacloud.jdbc.core.resultset.ColumnAccessor; -import com.salesforce.datacloud.jdbc.core.resultset.SimpleResultSet; -import com.salesforce.datacloud.jdbc.protocol.data.ColumnMetadata; -import java.sql.SQLException; -import java.sql.Statement; -import java.util.Collections; -import java.util.List; -import java.util.OptionalLong; - -/** - * Custom ResultSet implementation for metadata queries - */ -public class DataCloudMetadataResultSet extends SimpleResultSet { - - private final List data; - private int currentRow = -1; - private boolean closed = false; - - private DataCloudMetadataResultSet( - DataCloudResultSetMetaData metadata, - ColumnAccessor[] accessors, - List data) { - super(metadata, accessors, false); - this.data = data; - } - - public static DataCloudMetadataResultSet empty() throws SQLException { - return of(new DataCloudResultSetMetaData(Collections.emptyList()), Collections.emptyList()); - } - - public static DataCloudMetadataResultSet of(DataCloudResultSetMetaData metadata, List data) - throws SQLException { - @SuppressWarnings("unchecked") - ColumnAccessor[] accessors = new ColumnAccessor[metadata.getColumnCount()]; - for (int i = 0; i < metadata.getColumnCount(); i++) { - final int columnIndex = i; - accessors[i] = createAccessor(metadata.getColumn(i + 1), columnIndex); - } - - return new DataCloudMetadataResultSet(metadata, accessors, data); - } - - /** - * Creates a ColumnAccessor for a specific column. - */ - private static ColumnAccessor createAccessor(ColumnMetadata column, int columnIndex) { - return new ColumnAccessor() { - @Override - public String getString(DataCloudMetadataResultSet resultSet) throws SQLException { - Object value = getValue(resultSet, columnIndex); - if (value == null) { - return null; - } - return value.toString(); - } - - @Override - public Boolean getBoolean(DataCloudMetadataResultSet resultSet) throws SQLException { - Object value = getValue(resultSet, columnIndex); - if (value == null) { - return null; - } - if (value instanceof Boolean) { - return (Boolean) value; - } - return false; - } - - @Override - public OptionalLong getAnyInteger(DataCloudMetadataResultSet resultSet) throws SQLException { - Object value = getValue(resultSet, columnIndex); - if (value == null) { - return OptionalLong.empty(); - } - if (value instanceof Number) { - return OptionalLong.of(((Number) value).longValue()); - } - throw new SQLException( - "Cannot convert to integer: " + value.getClass().getName()); - } - - /** - * Helper method to get the value for the current row and column. - */ - private Object getValue(DataCloudMetadataResultSet resultSet, int columnIndex) throws SQLException { - if (resultSet.closed) { - throw new SQLException("ResultSet is closed"); - } - if (resultSet.currentRow < 0) { - throw new SQLException("No current row. Call next() first."); - } - if (resultSet.currentRow >= resultSet.data.size()) { - throw new SQLException("Row index out of bounds"); - } - - Object row = resultSet.data.get(resultSet.currentRow); - if (!(row instanceof List)) { - throw new SQLException("Data row is not a List"); - } - - @SuppressWarnings("unchecked") - List rowList = (List) row; - - if (columnIndex >= rowList.size()) { - throw new SQLException("Column index " + columnIndex + " out of bounds for row"); - } - - return rowList.get(columnIndex); - } - }; - } - - @Override - public boolean next() throws SQLException { - if (closed) { - throw new SQLException("ResultSet is closed"); - } - currentRow++; - return currentRow < data.size(); - } - - @Override - public void close() throws SQLException { - closed = true; - } - - @Override - public boolean isClosed() throws SQLException { - return closed; - } - - @Override - public int getRow() throws SQLException { - if (closed) { - throw new SQLException("ResultSet is closed"); - } - if (currentRow >= 0 && currentRow < data.size()) { - return currentRow + 1; - } - return 0; - } - - @Override - public Statement getStatement() throws SQLException { - return null; - } - - @Override - public T unwrap(Class iface) throws SQLException { - return null; - } - - @Override - public boolean isWrapperFor(Class iface) throws SQLException { - return false; - } -} diff --git a/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/QueryMetadataUtil.java b/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/QueryMetadataUtil.java index fa3e9b8d..53de6228 100644 --- a/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/QueryMetadataUtil.java +++ b/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/QueryMetadataUtil.java @@ -12,7 +12,7 @@ import com.google.common.base.Strings; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; -import com.salesforce.datacloud.jdbc.core.metadata.DataCloudResultSetMetaData; +import com.salesforce.datacloud.jdbc.core.metadata.MetadataResultSets; import com.salesforce.datacloud.jdbc.core.types.HyperTypes; import com.salesforce.datacloud.jdbc.protocol.data.ColumnMetadata; import com.salesforce.datacloud.jdbc.protocol.data.HyperType; @@ -75,7 +75,7 @@ public static ResultSet createTableResultSet( } static ResultSet getMetadataResultSet(List columns, List data) throws SQLException { - return DataCloudMetadataResultSet.of(new DataCloudResultSetMetaData(columns), data); + return MetadataResultSets.ofRawRows(columns, data); } private static List constructTableData(ResultSet resultSet) throws SQLException { diff --git a/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/SQLExceptionQueryResultIterator.java b/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/SQLExceptionQueryResultIterator.java index 95b1cd8b..1891d730 100644 --- a/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/SQLExceptionQueryResultIterator.java +++ b/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/SQLExceptionQueryResultIterator.java @@ -12,7 +12,6 @@ import lombok.AllArgsConstructor; import lombok.SneakyThrows; import lombok.val; -import org.apache.arrow.vector.ipc.ArrowStreamReader; import salesforce.cdp.hyperdb.v1.QueryResult; /** @@ -38,20 +37,22 @@ public void close() throws Exception { } /** - * Creates an {@link ArrowStreamReader} that wraps the given iterator with SQL exception handling. + * Creates an Arrow stream result that wraps the given iterator with SQL exception handling. *

* This factory method creates a {@link SQLExceptionQueryResultIterator} that wraps the provided - * iterator and converts it to an {@link ArrowStreamReader}. Any gRPC exceptions thrown during - * iteration will be converted to SQL exceptions with appropriate context information. + * iterator and converts it to a {@link QueryResultArrowStream.Result}. Any gRPC exceptions + * thrown during iteration will be converted to SQL exceptions with appropriate context + * information. The returned {@link QueryResultArrowStream.Result} owns the reader and its + * backing allocator — the caller must close it to release both. *

* * @param resultIterator the source iterator of {@link QueryResult} objects * @param includeCustomerDetail whether to include customer-specific details in exceptions * @param queryId the unique identifier of the query being executed * @param sql the SQL statement being executed - * @return an {@link ArrowStreamReader} that converts gRPC exceptions to SQL exceptions + * @return a {@link QueryResultArrowStream.Result} that converts gRPC exceptions to SQL exceptions */ - public static ArrowStreamReader createSqlExceptionArrowStreamReader( + public static QueryResultArrowStream.Result createSqlExceptionArrowStreamReader( CloseableIterator resultIterator, boolean includeCustomerDetail, String queryId, String sql) { val throwingSqlExceptionIterator = new SQLExceptionQueryResultIterator(resultIterator, includeCustomerDetail, queryId, sql); diff --git a/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/StreamingResultSet.java b/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/StreamingResultSet.java index 0a924747..7cf064e2 100644 --- a/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/StreamingResultSet.java +++ b/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/StreamingResultSet.java @@ -9,6 +9,7 @@ import com.salesforce.datacloud.jdbc.core.resultset.ForwardOnlyResultSet; import com.salesforce.datacloud.jdbc.core.resultset.ReadOnlyResultSet; import com.salesforce.datacloud.jdbc.core.resultset.ResultSetWithPositionalGetters; +import com.salesforce.datacloud.jdbc.protocol.QueryResultArrowStream; import com.salesforce.datacloud.jdbc.protocol.data.ArrowToHyperTypeMapper; import com.salesforce.datacloud.jdbc.protocol.data.ColumnMetadata; import com.salesforce.datacloud.jdbc.util.ThrowingJdbcSupplier; @@ -37,12 +38,13 @@ import java.sql.Types; import java.time.ZoneId; import java.util.Calendar; +import java.util.List; import java.util.Map; import java.util.stream.Collectors; import lombok.Getter; import lombok.extern.slf4j.Slf4j; import lombok.val; -import org.apache.arrow.vector.ipc.ArrowStreamReader; +import org.apache.arrow.vector.VectorSchemaRoot; @Slf4j public class StreamingResultSet @@ -73,37 +75,28 @@ private StreamingResultSet( this.closed = false; } - public static StreamingResultSet of(ArrowStreamReader resultStream, String queryId) throws SQLException { - return of(resultStream, queryId, ZoneId.systemDefault()); + public static StreamingResultSet of(QueryResultArrowStream.Result arrowStream, String queryId) throws SQLException { + return of(arrowStream, queryId, ZoneId.systemDefault()); } /** * Creates a StreamingResultSet with a specified session timezone. * - * @param resultStream The Arrow stream containing query results + *

Ownership of {@code arrowStream} (both the reader and its backing allocator) transfers + * to the returned result set — callers must not close it separately. + * + * @param arrowStream The Arrow stream containing query results, owned by the result set * @param queryId The query identifier * @param sessionZone The session timezone to use for timestamp conversions * @return A new StreamingResultSet * @throws SQLException If an error occurs during ResultSet creation */ - public static StreamingResultSet of(ArrowStreamReader resultStream, String queryId, ZoneId sessionZone) + public static StreamingResultSet of(QueryResultArrowStream.Result arrowStream, String queryId, ZoneId sessionZone) throws SQLException { try { - val schemaRoot = resultStream.getVectorSchemaRoot(); - val fields = schemaRoot.getSchema().getFields(); - - val columns = fields.stream() - .map(field -> new ColumnMetadata(field.getName(), ArrowToHyperTypeMapper.toHyperType(field))) - .collect(Collectors.toList()); - val metadata = new DataCloudResultSetMetaData(columns); - - val cursor = new ArrowStreamReaderCursor(resultStream, sessionZone); - val accessorList = cursor.createAccessors(); - val accessors = accessorList.toArray(new QueryJDBCAccessor[0]); - - val columnNameResolver = new ColumnNameResolver(columns); - - return new StreamingResultSet(cursor, queryId, metadata, accessors, columnNameResolver); + val schemaRoot = arrowStream.getReader().getVectorSchemaRoot(); + val cursor = ArrowStreamReaderCursor.streaming(arrowStream.getReader(), arrowStream, sessionZone); + return build(cursor, schemaRoot, queryId, null); } catch (IOException ex) { throw new SQLException("Unexpected error during ResultSet creation", "XX000", ex); } catch (IllegalArgumentException ex) { @@ -112,6 +105,66 @@ public static StreamingResultSet of(ArrowStreamReader resultStream, String query } } + /** + * Creates a StreamingResultSet over a pre-populated in-memory {@link VectorSchemaRoot}. + * + *

Used by the metadata path (e.g. {@code DatabaseMetaData.getTables}), where the rows are + * materialised into Arrow up front rather than streamed from the server. Ownership of + * {@code ownedResources} — typically the {@link org.apache.arrow.memory.BufferAllocator} and + * {@link VectorSchemaRoot} — transfers to the returned result set, which closes them when + * it is closed. + * + * @param columns optional column-metadata override. When non-null it takes precedence over + * what would be derived from the Arrow schema so that {@link ColumnMetadata#getTypeName()} + * overrides (e.g. {@code "TEXT"} for {@code getTables} rather than the derived + * {@code "VARCHAR"}) are preserved on the way through. + */ + public static StreamingResultSet ofInMemory( + VectorSchemaRoot schemaRoot, + AutoCloseable ownedResources, + String queryId, + ZoneId sessionZone, + List columns) + throws SQLException { + try { + val cursor = ArrowStreamReaderCursor.inMemory(schemaRoot, ownedResources, sessionZone); + return build(cursor, schemaRoot, queryId, columns); + } catch (IllegalArgumentException ex) { + throw new SQLException("Unsupported column type in result set: " + ex.getMessage(), "0A000", ex); + } + } + + /** + * Overload that derives the column metadata from the Arrow schema. Prefer the overload that + * takes an explicit {@code columns} list if callers need to preserve JDBC-spec type names + * (e.g. {@code "TEXT"}). + */ + public static StreamingResultSet ofInMemory( + VectorSchemaRoot schemaRoot, AutoCloseable ownedResources, String queryId, ZoneId sessionZone) + throws SQLException { + return ofInMemory(schemaRoot, ownedResources, queryId, sessionZone, null); + } + + private static StreamingResultSet build( + ArrowStreamReaderCursor cursor, + VectorSchemaRoot schemaRoot, + String queryId, + List columnOverride) + throws SQLException { + final List columns; + if (columnOverride != null) { + columns = columnOverride; + } else { + columns = schemaRoot.getSchema().getFields().stream() + .map(field -> new ColumnMetadata(field.getName(), ArrowToHyperTypeMapper.toHyperType(field))) + .collect(Collectors.toList()); + } + val metadata = new DataCloudResultSetMetaData(columns); + val accessors = cursor.createAccessors().toArray(new QueryJDBCAccessor[0]); + val columnNameResolver = new ColumnNameResolver(columns); + return new StreamingResultSet(cursor, queryId, metadata, accessors, columnNameResolver); + } + // --- Core ResultSet navigation --- @Override @@ -321,6 +374,10 @@ public Object getObject(int columnIndex) throws SQLException { @Override public Object getObject(int columnIndex, Map> map) throws SQLException { + if (map == null || map.isEmpty()) { + // JDBC allows a null/empty type map to behave like plain getObject(int). + return getObject(columnIndex); + } val accessor = getAccessor(columnIndex); val result = accessor.getObject(map); updateWasNull(accessor); @@ -329,10 +386,32 @@ public Object getObject(int columnIndex, Map> map) throws SQLEx @Override public T getObject(int columnIndex, Class type) throws SQLException { + if (type == null) { + throw new SQLException("Target type must not be null"); + } + // Default implementation: get the raw Object and check if it matches the requested type. + // Accessors that need richer conversion (e.g. Arrow Decimal → BigInteger) can still + // override the accessor-level getObject(Class) — in that case we dispatch to them first. val accessor = getAccessor(columnIndex); - val result = accessor.getObject(type); - updateWasNull(accessor); - return result; + try { + val typed = accessor.getObject(type); + updateWasNull(accessor); + return typed; + } catch (SQLException ex) { + // Accessor does not implement typed getObject — fall back to raw + isInstance check. + val raw = accessor.getObject(); + updateWasNull(accessor); + if (raw == null) { + return null; + } + if (type.isInstance(raw)) { + return type.cast(raw); + } + throw new SQLException( + "Cannot convert column value to " + type.getName() + "; actual type is " + + raw.getClass().getName(), + ex); + } } @Override diff --git a/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/metadata/MetadataArrowBuilder.java b/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/metadata/MetadataArrowBuilder.java new file mode 100644 index 00000000..a5900661 --- /dev/null +++ b/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/metadata/MetadataArrowBuilder.java @@ -0,0 +1,184 @@ +/** + * This file is part of https://github.com/forcedotcom/datacloud-jdbc which is released under the + * Apache 2.0 license. See https://github.com/forcedotcom/datacloud-jdbc/blob/main/LICENSE.txt + */ +package com.salesforce.datacloud.jdbc.core.metadata; + +import com.salesforce.datacloud.jdbc.protocol.data.ColumnMetadata; +import com.salesforce.datacloud.jdbc.protocol.data.HyperType; +import com.salesforce.datacloud.jdbc.protocol.data.HyperTypeKind; +import com.salesforce.datacloud.jdbc.protocol.data.HyperTypeToArrow; +import java.nio.charset.StandardCharsets; +import java.util.List; +import lombok.Value; +import org.apache.arrow.memory.RootAllocator; +import org.apache.arrow.vector.BigIntVector; +import org.apache.arrow.vector.BitVector; +import org.apache.arrow.vector.IntVector; +import org.apache.arrow.vector.SmallIntVector; +import org.apache.arrow.vector.TinyIntVector; +import org.apache.arrow.vector.ValueVector; +import org.apache.arrow.vector.VarCharVector; +import org.apache.arrow.vector.VectorSchemaRoot; +import org.apache.arrow.vector.types.pojo.Field; +import org.apache.arrow.vector.types.pojo.Schema; + +/** + * Materialises a list of row-oriented metadata values into a populated Arrow + * {@link VectorSchemaRoot}. + * + *

Used by the JDBC metadata path ({@code DatabaseMetaData.getTables}, {@code getColumns}, + * {@code getTypeInfo}, ...) so that both streaming query results and materialised metadata + * results run through the same Arrow-backed result set. + * + *

The builder owns a fresh {@link RootAllocator}; the returned {@link Result} transfers + * ownership of the allocator and schema root to the caller, which must close both — closing in + * the {@link Result#close()} order tears down the root before the allocator to satisfy Arrow's + * accounting invariants. + */ +public final class MetadataArrowBuilder { + + private MetadataArrowBuilder() { + throw new UnsupportedOperationException("This is a utility class and cannot be instantiated"); + } + + /** Populated result. Owns the allocator and the vector schema root. */ + @Value + public static class Result implements AutoCloseable { + VectorSchemaRoot root; + RootAllocator allocator; + + @Override + public void close() { + // Close the root first: it releases ArrowBuf accounting back to the allocator, so the + // allocator's closing budget check passes. Reversing the order trips a leak detector. + try { + root.close(); + } finally { + allocator.close(); + } + } + } + + /** + * Build an Arrow {@link VectorSchemaRoot} whose schema matches {@code columns} and whose rows + * come from {@code rows} (each inner list is a row in column order). + * + *

Values are coerced to the target vector type on a best-effort basis (e.g. a + * {@link Boolean} in a VARCHAR column becomes {@code "true"}/{@code "false"}). This mirrors + * the loose coercion the removed {@code SimpleResultSet.getString} used to do on row-based + * metadata data. + */ + public static Result build(List columns, List> rows) { + RootAllocator allocator = new RootAllocator(Long.MAX_VALUE); + VectorSchemaRoot root = null; + try { + Schema schema = buildSchema(columns); + root = VectorSchemaRoot.create(schema, allocator); + root.allocateNew(); + populate(root, columns, rows); + root.setRowCount(rows == null ? 0 : rows.size()); + Result result = new Result(root, allocator); + root = null; // ownership transferred + return result; + } finally { + if (root != null) { + try { + root.close(); + } finally { + allocator.close(); + } + } + } + } + + private static Schema buildSchema(List columns) { + java.util.List fields = new java.util.ArrayList<>(columns.size()); + for (ColumnMetadata column : columns) { + fields.add(HyperTypeToArrow.toField(column.getName(), column.getType())); + } + return new Schema(fields); + } + + private static void populate(VectorSchemaRoot root, List columns, List> rows) { + if (rows == null || rows.isEmpty()) { + return; + } + for (int col = 0; col < columns.size(); col++) { + ValueVector vector = root.getVector(columns.get(col).getName()); + HyperType type = columns.get(col).getType(); + for (int rowIdx = 0; rowIdx < rows.size(); rowIdx++) { + List row = rows.get(rowIdx); + Object value = row == null || col >= row.size() ? null : row.get(col); + setValue(vector, rowIdx, type, value); + } + vector.setValueCount(rows.size()); + } + } + + private static void setValue(ValueVector vector, int index, HyperType type, Object value) { + if (value == null) { + setNull(vector, index, type); + return; + } + HyperTypeKind kind = type.getKind(); + switch (kind) { + case VARCHAR: + case CHAR: + byte[] bytes = asString(value).getBytes(StandardCharsets.UTF_8); + ((VarCharVector) vector).setSafe(index, bytes); + return; + case INT8: + ((TinyIntVector) vector).setSafe(index, ((Number) value).byteValue()); + return; + case INT16: + ((SmallIntVector) vector).setSafe(index, ((Number) value).shortValue()); + return; + case INT32: + ((IntVector) vector).setSafe(index, ((Number) value).intValue()); + return; + case INT64: + ((BigIntVector) vector).setSafe(index, ((Number) value).longValue()); + return; + case BOOL: + ((BitVector) vector).setSafe(index, Boolean.TRUE.equals(value) ? 1 : 0); + return; + default: + throw new IllegalArgumentException("MetadataArrowBuilder does not support HyperTypeKind " + kind + + "; metadata schemas are expected to use VARCHAR/CHAR and fixed-width integers only"); + } + } + + private static void setNull(ValueVector vector, int index, HyperType type) { + switch (type.getKind()) { + case VARCHAR: + case CHAR: + ((VarCharVector) vector).setNull(index); + return; + case INT8: + ((TinyIntVector) vector).setNull(index); + return; + case INT16: + ((SmallIntVector) vector).setNull(index); + return; + case INT32: + ((IntVector) vector).setNull(index); + return; + case INT64: + ((BigIntVector) vector).setNull(index); + return; + case BOOL: + ((BitVector) vector).setNull(index); + return; + default: + throw new IllegalArgumentException("Unsupported metadata type kind for null: " + type.getKind()); + } + } + + private static String asString(Object value) { + if (value instanceof byte[]) { + return new String((byte[]) value, StandardCharsets.UTF_8); + } + return value.toString(); + } +} diff --git a/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/metadata/MetadataResultSets.java b/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/metadata/MetadataResultSets.java new file mode 100644 index 00000000..11d5b9fc --- /dev/null +++ b/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/metadata/MetadataResultSets.java @@ -0,0 +1,76 @@ +/** + * This file is part of https://github.com/forcedotcom/datacloud-jdbc which is released under the + * Apache 2.0 license. See https://github.com/forcedotcom/datacloud-jdbc/blob/main/LICENSE.txt + */ +package com.salesforce.datacloud.jdbc.core.metadata; + +import com.salesforce.datacloud.jdbc.core.StreamingResultSet; +import com.salesforce.datacloud.jdbc.protocol.data.ColumnMetadata; +import java.sql.SQLException; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * Factory for Arrow-backed metadata result sets. This is the single entry point callers use to + * materialise a list of {@link ColumnMetadata} + row values into a {@link StreamingResultSet}, + * replacing the historical row-based {@code DataCloudMetadataResultSet}. + */ +public final class MetadataResultSets { + + private MetadataResultSets() { + throw new UnsupportedOperationException("This is a utility class and cannot be instantiated"); + } + + /** Empty result set with the given column schema. */ + public static StreamingResultSet empty(List columns) throws SQLException { + return of(columns, Collections.emptyList()); + } + + /** Empty result set with no columns — used as a placeholder by unsupported metadata methods. */ + public static StreamingResultSet emptyNoColumns() throws SQLException { + return of(Collections.emptyList(), Collections.emptyList()); + } + + /** + * Build a result set whose schema is {@code columns} and whose rows are {@code rows}. Each + * inner list in {@code rows} supplies values in column order. + */ + public static StreamingResultSet of(List columns, List> rows) throws SQLException { + MetadataArrowBuilder.Result built = MetadataArrowBuilder.build(columns, rows); + // Pass the original columns through as the metadata override so that JDBC-spec type names + // ("TEXT", "INTEGER", "SHORT") survive the round-trip via Arrow — if we rederived from the + // Arrow schema we would get the generic {@code HyperType}-derived names ("VARCHAR" etc.). + return StreamingResultSet.ofInMemory( + built.getRoot(), built, /*queryId=*/ null, ZoneId.systemDefault(), columns); + } + + /** + * Convenience overload for callers that still speak in terms of {@code List} where + * each element is itself a {@code List} row. Mirrors the old + * {@code DataCloudMetadataResultSet.of(..., List data)} signature. + */ + public static StreamingResultSet ofRawRows(List columns, List rawRows) throws SQLException { + return of(columns, coerceRows(rawRows)); + } + + @SuppressWarnings("unchecked") + private static List> coerceRows(List rawRows) throws SQLException { + if (rawRows == null || rawRows.isEmpty()) { + return Collections.emptyList(); + } + List> result = new ArrayList<>(rawRows.size()); + for (Object row : rawRows) { + if (row == null) { + result.add(Collections.emptyList()); + } else if (row instanceof List) { + result.add((List) row); + } else { + throw new SQLException( + "Metadata row is not a List: " + row.getClass().getName()); + } + } + return result; + } +} diff --git a/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/resultset/ColumnAccessor.java b/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/resultset/ColumnAccessor.java deleted file mode 100644 index 42f24a9d..00000000 --- a/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/resultset/ColumnAccessor.java +++ /dev/null @@ -1,61 +0,0 @@ -/** - * This file is part of https://github.com/forcedotcom/datacloud-jdbc which is released under the - * Apache 2.0 license. See https://github.com/forcedotcom/datacloud-jdbc/blob/main/LICENSE.txt - */ -package com.salesforce.datacloud.jdbc.core.resultset; - -import java.math.BigDecimal; -import java.sql.Array; -import java.sql.Date; -import java.sql.SQLException; -import java.sql.Time; -import java.sql.Timestamp; -import java.util.OptionalDouble; -import java.util.OptionalLong; - -/** - * Accessor functions used to read column values from a {@link SimpleResultSet}. - * - * This interface is optimized for performance, and hence avoids the use of boxed types as much as possible. - */ -public interface ColumnAccessor { - public default Boolean getBoolean(ConcreteResultSet resultSet) throws SQLException { - throw new UnsupportedOperationException(); - } - /// Get the value of the column as an integer. Used for `getShort`, `getInt`, and `getLong`. - public default OptionalLong getAnyInteger(ConcreteResultSet resultSet) throws SQLException { - throw new UnsupportedOperationException(); - } - - public default BigDecimal getBigDecimal(ConcreteResultSet resultSet) throws SQLException { - throw new UnsupportedOperationException(); - } - - public default OptionalDouble getAnyFloatingPoint(ConcreteResultSet resultSet) throws SQLException { - throw new UnsupportedOperationException(); - } - - public default String getString(ConcreteResultSet resultSet) throws SQLException { - throw new UnsupportedOperationException(); - } - - public default byte[] getBytes(ConcreteResultSet resultSet) throws SQLException { - throw new UnsupportedOperationException(); - } - - public default Date getDate(ConcreteResultSet resultSet) throws SQLException { - throw new UnsupportedOperationException(); - } - - public default Time getTime(ConcreteResultSet resultSet) throws SQLException { - throw new UnsupportedOperationException(); - } - - public default Timestamp getTimestamp(ConcreteResultSet resultSet) throws SQLException { - throw new UnsupportedOperationException(); - } - - public default Array getArray(ConcreteResultSet resultSet) throws SQLException { - throw new UnsupportedOperationException(); - } -} diff --git a/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/resultset/SimpleResultSet.java b/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/resultset/SimpleResultSet.java deleted file mode 100644 index 6c84555e..00000000 --- a/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/resultset/SimpleResultSet.java +++ /dev/null @@ -1,379 +0,0 @@ -/** - * This file is part of https://github.com/forcedotcom/datacloud-jdbc which is released under the - * Apache 2.0 license. See https://github.com/forcedotcom/datacloud-jdbc/blob/main/LICENSE.txt - */ -package com.salesforce.datacloud.jdbc.core.resultset; - -import com.salesforce.datacloud.jdbc.core.metadata.DataCloudResultSetMetaData; -import com.salesforce.datacloud.jdbc.core.types.HyperTypes; -import com.salesforce.datacloud.jdbc.protocol.data.HyperTypeKind; -import java.io.InputStream; -import java.io.Reader; -import java.math.BigDecimal; -import java.net.URL; -import java.sql.Array; -import java.sql.Blob; -import java.sql.Clob; -import java.sql.Date; -import java.sql.Ref; -import java.sql.ResultSet; -import java.sql.ResultSetMetaData; -import java.sql.RowId; -import java.sql.SQLException; -import java.sql.SQLFeatureNotSupportedException; -import java.sql.SQLWarning; -import java.sql.SQLXML; -import java.sql.Time; -import java.sql.Timestamp; -import java.util.Calendar; -import java.util.Map; -import java.util.OptionalLong; -import lombok.AllArgsConstructor; -import lombok.val; - -/** - * A base class for simple result sets. - * - * This class provides a basic implementation of the {@link ResultSet} interface, - * with support for read-only access and forward-only cursors. - * - * Access to SQL values is provided via {@link ColumnAccessor} instances. This class - * already takes care of casting from SQL types to the compatible Java types. - * - *

Planned for removal. This implementation only covers the narrow slice of - * {@link HyperTypeKind} used by JDBC metadata result sets (INT32 for columns like - * {@code DATA_TYPE}, CHAR/VARCHAR for text columns, and INT8-INT64/OID via the - * {@code isIntegerLike} helper) — not the full query-result type universe. Rather - * than expanding the {@link #getLong}, {@link #getDouble}, {@link #getBigDecimal}, - * {@link #getObject} switches to cover every kind, we intend to migrate - * {@code DataCloudMetadataResultSet} to build on {@link StreamingResultSet} so there - * is only one result-set implementation in the driver. Treat this class as - * maintenance-only until that refactor lands. - */ -@AllArgsConstructor -public abstract class SimpleResultSet - implements ReadOnlyResultSet, ForwardOnlyResultSet, ResultSetWithPositionalGetters { - /// The metadata for the result set - protected final DataCloudResultSetMetaData metadata; - /// The accessor functions for the columns - protected final ColumnAccessor[] accessors; - /// Was the previously read value null? - private boolean wasNull = false; - - @Override - public ResultSetMetaData getMetaData() throws SQLException { - return metadata; - } - - @Override - public int findColumn(String columnLabel) throws SQLException { - return metadata.findColumn(columnLabel); - } - - @Override - public int getHoldability() throws SQLException { - // Our result sets are independent of transaction state - return ResultSet.HOLD_CURSORS_OVER_COMMIT; - } - - @Override - public void setFetchSize(int rows) throws SQLException { - throw new SQLFeatureNotSupportedException("setFetchSize is not supported"); - } - - @Override - public int getFetchSize() throws SQLException { - return 0; - } - - @Override - public SQLWarning getWarnings() throws SQLException { - // We don't support per-row warnings. - // Note that `ResultSet.getWarnings()` retrieves per-row warnings. - // Warnings for the overall query would be attached to the Statement, not the ResultSet. - return null; - } - - @Override - public void clearWarnings() throws SQLException { - throw new SQLFeatureNotSupportedException("clearWarnings is not supported"); - } - - @Override - public String getCursorName() throws SQLException { - throw new SQLFeatureNotSupportedException("getCursorName is not supported"); - } - - //////////////////////////////// - /// Accessors for SQL values - - /// Get the accessor for a column - private ColumnAccessor getAccessor(int columnIndex) throws SQLException { - if (columnIndex <= 0 || columnIndex > accessors.length) { - throw new SQLException( - "Column index " + columnIndex + " out of bounds (" + accessors.length + " columns available)"); - } - return accessors[columnIndex - 1]; - } - - @SuppressWarnings("unchecked") - private SELF getSubclass() { - return (SELF) this; - } - - @Override - public boolean wasNull() throws SQLException { - return wasNull; - } - - @Override - public String getString(int columnIndex) throws SQLException { - String value = getAccessor(columnIndex).getString(getSubclass()); - wasNull = value == null; - return value; - } - - @Override - public boolean getBoolean(int columnIndex) throws SQLException { - Boolean value = getAccessor(columnIndex).getBoolean(getSubclass()); - wasNull = value == null; - return value == null ? false : value; - } - - public byte getByte(int columnIndex) throws SQLException { - long v = getLong(columnIndex); - if (wasNull) { - return 0; - } - if (v < Byte.MIN_VALUE || v > Byte.MAX_VALUE) { - throw new SQLException( - "Column " + getMetaData().getColumnName(columnIndex) + " is out of range for a byte"); - } - return (byte) v; - } - - @Override - public short getShort(int columnIndex) throws SQLException { - long v = getLong(columnIndex); - if (wasNull) { - return 0; - } - if (v < Short.MIN_VALUE || v > Short.MAX_VALUE) { - throw new SQLException( - "Column " + getMetaData().getColumnName(columnIndex) + " is out of range for a short"); - } - return (short) v; - } - - @Override - public int getInt(int columnIndex) throws SQLException { - long v = getLong(columnIndex); - if (wasNull) { - return 0; - } - if (v < Integer.MIN_VALUE || v > Integer.MAX_VALUE) { - throw new SQLException( - "Column " + getMetaData().getColumnName(columnIndex) + " is out of range for an int"); - } - return (int) v; - } - - @Override - public long getLong(int columnIndex) throws SQLException { - val type = metadata.getColumn(columnIndex).getType(); - if (!HyperTypes.isIntegerLike(type)) { - throw new SQLException("Unsupported column type for integer-like types: " + type); - } - OptionalLong v = getAccessor(columnIndex).getAnyInteger(getSubclass()); - wasNull = !v.isPresent(); - return v.orElse(0L); - } - - private static final double LONG_MAX_DOUBLE = StrictMath.nextDown((double) Long.MAX_VALUE); - private static final double LONG_MIN_DOUBLE = StrictMath.nextUp((double) Long.MIN_VALUE); - - @Override - public float getFloat(int columnIndex) throws SQLException { - double v = getDouble(columnIndex); - if (wasNull) { - return 0; - } - if (v < -Float.MAX_VALUE || v > Float.MAX_VALUE) { - throw new SQLException( - "Column " + getMetaData().getColumnName(columnIndex) + " is out of range for a float"); - } - return (float) v; - } - - @Override - public double getDouble(int columnIndex) throws SQLException { - val type = metadata.getColumn(columnIndex).getType(); - if (!HyperTypes.isIntegerLike(type)) { - throw new SQLException("Unsupported column type for floating-point types: " + type); - } - OptionalLong v = getAccessor(columnIndex).getAnyInteger(getSubclass()); - wasNull = !v.isPresent(); - return v.orElse(0L); - } - - @Override - public BigDecimal getBigDecimal(int columnIndex) throws SQLException { - val type = metadata.getColumn(columnIndex).getType(); - if (!HyperTypes.isIntegerLike(type)) { - // TODO: apparently, PostgreSQL does not support float/decimal conversion. Double-check this with test - // cases. - throw new SQLException("Unsupported column type for numeric types: " + type); - } - OptionalLong v = getAccessor(columnIndex).getAnyInteger(getSubclass()); - wasNull = !v.isPresent(); - return v.isPresent() ? new BigDecimal(v.getAsLong()) : null; - } - - @Override - public BigDecimal getBigDecimal(int columnIndex, int scale) throws SQLException { - // TODO implement this - throw new UnsupportedOperationException("Unimplemented method 'getBigDecimal'"); - } - - @Override - public byte[] getBytes(int columnIndex) throws SQLException { - // TODO implement this - throw new UnsupportedOperationException("Unimplemented method 'getBytes'"); - } - - @Override - public Date getDate(int columnIndex) throws SQLException { - return getDate(columnIndex, null); - } - - @Override - public Date getDate(int columnIndex, Calendar cal) throws SQLException { - // TODO implement this - throw new UnsupportedOperationException("Unimplemented method 'getDate'"); - } - - @Override - public Time getTime(int columnIndex) throws SQLException { - return getTime(columnIndex, null); - } - - @Override - public Time getTime(int columnIndex, Calendar cal) throws SQLException { - // TODO Auto-generated method stub - throw new UnsupportedOperationException("Unimplemented method 'getTime'"); - } - - @Override - public Timestamp getTimestamp(int columnIndex) throws SQLException { - return getTimestamp(columnIndex, null); - } - - @Override - public Timestamp getTimestamp(int columnIndex, Calendar cal) throws SQLException { - // TODO Auto-generated method stub - throw new UnsupportedOperationException("Unimplemented method 'getTimestamp'"); - } - - @Override - public Array getArray(int columnIndex) throws SQLException { - // TODO implement this - throw new UnsupportedOperationException("Unimplemented method 'getArray'"); - } - - @Override - public Object getObject(int columnIndex) throws SQLException { - val type = metadata.getColumn(columnIndex).getType(); - if (type.getKind() == HyperTypeKind.INT32) { - val v = getInt(columnIndex); - return wasNull ? null : v; - } - if (HyperTypes.isStringLike(type)) { - return getString(columnIndex); - } - throw new SQLException("Unsupported column type in `getObject`: " + type); - } - - @Override - public T getObject(int columnIndex, Class type) throws SQLException { - val v = getObject(columnIndex); - if (v == null) { - return null; - } - if (type.isInstance(v)) { - return type.cast(v); - } - throw new SQLException("Unsupported column type in `getObject`: " - + metadata.getColumn(columnIndex).getType().toString()); - } - - @Override - public Object getObject(int columnIndex, Map> map) throws SQLException { - if (map == null || map.isEmpty()) { - return getObject(columnIndex); - } - throw new UnsupportedOperationException("Unimplemented method 'getObject'"); - } - - //////////////////////////////// - // Unsupported getters - - @Override - public Blob getBlob(int columnIndex) throws SQLException { - throw new SQLFeatureNotSupportedException("Retrieving Blobs is not supported"); - } - - @Override - public Clob getClob(int columnIndex) throws SQLException { - throw new SQLFeatureNotSupportedException("Retrieving Clobs is not supported"); - } - - @Override - public Ref getRef(int columnIndex) throws SQLException { - throw new SQLFeatureNotSupportedException("Retrieving Ref objects is not supported"); - } - - @Override - public URL getURL(int columnIndex) throws SQLException { - throw new SQLFeatureNotSupportedException("Retrieving URLs is not supported"); - } - - @Override - public RowId getRowId(int columnIndex) throws SQLException { - throw new SQLFeatureNotSupportedException("Retrieving row IDs is not supported"); - } - - @Override - public SQLXML getSQLXML(int columnIndex) throws SQLException { - throw new SQLFeatureNotSupportedException("Retrieving SQLXML is not supported"); - } - - @Override - public String getNString(int columnIndex) throws SQLException { - throw new SQLFeatureNotSupportedException("Retrieving NStrings is not supported"); - } - - @Override - public Reader getNCharacterStream(int columnIndex) throws SQLException { - throw new SQLFeatureNotSupportedException("Retrieving NCharacterStreams is not supported"); - } - - @Override - public InputStream getAsciiStream(int columnIndex) throws SQLException { - throw new SQLFeatureNotSupportedException("Retrieving AsciiStreams is not supported"); - } - - @Override - public InputStream getUnicodeStream(int columnIndex) throws SQLException { - throw new SQLFeatureNotSupportedException("Retrieving UnicodeStreams is not supported"); - } - - @Override - public InputStream getBinaryStream(int columnIndex) throws SQLException { - throw new SQLFeatureNotSupportedException("Retrieving BinaryStreams is not supported"); - } - - @Override - public Reader getCharacterStream(int columnIndex) throws SQLException { - throw new SQLFeatureNotSupportedException("Retrieving CharacterStreams is not supported"); - } -} diff --git a/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/protocol/QueryResultArrowStream.java b/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/protocol/QueryResultArrowStream.java index c2a1609a..8e88556c 100644 --- a/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/protocol/QueryResultArrowStream.java +++ b/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/protocol/QueryResultArrowStream.java @@ -7,7 +7,9 @@ import com.google.common.base.Predicates; import com.google.common.collect.FluentIterable; import com.salesforce.datacloud.jdbc.core.ByteStringReadableByteChannel; +import lombok.Value; import lombok.val; +import org.apache.arrow.memory.BufferAllocator; import org.apache.arrow.memory.RootAllocator; import org.apache.arrow.vector.ipc.ArrowStreamReader; import salesforce.cdp.hyperdb.v1.OutputFormat; @@ -21,7 +23,30 @@ public class QueryResultArrowStream { private static final int ROOT_ALLOCATOR_MB_FROM_V2 = 100 * 1024 * 1024; - public static ArrowStreamReader toArrowStreamReader(CloseableIterator iterator) { + /** + * Result of {@link #toArrowStreamReader(CloseableIterator)}: the {@link ArrowStreamReader} for + * loading batches plus the {@link RootAllocator} backing it. The caller is responsible for + * closing both — the reader closes its vectors, but the allocator outlives the reader and must + * be closed separately to return its budget to the JVM. + */ + @Value + public static class Result implements AutoCloseable { + ArrowStreamReader reader; + RootAllocator allocator; + + @Override + public void close() throws Exception { + // Order matters: the reader holds ArrowBuf instances whose accounting lives on the + // allocator, so the reader must release them before the allocator's budget check runs. + try { + reader.close(); + } finally { + allocator.close(); + } + } + } + + public static Result toArrowStreamReader(CloseableIterator iterator) { val byteStringIterator = FluentIterable.from(() -> iterator) .transform( input -> input.hasBinaryPart() ? input.getBinaryPart().getData() : null) @@ -47,6 +72,8 @@ public void close() throws Exception { } }; val channel = new ByteStringReadableByteChannel(closeable); - return new ArrowStreamReader(channel, new RootAllocator(ROOT_ALLOCATOR_MB_FROM_V2)); + RootAllocator allocator = new RootAllocator(ROOT_ALLOCATOR_MB_FROM_V2); + ArrowStreamReader reader = new ArrowStreamReader(channel, (BufferAllocator) allocator); + return new Result(reader, allocator); } } diff --git a/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/core/ArrowStreamReaderCursorTest.java b/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/core/ArrowStreamReaderCursorTest.java index 287db7e0..d6613083 100644 --- a/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/core/ArrowStreamReaderCursorTest.java +++ b/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/core/ArrowStreamReaderCursorTest.java @@ -37,12 +37,16 @@ class ArrowStreamReaderCursorTest { @Mock protected VectorSchemaRoot root; + @Mock + protected AutoCloseable ownedResources; + @Test @SneakyThrows - void closesTheReader() { - val sut = new ArrowStreamReaderCursor(reader, ZoneId.systemDefault()); + void closesOwnedResources() { + when(reader.getVectorSchemaRoot()).thenReturn(root); + val sut = ArrowStreamReaderCursor.streaming(reader, ownedResources, ZoneId.systemDefault()); sut.close(); - verify(reader, times(1)).close(); + verify(ownedResources, times(1)).close(); } @Test @@ -56,7 +60,7 @@ void incrementsInternalIndexUntilRowsExhaustedThenLoadsNextBatch() { when(reader.loadNextBatch()).thenReturn(false); when(root.getRowCount()).thenReturn(times); - val sut = new ArrowStreamReaderCursor(reader, ZoneId.systemDefault()); + val sut = ArrowStreamReaderCursor.streaming(reader, ownedResources, ZoneId.systemDefault()); IntStream.range(0, times + 1).forEach(i -> sut.next()); verify(root, times(times + 1)).getRowCount(); @@ -69,7 +73,7 @@ void firstNextReturnsTrueWhenInitialBatchHasRows() { when(root.getRowCount()).thenReturn(1); when(reader.getVectorSchemaRoot()).thenReturn(root); - val sut = new ArrowStreamReaderCursor(reader, ZoneId.systemDefault()); + val sut = ArrowStreamReaderCursor.streaming(reader, ownedResources, ZoneId.systemDefault()); assertThat(sut.next()).isTrue(); } @@ -81,7 +85,7 @@ void firstNextReturnsFalseWhenStreamHasNoBatches() { when(reader.getVectorSchemaRoot()).thenReturn(root); when(reader.loadNextBatch()).thenReturn(false); - val sut = new ArrowStreamReaderCursor(reader, ZoneId.systemDefault()); + val sut = ArrowStreamReaderCursor.streaming(reader, ownedResources, ZoneId.systemDefault()); assertThat(sut.next()).isFalse(); } @@ -118,7 +122,7 @@ void skipsZeroRowBatchAndYieldsSubsequentNonEmptyRows() { try (RootAllocator readAlloc = new RootAllocator(Long.MAX_VALUE); ArrowStreamReader streamReader = new ArrowStreamReader(new ByteArrayInputStream(ipc), readAlloc)) { - val sut = new ArrowStreamReaderCursor(streamReader, ZoneId.systemDefault()); + val sut = ArrowStreamReaderCursor.streaming(streamReader, streamReader, ZoneId.systemDefault()); assertThat(sut.next()) .as("skips zero-row batch, advances to row in second batch") @@ -157,8 +161,20 @@ void zeroRowOnlyBatchYieldsNoRows() { try (RootAllocator readAlloc = new RootAllocator(Long.MAX_VALUE); ArrowStreamReader streamReader = new ArrowStreamReader(new ByteArrayInputStream(ipc), readAlloc)) { - val sut = new ArrowStreamReaderCursor(streamReader, ZoneId.systemDefault()); + val sut = ArrowStreamReaderCursor.streaming(streamReader, streamReader, ZoneId.systemDefault()); assertThat(sut.next()).isFalse(); } } + + @Test + @SneakyThrows + void inMemoryCursorReportsFalseWhenRowCountExhausted() { + when(root.getRowCount()).thenReturn(2); + + val sut = ArrowStreamReaderCursor.inMemory(root, ownedResources, ZoneId.systemDefault()); + + assertThat(sut.next()).isTrue(); + assertThat(sut.next()).isTrue(); + assertThat(sut.next()).isFalse(); + } } diff --git a/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/core/DataCloudDatabaseMetadataTest.java b/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/core/DataCloudDatabaseMetadataTest.java index 15f2d02b..b3fd8c60 100644 --- a/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/core/DataCloudDatabaseMetadataTest.java +++ b/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/core/DataCloudDatabaseMetadataTest.java @@ -1082,9 +1082,14 @@ public void testTestTest() throws SQLException { ResultSet columnResultSet = QueryMetadataUtil.createColumnResultSet( StringUtils.EMPTY, StringUtils.EMPTY, StringUtils.EMPTY, connection); while (columnResultSet.next()) { + // The metadata result set is Arrow-backed; TYPE_NAME carries "TEXT" (preserved from + // the JDBC-spec MetadataSchemas override), while TYPE_NAME's *value* is the HyperType's + // JDBC name ("VARCHAR" for varchar columns). assertThat(columnResultSet.getString("TYPE_NAME")).isEqualTo("VARCHAR"); assertThat(columnResultSet.getInt("DATA_TYPE")).isEqualTo(12); - assertThat(columnResultSet.getBoolean("NULLABLE")).isFalse(); + // NULLABLE is an INTEGER column. Arrow-backed getInt reports the nullability enum; + // 0 (columnNoNulls) for NOT NULL rows, which coerces to false via long→boolean. + assertThat(columnResultSet.getInt("NULLABLE")).isEqualTo(0); assertThat(columnResultSet.getInt("ORDINAL_POSITION")).isEqualTo(ordinalValue); assertThat(columnResultSet.getByte("ORDINAL_POSITION")).isEqualTo(ordinalValue.byteValue()); } @@ -1114,6 +1119,7 @@ public void testMetadataColumnAccessors() throws SQLException { ResultSet columnResultSet = QueryMetadataUtil.createColumnResultSet( StringUtils.EMPTY, StringUtils.EMPTY, StringUtils.EMPTY, connection); while (columnResultSet.next()) { + // Integer accessor widens to all numeric getters. assertThat(columnResultSet.getDouble("DATA_TYPE")).isEqualTo(12); assertThat(columnResultSet.getShort("DATA_TYPE")).isEqualTo(new Short("12")); assertThat(columnResultSet.getFloat("DATA_TYPE")).isEqualTo(12); @@ -1125,19 +1131,25 @@ public void testMetadataColumnAccessors() throws SQLException { assertThat(columnResultSet.getObject("DATA_TYPE", new HashMap<>())).isEqualTo(12); assertThat(columnResultSet.getObject("TYPE_NAME", String.class)).isEqualTo("VARCHAR"); + // Requesting an Integer column as Boolean is not supported by the Arrow int accessor. assertThrows(SQLException.class, () -> columnResultSet.getObject("ORDINAL_POSITION", Boolean.class)); + // Numeric accessors on a VARCHAR column are not supported by the Arrow varchar + // accessor — they throw SQLFeatureNotSupportedException (a SQLException). assertThrows(SQLException.class, () -> columnResultSet.getBigDecimal("TYPE_NAME")); assertThrows(SQLException.class, () -> columnResultSet.getDouble("TYPE_NAME")); assertThrows(SQLException.class, () -> columnResultSet.getLong("TYPE_NAME")); assertThrows(SQLException.class, () -> columnResultSet.getInt("TYPE_NAME")); - assertThrows(SQLException.class, () -> columnResultSet.getByte("ORDINAL_POSITION")); - - assertThrows(UnsupportedOperationException.class, () -> columnResultSet.getDate("ORDINAL_POSITION")); - assertThrows(UnsupportedOperationException.class, () -> columnResultSet.getTimestamp("ORDINAL_POSITION")); - assertThrows(UnsupportedOperationException.class, () -> columnResultSet.getTime("ORDINAL_POSITION")); - assertThrows(UnsupportedOperationException.class, () -> columnResultSet.getDate("ORDINAL_POSITION", null)); - assertThrows(UnsupportedOperationException.class, () -> columnResultSet.getTimestamp("ORDINAL_POSITION")); - assertThrows(UnsupportedOperationException.class, () -> columnResultSet.getTime("ORDINAL_POSITION", null)); + // getByte on an int column is supported by BaseIntVectorAccessor, so this should NOT + // throw — remove the expectation. + assertThat(columnResultSet.getByte("ORDINAL_POSITION")).isEqualTo(ordinalValue.byteValue()); + + // Date/Time getters on an integer column are not supported by the Arrow int accessor. + assertThrows(SQLException.class, () -> columnResultSet.getDate("ORDINAL_POSITION")); + assertThrows(SQLException.class, () -> columnResultSet.getTimestamp("ORDINAL_POSITION")); + assertThrows(SQLException.class, () -> columnResultSet.getTime("ORDINAL_POSITION")); + assertThrows(SQLException.class, () -> columnResultSet.getDate("ORDINAL_POSITION", null)); + assertThrows(SQLException.class, () -> columnResultSet.getTimestamp("ORDINAL_POSITION")); + assertThrows(SQLException.class, () -> columnResultSet.getTime("ORDINAL_POSITION", null)); assertThrows(SQLFeatureNotSupportedException.class, () -> columnResultSet.getBlob("ORDINAL_POSITION")); assertThrows(SQLFeatureNotSupportedException.class, () -> columnResultSet.getClob("ORDINAL_POSITION")); diff --git a/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/core/DataCloudMetadataResultSetTest.java b/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/core/DataCloudMetadataResultSetTest.java index 7b171175..9323b050 100644 --- a/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/core/DataCloudMetadataResultSetTest.java +++ b/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/core/DataCloudMetadataResultSetTest.java @@ -5,98 +5,103 @@ package com.salesforce.datacloud.jdbc.core; import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; -import com.salesforce.datacloud.jdbc.core.metadata.DataCloudResultSetMetaData; -import com.salesforce.datacloud.jdbc.core.resultset.SimpleResultSet; +import com.salesforce.datacloud.jdbc.core.metadata.MetadataResultSets; import java.sql.ResultSet; import java.sql.ResultSetMetaData; import java.sql.SQLException; -import java.sql.SQLFeatureNotSupportedException; import lombok.SneakyThrows; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +/** + * Smoke-test the Arrow-backed metadata path: empty metadata result sets expose the standard + * JDBC shape (row=0, closeable, forward-only, holdability, etc.). + */ class DataCloudMetadataResultSetTest { - DataCloudMetadataResultSet dataCloudMetadataResultSet; + ResultSet metadataResultSet; @BeforeEach public void init() throws SQLException { - dataCloudMetadataResultSet = - DataCloudMetadataResultSet.of(new DataCloudResultSetMetaData(MetadataSchemas.COLUMNS), null); + metadataResultSet = MetadataResultSets.empty(MetadataSchemas.COLUMNS); } @Test void getRow() throws SQLException { - assertThat(dataCloudMetadataResultSet.getRow()).isEqualTo(0); + assertThat(metadataResultSet.getRow()).isEqualTo(0); - dataCloudMetadataResultSet.close(); - assertThrows(SQLException.class, () -> dataCloudMetadataResultSet.next()); + metadataResultSet.close(); + assertThrows(SQLException.class, () -> metadataResultSet.next()); } @Test void next() throws SQLException { - dataCloudMetadataResultSet.close(); - assertThrows(SQLException.class, () -> dataCloudMetadataResultSet.next()); + metadataResultSet.close(); + assertThrows(SQLException.class, () -> metadataResultSet.next()); } @Test void isClosed() throws SQLException { - assertFalse(dataCloudMetadataResultSet.isClosed()); - dataCloudMetadataResultSet.close(); - assertTrue(dataCloudMetadataResultSet.isClosed()); + assertFalse(metadataResultSet.isClosed()); + metadataResultSet.close(); + assertTrue(metadataResultSet.isClosed()); } @Test void getStatement() throws SQLException { - assertThat(dataCloudMetadataResultSet.getStatement()).isNull(); + assertThat(metadataResultSet.getStatement()).isNull(); } @Test void unwrap() throws SQLException { - assertThat(dataCloudMetadataResultSet.unwrap(ResultSetMetaData.class)).isNull(); + assertThrows(SQLException.class, () -> metadataResultSet.unwrap(ResultSetMetaData.class)); } @Test void isWrapperFor() throws SQLException { - assertThat(dataCloudMetadataResultSet.isWrapperFor(SimpleResultSet.class)) - .isFalse(); + // StreamingResultSet implements DataCloudResultSet / ResultSet; it is not a wrapper for + // arbitrary unrelated types. + assertThat(metadataResultSet.isWrapperFor(ResultSetMetaData.class)).isFalse(); } @Test void getHoldability() throws SQLException { - assertThat(dataCloudMetadataResultSet.getHoldability()).isEqualTo(ResultSet.HOLD_CURSORS_OVER_COMMIT); + assertThat(metadataResultSet.getHoldability()).isEqualTo(ResultSet.HOLD_CURSORS_OVER_COMMIT); } @Test void getFetchSize() throws SQLException { - assertThat(dataCloudMetadataResultSet.getFetchSize()).isEqualTo(0); + assertThat(metadataResultSet.getFetchSize()).isEqualTo(0); } @Test - void setFetchSize() { - assertThrows(SQLFeatureNotSupportedException.class, () -> dataCloudMetadataResultSet.setFetchSize(0)); + void setFetchSize() throws SQLException { + // StreamingResultSet controls its own fetch size and ignores caller-supplied hints. + metadataResultSet.setFetchSize(0); } @SneakyThrows @Test void getWarnings() { - assertThat((Iterable) dataCloudMetadataResultSet.getWarnings()) + assertThat((Iterable) metadataResultSet.getWarnings()) .isNull(); } @Test void getConcurrency() throws SQLException { - assertThat(dataCloudMetadataResultSet.getConcurrency()).isEqualTo(ResultSet.CONCUR_READ_ONLY); + assertThat(metadataResultSet.getConcurrency()).isEqualTo(ResultSet.CONCUR_READ_ONLY); } @Test void getType() throws SQLException { - assertThat(dataCloudMetadataResultSet.getType()).isEqualTo(ResultSet.TYPE_FORWARD_ONLY); + assertThat(metadataResultSet.getType()).isEqualTo(ResultSet.TYPE_FORWARD_ONLY); } @Test void getFetchDirection() throws SQLException { - assertThat(dataCloudMetadataResultSet.getFetchDirection()).isEqualTo(ResultSet.FETCH_FORWARD); + assertThat(metadataResultSet.getFetchDirection()).isEqualTo(ResultSet.FETCH_FORWARD); } } diff --git a/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/core/StreamingResultSetMethodTest.java b/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/core/StreamingResultSetMethodTest.java index 3d43cf69..f835e5e7 100644 --- a/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/core/StreamingResultSetMethodTest.java +++ b/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/core/StreamingResultSetMethodTest.java @@ -8,20 +8,17 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import com.salesforce.datacloud.jdbc.util.RootAllocatorTestExtension; -import java.io.ByteArrayInputStream; -import java.io.ByteArrayOutputStream; import java.nio.charset.StandardCharsets; import java.sql.ResultSet; import java.sql.SQLException; import java.sql.SQLFeatureNotSupportedException; +import java.time.ZoneId; import java.util.Arrays; import java.util.stream.Stream; import lombok.SneakyThrows; import lombok.val; import org.apache.arrow.vector.VarCharVector; import org.apache.arrow.vector.VectorSchemaRoot; -import org.apache.arrow.vector.ipc.ArrowStreamReader; -import org.apache.arrow.vector.ipc.ArrowStreamWriter; import org.junit.jupiter.api.Named; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; @@ -48,6 +45,9 @@ private StreamingResultSet createResultSetWithNullValue() { @SneakyThrows private StreamingResultSet createSingleVarCharResultSet(boolean nullValue) { + // Build an in-memory VectorSchemaRoot with one VARCHAR column and one row ("hello", or + // null), then hand it to StreamingResultSet.ofInMemory. The result set owns the vector + // lifecycle and closes it via the returned AutoCloseable. val allocator = ext.getRootAllocator(); val vector = new VarCharVector("col1", allocator); vector.allocateNew(); @@ -61,14 +61,7 @@ private StreamingResultSet createSingleVarCharResultSet(boolean nullValue) { val root = new VectorSchemaRoot(Arrays.asList(vector.getField()), Arrays.asList(vector)); root.setRowCount(1); - val out = new ByteArrayOutputStream(); - try (val writer = new ArrowStreamWriter(root, null, out)) { - writer.writeBatch(); - } - root.close(); - - val reader = new ArrowStreamReader(new ByteArrayInputStream(out.toByteArray()), allocator); - return StreamingResultSet.of(reader, QUERY_ID); + return StreamingResultSet.ofInMemory(root, root, QUERY_ID, ZoneId.systemDefault()); } // --- Unsupported methods --- diff --git a/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/core/metadata/DataCloudResultSetMetaDataTest.java b/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/core/metadata/DataCloudResultSetMetaDataTest.java index d8e1f1ba..f83ba615 100644 --- a/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/core/metadata/DataCloudResultSetMetaDataTest.java +++ b/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/core/metadata/DataCloudResultSetMetaDataTest.java @@ -6,7 +6,6 @@ import static org.assertj.core.api.Assertions.assertThat; -import com.salesforce.datacloud.jdbc.core.DataCloudMetadataResultSet; import com.salesforce.datacloud.jdbc.core.MetadataSchemas; import com.salesforce.datacloud.jdbc.protocol.data.ColumnMetadata; import com.salesforce.datacloud.jdbc.protocol.data.HyperType; @@ -24,9 +23,7 @@ class DataCloudResultSetMetaDataTest { @BeforeEach public void init() throws SQLException { - DataCloudMetadataResultSet dataCloudMetadataResultSet = - DataCloudMetadataResultSet.of(new DataCloudResultSetMetaData(COLUMNS_SCHEMA), null); - resultSetMetaData = dataCloudMetadataResultSet.getMetaData(); + resultSetMetaData = new DataCloudResultSetMetaData(COLUMNS_SCHEMA); } @Test diff --git a/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/core/resultset/ColumnAccessorTest.java b/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/core/resultset/ColumnAccessorTest.java deleted file mode 100644 index d919154b..00000000 --- a/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/core/resultset/ColumnAccessorTest.java +++ /dev/null @@ -1,31 +0,0 @@ -/** - * This file is part of https://github.com/forcedotcom/datacloud-jdbc which is released under the - * Apache 2.0 license. See https://github.com/forcedotcom/datacloud-jdbc/blob/main/LICENSE.txt - */ -package com.salesforce.datacloud.jdbc.core.resultset; - -import static org.junit.jupiter.api.Assertions.*; - -import org.junit.jupiter.api.Test; -import org.mockito.Mockito; - -class ColumnAccessorTest { - - @Test - public void shouldThrowUnsupportedError() { - ColumnAccessor columnAccessor = Mockito.mock(ColumnAccessor.class, Mockito.CALLS_REAL_METHODS); - SimpleResultSet resultSet = Mockito.mock(SimpleResultSet.class, Mockito.CALLS_REAL_METHODS); - - // Test methods from SimpleResultSet that throw SQLFeatureNotSupportedException - assertThrows(UnsupportedOperationException.class, () -> columnAccessor.getBoolean(resultSet)); - assertThrows(UnsupportedOperationException.class, () -> columnAccessor.getAnyInteger(resultSet)); - assertThrows(UnsupportedOperationException.class, () -> columnAccessor.getBigDecimal(resultSet)); - assertThrows(UnsupportedOperationException.class, () -> columnAccessor.getAnyFloatingPoint(resultSet)); - assertThrows(UnsupportedOperationException.class, () -> columnAccessor.getString(resultSet)); - assertThrows(UnsupportedOperationException.class, () -> columnAccessor.getBytes(resultSet)); - assertThrows(UnsupportedOperationException.class, () -> columnAccessor.getDate(resultSet)); - assertThrows(UnsupportedOperationException.class, () -> columnAccessor.getTime(resultSet)); - assertThrows(UnsupportedOperationException.class, () -> columnAccessor.getTimestamp(resultSet)); - assertThrows(UnsupportedOperationException.class, () -> columnAccessor.getArray(resultSet)); - } -} diff --git a/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/core/resultset/SimpleResultSetTest.java b/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/core/resultset/SimpleResultSetTest.java deleted file mode 100644 index 02198bb0..00000000 --- a/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/core/resultset/SimpleResultSetTest.java +++ /dev/null @@ -1,462 +0,0 @@ -/** - * This file is part of https://github.com/forcedotcom/datacloud-jdbc which is released under the - * Apache 2.0 license. See https://github.com/forcedotcom/datacloud-jdbc/blob/main/LICENSE.txt - */ -package com.salesforce.datacloud.jdbc.core.resultset; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import com.salesforce.datacloud.jdbc.core.DataCloudMetadataResultSet; -import com.salesforce.datacloud.jdbc.core.MetadataSchemas; -import com.salesforce.datacloud.jdbc.core.metadata.DataCloudResultSetMetaData; -import com.salesforce.datacloud.jdbc.protocol.data.ColumnMetadata; -import com.salesforce.datacloud.jdbc.protocol.data.HyperType; -import java.io.InputStream; -import java.io.Reader; -import java.math.BigDecimal; -import java.sql.Blob; -import java.sql.Clob; -import java.sql.NClob; -import java.sql.Ref; -import java.sql.ResultSet; -import java.sql.RowId; -import java.sql.SQLException; -import java.sql.SQLFeatureNotSupportedException; -import java.sql.SQLXML; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Calendar; -import java.util.Collections; -import java.util.List; -import java.util.Map; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.function.Executable; -import org.mockito.Mockito; - -class SimpleResultSetTest { - - @Test - public void shouldThrowUnsupportedError() { - SimpleResultSet resultSet = Mockito.mock(SimpleResultSet.class, Mockito.CALLS_REAL_METHODS); - - // Test methods from SimpleResultSet that throw SQLFeatureNotSupportedException - assertThrows(SQLFeatureNotSupportedException.class, resultSet::clearWarnings); - assertThrows(SQLFeatureNotSupportedException.class, resultSet::getCursorName); - assertThrows(SQLFeatureNotSupportedException.class, () -> resultSet.getBlob(1)); - assertThrows(SQLFeatureNotSupportedException.class, () -> resultSet.getClob(1)); - assertThrows(SQLFeatureNotSupportedException.class, () -> resultSet.getNClob(1)); - assertThrows(SQLFeatureNotSupportedException.class, () -> resultSet.getRef(1)); - assertThrows(SQLFeatureNotSupportedException.class, () -> resultSet.getURL(1)); - assertThrows(SQLFeatureNotSupportedException.class, () -> resultSet.getRowId(1)); - assertThrows(SQLFeatureNotSupportedException.class, () -> resultSet.getSQLXML(1)); - assertThrows(SQLFeatureNotSupportedException.class, () -> resultSet.getNString(1)); - assertThrows(SQLFeatureNotSupportedException.class, () -> resultSet.getNCharacterStream(1)); - assertThrows(SQLFeatureNotSupportedException.class, () -> resultSet.getAsciiStream(1)); - assertThrows(SQLFeatureNotSupportedException.class, () -> resultSet.getUnicodeStream(1)); - assertThrows(SQLFeatureNotSupportedException.class, () -> resultSet.getBinaryStream(1)); - assertThrows(SQLFeatureNotSupportedException.class, () -> resultSet.getCharacterStream(1)); - - // Test un-implemented methods from SimpleResultSet that throw UnsupportedOperationException - assertThrows(UnsupportedOperationException.class, () -> resultSet.getDate(1)); - assertThrows(UnsupportedOperationException.class, () -> resultSet.getTimestamp(1)); - assertThrows(UnsupportedOperationException.class, () -> resultSet.getTime(1)); - assertThrows(UnsupportedOperationException.class, () -> resultSet.getDate(1, null)); - assertThrows(UnsupportedOperationException.class, () -> resultSet.getTimestamp(1, null)); - assertThrows(UnsupportedOperationException.class, () -> resultSet.getTime(1, null)); - assertThrows(UnsupportedOperationException.class, () -> resultSet.getBigDecimal(1, 1)); - assertThrows(UnsupportedOperationException.class, () -> resultSet.getBytes(1)); - assertThrows(UnsupportedOperationException.class, () -> resultSet.getArray(1)); - - // Test methods from ForwardOnlyResultSet interface - assertThrows(SQLFeatureNotSupportedException.class, () -> resultSet.setFetchDirection(ResultSet.FETCH_REVERSE)); - assertThrows(SQLFeatureNotSupportedException.class, resultSet::isBeforeFirst); - assertThrows(SQLFeatureNotSupportedException.class, resultSet::isAfterLast); - assertThrows(SQLFeatureNotSupportedException.class, resultSet::isFirst); - assertThrows(SQLFeatureNotSupportedException.class, resultSet::isLast); - assertThrows(SQLFeatureNotSupportedException.class, resultSet::first); - assertThrows(SQLFeatureNotSupportedException.class, resultSet::last); - assertThrows(SQLFeatureNotSupportedException.class, () -> resultSet.absolute(1)); - assertThrows(SQLFeatureNotSupportedException.class, () -> resultSet.relative(1)); - assertThrows(SQLFeatureNotSupportedException.class, resultSet::previous); - assertThrows(SQLFeatureNotSupportedException.class, resultSet::beforeFirst); - assertThrows(SQLFeatureNotSupportedException.class, resultSet::afterLast); - - // Test methods from ReadOnlyResultSet interface - assertThrows(SQLFeatureNotSupportedException.class, resultSet::rowUpdated); - assertThrows(SQLFeatureNotSupportedException.class, resultSet::rowInserted); - assertThrows(SQLFeatureNotSupportedException.class, resultSet::rowDeleted); - assertThrows(SQLFeatureNotSupportedException.class, resultSet::insertRow); - assertThrows(SQLFeatureNotSupportedException.class, resultSet::updateRow); - assertThrows(SQLFeatureNotSupportedException.class, resultSet::deleteRow); - assertThrows(SQLFeatureNotSupportedException.class, resultSet::cancelRowUpdates); - assertThrows(SQLFeatureNotSupportedException.class, resultSet::refreshRow); - assertThrows(SQLFeatureNotSupportedException.class, resultSet::moveToInsertRow); - assertThrows(SQLFeatureNotSupportedException.class, resultSet::moveToCurrentRow); - assertThrows(SQLFeatureNotSupportedException.class, () -> resultSet.updateNull(1)); - assertThrows(SQLFeatureNotSupportedException.class, () -> resultSet.updateBoolean(1, true)); - assertThrows(SQLFeatureNotSupportedException.class, () -> resultSet.updateByte(1, (byte) 1)); - assertThrows(SQLFeatureNotSupportedException.class, () -> resultSet.updateShort(1, (short) 1)); - assertThrows(SQLFeatureNotSupportedException.class, () -> resultSet.updateInt(1, 1)); - assertThrows(SQLFeatureNotSupportedException.class, () -> resultSet.updateLong(1, 1L)); - assertThrows(SQLFeatureNotSupportedException.class, () -> resultSet.updateFloat(1, 1.0f)); - assertThrows(SQLFeatureNotSupportedException.class, () -> resultSet.updateDouble(1, 1.0)); - assertThrows(SQLFeatureNotSupportedException.class, () -> resultSet.updateBigDecimal(1, BigDecimal.ONE)); - assertThrows(SQLFeatureNotSupportedException.class, () -> resultSet.updateString(1, "test")); - assertThrows(SQLFeatureNotSupportedException.class, () -> resultSet.updateBytes(1, new byte[0])); - assertThrows(SQLFeatureNotSupportedException.class, () -> resultSet.updateDate(1, new java.sql.Date(0))); - assertThrows(SQLFeatureNotSupportedException.class, () -> resultSet.updateTime(1, new java.sql.Time(0))); - assertThrows( - SQLFeatureNotSupportedException.class, () -> resultSet.updateTimestamp(1, new java.sql.Timestamp(0))); - assertThrows( - SQLFeatureNotSupportedException.class, - () -> resultSet.updateAsciiStream(1, Mockito.mock(InputStream.class), 1)); - assertThrows( - SQLFeatureNotSupportedException.class, - () -> resultSet.updateBinaryStream(1, Mockito.mock(InputStream.class), 1)); - assertThrows( - SQLFeatureNotSupportedException.class, - () -> resultSet.updateCharacterStream(1, Mockito.mock(Reader.class), 1)); - assertThrows(SQLFeatureNotSupportedException.class, () -> resultSet.updateObject(1, new Object(), 1)); - assertThrows(SQLFeatureNotSupportedException.class, () -> resultSet.updateObject(1, new Object())); - assertThrows(SQLFeatureNotSupportedException.class, () -> resultSet.updateNull("col")); - assertThrows(SQLFeatureNotSupportedException.class, () -> resultSet.updateBoolean("col", true)); - assertThrows(SQLFeatureNotSupportedException.class, () -> resultSet.updateByte("col", (byte) 1)); - assertThrows(SQLFeatureNotSupportedException.class, () -> resultSet.updateShort("col", (short) 1)); - assertThrows(SQLFeatureNotSupportedException.class, () -> resultSet.updateInt("col", 1)); - assertThrows(SQLFeatureNotSupportedException.class, () -> resultSet.updateLong("col", 1L)); - assertThrows(SQLFeatureNotSupportedException.class, () -> resultSet.updateFloat("col", 1.0f)); - assertThrows(SQLFeatureNotSupportedException.class, () -> resultSet.updateDouble("col", 1.0)); - assertThrows(SQLFeatureNotSupportedException.class, () -> resultSet.updateBigDecimal("col", BigDecimal.ONE)); - assertThrows(SQLFeatureNotSupportedException.class, () -> resultSet.updateString("col", "test")); - assertThrows(SQLFeatureNotSupportedException.class, () -> resultSet.updateBytes("col", new byte[0])); - assertThrows(SQLFeatureNotSupportedException.class, () -> resultSet.updateDate("col", new java.sql.Date(0))); - assertThrows(SQLFeatureNotSupportedException.class, () -> resultSet.updateTime("col", new java.sql.Time(0))); - assertThrows( - SQLFeatureNotSupportedException.class, - () -> resultSet.updateTimestamp("col", new java.sql.Timestamp(0))); - assertThrows( - SQLFeatureNotSupportedException.class, - () -> resultSet.updateAsciiStream("col", Mockito.mock(InputStream.class), 1)); - assertThrows( - SQLFeatureNotSupportedException.class, - () -> resultSet.updateBinaryStream("col", Mockito.mock(InputStream.class), 1)); - assertThrows( - SQLFeatureNotSupportedException.class, - () -> resultSet.updateCharacterStream("col", Mockito.mock(Reader.class), 1)); - assertThrows(SQLFeatureNotSupportedException.class, () -> resultSet.updateObject("col", new Object(), 1)); - assertThrows(SQLFeatureNotSupportedException.class, () -> resultSet.updateObject("col", new Object())); - assertThrows(SQLFeatureNotSupportedException.class, () -> resultSet.updateRef(1, Mockito.mock(Ref.class))); - assertThrows(SQLFeatureNotSupportedException.class, () -> resultSet.updateRef("col", Mockito.mock(Ref.class))); - assertThrows(SQLFeatureNotSupportedException.class, () -> resultSet.updateBlob(1, Mockito.mock(Blob.class))); - assertThrows( - SQLFeatureNotSupportedException.class, () -> resultSet.updateBlob("col", Mockito.mock(Blob.class))); - assertThrows(SQLFeatureNotSupportedException.class, () -> resultSet.updateClob(1, Mockito.mock(Clob.class))); - assertThrows( - SQLFeatureNotSupportedException.class, () -> resultSet.updateClob("col", Mockito.mock(Clob.class))); - assertThrows( - SQLFeatureNotSupportedException.class, - () -> resultSet.updateArray(1, Mockito.mock(java.sql.Array.class))); - assertThrows( - SQLFeatureNotSupportedException.class, - () -> resultSet.updateArray("col", Mockito.mock(java.sql.Array.class))); - assertThrows(SQLFeatureNotSupportedException.class, () -> resultSet.updateRowId(1, Mockito.mock(RowId.class))); - assertThrows( - SQLFeatureNotSupportedException.class, () -> resultSet.updateRowId("col", Mockito.mock(RowId.class))); - assertThrows(SQLFeatureNotSupportedException.class, () -> resultSet.updateNString(1, "test")); - assertThrows(SQLFeatureNotSupportedException.class, () -> resultSet.updateNString("col", "test")); - assertThrows(SQLFeatureNotSupportedException.class, () -> resultSet.updateNClob(1, Mockito.mock(NClob.class))); - assertThrows( - SQLFeatureNotSupportedException.class, () -> resultSet.updateNClob("col", Mockito.mock(NClob.class))); - assertThrows( - SQLFeatureNotSupportedException.class, () -> resultSet.updateSQLXML(1, Mockito.mock(SQLXML.class))); - assertThrows( - SQLFeatureNotSupportedException.class, () -> resultSet.updateSQLXML("col", Mockito.mock(SQLXML.class))); - assertThrows( - SQLFeatureNotSupportedException.class, - () -> resultSet.updateNCharacterStream(1, Mockito.mock(Reader.class), 1L)); - assertThrows( - SQLFeatureNotSupportedException.class, - () -> resultSet.updateNCharacterStream("col", Mockito.mock(Reader.class), 1L)); - assertThrows( - SQLFeatureNotSupportedException.class, - () -> resultSet.updateAsciiStream(1, Mockito.mock(InputStream.class), 1L)); - assertThrows( - SQLFeatureNotSupportedException.class, - () -> resultSet.updateBinaryStream(1, Mockito.mock(InputStream.class), 1L)); - assertThrows( - SQLFeatureNotSupportedException.class, - () -> resultSet.updateCharacterStream(1, Mockito.mock(Reader.class), 1L)); - assertThrows( - SQLFeatureNotSupportedException.class, - () -> resultSet.updateAsciiStream("col", Mockito.mock(InputStream.class), 1L)); - assertThrows( - SQLFeatureNotSupportedException.class, - () -> resultSet.updateBinaryStream("col", Mockito.mock(InputStream.class), 1L)); - assertThrows( - SQLFeatureNotSupportedException.class, - () -> resultSet.updateCharacterStream("col", Mockito.mock(Reader.class), 1L)); - assertThrows( - SQLFeatureNotSupportedException.class, - () -> resultSet.updateBlob(1, Mockito.mock(InputStream.class), 1L)); - assertThrows( - SQLFeatureNotSupportedException.class, - () -> resultSet.updateBlob("col", Mockito.mock(InputStream.class), 1L)); - assertThrows( - SQLFeatureNotSupportedException.class, () -> resultSet.updateClob(1, Mockito.mock(Reader.class), 1L)); - assertThrows( - SQLFeatureNotSupportedException.class, - () -> resultSet.updateClob("col", Mockito.mock(Reader.class), 1L)); - assertThrows( - SQLFeatureNotSupportedException.class, () -> resultSet.updateNClob(1, Mockito.mock(Reader.class), 1L)); - assertThrows( - SQLFeatureNotSupportedException.class, - () -> resultSet.updateNClob("col", Mockito.mock(Reader.class), 1L)); - assertThrows( - SQLFeatureNotSupportedException.class, - () -> resultSet.updateNCharacterStream(1, Mockito.mock(Reader.class))); - assertThrows( - SQLFeatureNotSupportedException.class, - () -> resultSet.updateNCharacterStream("col", Mockito.mock(Reader.class))); - assertThrows( - SQLFeatureNotSupportedException.class, - () -> resultSet.updateAsciiStream(1, Mockito.mock(InputStream.class))); - assertThrows( - SQLFeatureNotSupportedException.class, - () -> resultSet.updateBinaryStream(1, Mockito.mock(InputStream.class))); - assertThrows( - SQLFeatureNotSupportedException.class, - () -> resultSet.updateCharacterStream(1, Mockito.mock(Reader.class))); - assertThrows( - SQLFeatureNotSupportedException.class, - () -> resultSet.updateAsciiStream("col", Mockito.mock(InputStream.class))); - assertThrows( - SQLFeatureNotSupportedException.class, - () -> resultSet.updateBinaryStream("col", Mockito.mock(InputStream.class))); - assertThrows( - SQLFeatureNotSupportedException.class, - () -> resultSet.updateCharacterStream("col", Mockito.mock(Reader.class))); - assertThrows( - SQLFeatureNotSupportedException.class, () -> resultSet.updateBlob(1, Mockito.mock(InputStream.class))); - assertThrows( - SQLFeatureNotSupportedException.class, - () -> resultSet.updateBlob("col", Mockito.mock(InputStream.class))); - assertThrows(SQLFeatureNotSupportedException.class, () -> resultSet.updateClob(1, Mockito.mock(Reader.class))); - assertThrows( - SQLFeatureNotSupportedException.class, () -> resultSet.updateClob("col", Mockito.mock(Reader.class))); - assertThrows(SQLFeatureNotSupportedException.class, () -> resultSet.updateNClob(1, Mockito.mock(Reader.class))); - assertThrows( - SQLFeatureNotSupportedException.class, () -> resultSet.updateNClob("col", Mockito.mock(Reader.class))); - } - - @Test - void wasNullReflectsNullAndNonNullColumnValues() throws SQLException { - List data = Arrays.asList(Collections.singletonList("TABLE"), Collections.singletonList(null)); - SimpleResultSet resultSet = - DataCloudMetadataResultSet.of(new DataCloudResultSetMetaData(MetadataSchemas.TABLE_TYPES), data); - - assertTrue(resultSet.next()); - assertEquals("TABLE", resultSet.getString(1)); - assertFalse(resultSet.wasNull()); - - assertTrue(resultSet.next()); - assertNull(resultSet.getString(1)); - assertTrue(resultSet.wasNull()); - } - - /** - * Covers branches in SimpleMetadataResultSet's private getValue (invoked via getString/getInt - * etc.): closed result set, no current row, row index out of bounds, row not a List, column - * index out of bounds for row. - */ - @Test - void simpleMetadataResultSetStatusValue() throws SQLException { - // 1. ResultSet is closed - SimpleResultSet rs = DataCloudMetadataResultSet.of( - new DataCloudResultSetMetaData(MetadataSchemas.TABLE_TYPES), - Arrays.asList(Collections.singletonList("x"))); - rs.close(); - SQLException ex = assertThrows(SQLException.class, () -> rs.getString(1)); - assertTrue(ex.getMessage().contains("ResultSet is closed"), "message: " + ex.getMessage()); - - // 2. No current row (get before next()) - SimpleResultSet rs2 = DataCloudMetadataResultSet.of( - new DataCloudResultSetMetaData(MetadataSchemas.TABLE_TYPES), - Arrays.asList(Collections.singletonList("x"))); - ex = assertThrows(SQLException.class, () -> rs2.getString(1)); - assertTrue(ex.getMessage().contains("No current row"), "message: " + ex.getMessage()); - - // 3. Row index out of bounds (past last row) - SimpleResultSet rs3 = DataCloudMetadataResultSet.of( - new DataCloudResultSetMetaData(MetadataSchemas.TABLE_TYPES), - Arrays.asList(Collections.singletonList("x"))); - assertTrue(rs3.next()); - assertFalse(rs3.next()); - ex = assertThrows(SQLException.class, () -> rs3.getString(1)); - assertTrue(ex.getMessage().contains("Row index out of bounds"), "message: " + ex.getMessage()); - - // 4. Data row is not a List - SimpleResultSet rs4 = DataCloudMetadataResultSet.of( - new DataCloudResultSetMetaData(MetadataSchemas.TABLE_TYPES), Arrays.asList(123)); - assertTrue(rs4.next()); - ex = assertThrows(SQLException.class, () -> rs4.getString(1)); - assertTrue(ex.getMessage().contains("Data row is not a List"), "message: " + ex.getMessage()); - - // 5. Column index out of bounds for row (row has fewer columns than requested) - SimpleResultSet rs5 = DataCloudMetadataResultSet.of( - new DataCloudResultSetMetaData(MetadataSchemas.COLUMNS), - Arrays.asList(Collections.singletonList("only one"))); - assertTrue(rs5.next()); - ex = assertThrows(SQLException.class, () -> rs5.getString(5)); - assertTrue(ex.getMessage().contains("out of bounds for row"), "message: " + ex.getMessage()); - } - - /** - * Covers the out-of-range branches in SimpleResultSet for getByte, getShort, and getInt: when - * the long value is outside the target type's range, the getter throws SQLException. (getFloat - * uses getDouble and would need a double column to trigger out-of-range; current metadata has - * only integer/varchar columns.) - */ - @Test - void getNumericTypesThrowWhenValueOutOfRange() throws SQLException { - // GET_COLUMNS: column 5 (1-based) is DATA_TYPE (INTEGER) - int col = 5; - - SimpleResultSet resultSet = DataCloudMetadataResultSet.of( - new DataCloudResultSetMetaData(MetadataSchemas.COLUMNS), - Arrays.asList( - rowWithLongAt(col, (long) Byte.MAX_VALUE + 1), - rowWithLongAt(col, (long) Byte.MIN_VALUE - 1), - rowWithLongAt(col, (long) Short.MAX_VALUE + 1), - rowWithLongAt(col, (long) Short.MIN_VALUE - 1), - rowWithLongAt(col, (long) Integer.MAX_VALUE + 1), - rowWithLongAt(col, (long) Integer.MIN_VALUE - 1))); - - // getByte: above and below byte range - assertTrue(resultSet.next()); - assertThrowsOutOfRange("byte", () -> resultSet.getByte(col)); - assertTrue(resultSet.next()); - assertThrowsOutOfRange("byte", () -> resultSet.getByte(col)); - - // getShort: above and below short range - assertTrue(resultSet.next()); - assertThrowsOutOfRange("short", () -> resultSet.getShort(col)); - assertTrue(resultSet.next()); - assertThrowsOutOfRange("short", () -> resultSet.getShort(col)); - - // getInt: above and below int range - assertTrue(resultSet.next()); - assertThrowsOutOfRange("int", () -> resultSet.getInt(col)); - assertTrue(resultSet.next()); - assertThrowsOutOfRange("int", () -> resultSet.getInt(col)); - - assertFalse(resultSet.next()); - } - - private static List rowWithLongAt(int oneBasedColumnIndex, long value) { - List row = new ArrayList<>(Collections.nCopies(24, null)); - row.set(oneBasedColumnIndex - 1, value); - return row; - } - - private static void assertThrowsOutOfRange(String typeName, Executable executable) { - SQLException ex = assertThrows(SQLException.class, executable); - String suffix = "byte".equals(typeName) || "short".equals(typeName) ? "a " + typeName : "an " + typeName; - assertTrue(ex.getMessage().contains("out of range for " + suffix), "message: " + ex.getMessage()); - } - - /** - * Exercises the label-based getters from {@link ResultSetWithPositionalGetters} so that the - * interface default methods (which delegate via findColumn) are covered. Uses a real - * {@link DataCloudMetadataResultSet} (not a mock) so JaCoCo attributes execution to the - * interface and Codecov reports coverage for ResultSetWithPositionalGetters. - */ - @Test - void resultSetWithPositionalGettersDelegateAndThrow() throws SQLException { - ResultSetWithPositionalGetters resultSet = - DataCloudMetadataResultSet.of(new DataCloudResultSetMetaData(MetadataSchemas.TABLE_TYPES), null); - String col = "TABLE_TYPE"; // single column from GET_TABLE_TYPES - - // UnsupportedOperationException from SimpleResultSet positional implementations - assertThrows(UnsupportedOperationException.class, () -> resultSet.getBytes(col)); - assertThrows(UnsupportedOperationException.class, () -> resultSet.getDate(col)); - assertThrows(UnsupportedOperationException.class, () -> resultSet.getDate(col, (Calendar) null)); - assertThrows(UnsupportedOperationException.class, () -> resultSet.getTime(col)); - assertThrows(UnsupportedOperationException.class, () -> resultSet.getTime(col, (Calendar) null)); - assertThrows(UnsupportedOperationException.class, () -> resultSet.getTimestamp(col)); - assertThrows(UnsupportedOperationException.class, () -> resultSet.getTimestamp(col, (Calendar) null)); - assertThrows(UnsupportedOperationException.class, () -> resultSet.getBigDecimal(col, 1)); - assertThrows(UnsupportedOperationException.class, () -> resultSet.getArray(col)); - - // SQLFeatureNotSupportedException from SimpleResultSet positional implementations - assertThrows(SQLFeatureNotSupportedException.class, () -> resultSet.getAsciiStream(col)); - assertThrows(SQLFeatureNotSupportedException.class, () -> resultSet.getUnicodeStream(col)); - assertThrows(SQLFeatureNotSupportedException.class, () -> resultSet.getBinaryStream(col)); - assertThrows(SQLFeatureNotSupportedException.class, () -> resultSet.getCharacterStream(col)); - assertThrows(SQLFeatureNotSupportedException.class, () -> resultSet.getRef(col)); - assertThrows(SQLFeatureNotSupportedException.class, () -> resultSet.getBlob(col)); - assertThrows(SQLFeatureNotSupportedException.class, () -> resultSet.getClob(col)); - assertThrows(SQLFeatureNotSupportedException.class, () -> resultSet.getURL(col)); - assertThrows(SQLFeatureNotSupportedException.class, () -> resultSet.getRowId(col)); - assertThrows(SQLFeatureNotSupportedException.class, () -> resultSet.getNClob(col)); - assertThrows(SQLFeatureNotSupportedException.class, () -> resultSet.getSQLXML(col)); - assertThrows(SQLFeatureNotSupportedException.class, () -> resultSet.getNString(col)); - assertThrows(SQLFeatureNotSupportedException.class, () -> resultSet.getNCharacterStream(col)); - - // getObject(String, Map) -> UnsupportedOperationException (non-empty map) - Map> map = Collections.singletonMap("key", String.class); - assertThrows(UnsupportedOperationException.class, () -> resultSet.getObject(col, map)); - - // No current row: delegate to positional getters which throw SQLException - assertThrows(SQLException.class, () -> resultSet.getObject(col)); - assertThrows(SQLException.class, () -> resultSet.getObject(col, String.class)); - assertThrows(SQLException.class, () -> resultSet.getBigDecimal(col)); - } - - @Test - void tinyintColumnSupportsGetLongGetDoubleGetBigDecimal() throws SQLException { - List schema = - Collections.singletonList(new ColumnMetadata("val", HyperType.int8(true), "TINYINT")); - SimpleResultSet rs = DataCloudMetadataResultSet.of( - new DataCloudResultSetMetaData(schema), Arrays.asList(Collections.singletonList(42L))); - - assertTrue(rs.next()); - assertEquals(42L, rs.getLong(1)); - assertFalse(rs.wasNull()); - } - - @Test - void tinyintColumnSupportsGetDouble() throws SQLException { - List schema = - Collections.singletonList(new ColumnMetadata("val", HyperType.int8(true), "TINYINT")); - SimpleResultSet rs = DataCloudMetadataResultSet.of( - new DataCloudResultSetMetaData(schema), Arrays.asList(Collections.singletonList(7L))); - - assertTrue(rs.next()); - assertEquals(7.0, rs.getDouble(1)); - } - - @Test - void tinyintColumnSupportsGetBigDecimal() throws SQLException { - List schema = - Collections.singletonList(new ColumnMetadata("val", HyperType.int8(true), "TINYINT")); - SimpleResultSet rs = DataCloudMetadataResultSet.of( - new DataCloudResultSetMetaData(schema), Arrays.asList(Collections.singletonList(99L))); - - assertTrue(rs.next()); - assertEquals(new BigDecimal(99), rs.getBigDecimal(1)); - } - - @Test - void getFloatAcceptsNegativeValuesWithinRange() throws SQLException { - int col = 5; - SimpleResultSet rs = DataCloudMetadataResultSet.of( - new DataCloudResultSetMetaData(MetadataSchemas.COLUMNS), Arrays.asList(rowWithLongAt(col, -42L))); - - assertTrue(rs.next()); - assertEquals(-42.0f, rs.getFloat(col)); - } -} diff --git a/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/protocol/QueryResultArrowStreamTest.java b/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/protocol/QueryResultArrowStreamTest.java index 0049ebad..6a9ea211 100644 --- a/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/protocol/QueryResultArrowStreamTest.java +++ b/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/protocol/QueryResultArrowStreamTest.java @@ -33,7 +33,8 @@ void testArrowStreamWithSimpleSelectQuery() { val chunkIterator = ChunkRangeIterator.of(queryClient, 0, 3, false, QueryResultArrowStream.OUTPUT_FORMAT); // Create ArrowStreamReader from the iterator - try (val reader = QueryResultArrowStream.toArrowStreamReader(chunkIterator)) { + try (val arrowStream = QueryResultArrowStream.toArrowStreamReader(chunkIterator)) { + val reader = arrowStream.getReader(); int rowCount = 0; // Count all rows in the arrow stream @@ -63,7 +64,8 @@ void testArrowStreamWithNoColumnsQuery() { val chunkIterator = ChunkRangeIterator.of(queryClient, 0, 3, false, QueryResultArrowStream.OUTPUT_FORMAT); // Create ArrowStreamReader from the iterator - try (val reader = QueryResultArrowStream.toArrowStreamReader(chunkIterator)) { + try (val arrowStream = QueryResultArrowStream.toArrowStreamReader(chunkIterator)) { + val reader = arrowStream.getReader(); int rowCount = 0; // Count all rows in the arrow stream From 98630831011bac1927ef01789a43b4f039ae312a Mon Sep 17 00:00:00 2001 From: Moritz Kaufmann Date: Fri, 24 Apr 2026 21:41:54 +0200 Subject: [PATCH 02/24] =?UTF-8?q?refactor:=20address=20PR=20review=20?= =?UTF-8?q?=E2=80=94=20share=20vector=20builder=20and=20use=20in-memory=20?= =?UTF-8?q?ArrowStreamReader?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rework the ResultSet unification to address two reviewer requests on #175: 1. Share the vector-building code with the parameter-encoding path instead of having a dedicated MetadataArrowBuilder. VectorPopulator now exposes a row-indexed primitive (setCell) used by both callers. The existing single-row parameter-binding overload and a new many-row metadata overload both funnel through it, and all the individual vector setters are parameterised by row index. 2. Keep ArrowStreamReaderCursor on its original ArrowStreamReader-only interface. The metadata path now serialises a populated VSR to Arrow IPC bytes and wraps the result in a ByteArrayInputStream-backed ArrowStreamReader, so both streaming and metadata result sets travel through exactly the same reader/cursor plumbing. Supporting changes: - typeName overrides (e.g. "TEXT" for JDBC-spec metadata columns) now round-trip through Arrow via a jdbc:type_name field-metadata key rather than a columns-override parameter on StreamingResultSet. HyperTypeToArrow stamps the key on write; ArrowToHyperTypeMapper.toColumnMetadata reads it back. - StreamingResultSet drops the ofInMemory(...) factory and the columns override; callers construct an ArrowStreamReader + BufferAllocator pair and hand them to of(reader, allocator, queryId, zone). The cursor owns both and closes reader-then-allocator on close. - QueryResultArrowStream.toArrowStreamReader returns a simple Result holder (reader + allocator) instead of an AutoCloseable bundle. - MetadataResultSets is the single entry point for Arrow-backed metadata result sets; MetadataArrowBuilder is deleted. - Empty metadata results skip writeBatch() entirely so ArrowStreamReaderCursor doesn't interpret a zero-row batch as "at least one row available". - Tests updated to the new API; StreamingResultSetMethodTest builds its in-memory ResultSet the same way as the metadata path (IPC round-trip). --- .../jdbc/core/ArrowStreamReaderCursor.java | 85 +++---- .../jdbc/core/DataCloudConnection.java | 4 +- .../jdbc/core/DataCloudStatement.java | 10 +- .../jdbc/core/StreamingResultSet.java | 107 +++------ .../core/metadata/MetadataArrowBuilder.java | 184 --------------- .../core/metadata/MetadataResultSets.java | 68 +++++- .../jdbc/protocol/QueryResultArrowStream.java | 25 +- .../protocol/data/ArrowToHyperTypeMapper.java | 13 + .../jdbc/protocol/data/HyperTypeToArrow.java | 39 ++- .../jdbc/protocol/data/VectorPopulator.java | 222 ++++++++++-------- .../core/ArrowStreamReaderCursorTest.java | 33 +-- .../datacloud/jdbc/core/StreamCloseTest.java | 2 +- .../core/StreamingResultSetMethodTest.java | 31 ++- .../protocol/QueryResultArrowStreamTest.java | 20 +- 14 files changed, 355 insertions(+), 488 deletions(-) delete mode 100644 jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/metadata/MetadataArrowBuilder.java diff --git a/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/ArrowStreamReaderCursor.java b/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/ArrowStreamReaderCursor.java index 725e327c..37605df9 100644 --- a/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/ArrowStreamReaderCursor.java +++ b/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/ArrowStreamReaderCursor.java @@ -8,6 +8,7 @@ import com.salesforce.datacloud.jdbc.core.accessor.QueryJDBCAccessor; import com.salesforce.datacloud.jdbc.core.accessor.QueryJDBCAccessorFactory; +import java.io.IOException; import java.sql.SQLException; import java.time.ZoneId; import java.util.List; @@ -16,31 +17,27 @@ import lombok.SneakyThrows; import lombok.extern.slf4j.Slf4j; import lombok.val; +import org.apache.arrow.memory.BufferAllocator; import org.apache.arrow.vector.FieldVector; import org.apache.arrow.vector.VectorSchemaRoot; import org.apache.arrow.vector.ipc.ArrowStreamReader; /** - * Row cursor over a {@link VectorSchemaRoot} that drives the {@link StreamingResultSet}. + * Row cursor over an {@link ArrowStreamReader} that drives the {@link StreamingResultSet}. * - *

The cursor is source-agnostic: a {@link BatchLoader} loads the next batch into the vector - * schema root, whether that comes from an Arrow IPC stream or a pre-populated in-memory batch. - * This is the single codepath that the driver exposes to JDBC callers — streaming query results - * and materialised metadata results both funnel through here. - * - *

The cursor owns an optional {@link AutoCloseable} holding the resources that back the - * vector schema root (allocator, underlying reader, etc.). Closing the cursor closes that holder, - * guaranteeing root-allocator hygiene without requiring each call site to manage the allocator - * separately. + *

The cursor owns the supplied {@link BufferAllocator} alongside the reader: closing the + * cursor closes the reader (which releases ArrowBuf accounting) and then the allocator (which + * returns its budget). This is the single place that guarantees root-allocator hygiene for the + * driver; callers of {@link StreamingResultSet#of} hand ownership over and do not close the + * allocator themselves. */ @Slf4j class ArrowStreamReaderCursor implements AutoCloseable { private static final int INIT_ROW_NUMBER = -1; - private final VectorSchemaRoot root; - private final BatchLoader batchLoader; - private final AutoCloseable ownedResources; + private final ArrowStreamReader reader; + private final BufferAllocator allocator; private final ZoneId sessionZone; @lombok.Getter @@ -48,51 +45,19 @@ class ArrowStreamReaderCursor implements AutoCloseable { private final AtomicInteger currentIndex = new AtomicInteger(INIT_ROW_NUMBER); - /** - * Loads the next batch of rows into the vector schema root. - * - *

Implementations should return {@code true} if the vector schema root now holds rows from - * a newly-loaded batch, and {@code false} if the source has no more data. - */ - @FunctionalInterface - interface BatchLoader { - boolean loadNextBatch() throws Exception; + ArrowStreamReaderCursor(ArrowStreamReader reader, BufferAllocator allocator, ZoneId sessionZone) { + this.reader = reader; + this.allocator = allocator; + this.sessionZone = sessionZone; } - /** - * Create a cursor that pulls batches from an {@link ArrowStreamReader}. The reader (and the - * allocator it was constructed with) are owned by the cursor — closing the cursor closes the - * supplied {@code ownedResources}. - */ @SneakyThrows - static ArrowStreamReaderCursor streaming( - ArrowStreamReader reader, AutoCloseable ownedResources, ZoneId sessionZone) { - val root = reader.getVectorSchemaRoot(); - BatchLoader loader = reader::loadNextBatch; - return new ArrowStreamReaderCursor(root, loader, ownedResources, sessionZone); - } - - /** - * Create a cursor over a single pre-populated {@link VectorSchemaRoot}. The root (and any - * backing allocator wrapped in {@code ownedResources}) are owned by the cursor — closing the - * cursor closes the supplied {@code ownedResources}. - */ - static ArrowStreamReaderCursor inMemory(VectorSchemaRoot root, AutoCloseable ownedResources, ZoneId sessionZone) { - // The VSR is already populated, so there is nothing more to load — the cursor walks the - // row count until exhausted and then reports end-of-stream. - return new ArrowStreamReaderCursor(root, () -> false, ownedResources, sessionZone); - } - - private ArrowStreamReaderCursor( - VectorSchemaRoot root, BatchLoader batchLoader, AutoCloseable ownedResources, ZoneId sessionZone) { - this.root = root; - this.batchLoader = batchLoader; - this.ownedResources = ownedResources; - this.sessionZone = sessionZone; + private VectorSchemaRoot getSchemaRoot() { + return reader.getVectorSchemaRoot(); } List createAccessors() { - return root.getFieldVectors().stream() + return getSchemaRoot().getFieldVectors().stream() .map(rethrowFunction(this::createAccessor)) .collect(Collectors.toList()); } @@ -104,9 +69,9 @@ private QueryJDBCAccessor createAccessor(FieldVector vector) throws SQLException /** * Load the next batch that has at least one row, skipping any zero-row batches in between. */ - private boolean loadNextNonEmptyBatch() throws Exception { - while (batchLoader.loadNextBatch()) { - if (root.getRowCount() > 0) { + private boolean loadNextNonEmptyBatch() throws IOException { + while (reader.loadNextBatch()) { + if (getSchemaRoot().getRowCount() > 0) { currentIndex.set(0); return true; } @@ -117,7 +82,7 @@ private boolean loadNextNonEmptyBatch() throws Exception { @SneakyThrows public boolean next() { val current = currentIndex.incrementAndGet(); - val total = root.getRowCount(); + val total = getSchemaRoot().getRowCount(); try { val next = current < total || loadNextNonEmptyBatch(); @@ -138,8 +103,12 @@ public boolean next() { @SneakyThrows @Override public void close() { - if (ownedResources != null) { - ownedResources.close(); + // Close the reader first: it releases the buffers accounted against the allocator, so the + // allocator's closing budget check passes. Reversing the order trips a leak detector. + try { + reader.close(); + } finally { + allocator.close(); } } } diff --git a/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/DataCloudConnection.java b/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/DataCloudConnection.java index fdc32e92..012a07f8 100644 --- a/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/DataCloudConnection.java +++ b/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/DataCloudConnection.java @@ -220,7 +220,7 @@ public DataCloudResultSet getRowBasedResultSet(String queryId, long offset, long QueryResultArrowStream.OUTPUT_FORMAT); val arrowStream = SQLExceptionQueryResultIterator.createSqlExceptionArrowStreamReader( iterator, connectionProperties.isIncludeCustomerDetailInReason(), queryId, null); - return StreamingResultSet.of(arrowStream, queryId); + return StreamingResultSet.of(arrowStream.getReader(), arrowStream.getAllocator(), queryId); } catch (StatusRuntimeException ex) { throw QueryExceptionHandler.createException( connectionProperties.isIncludeCustomerDetailInReason(), null, queryId, ex); @@ -263,7 +263,7 @@ public DataCloudResultSet getChunkBasedResultSet(String queryId, long chunkId, l QueryResultArrowStream.OUTPUT_FORMAT); val arrowStream = SQLExceptionQueryResultIterator.createSqlExceptionArrowStreamReader( iterator, connectionProperties.isIncludeCustomerDetailInReason(), queryId, null); - return StreamingResultSet.of(arrowStream, queryId); + return StreamingResultSet.of(arrowStream.getReader(), arrowStream.getAllocator(), queryId); } catch (StatusRuntimeException ex) { throw QueryExceptionHandler.createException( connectionProperties.isIncludeCustomerDetailInReason(), null, queryId, ex); diff --git a/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/DataCloudStatement.java b/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/DataCloudStatement.java index 138d95d3..094e8414 100644 --- a/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/DataCloudStatement.java +++ b/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/DataCloudStatement.java @@ -196,8 +196,11 @@ public ResultSet executeQuery(String sql) throws SQLException { val iterator = executeAdaptiveQuery(sql); val arrowStream = SQLExceptionQueryResultIterator.createSqlExceptionArrowStreamReader( iterator, includeCustomerDetail, iterator.getQueryStatus().getQueryId(), sql); - resultSet = - StreamingResultSet.of(arrowStream, iterator.getQueryStatus().getQueryId(), sessionZone); + resultSet = StreamingResultSet.of( + arrowStream.getReader(), + arrowStream.getAllocator(), + iterator.getQueryStatus().getQueryId(), + sessionZone); log.info( "executeAdaptiveQuery completed. queryId={}, sessionZone={}", queryHandle.getQueryStatus().getQueryId(), @@ -437,7 +440,8 @@ public ResultSet getResultSet() throws SQLException { adaptiveIterator.getQueryStatus().getQueryId(), null); resultSet = StreamingResultSet.of( - arrowStream, + arrowStream.getReader(), + arrowStream.getAllocator(), adaptiveIterator.getQueryStatus().getQueryId(), sessionZone); } else if (resultSet == null) { diff --git a/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/StreamingResultSet.java b/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/StreamingResultSet.java index 7cf064e2..8fb39040 100644 --- a/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/StreamingResultSet.java +++ b/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/StreamingResultSet.java @@ -9,9 +9,7 @@ import com.salesforce.datacloud.jdbc.core.resultset.ForwardOnlyResultSet; import com.salesforce.datacloud.jdbc.core.resultset.ReadOnlyResultSet; import com.salesforce.datacloud.jdbc.core.resultset.ResultSetWithPositionalGetters; -import com.salesforce.datacloud.jdbc.protocol.QueryResultArrowStream; import com.salesforce.datacloud.jdbc.protocol.data.ArrowToHyperTypeMapper; -import com.salesforce.datacloud.jdbc.protocol.data.ColumnMetadata; import com.salesforce.datacloud.jdbc.util.ThrowingJdbcSupplier; import com.salesforce.datacloud.query.v3.QueryStatus; import java.io.IOException; @@ -38,13 +36,13 @@ import java.sql.Types; import java.time.ZoneId; import java.util.Calendar; -import java.util.List; import java.util.Map; import java.util.stream.Collectors; import lombok.Getter; import lombok.extern.slf4j.Slf4j; import lombok.val; -import org.apache.arrow.vector.VectorSchemaRoot; +import org.apache.arrow.memory.BufferAllocator; +import org.apache.arrow.vector.ipc.ArrowStreamReader; @Slf4j public class StreamingResultSet @@ -75,28 +73,41 @@ private StreamingResultSet( this.closed = false; } - public static StreamingResultSet of(QueryResultArrowStream.Result arrowStream, String queryId) throws SQLException { - return of(arrowStream, queryId, ZoneId.systemDefault()); + public static StreamingResultSet of(ArrowStreamReader reader, BufferAllocator allocator, String queryId) + throws SQLException { + return of(reader, allocator, queryId, ZoneId.systemDefault()); } /** - * Creates a StreamingResultSet with a specified session timezone. + * Creates a StreamingResultSet from an {@link ArrowStreamReader} and its backing allocator. + * + *

Ownership of both the reader and the allocator transfers to the returned result set — + * closing the result set closes the reader and then the allocator, in that order, so Arrow's + * buffer accounting clears before the allocator's budget check. Callers must not close + * either separately. * - *

Ownership of {@code arrowStream} (both the reader and its backing allocator) transfers - * to the returned result set — callers must not close it separately. + *

The column metadata (including any {@link ColumnMetadata#getTypeName()} override + * stamped under {@link com.salesforce.datacloud.jdbc.protocol.data.HyperTypeToArrow#JDBC_TYPE_NAME_METADATA_KEY}) + * is derived from the Arrow schema via {@link ArrowToHyperTypeMapper#toColumnMetadata(org.apache.arrow.vector.types.pojo.Field)}. * - * @param arrowStream The Arrow stream containing query results, owned by the result set - * @param queryId The query identifier - * @param sessionZone The session timezone to use for timestamp conversions - * @return A new StreamingResultSet - * @throws SQLException If an error occurs during ResultSet creation + * @param reader The Arrow stream, owned by the result set. + * @param allocator The allocator backing the reader, owned by the result set. + * @param queryId The query identifier. + * @param sessionZone The session timezone used for timestamp conversions. */ - public static StreamingResultSet of(QueryResultArrowStream.Result arrowStream, String queryId, ZoneId sessionZone) + public static StreamingResultSet of( + ArrowStreamReader reader, BufferAllocator allocator, String queryId, ZoneId sessionZone) throws SQLException { try { - val schemaRoot = arrowStream.getReader().getVectorSchemaRoot(); - val cursor = ArrowStreamReaderCursor.streaming(arrowStream.getReader(), arrowStream, sessionZone); - return build(cursor, schemaRoot, queryId, null); + val schemaRoot = reader.getVectorSchemaRoot(); + val columns = schemaRoot.getSchema().getFields().stream() + .map(ArrowToHyperTypeMapper::toColumnMetadata) + .collect(Collectors.toList()); + val metadata = new DataCloudResultSetMetaData(columns); + val cursor = new ArrowStreamReaderCursor(reader, allocator, sessionZone); + val accessors = cursor.createAccessors().toArray(new QueryJDBCAccessor[0]); + val columnNameResolver = new ColumnNameResolver(columns); + return new StreamingResultSet(cursor, queryId, metadata, accessors, columnNameResolver); } catch (IOException ex) { throw new SQLException("Unexpected error during ResultSet creation", "XX000", ex); } catch (IllegalArgumentException ex) { @@ -105,66 +116,6 @@ public static StreamingResultSet of(QueryResultArrowStream.Result arrowStream, S } } - /** - * Creates a StreamingResultSet over a pre-populated in-memory {@link VectorSchemaRoot}. - * - *

Used by the metadata path (e.g. {@code DatabaseMetaData.getTables}), where the rows are - * materialised into Arrow up front rather than streamed from the server. Ownership of - * {@code ownedResources} — typically the {@link org.apache.arrow.memory.BufferAllocator} and - * {@link VectorSchemaRoot} — transfers to the returned result set, which closes them when - * it is closed. - * - * @param columns optional column-metadata override. When non-null it takes precedence over - * what would be derived from the Arrow schema so that {@link ColumnMetadata#getTypeName()} - * overrides (e.g. {@code "TEXT"} for {@code getTables} rather than the derived - * {@code "VARCHAR"}) are preserved on the way through. - */ - public static StreamingResultSet ofInMemory( - VectorSchemaRoot schemaRoot, - AutoCloseable ownedResources, - String queryId, - ZoneId sessionZone, - List columns) - throws SQLException { - try { - val cursor = ArrowStreamReaderCursor.inMemory(schemaRoot, ownedResources, sessionZone); - return build(cursor, schemaRoot, queryId, columns); - } catch (IllegalArgumentException ex) { - throw new SQLException("Unsupported column type in result set: " + ex.getMessage(), "0A000", ex); - } - } - - /** - * Overload that derives the column metadata from the Arrow schema. Prefer the overload that - * takes an explicit {@code columns} list if callers need to preserve JDBC-spec type names - * (e.g. {@code "TEXT"}). - */ - public static StreamingResultSet ofInMemory( - VectorSchemaRoot schemaRoot, AutoCloseable ownedResources, String queryId, ZoneId sessionZone) - throws SQLException { - return ofInMemory(schemaRoot, ownedResources, queryId, sessionZone, null); - } - - private static StreamingResultSet build( - ArrowStreamReaderCursor cursor, - VectorSchemaRoot schemaRoot, - String queryId, - List columnOverride) - throws SQLException { - final List columns; - if (columnOverride != null) { - columns = columnOverride; - } else { - columns = schemaRoot.getSchema().getFields().stream() - .map(field -> new ColumnMetadata(field.getName(), ArrowToHyperTypeMapper.toHyperType(field))) - .collect(Collectors.toList()); - } - val metadata = new DataCloudResultSetMetaData(columns); - val accessors = cursor.createAccessors().toArray(new QueryJDBCAccessor[0]); - val columnNameResolver = new ColumnNameResolver(columns); - return new StreamingResultSet(cursor, queryId, metadata, accessors, columnNameResolver); - } - // --- Core ResultSet navigation --- @Override diff --git a/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/metadata/MetadataArrowBuilder.java b/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/metadata/MetadataArrowBuilder.java deleted file mode 100644 index a5900661..00000000 --- a/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/metadata/MetadataArrowBuilder.java +++ /dev/null @@ -1,184 +0,0 @@ -/** - * This file is part of https://github.com/forcedotcom/datacloud-jdbc which is released under the - * Apache 2.0 license. See https://github.com/forcedotcom/datacloud-jdbc/blob/main/LICENSE.txt - */ -package com.salesforce.datacloud.jdbc.core.metadata; - -import com.salesforce.datacloud.jdbc.protocol.data.ColumnMetadata; -import com.salesforce.datacloud.jdbc.protocol.data.HyperType; -import com.salesforce.datacloud.jdbc.protocol.data.HyperTypeKind; -import com.salesforce.datacloud.jdbc.protocol.data.HyperTypeToArrow; -import java.nio.charset.StandardCharsets; -import java.util.List; -import lombok.Value; -import org.apache.arrow.memory.RootAllocator; -import org.apache.arrow.vector.BigIntVector; -import org.apache.arrow.vector.BitVector; -import org.apache.arrow.vector.IntVector; -import org.apache.arrow.vector.SmallIntVector; -import org.apache.arrow.vector.TinyIntVector; -import org.apache.arrow.vector.ValueVector; -import org.apache.arrow.vector.VarCharVector; -import org.apache.arrow.vector.VectorSchemaRoot; -import org.apache.arrow.vector.types.pojo.Field; -import org.apache.arrow.vector.types.pojo.Schema; - -/** - * Materialises a list of row-oriented metadata values into a populated Arrow - * {@link VectorSchemaRoot}. - * - *

Used by the JDBC metadata path ({@code DatabaseMetaData.getTables}, {@code getColumns}, - * {@code getTypeInfo}, ...) so that both streaming query results and materialised metadata - * results run through the same Arrow-backed result set. - * - *

The builder owns a fresh {@link RootAllocator}; the returned {@link Result} transfers - * ownership of the allocator and schema root to the caller, which must close both — closing in - * the {@link Result#close()} order tears down the root before the allocator to satisfy Arrow's - * accounting invariants. - */ -public final class MetadataArrowBuilder { - - private MetadataArrowBuilder() { - throw new UnsupportedOperationException("This is a utility class and cannot be instantiated"); - } - - /** Populated result. Owns the allocator and the vector schema root. */ - @Value - public static class Result implements AutoCloseable { - VectorSchemaRoot root; - RootAllocator allocator; - - @Override - public void close() { - // Close the root first: it releases ArrowBuf accounting back to the allocator, so the - // allocator's closing budget check passes. Reversing the order trips a leak detector. - try { - root.close(); - } finally { - allocator.close(); - } - } - } - - /** - * Build an Arrow {@link VectorSchemaRoot} whose schema matches {@code columns} and whose rows - * come from {@code rows} (each inner list is a row in column order). - * - *

Values are coerced to the target vector type on a best-effort basis (e.g. a - * {@link Boolean} in a VARCHAR column becomes {@code "true"}/{@code "false"}). This mirrors - * the loose coercion the removed {@code SimpleResultSet.getString} used to do on row-based - * metadata data. - */ - public static Result build(List columns, List> rows) { - RootAllocator allocator = new RootAllocator(Long.MAX_VALUE); - VectorSchemaRoot root = null; - try { - Schema schema = buildSchema(columns); - root = VectorSchemaRoot.create(schema, allocator); - root.allocateNew(); - populate(root, columns, rows); - root.setRowCount(rows == null ? 0 : rows.size()); - Result result = new Result(root, allocator); - root = null; // ownership transferred - return result; - } finally { - if (root != null) { - try { - root.close(); - } finally { - allocator.close(); - } - } - } - } - - private static Schema buildSchema(List columns) { - java.util.List fields = new java.util.ArrayList<>(columns.size()); - for (ColumnMetadata column : columns) { - fields.add(HyperTypeToArrow.toField(column.getName(), column.getType())); - } - return new Schema(fields); - } - - private static void populate(VectorSchemaRoot root, List columns, List> rows) { - if (rows == null || rows.isEmpty()) { - return; - } - for (int col = 0; col < columns.size(); col++) { - ValueVector vector = root.getVector(columns.get(col).getName()); - HyperType type = columns.get(col).getType(); - for (int rowIdx = 0; rowIdx < rows.size(); rowIdx++) { - List row = rows.get(rowIdx); - Object value = row == null || col >= row.size() ? null : row.get(col); - setValue(vector, rowIdx, type, value); - } - vector.setValueCount(rows.size()); - } - } - - private static void setValue(ValueVector vector, int index, HyperType type, Object value) { - if (value == null) { - setNull(vector, index, type); - return; - } - HyperTypeKind kind = type.getKind(); - switch (kind) { - case VARCHAR: - case CHAR: - byte[] bytes = asString(value).getBytes(StandardCharsets.UTF_8); - ((VarCharVector) vector).setSafe(index, bytes); - return; - case INT8: - ((TinyIntVector) vector).setSafe(index, ((Number) value).byteValue()); - return; - case INT16: - ((SmallIntVector) vector).setSafe(index, ((Number) value).shortValue()); - return; - case INT32: - ((IntVector) vector).setSafe(index, ((Number) value).intValue()); - return; - case INT64: - ((BigIntVector) vector).setSafe(index, ((Number) value).longValue()); - return; - case BOOL: - ((BitVector) vector).setSafe(index, Boolean.TRUE.equals(value) ? 1 : 0); - return; - default: - throw new IllegalArgumentException("MetadataArrowBuilder does not support HyperTypeKind " + kind - + "; metadata schemas are expected to use VARCHAR/CHAR and fixed-width integers only"); - } - } - - private static void setNull(ValueVector vector, int index, HyperType type) { - switch (type.getKind()) { - case VARCHAR: - case CHAR: - ((VarCharVector) vector).setNull(index); - return; - case INT8: - ((TinyIntVector) vector).setNull(index); - return; - case INT16: - ((SmallIntVector) vector).setNull(index); - return; - case INT32: - ((IntVector) vector).setNull(index); - return; - case INT64: - ((BigIntVector) vector).setNull(index); - return; - case BOOL: - ((BitVector) vector).setNull(index); - return; - default: - throw new IllegalArgumentException("Unsupported metadata type kind for null: " + type.getKind()); - } - } - - private static String asString(Object value) { - if (value instanceof byte[]) { - return new String((byte[]) value, StandardCharsets.UTF_8); - } - return value.toString(); - } -} diff --git a/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/metadata/MetadataResultSets.java b/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/metadata/MetadataResultSets.java index 11d5b9fc..3cdc556a 100644 --- a/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/metadata/MetadataResultSets.java +++ b/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/metadata/MetadataResultSets.java @@ -6,16 +6,31 @@ import com.salesforce.datacloud.jdbc.core.StreamingResultSet; import com.salesforce.datacloud.jdbc.protocol.data.ColumnMetadata; +import com.salesforce.datacloud.jdbc.protocol.data.HyperTypeToArrow; +import com.salesforce.datacloud.jdbc.protocol.data.VectorPopulator; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; import java.sql.SQLException; import java.time.ZoneId; import java.util.ArrayList; import java.util.Collections; import java.util.List; +import java.util.stream.Collectors; +import org.apache.arrow.memory.RootAllocator; +import org.apache.arrow.vector.VectorSchemaRoot; +import org.apache.arrow.vector.ipc.ArrowStreamReader; +import org.apache.arrow.vector.ipc.ArrowStreamWriter; +import org.apache.arrow.vector.types.pojo.Schema; /** - * Factory for Arrow-backed metadata result sets. This is the single entry point callers use to - * materialise a list of {@link ColumnMetadata} + row values into a {@link StreamingResultSet}, - * replacing the historical row-based {@code DataCloudMetadataResultSet}. + * Factory for Arrow-backed metadata result sets. Materialises a row-oriented list of metadata + * values into the Arrow IPC format used by every other driver result set, so streaming query + * results and materialised metadata results both flow through {@link StreamingResultSet}. + * + *

Each call builds a fresh single-batch Arrow stream: a writer-side {@link VectorSchemaRoot} + * is populated via {@link VectorPopulator} (the same code path the JDBC parameter encoder uses), + * serialised to bytes, and wrapped in an {@link ArrowStreamReader} that the result set owns. */ public final class MetadataResultSets { @@ -38,12 +53,17 @@ public static StreamingResultSet emptyNoColumns() throws SQLException { * inner list in {@code rows} supplies values in column order. */ public static StreamingResultSet of(List columns, List> rows) throws SQLException { - MetadataArrowBuilder.Result built = MetadataArrowBuilder.build(columns, rows); - // Pass the original columns through as the metadata override so that JDBC-spec type names - // ("TEXT", "INTEGER", "SHORT") survive the round-trip via Arrow — if we rederived from the - // Arrow schema we would get the generic {@code HyperType}-derived names ("VARCHAR" etc.). - return StreamingResultSet.ofInMemory( - built.getRoot(), built, /*queryId=*/ null, ZoneId.systemDefault(), columns); + byte[] ipcBytes = writeArrowStream(columns, rows); + // Allocator is handed to StreamingResultSet along with the reader; the result set owns + // its lifecycle and closes it when close() is called. + RootAllocator allocator = new RootAllocator(Long.MAX_VALUE); + try { + ArrowStreamReader reader = new ArrowStreamReader(new ByteArrayInputStream(ipcBytes), allocator); + return StreamingResultSet.of(reader, allocator, /*queryId=*/ null, ZoneId.systemDefault()); + } catch (SQLException | RuntimeException ex) { + allocator.close(); + throw ex; + } } /** @@ -55,6 +75,36 @@ public static StreamingResultSet ofRawRows(List columns, List columns, List> rows) throws SQLException { + Schema schema = new Schema(columns.stream() + .map(c -> HyperTypeToArrow.toField(c.getName(), c.getType(), c.getTypeName())) + .collect(Collectors.toList())); + try (RootAllocator writeAllocator = new RootAllocator(Long.MAX_VALUE); + VectorSchemaRoot root = VectorSchemaRoot.create(schema, writeAllocator)) { + root.allocateNew(); + VectorPopulator.populateVectors(root, columns, rows, /*calendar=*/ null); + + ByteArrayOutputStream out = new ByteArrayOutputStream(); + try (ArrowStreamWriter writer = new ArrowStreamWriter(root, null, out)) { + writer.start(); + // Skip writeBatch() for empty results — writing a zero-row batch confuses the + // cursor (see ArrowStreamReaderCursor.next), which interprets a successfully + // loaded batch as "at least one row available". + if (root.getRowCount() > 0) { + writer.writeBatch(); + } + writer.end(); + } + return out.toByteArray(); + } catch (IOException ex) { + throw new SQLException("Failed to build metadata result set", "XX000", ex); + } + } + @SuppressWarnings("unchecked") private static List> coerceRows(List rawRows) throws SQLException { if (rawRows == null || rawRows.isEmpty()) { diff --git a/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/protocol/QueryResultArrowStream.java b/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/protocol/QueryResultArrowStream.java index 8e88556c..cac6297f 100644 --- a/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/protocol/QueryResultArrowStream.java +++ b/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/protocol/QueryResultArrowStream.java @@ -9,7 +9,6 @@ import com.salesforce.datacloud.jdbc.core.ByteStringReadableByteChannel; import lombok.Value; import lombok.val; -import org.apache.arrow.memory.BufferAllocator; import org.apache.arrow.memory.RootAllocator; import org.apache.arrow.vector.ipc.ArrowStreamReader; import salesforce.cdp.hyperdb.v1.OutputFormat; @@ -24,26 +23,15 @@ public class QueryResultArrowStream { private static final int ROOT_ALLOCATOR_MB_FROM_V2 = 100 * 1024 * 1024; /** - * Result of {@link #toArrowStreamReader(CloseableIterator)}: the {@link ArrowStreamReader} for - * loading batches plus the {@link RootAllocator} backing it. The caller is responsible for - * closing both — the reader closes its vectors, but the allocator outlives the reader and must - * be closed separately to return its budget to the JVM. + * Pair of the {@link ArrowStreamReader} that decodes gRPC chunks and the {@link RootAllocator} + * that backs it. Callers hand ownership to {@link + * com.salesforce.datacloud.jdbc.core.StreamingResultSet#of} which closes both; the pair is + * never closed directly. */ @Value - public static class Result implements AutoCloseable { + public static class Result { ArrowStreamReader reader; RootAllocator allocator; - - @Override - public void close() throws Exception { - // Order matters: the reader holds ArrowBuf instances whose accounting lives on the - // allocator, so the reader must release them before the allocator's budget check runs. - try { - reader.close(); - } finally { - allocator.close(); - } - } } public static Result toArrowStreamReader(CloseableIterator iterator) { @@ -73,7 +61,6 @@ public void close() throws Exception { }; val channel = new ByteStringReadableByteChannel(closeable); RootAllocator allocator = new RootAllocator(ROOT_ALLOCATOR_MB_FROM_V2); - ArrowStreamReader reader = new ArrowStreamReader(channel, (BufferAllocator) allocator); - return new Result(reader, allocator); + return new Result(new ArrowStreamReader(channel, allocator), allocator); } } diff --git a/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/protocol/data/ArrowToHyperTypeMapper.java b/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/protocol/data/ArrowToHyperTypeMapper.java index 1c4e02ac..53eeafed 100644 --- a/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/protocol/data/ArrowToHyperTypeMapper.java +++ b/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/protocol/data/ArrowToHyperTypeMapper.java @@ -30,6 +30,19 @@ public static HyperType toHyperType(Field field) { return field.getType().accept(new ArrowTypeVisitor(field)); } + /** + * Translate an Arrow {@link Field} to a full {@link ColumnMetadata}, picking up the optional + * JDBC type-name override stamped under + * {@link HyperTypeToArrow#JDBC_TYPE_NAME_METADATA_KEY} when present. + */ + public static ColumnMetadata toColumnMetadata(Field field) { + HyperType type = toHyperType(field); + String override = field.getMetadata() == null + ? null + : field.getMetadata().get(HyperTypeToArrow.JDBC_TYPE_NAME_METADATA_KEY); + return new ColumnMetadata(field.getName(), type, override); + } + /** Arrow visitor that produces a {@link HyperType} for each supported Arrow type. */ private static class ArrowTypeVisitor implements ArrowType.ArrowTypeVisitor { private final Field field; diff --git a/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/protocol/data/HyperTypeToArrow.java b/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/protocol/data/HyperTypeToArrow.java index 55e5e15c..5de5c8cb 100644 --- a/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/protocol/data/HyperTypeToArrow.java +++ b/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/protocol/data/HyperTypeToArrow.java @@ -30,13 +30,28 @@ private HyperTypeToArrow() { /** Build an Arrow {@link Field} with the given name and the mapped {@link FieldType}. */ public static Field toField(String name, HyperType type) { + return toField(name, type, null); + } + + /** + * Build an Arrow {@link Field} and stamp an optional JDBC type-name override into the field + * metadata under {@code jdbc:type_name}. The override is how {@link ColumnMetadata#getTypeName()} + * round-trips through Arrow — it lets JDBC-spec labels (e.g. {@code "TEXT"} for metadata + * columns) survive serialisation, which is the only way to carry them through an + * {@link org.apache.arrow.vector.ipc.ArrowStreamReader}-backed code path. + */ + public static Field toField(String name, HyperType type, String jdbcTypeName) { + FieldType fieldType = toFieldType(type, jdbcTypeName); if (type.getKind() == HyperTypeKind.ARRAY) { Field childField = toField("$element", type.getElement()); - return new Field(name, toFieldType(type), Collections.singletonList(childField)); + return new Field(name, fieldType, Collections.singletonList(childField)); } - return new Field(name, toFieldType(type), null); + return new Field(name, fieldType, null); } + /** Key used to stamp the JDBC type-name override on an Arrow field. */ + public static final String JDBC_TYPE_NAME_METADATA_KEY = "jdbc:type_name"; + /** * Map a {@link HyperType} to an Arrow {@link FieldType}. * @@ -47,12 +62,26 @@ public static Field toField(String name, HyperType type) { * without loss. */ public static FieldType toFieldType(HyperType type) { + return toFieldType(type, null); + } + + /** + * Overload that stamps an optional JDBC type-name override into the field metadata under + * {@link #JDBC_TYPE_NAME_METADATA_KEY} so {@link ColumnMetadata#getTypeName()} round-trips + * through Arrow without needing a parallel metadata channel. + */ + public static FieldType toFieldType(HyperType type, String jdbcTypeName) { ArrowType arrowType = toArrowType(type); Map metadata = metadataFor(type); - if (type.isNullable()) { - return new FieldType(true, arrowType, null, metadata); + if (jdbcTypeName != null) { + if (metadata == null) { + metadata = new HashMap<>(); + } else { + metadata = new HashMap<>(metadata); + } + metadata.put(JDBC_TYPE_NAME_METADATA_KEY, jdbcTypeName); } - return new FieldType(false, arrowType, null, metadata); + return new FieldType(type.isNullable(), arrowType, null, metadata); } /** Hyper-compatible field metadata for types whose length is not carried in the ArrowType. */ diff --git a/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/protocol/data/VectorPopulator.java b/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/protocol/data/VectorPopulator.java index a298e8b4..84554a41 100644 --- a/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/protocol/data/VectorPopulator.java +++ b/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/protocol/data/VectorPopulator.java @@ -36,7 +36,15 @@ import org.apache.arrow.vector.VarCharVector; import org.apache.arrow.vector.VectorSchemaRoot; -/** Populates vectors in a VectorSchemaRoot with values from a list of parameters. */ +/** + * Populates vectors in a {@link VectorSchemaRoot} with Java values, dispatching per column by + * {@link HyperTypeKind}. + * + *

The primitive {@link #setCell(ValueVector, HyperTypeKind, int, Object, Calendar)} is the + * single place the driver converts a Java {@link Object} into the right Arrow setter call. Both + * the JDBC parameter-encoding path (single row) and the JDBC metadata path (many rows) go + * through it. + */ public final class VectorPopulator { private VectorPopulator() { @@ -44,14 +52,10 @@ private VectorPopulator() { } /** - * Populates the vectors in the given VectorSchemaRoot using the {@link HyperType} of each - * parameter to decide which typed setter to dispatch to. - * - * @param root the VectorSchemaRoot to populate + * Populate a single-row VSR from a list of {@link ParameterBinding}s. Used by the parameter + * encoding path; the VSR's schema is built from the bindings' {@link HyperType}s. */ public static void populateVectors(VectorSchemaRoot root, List parameters, Calendar calendar) { - VectorValueSetterFactory factory = new VectorValueSetterFactory(calendar); - for (int i = 0; i < parameters.size(); i++) { ParameterBinding binding = parameters.get(i); if (binding == null) { @@ -62,32 +66,59 @@ public static void populateVectors(VectorSchemaRoot root, List HyperTypeKind kind = binding.getType().getKind(); ValueVector vector = root.getVector(root.getSchema().getFields().get(i).getName()); - Object value = binding.getValue(); - - @SuppressWarnings(value = "unchecked") - VectorValueSetter setter = (VectorValueSetter) factory.getSetter(kind); + setCell(vector, kind, 0, binding.getValue(), calendar); + } + root.setRowCount(1); + } - if (setter != null) { - setter.setValue(vector, value); - } else { - throw new UnsupportedOperationException("Unsupported HyperTypeKind for parameter binding: " + kind); + /** + * Populate {@code root} from a row-major list of Java values. {@code columns} supplies the + * per-column {@link HyperType} used to dispatch the setter; row {@code r}, column {@code c} + * is taken from {@code rows.get(r).get(c)} (a missing/short row yields a null cell). + * + *

Row count is set to {@code rows.size()}. Used by the metadata path. + */ + public static void populateVectors( + VectorSchemaRoot root, List columns, List> rows, Calendar calendar) { + int rowCount = rows == null ? 0 : rows.size(); + for (int c = 0; c < columns.size(); c++) { + ValueVector vector = root.getVector(columns.get(c).getName()); + HyperTypeKind kind = columns.get(c).getType().getKind(); + for (int r = 0; r < rowCount; r++) { + List row = rows.get(r); + Object value = row == null || c >= row.size() ? null : row.get(c); + setCell(vector, kind, r, value, calendar); } + vector.setValueCount(rowCount); + } + root.setRowCount(rowCount); + } + + /** Sets cell ({@code vector}, {@code index}) to {@code value}, or null if value is null. */ + static void setCell(ValueVector vector, HyperTypeKind kind, int index, Object value, Calendar calendar) { + @SuppressWarnings("unchecked") + VectorValueSetter setter = + (VectorValueSetter) VectorValueSetterFactory.getSetter(kind, calendar); + if (setter == null) { + throw new UnsupportedOperationException("Unsupported HyperTypeKind for vector population: " + kind); } - root.setRowCount(1); // Set row count to 1 since we have exactly one row + setter.setValue(vector, index, value); } } @FunctionalInterface interface VectorValueSetter { - void setValue(T vector, Object value); + void setValue(T vector, int index, Object value); } -/** Factory for creating appropriate setter instances based on {@link HyperTypeKind}. */ -class VectorValueSetterFactory { - private final Map> setterMap; +/** Factory for indexed setters keyed by {@link HyperTypeKind}. */ +final class VectorValueSetterFactory { + private VectorValueSetterFactory() {} - VectorValueSetterFactory(Calendar calendar) { - setterMap = ImmutableMap.ofEntries( + private static final Map> SETTERS_NO_CAL = build(null); + + private static Map> build(Calendar calendar) { + return ImmutableMap.ofEntries( Maps.immutableEntry(HyperTypeKind.VARCHAR, new VarCharVectorSetter()), Maps.immutableEntry(HyperTypeKind.CHAR, new VarCharVectorSetter()), Maps.immutableEntry(HyperTypeKind.FLOAT4, new Float4VectorSetter()), @@ -104,8 +135,13 @@ class VectorValueSetterFactory { Maps.immutableEntry(HyperTypeKind.INT8, new TinyIntVectorSetter())); } - VectorValueSetter getSetter(HyperTypeKind kind) { - return setterMap.get(kind); + static VectorValueSetter getSetter(HyperTypeKind kind, Calendar calendar) { + if (calendar == null) { + return SETTERS_NO_CAL.get(kind); + } + // Only TIME uses the calendar; build a per-call map rather than caching so tests that + // pass different Calendars cannot race. This path is cold (parameter-binding only). + return build(calendar).get(kind); } } @@ -118,36 +154,38 @@ abstract class BaseVectorSetter implements VectorValue } @Override - public void setValue(T vector, Object value) { + public void setValue(T vector, int index, Object value) { if (value == null) { - setNullValue(vector); + setNullValue(vector, index); } else if (valueType.isInstance(value)) { - setValueInternal(vector, valueType.cast(value)); + setValueInternal(vector, index, valueType.cast(value)); } else { throw new IllegalArgumentException( "Value for " + vector.getClass().getSimpleName() + " must be of type " + valueType.getSimpleName()); } } - protected abstract void setNullValue(T vector); + protected abstract void setNullValue(T vector, int index); - protected abstract void setValueInternal(T vector, V value); + protected abstract void setValueInternal(T vector, int index, V value); } /** Setter implementation for VarCharVector. */ -class VarCharVectorSetter extends BaseVectorSetter { +class VarCharVectorSetter extends BaseVectorSetter { VarCharVectorSetter() { - super(String.class); + super(Object.class); // accept String, Number, Boolean, byte[] — coerce to UTF-8 bytes } @Override - protected void setValueInternal(VarCharVector vector, String value) { - vector.setSafe(0, value.getBytes(StandardCharsets.UTF_8)); + protected void setValueInternal(VarCharVector vector, int index, Object value) { + byte[] bytes = + value instanceof byte[] ? (byte[]) value : value.toString().getBytes(StandardCharsets.UTF_8); + vector.setSafe(index, bytes); } @Override - protected void setNullValue(VarCharVector vector) { - vector.setNull(0); + protected void setNullValue(VarCharVector vector, int index) { + vector.setNull(index); } } @@ -158,13 +196,13 @@ class Float4VectorSetter extends BaseVectorSetter { } @Override - protected void setValueInternal(Float4Vector vector, Float value) { - vector.setSafe(0, value); + protected void setValueInternal(Float4Vector vector, int index, Float value) { + vector.setSafe(index, value); } @Override - protected void setNullValue(Float4Vector vector) { - vector.setNull(0); + protected void setNullValue(Float4Vector vector, int index) { + vector.setNull(index); } } @@ -175,64 +213,64 @@ class Float8VectorSetter extends BaseVectorSetter { } @Override - protected void setValueInternal(Float8Vector vector, Double value) { - vector.setSafe(0, value); + protected void setValueInternal(Float8Vector vector, int index, Double value) { + vector.setSafe(index, value); } @Override - protected void setNullValue(Float8Vector vector) { - vector.setNull(0); + protected void setNullValue(Float8Vector vector, int index) { + vector.setNull(index); } } -/** Setter implementation for IntVector. */ -class IntVectorSetter extends BaseVectorSetter { +/** Setter implementation for IntVector. Accepts any Number to support metadata rows using long/int. */ +class IntVectorSetter extends BaseVectorSetter { IntVectorSetter() { - super(Integer.class); + super(Number.class); } @Override - protected void setValueInternal(IntVector vector, Integer value) { - vector.setSafe(0, value); + protected void setValueInternal(IntVector vector, int index, Number value) { + vector.setSafe(index, value.intValue()); } @Override - protected void setNullValue(IntVector vector) { - vector.setNull(0); + protected void setNullValue(IntVector vector, int index) { + vector.setNull(index); } } /** Setter implementation for SmallIntVector. */ -class SmallIntVectorSetter extends BaseVectorSetter { +class SmallIntVectorSetter extends BaseVectorSetter { SmallIntVectorSetter() { - super(Short.class); + super(Number.class); } @Override - protected void setValueInternal(SmallIntVector vector, Short value) { - vector.setSafe(0, value); + protected void setValueInternal(SmallIntVector vector, int index, Number value) { + vector.setSafe(index, value.shortValue()); } @Override - protected void setNullValue(SmallIntVector vector) { - vector.setNull(0); + protected void setNullValue(SmallIntVector vector, int index) { + vector.setNull(index); } } /** Setter implementation for BigIntVector. */ -class BigIntVectorSetter extends BaseVectorSetter { +class BigIntVectorSetter extends BaseVectorSetter { BigIntVectorSetter() { - super(Long.class); + super(Number.class); } @Override - protected void setValueInternal(BigIntVector vector, Long value) { - vector.setSafe(0, value); + protected void setValueInternal(BigIntVector vector, int index, Number value) { + vector.setSafe(index, value.longValue()); } @Override - protected void setNullValue(BigIntVector vector) { - vector.setNull(0); + protected void setNullValue(BigIntVector vector, int index) { + vector.setNull(index); } } @@ -243,13 +281,13 @@ class BitVectorSetter extends BaseVectorSetter { } @Override - protected void setValueInternal(BitVector vector, Boolean value) { - vector.setSafe(0, Boolean.TRUE.equals(value) ? 1 : 0); + protected void setValueInternal(BitVector vector, int index, Boolean value) { + vector.setSafe(index, Boolean.TRUE.equals(value) ? 1 : 0); } @Override - protected void setNullValue(BitVector vector) { - vector.setNull(0); + protected void setNullValue(BitVector vector, int index) { + vector.setNull(index); } } @@ -260,13 +298,13 @@ class DecimalVectorSetter extends BaseVectorSetter { } @Override - protected void setValueInternal(DecimalVector vector, BigDecimal value) { - vector.setSafe(0, value.unscaledValue().longValue()); + protected void setValueInternal(DecimalVector vector, int index, BigDecimal value) { + vector.setSafe(index, value.unscaledValue().longValue()); } @Override - protected void setNullValue(DecimalVector vector) { - vector.setNull(0); + protected void setNullValue(DecimalVector vector, int index) { + vector.setNull(index); } } @@ -277,14 +315,14 @@ class DateDayVectorSetter extends BaseVectorSetter { } @Override - protected void setValueInternal(DateDayVector vector, Date value) { + protected void setValueInternal(DateDayVector vector, int index, Date value) { long daysSinceEpoch = value.toLocalDate().toEpochDay(); - vector.setSafe(0, (int) daysSinceEpoch); + vector.setSafe(index, (int) daysSinceEpoch); } @Override - protected void setNullValue(DateDayVector vector) { - vector.setNull(0); + protected void setNullValue(DateDayVector vector, int index) { + vector.setNull(index); } } @@ -298,18 +336,18 @@ class TimeMicroVectorSetter extends BaseVectorSetter { } @Override - protected void setValueInternal(TimeMicroVector vector, Time value) { + protected void setValueInternal(TimeMicroVector vector, int index, Time value) { LocalDateTime localDateTime = new Timestamp(value.getTime()).toLocalDateTime(); localDateTime = adjustForCalendar(localDateTime, calendar, TimeZone.getTimeZone("UTC")); long midnightMillis = localDateTime.toLocalTime().toNanoOfDay() / 1_000_000; long microsecondsSinceMidnight = millisToMicrosecondsSinceMidnight(midnightMillis); - vector.setSafe(0, microsecondsSinceMidnight); + vector.setSafe(index, microsecondsSinceMidnight); } @Override - protected void setNullValue(TimeMicroVector vector) { - vector.setNull(0); + protected void setNullValue(TimeMicroVector vector, int index) { + vector.setNull(index); } } @@ -329,15 +367,15 @@ class TimeStampMicroVectorSetter extends BaseVectorSetter { +class TinyIntVectorSetter extends BaseVectorSetter { TinyIntVectorSetter() { - super(Byte.class); + super(Number.class); } @Override - protected void setValueInternal(TinyIntVector vector, Byte value) { - vector.setSafe(0, value); + protected void setValueInternal(TinyIntVector vector, int index, Number value) { + vector.setSafe(index, value.byteValue()); } @Override - protected void setNullValue(TinyIntVector vector) { - vector.setNull(0); + protected void setNullValue(TinyIntVector vector, int index) { + vector.setNull(index); } } diff --git a/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/core/ArrowStreamReaderCursorTest.java b/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/core/ArrowStreamReaderCursorTest.java index d6613083..d5874a7b 100644 --- a/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/core/ArrowStreamReaderCursorTest.java +++ b/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/core/ArrowStreamReaderCursorTest.java @@ -16,6 +16,7 @@ import java.util.stream.IntStream; import lombok.SneakyThrows; import lombok.val; +import org.apache.arrow.memory.BufferAllocator; import org.apache.arrow.memory.RootAllocator; import org.apache.arrow.vector.VectorSchemaRoot; import org.apache.arrow.vector.ipc.ArrowStreamReader; @@ -38,15 +39,15 @@ class ArrowStreamReaderCursorTest { protected VectorSchemaRoot root; @Mock - protected AutoCloseable ownedResources; + protected BufferAllocator allocator; @Test @SneakyThrows - void closesOwnedResources() { - when(reader.getVectorSchemaRoot()).thenReturn(root); - val sut = ArrowStreamReaderCursor.streaming(reader, ownedResources, ZoneId.systemDefault()); + void closesReaderAndAllocator() { + val sut = new ArrowStreamReaderCursor(reader, allocator, ZoneId.systemDefault()); sut.close(); - verify(ownedResources, times(1)).close(); + verify(reader, times(1)).close(); + verify(allocator, times(1)).close(); } @Test @@ -60,7 +61,7 @@ void incrementsInternalIndexUntilRowsExhaustedThenLoadsNextBatch() { when(reader.loadNextBatch()).thenReturn(false); when(root.getRowCount()).thenReturn(times); - val sut = ArrowStreamReaderCursor.streaming(reader, ownedResources, ZoneId.systemDefault()); + val sut = new ArrowStreamReaderCursor(reader, allocator, ZoneId.systemDefault()); IntStream.range(0, times + 1).forEach(i -> sut.next()); verify(root, times(times + 1)).getRowCount(); @@ -73,7 +74,7 @@ void firstNextReturnsTrueWhenInitialBatchHasRows() { when(root.getRowCount()).thenReturn(1); when(reader.getVectorSchemaRoot()).thenReturn(root); - val sut = ArrowStreamReaderCursor.streaming(reader, ownedResources, ZoneId.systemDefault()); + val sut = new ArrowStreamReaderCursor(reader, allocator, ZoneId.systemDefault()); assertThat(sut.next()).isTrue(); } @@ -85,7 +86,7 @@ void firstNextReturnsFalseWhenStreamHasNoBatches() { when(reader.getVectorSchemaRoot()).thenReturn(root); when(reader.loadNextBatch()).thenReturn(false); - val sut = ArrowStreamReaderCursor.streaming(reader, ownedResources, ZoneId.systemDefault()); + val sut = new ArrowStreamReaderCursor(reader, allocator, ZoneId.systemDefault()); assertThat(sut.next()).isFalse(); } @@ -122,7 +123,7 @@ void skipsZeroRowBatchAndYieldsSubsequentNonEmptyRows() { try (RootAllocator readAlloc = new RootAllocator(Long.MAX_VALUE); ArrowStreamReader streamReader = new ArrowStreamReader(new ByteArrayInputStream(ipc), readAlloc)) { - val sut = ArrowStreamReaderCursor.streaming(streamReader, streamReader, ZoneId.systemDefault()); + val sut = new ArrowStreamReaderCursor(streamReader, readAlloc, ZoneId.systemDefault()); assertThat(sut.next()) .as("skips zero-row batch, advances to row in second batch") @@ -161,20 +162,8 @@ void zeroRowOnlyBatchYieldsNoRows() { try (RootAllocator readAlloc = new RootAllocator(Long.MAX_VALUE); ArrowStreamReader streamReader = new ArrowStreamReader(new ByteArrayInputStream(ipc), readAlloc)) { - val sut = ArrowStreamReaderCursor.streaming(streamReader, streamReader, ZoneId.systemDefault()); + val sut = new ArrowStreamReaderCursor(streamReader, readAlloc, ZoneId.systemDefault()); assertThat(sut.next()).isFalse(); } } - - @Test - @SneakyThrows - void inMemoryCursorReportsFalseWhenRowCountExhausted() { - when(root.getRowCount()).thenReturn(2); - - val sut = ArrowStreamReaderCursor.inMemory(root, ownedResources, ZoneId.systemDefault()); - - assertThat(sut.next()).isTrue(); - assertThat(sut.next()).isTrue(); - assertThat(sut.next()).isFalse(); - } } diff --git a/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/core/StreamCloseTest.java b/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/core/StreamCloseTest.java index 4d626456..f218b279 100644 --- a/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/core/StreamCloseTest.java +++ b/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/core/StreamCloseTest.java @@ -81,7 +81,7 @@ void closingResultSetClosesUnderlyingIterator() { // ByteStringReadableByteChannel(iterator, resource) → ArrowStreamReader val arrowStream = SQLExceptionQueryResultIterator.createSqlExceptionArrowStreamReader( tracked, false, "test-query", null); - val resultSet = StreamingResultSet.of(arrowStream, "test-query"); + val resultSet = StreamingResultSet.of(arrowStream.getReader(), arrowStream.getAllocator(), "test-query"); // Read one row — stream is still open with remaining rows assertThat(resultSet.next()).isTrue(); diff --git a/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/core/StreamingResultSetMethodTest.java b/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/core/StreamingResultSetMethodTest.java index f835e5e7..d124a5fe 100644 --- a/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/core/StreamingResultSetMethodTest.java +++ b/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/core/StreamingResultSetMethodTest.java @@ -8,17 +8,21 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import com.salesforce.datacloud.jdbc.util.RootAllocatorTestExtension; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; import java.nio.charset.StandardCharsets; import java.sql.ResultSet; import java.sql.SQLException; import java.sql.SQLFeatureNotSupportedException; -import java.time.ZoneId; import java.util.Arrays; import java.util.stream.Stream; import lombok.SneakyThrows; import lombok.val; +import org.apache.arrow.memory.RootAllocator; import org.apache.arrow.vector.VarCharVector; import org.apache.arrow.vector.VectorSchemaRoot; +import org.apache.arrow.vector.ipc.ArrowStreamReader; +import org.apache.arrow.vector.ipc.ArrowStreamWriter; import org.junit.jupiter.api.Named; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; @@ -45,11 +49,11 @@ private StreamingResultSet createResultSetWithNullValue() { @SneakyThrows private StreamingResultSet createSingleVarCharResultSet(boolean nullValue) { - // Build an in-memory VectorSchemaRoot with one VARCHAR column and one row ("hello", or - // null), then hand it to StreamingResultSet.ofInMemory. The result set owns the vector - // lifecycle and closes it via the returned AutoCloseable. - val allocator = ext.getRootAllocator(); - val vector = new VarCharVector("col1", allocator); + // Build a single-row VARCHAR batch, serialise to IPC bytes, and wrap in an + // ArrowStreamReader. Using a fresh RootAllocator so the result set owns its own + // allocator lifecycle (independent of the shared test extension allocator). + val writeAllocator = ext.getRootAllocator(); + val vector = new VarCharVector("col1", writeAllocator); vector.allocateNew(); if (nullValue) { vector.setNull(0); @@ -58,10 +62,19 @@ private StreamingResultSet createSingleVarCharResultSet(boolean nullValue) { } vector.setValueCount(1); - val root = new VectorSchemaRoot(Arrays.asList(vector.getField()), Arrays.asList(vector)); - root.setRowCount(1); + val out = new ByteArrayOutputStream(); + try (VectorSchemaRoot root = new VectorSchemaRoot(Arrays.asList(vector.getField()), Arrays.asList(vector))) { + root.setRowCount(1); + try (ArrowStreamWriter writer = new ArrowStreamWriter(root, null, out)) { + writer.start(); + writer.writeBatch(); + writer.end(); + } + } - return StreamingResultSet.ofInMemory(root, root, QUERY_ID, ZoneId.systemDefault()); + RootAllocator readerAllocator = new RootAllocator(Long.MAX_VALUE); + ArrowStreamReader reader = new ArrowStreamReader(new ByteArrayInputStream(out.toByteArray()), readerAllocator); + return StreamingResultSet.of(reader, readerAllocator, QUERY_ID); } // --- Unsupported methods --- diff --git a/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/protocol/QueryResultArrowStreamTest.java b/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/protocol/QueryResultArrowStreamTest.java index 6a9ea211..5affe777 100644 --- a/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/protocol/QueryResultArrowStreamTest.java +++ b/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/protocol/QueryResultArrowStreamTest.java @@ -32,9 +32,13 @@ void testArrowStreamWithSimpleSelectQuery() { val queryClient = QueryAccessGrpcClient.of(queryId, stubProvider.getStub()); val chunkIterator = ChunkRangeIterator.of(queryClient, 0, 3, false, QueryResultArrowStream.OUTPUT_FORMAT); - // Create ArrowStreamReader from the iterator - try (val arrowStream = QueryResultArrowStream.toArrowStreamReader(chunkIterator)) { - val reader = arrowStream.getReader(); + // Create ArrowStreamReader from the iterator. + // Close order matters: the reader must close before the allocator because + // try-with-resources closes in reverse declaration order, and closing the allocator + // while the reader still holds buffers trips the leak detector. + val arrowStream = QueryResultArrowStream.toArrowStreamReader(chunkIterator); + try (val allocator = arrowStream.getAllocator(); + val reader = arrowStream.getReader()) { int rowCount = 0; // Count all rows in the arrow stream @@ -63,9 +67,13 @@ void testArrowStreamWithNoColumnsQuery() { val queryClient = QueryAccessGrpcClient.of(queryId, stubProvider.getStub()); val chunkIterator = ChunkRangeIterator.of(queryClient, 0, 3, false, QueryResultArrowStream.OUTPUT_FORMAT); - // Create ArrowStreamReader from the iterator - try (val arrowStream = QueryResultArrowStream.toArrowStreamReader(chunkIterator)) { - val reader = arrowStream.getReader(); + // Create ArrowStreamReader from the iterator. + // Close order matters: the reader must close before the allocator because + // try-with-resources closes in reverse declaration order, and closing the allocator + // while the reader still holds buffers trips the leak detector. + val arrowStream = QueryResultArrowStream.toArrowStreamReader(chunkIterator); + try (val allocator = arrowStream.getAllocator(); + val reader = arrowStream.getReader()) { int rowCount = 0; // Count all rows in the arrow stream From 3a37fe25e680d9d5533c236f2967f8aa7313d36b Mon Sep 17 00:00:00 2001 From: Moritz Kaufmann Date: Mon, 11 May 2026 14:13:46 +0200 Subject: [PATCH 03/24] fix: close allocator on StreamingResultSet.of failure in query path StreamingResultSet.of catches IOException and IllegalArgumentException from the Arrow schema decode and rewraps as SQLException. At all four query-path call sites (DataCloudConnection.getRowBasedResultSet, getChunkBasedResultSet, DataCloudStatement.executeQuery, getResultSet) the surrounding try-catch only catches StatusRuntimeException, so a SQLException thrown from of() bypasses it and leaks the 100 MB RootAllocator returned by QueryResultArrowStream.toArrowStreamReader. Introduce StreamingResultSet.ofClosingOnFailure(Result, queryId, sessionZone) that takes the reader+allocator pair and closes both on construction failure (reader first so its buffers release before the allocator's budget check). Switch all four call sites to it. The metadata path in MetadataResultSets.of already had this shape; this fixes the matching gap on the query side. Add a regression test that builds an Arrow IPC stream with an unsupported field type (LargeUtf8) and asserts the helper closes both the reader and the allocator on the resulting SQLException. --- .../jdbc/core/DataCloudConnection.java | 5 ++- .../jdbc/core/DataCloudStatement.java | 12 ++---- .../jdbc/core/StreamingResultSet.java | 23 +++++++++++ .../core/StreamingResultSetMethodTest.java | 41 +++++++++++++++++++ 4 files changed, 71 insertions(+), 10 deletions(-) diff --git a/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/DataCloudConnection.java b/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/DataCloudConnection.java index 012a07f8..96dc8de6 100644 --- a/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/DataCloudConnection.java +++ b/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/DataCloudConnection.java @@ -48,6 +48,7 @@ import java.sql.Statement; import java.sql.Struct; import java.time.Duration; +import java.time.ZoneId; import java.util.Arrays; import java.util.List; import java.util.Map; @@ -220,7 +221,7 @@ public DataCloudResultSet getRowBasedResultSet(String queryId, long offset, long QueryResultArrowStream.OUTPUT_FORMAT); val arrowStream = SQLExceptionQueryResultIterator.createSqlExceptionArrowStreamReader( iterator, connectionProperties.isIncludeCustomerDetailInReason(), queryId, null); - return StreamingResultSet.of(arrowStream.getReader(), arrowStream.getAllocator(), queryId); + return StreamingResultSet.ofClosingOnFailure(arrowStream, queryId, ZoneId.systemDefault()); } catch (StatusRuntimeException ex) { throw QueryExceptionHandler.createException( connectionProperties.isIncludeCustomerDetailInReason(), null, queryId, ex); @@ -263,7 +264,7 @@ public DataCloudResultSet getChunkBasedResultSet(String queryId, long chunkId, l QueryResultArrowStream.OUTPUT_FORMAT); val arrowStream = SQLExceptionQueryResultIterator.createSqlExceptionArrowStreamReader( iterator, connectionProperties.isIncludeCustomerDetailInReason(), queryId, null); - return StreamingResultSet.of(arrowStream.getReader(), arrowStream.getAllocator(), queryId); + return StreamingResultSet.ofClosingOnFailure(arrowStream, queryId, ZoneId.systemDefault()); } catch (StatusRuntimeException ex) { throw QueryExceptionHandler.createException( connectionProperties.isIncludeCustomerDetailInReason(), null, queryId, ex); diff --git a/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/DataCloudStatement.java b/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/DataCloudStatement.java index 094e8414..6dd87623 100644 --- a/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/DataCloudStatement.java +++ b/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/DataCloudStatement.java @@ -196,11 +196,8 @@ public ResultSet executeQuery(String sql) throws SQLException { val iterator = executeAdaptiveQuery(sql); val arrowStream = SQLExceptionQueryResultIterator.createSqlExceptionArrowStreamReader( iterator, includeCustomerDetail, iterator.getQueryStatus().getQueryId(), sql); - resultSet = StreamingResultSet.of( - arrowStream.getReader(), - arrowStream.getAllocator(), - iterator.getQueryStatus().getQueryId(), - sessionZone); + resultSet = StreamingResultSet.ofClosingOnFailure( + arrowStream, iterator.getQueryStatus().getQueryId(), sessionZone); log.info( "executeAdaptiveQuery completed. queryId={}, sessionZone={}", queryHandle.getQueryStatus().getQueryId(), @@ -439,9 +436,8 @@ public ResultSet getResultSet() throws SQLException { includeCustomerDetail, adaptiveIterator.getQueryStatus().getQueryId(), null); - resultSet = StreamingResultSet.of( - arrowStream.getReader(), - arrowStream.getAllocator(), + resultSet = StreamingResultSet.ofClosingOnFailure( + arrowStream, adaptiveIterator.getQueryStatus().getQueryId(), sessionZone); } else if (resultSet == null) { diff --git a/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/StreamingResultSet.java b/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/StreamingResultSet.java index 8fb39040..d9d03aa2 100644 --- a/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/StreamingResultSet.java +++ b/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/StreamingResultSet.java @@ -9,6 +9,7 @@ import com.salesforce.datacloud.jdbc.core.resultset.ForwardOnlyResultSet; import com.salesforce.datacloud.jdbc.core.resultset.ReadOnlyResultSet; import com.salesforce.datacloud.jdbc.core.resultset.ResultSetWithPositionalGetters; +import com.salesforce.datacloud.jdbc.protocol.QueryResultArrowStream; import com.salesforce.datacloud.jdbc.protocol.data.ArrowToHyperTypeMapper; import com.salesforce.datacloud.jdbc.util.ThrowingJdbcSupplier; import com.salesforce.datacloud.query.v3.QueryStatus; @@ -116,6 +117,28 @@ public static StreamingResultSet of( } } + /** + * Hand the reader + allocator pair from {@link QueryResultArrowStream.Result} to {@link + * #of(ArrowStreamReader, BufferAllocator, String, ZoneId)} and close both on construction + * failure. Without this, an {@code of} call that throws (for example {@code SQLException} + * wrapping an unsupported Arrow type) would leak the 100 MB + * {@link org.apache.arrow.memory.RootAllocator} held by {@code Result}. + */ + public static StreamingResultSet ofClosingOnFailure( + QueryResultArrowStream.Result arrowStream, String queryId, ZoneId sessionZone) throws SQLException { + try { + return of(arrowStream.getReader(), arrowStream.getAllocator(), queryId, sessionZone); + } catch (SQLException | RuntimeException ex) { + try { + arrowStream.getReader().close(); + } catch (Exception suppressed) { + ex.addSuppressed(suppressed); + } + arrowStream.getAllocator().close(); + throw ex; + } + } + // --- Core ResultSet navigation --- @Override diff --git a/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/core/StreamingResultSetMethodTest.java b/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/core/StreamingResultSetMethodTest.java index d124a5fe..bacb8863 100644 --- a/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/core/StreamingResultSetMethodTest.java +++ b/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/core/StreamingResultSetMethodTest.java @@ -6,7 +6,11 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; +import com.salesforce.datacloud.jdbc.protocol.QueryResultArrowStream; import com.salesforce.datacloud.jdbc.util.RootAllocatorTestExtension; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; @@ -14,7 +18,9 @@ import java.sql.ResultSet; import java.sql.SQLException; import java.sql.SQLFeatureNotSupportedException; +import java.time.ZoneId; import java.util.Arrays; +import java.util.Collections; import java.util.stream.Stream; import lombok.SneakyThrows; import lombok.val; @@ -23,6 +29,10 @@ import org.apache.arrow.vector.VectorSchemaRoot; import org.apache.arrow.vector.ipc.ArrowStreamReader; import org.apache.arrow.vector.ipc.ArrowStreamWriter; +import org.apache.arrow.vector.types.pojo.ArrowType; +import org.apache.arrow.vector.types.pojo.Field; +import org.apache.arrow.vector.types.pojo.FieldType; +import org.apache.arrow.vector.types.pojo.Schema; import org.junit.jupiter.api.Named; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; @@ -135,6 +145,37 @@ void getAccessorThrowsOnTooLargeIndex() throws Exception { // --- Lifecycle and navigation --- + @Test + @SneakyThrows + void ofClosingOnFailureClosesAllocatorWhenSchemaIsUnsupported() { + // Build an Arrow IPC stream containing one column of LargeUtf8, which + // ArrowToHyperTypeMapper does not model — StreamingResultSet.of will throw SQLException. + // Without the leak fix, the RootAllocator passed in would never be closed. + val unsupportedField = new Field("col", new FieldType(true, new ArrowType.LargeUtf8(), null), null); + val schema = new Schema(Collections.singletonList(unsupportedField)); + val out = new ByteArrayOutputStream(); + try (RootAllocator writeAllocator = new RootAllocator(Long.MAX_VALUE); + VectorSchemaRoot root = VectorSchemaRoot.create(schema, writeAllocator)) { + root.setRowCount(0); + try (ArrowStreamWriter writer = new ArrowStreamWriter(root, null, out)) { + writer.start(); + writer.end(); + } + } + + val readerAllocator = spy(new RootAllocator(Long.MAX_VALUE)); + val reader = spy(new ArrowStreamReader(new ByteArrayInputStream(out.toByteArray()), readerAllocator)); + val arrowStream = new QueryResultArrowStream.Result(reader, readerAllocator); + + assertThatThrownBy(() -> StreamingResultSet.ofClosingOnFailure(arrowStream, QUERY_ID, ZoneId.systemDefault())) + .isInstanceOf(SQLException.class) + .hasMessageContaining("Unsupported column type"); + + // The leak fix must close both the reader and the allocator before re-throwing. + verify(reader, atLeastOnce()).close(); + verify(readerAllocator, atLeastOnce()).close(); + } + @Test void closeAndIsClosed() throws Exception { val rs = createResultSet(); From 3bfd82167af4bab539c106464006ac1d363f6ce4 Mon Sep 17 00:00:00 2001 From: Moritz Kaufmann Date: Mon, 11 May 2026 14:20:45 +0200 Subject: [PATCH 04/24] fix: range-check narrowing integer setters in VectorPopulator The Int/SmallInt/TinyInt setters widened from concrete boxed types (Integer/Short/Byte) to Number so metadata rows could pass long values, but lost the implicit "right boxed type" check at the call sites that went through DataCloudPreparedStatement.setObject for parameter binding. A user binding Long.MAX_VALUE to an INT32 parameter would silently get (int) Long.MAX_VALUE = -1 written to the vector. Add an explicit range check on Int/SmallInt/TinyInt setters before narrowing. Both the metadata path and the parameter-binding path go through these setters, so strict checks here mean strict on both paths. BigInt accepts the full long range and is unchanged. Pin the behavior with a focused unit test (IntegerVectorSetterRangeCheckTest). --- .../jdbc/protocol/data/VectorPopulator.java | 29 ++++- .../IntegerVectorSetterRangeCheckTest.java | 114 ++++++++++++++++++ 2 files changed, 139 insertions(+), 4 deletions(-) create mode 100644 jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/protocol/data/IntegerVectorSetterRangeCheckTest.java diff --git a/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/protocol/data/VectorPopulator.java b/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/protocol/data/VectorPopulator.java index 84554a41..e053e59f 100644 --- a/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/protocol/data/VectorPopulator.java +++ b/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/protocol/data/VectorPopulator.java @@ -223,7 +223,11 @@ protected void setNullValue(Float8Vector vector, int index) { } } -/** Setter implementation for IntVector. Accepts any Number to support metadata rows using long/int. */ +/** + * Setter implementation for IntVector. Accepts any Number so metadata rows can pass long/short + * values, but range-checks before narrowing to int — silent truncation of an out-of-range Long + * (e.g. binding {@code Long.MAX_VALUE} to an INT32 parameter) is never the right answer. + */ class IntVectorSetter extends BaseVectorSetter { IntVectorSetter() { super(Number.class); @@ -231,6 +235,10 @@ class IntVectorSetter extends BaseVectorSetter { @Override protected void setValueInternal(IntVector vector, int index, Number value) { + long lv = value.longValue(); + if (lv < Integer.MIN_VALUE || lv > Integer.MAX_VALUE) { + throw new IllegalArgumentException("Value " + lv + " is out of range for INT32"); + } vector.setSafe(index, value.intValue()); } @@ -240,7 +248,7 @@ protected void setNullValue(IntVector vector, int index) { } } -/** Setter implementation for SmallIntVector. */ +/** Setter implementation for SmallIntVector. Range-checks before narrowing to short. */ class SmallIntVectorSetter extends BaseVectorSetter { SmallIntVectorSetter() { super(Number.class); @@ -248,6 +256,10 @@ class SmallIntVectorSetter extends BaseVectorSetter { @Override protected void setValueInternal(SmallIntVector vector, int index, Number value) { + long lv = value.longValue(); + if (lv < Short.MIN_VALUE || lv > Short.MAX_VALUE) { + throw new IllegalArgumentException("Value " + lv + " is out of range for INT16"); + } vector.setSafe(index, value.shortValue()); } @@ -257,7 +269,12 @@ protected void setNullValue(SmallIntVector vector, int index) { } } -/** Setter implementation for BigIntVector. */ +/** + * Setter implementation for BigIntVector. Accepts any Number; the natural range of long is the + * widest integer type the vector encodes, so no range narrowing happens here. Non-integral + * Numbers (e.g. Double) are normalized via Number.longValue, mirroring the rest of the integer + * setters in this file. + */ class BigIntVectorSetter extends BaseVectorSetter { BigIntVectorSetter() { super(Number.class); @@ -403,7 +420,7 @@ protected void setNullValue(TimeStampMicroTZVector vector, int index) { } } -/** Setter implementation for TinyIntVectorSetter. */ +/** Setter implementation for TinyIntVector. Range-checks before narrowing to byte. */ class TinyIntVectorSetter extends BaseVectorSetter { TinyIntVectorSetter() { super(Number.class); @@ -411,6 +428,10 @@ class TinyIntVectorSetter extends BaseVectorSetter { @Override protected void setValueInternal(TinyIntVector vector, int index, Number value) { + long lv = value.longValue(); + if (lv < Byte.MIN_VALUE || lv > Byte.MAX_VALUE) { + throw new IllegalArgumentException("Value " + lv + " is out of range for INT8"); + } vector.setSafe(index, value.byteValue()); } diff --git a/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/protocol/data/IntegerVectorSetterRangeCheckTest.java b/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/protocol/data/IntegerVectorSetterRangeCheckTest.java new file mode 100644 index 00000000..ce779e98 --- /dev/null +++ b/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/protocol/data/IntegerVectorSetterRangeCheckTest.java @@ -0,0 +1,114 @@ +/** + * This file is part of https://github.com/forcedotcom/datacloud-jdbc which is released under the + * Apache 2.0 license. See https://github.com/forcedotcom/datacloud-jdbc/blob/main/LICENSE.txt + */ +package com.salesforce.datacloud.jdbc.protocol.data; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import lombok.val; +import org.apache.arrow.memory.RootAllocator; +import org.apache.arrow.vector.BigIntVector; +import org.apache.arrow.vector.IntVector; +import org.apache.arrow.vector.SmallIntVector; +import org.apache.arrow.vector.TinyIntVector; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** + * Pin the range-check behavior on the integer-family vector setters: every narrowing setter + * (TinyInt/SmallInt/Int) refuses out-of-range Number inputs rather than silently truncating. + * BigInt accepts the full long range. + * + *

Both code paths (parameter binding via DataCloudPreparedStatement.setObject and metadata + * row population via MetadataResultSets) reach these same setters, so strict checks here mean + * strict checks on both paths. + */ +class IntegerVectorSetterRangeCheckTest { + + private RootAllocator allocator; + + @BeforeEach + void setUp() { + allocator = new RootAllocator(Long.MAX_VALUE); + } + + @AfterEach + void tearDown() { + allocator.close(); + } + + @Test + void intVectorSetterAcceptsValuesInRange() { + try (val vector = new IntVector("col", allocator)) { + vector.allocateNew(3); + val setter = new IntVectorSetter(); + setter.setValueInternal(vector, 0, 0); + setter.setValueInternal(vector, 1, Integer.MAX_VALUE); + setter.setValueInternal(vector, 2, Long.valueOf(Integer.MIN_VALUE)); + vector.setValueCount(3); + assertThat(vector.get(0)).isEqualTo(0); + assertThat(vector.get(1)).isEqualTo(Integer.MAX_VALUE); + assertThat(vector.get(2)).isEqualTo(Integer.MIN_VALUE); + } + } + + @Test + void intVectorSetterRejectsLongAboveRange() { + try (val vector = new IntVector("col", allocator)) { + vector.allocateNew(1); + val setter = new IntVectorSetter(); + assertThatThrownBy(() -> setter.setValueInternal(vector, 0, Long.MAX_VALUE)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("out of range for INT32"); + } + } + + @Test + void intVectorSetterRejectsLongBelowRange() { + try (val vector = new IntVector("col", allocator)) { + vector.allocateNew(1); + val setter = new IntVectorSetter(); + assertThatThrownBy(() -> setter.setValueInternal(vector, 0, Long.MIN_VALUE)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("out of range for INT32"); + } + } + + @Test + void smallIntVectorSetterRejectsValueAboveRange() { + try (val vector = new SmallIntVector("col", allocator)) { + vector.allocateNew(1); + val setter = new SmallIntVectorSetter(); + assertThatThrownBy(() -> setter.setValueInternal(vector, 0, (long) Short.MAX_VALUE + 1)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("out of range for INT16"); + } + } + + @Test + void tinyIntVectorSetterRejectsValueAboveRange() { + try (val vector = new TinyIntVector("col", allocator)) { + vector.allocateNew(1); + val setter = new TinyIntVectorSetter(); + assertThatThrownBy(() -> setter.setValueInternal(vector, 0, (long) Byte.MAX_VALUE + 1)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("out of range for INT8"); + } + } + + @Test + void bigIntVectorSetterAcceptsFullLongRange() { + try (val vector = new BigIntVector("col", allocator)) { + vector.allocateNew(2); + val setter = new BigIntVectorSetter(); + setter.setValueInternal(vector, 0, Long.MAX_VALUE); + setter.setValueInternal(vector, 1, Long.MIN_VALUE); + vector.setValueCount(2); + assertThat(vector.get(0)).isEqualTo(Long.MAX_VALUE); + assertThat(vector.get(1)).isEqualTo(Long.MIN_VALUE); + } + } +} From 99d9b559c8eecccd2cefbb27f93d0c6a15f4532c Mon Sep 17 00:00:00 2001 From: Moritz Kaufmann Date: Mon, 11 May 2026 14:26:01 +0200 Subject: [PATCH 05/24] refactor: namespace jdbc type-name field-metadata key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The driver round-trips JDBC-spec type-name overrides (e.g. "TEXT" for metadata columns) through Arrow field metadata under a custom key. The previous key "jdbc:type_name" used an unprefixed namespace not reserved by the Arrow spec — Hyper, query-federator, or another Arrow producer could emit a same-named key in a future protocol version, in which case ArrowToHyperTypeMapper would silently override its own derived type name with whatever upstream stamped. Rename to "datacloud-jdbc:type_name" so the namespace is unambiguous, and expand the field's javadoc to document the namespace rationale. --- .../jdbc/protocol/data/HyperTypeToArrow.java | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/protocol/data/HyperTypeToArrow.java b/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/protocol/data/HyperTypeToArrow.java index 5de5c8cb..ee8d8f02 100644 --- a/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/protocol/data/HyperTypeToArrow.java +++ b/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/protocol/data/HyperTypeToArrow.java @@ -35,10 +35,11 @@ public static Field toField(String name, HyperType type) { /** * Build an Arrow {@link Field} and stamp an optional JDBC type-name override into the field - * metadata under {@code jdbc:type_name}. The override is how {@link ColumnMetadata#getTypeName()} - * round-trips through Arrow — it lets JDBC-spec labels (e.g. {@code "TEXT"} for metadata - * columns) survive serialisation, which is the only way to carry them through an - * {@link org.apache.arrow.vector.ipc.ArrowStreamReader}-backed code path. + * metadata under {@link #JDBC_TYPE_NAME_METADATA_KEY}. The override is how {@link + * ColumnMetadata#getTypeName()} round-trips through Arrow — it lets JDBC-spec labels (e.g. + * {@code "TEXT"} for metadata columns) survive serialisation, which is the only way to + * carry them through an {@link org.apache.arrow.vector.ipc.ArrowStreamReader}-backed code + * path. */ public static Field toField(String name, HyperType type, String jdbcTypeName) { FieldType fieldType = toFieldType(type, jdbcTypeName); @@ -49,8 +50,14 @@ public static Field toField(String name, HyperType type, String jdbcTypeName) { return new Field(name, fieldType, null); } - /** Key used to stamp the JDBC type-name override on an Arrow field. */ - public static final String JDBC_TYPE_NAME_METADATA_KEY = "jdbc:type_name"; + /** + * Arrow field-metadata key under which the JDBC-spec {@link ColumnMetadata#getTypeName() + * typeName} override is round-tripped. The {@code datacloud-jdbc:} prefix namespaces the + * key so it cannot collide with anything Hyper, query-federator, or another Arrow producer + * might stamp on its own field metadata; the unprefixed {@code jdbc:} namespace is not + * reserved by the Arrow spec. + */ + public static final String JDBC_TYPE_NAME_METADATA_KEY = "datacloud-jdbc:type_name"; /** * Map a {@link HyperType} to an Arrow {@link FieldType}. From 00894a5fc86365968537b8d18a49b148a49f682b Mon Sep 17 00:00:00 2001 From: Moritz Kaufmann Date: Mon, 11 May 2026 14:30:34 +0200 Subject: [PATCH 06/24] test: pin jdbc type-name fallback path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fallback in ArrowToHyperTypeMapper.toColumnMetadata — when a field has no datacloud-jdbc:type_name override, ColumnMetadata.typeName is null and the JDBC layer derives the column type-name from the HyperType — was load-bearing but unasserted. Real Hyper Arrow streams never stamp the override, so every functional query test exercised the fallback implicitly; if a future refactor broke it, the regression would not surface in the existing suite. Two new pin tests: - ArrowToHyperTypeMapperTest at the unit boundary: field with override -> typeName matches; field without override (null metadata, empty metadata) -> typeName is null. - StreamingResultSetTest.getColumnTypeNameFallsBackToDerivedNameOnRealHyperStream end-to-end against local Hyper: executeQuery on a select with INT, VARCHAR, DECIMAL columns asserts ResultSetMetaData.getColumnTypeName returns the derived names ("INTEGER", "VARCHAR", "DECIMAL"). --- .../jdbc/core/StreamingResultSetTest.java | 24 +++++++ .../data/ArrowToHyperTypeMapperTest.java | 67 +++++++++++++++++++ 2 files changed, 91 insertions(+) create mode 100644 jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/protocol/data/ArrowToHyperTypeMapperTest.java diff --git a/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/core/StreamingResultSetTest.java b/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/core/StreamingResultSetTest.java index 6a8b1afb..75288403 100644 --- a/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/core/StreamingResultSetTest.java +++ b/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/core/StreamingResultSetTest.java @@ -116,6 +116,30 @@ private void assertThatResultSetIsCorrect(DataCloudConnection conn, DataCloudRes .isEqualTo(rows); } + /** + * Real-Hyper Arrow streams do not stamp the {@code datacloud-jdbc:type_name} field-metadata + * key, so {@code ArrowToHyperTypeMapper.toColumnMetadata} must fall back to the type-derived + * default (e.g. {@code "INTEGER"} for an INT32 column). The fallback is implicit in every + * functional test that hits local Hyper, but no assertion pinned it on the streaming-result + * path. This test does — paired with the unit-level + * {@code ArrowToHyperTypeMapperTest.typeNameOverrideIsNullWhenAbsent} which pins the same + * contract at the mapper boundary. + */ + @SneakyThrows + @Test + public void getColumnTypeNameFallsBackToDerivedNameOnRealHyperStream() { + try (val conn = getHyperQueryConnection().unwrap(DataCloudConnection.class); + val stmt = conn.createStatement().unwrap(DataCloudStatement.class)) { + try (val rs = stmt.executeQuery("select 1 as id, 'x' as name, cast(3.14 as numeric(10,2)) as value") + .unwrap(DataCloudResultSet.class)) { + val md = rs.getMetaData(); + assertThat(md.getColumnTypeName(1)).isEqualTo("INTEGER"); + assertThat(md.getColumnTypeName(2)).isEqualTo("VARCHAR"); + assertThat(md.getColumnTypeName(3)).isEqualTo("DECIMAL"); + } + } + } + @SneakyThrows @Test public void testGetSchemaForQueryIdWithZeroResults() { diff --git a/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/protocol/data/ArrowToHyperTypeMapperTest.java b/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/protocol/data/ArrowToHyperTypeMapperTest.java new file mode 100644 index 00000000..f6ec6200 --- /dev/null +++ b/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/protocol/data/ArrowToHyperTypeMapperTest.java @@ -0,0 +1,67 @@ +/** + * This file is part of https://github.com/forcedotcom/datacloud-jdbc which is released under the + * Apache 2.0 license. See https://github.com/forcedotcom/datacloud-jdbc/blob/main/LICENSE.txt + */ +package com.salesforce.datacloud.jdbc.protocol.data; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Collections; +import lombok.val; +import org.apache.arrow.vector.types.pojo.ArrowType; +import org.apache.arrow.vector.types.pojo.Field; +import org.apache.arrow.vector.types.pojo.FieldType; +import org.junit.jupiter.api.Test; + +/** + * Pin the {@link ArrowToHyperTypeMapper#toColumnMetadata(Field)} contract around the + * {@link HyperTypeToArrow#JDBC_TYPE_NAME_METADATA_KEY} field-metadata override. + * + *

Two paths must work: + *

    + *
  • An Arrow field that does stamp the override (the metadata path) returns a + * {@code ColumnMetadata} whose {@code typeName} matches the override exactly. + *
  • An Arrow field that does not stamp the override (every real-Hyper query stream) + * returns a {@code ColumnMetadata} whose {@code typeName} is {@code null}, so the JDBC + * layer falls back to the type-derived default. The fallback is implicit in the rest of + * the test suite — every functional test against local Hyper goes through this code + * path — but no assertion pinned it. This test does. + *
+ */ +class ArrowToHyperTypeMapperTest { + + @Test + void typeNameOverrideIsPickedUpWhenStamped() { + val metadata = Collections.singletonMap(HyperTypeToArrow.JDBC_TYPE_NAME_METADATA_KEY, "TEXT"); + val field = new Field("c", new FieldType(true, new ArrowType.Utf8(), null, metadata), null); + + val column = ArrowToHyperTypeMapper.toColumnMetadata(field); + + assertThat(column.getName()).isEqualTo("c"); + assertThat(column.getType()).isEqualTo(HyperType.varcharUnlimited(true)); + assertThat(column.getTypeName()).isEqualTo("TEXT"); + } + + @Test + void typeNameOverrideIsNullWhenAbsent() { + // Mirrors what a real Hyper Arrow stream looks like: no datacloud-jdbc:type_name key. + val field = new Field("c", new FieldType(true, new ArrowType.Utf8(), null), null); + + val column = ArrowToHyperTypeMapper.toColumnMetadata(field); + + assertThat(column.getName()).isEqualTo("c"); + assertThat(column.getType()).isEqualTo(HyperType.varcharUnlimited(true)); + // Null override means the JDBC layer falls back to HyperType-derived "VARCHAR". + assertThat(column.getTypeName()).isNull(); + } + + @Test + void typeNameOverrideIsNullWhenMetadataIsEmptyButPresent() { + val field = + new Field("c", new FieldType(true, new ArrowType.Int(32, true), null, Collections.emptyMap()), null); + + val column = ArrowToHyperTypeMapper.toColumnMetadata(field); + + assertThat(column.getTypeName()).isNull(); + } +} From ed477c187104a20403adecec3733345a270fa16e Mon Sep 17 00:00:00 2001 From: Moritz Kaufmann Date: Mon, 11 May 2026 14:32:11 +0200 Subject: [PATCH 07/24] test: pin getObject(int, Map) null/empty fast path Drive-by pin test: StreamingResultSet.getObject(int, Map>) with a null or empty type map should behave like plain getObject(int) per the JDBC spec. Previously not asserted anywhere. The companion getObject(Class) fallback test landed earlier on this branch, bundled into the QueryJDBCAccessor base-class fix commit so the fix and its end-to-end coverage ship as a single cherry-pickable unit. --- .../jdbc/core/StreamingResultSetMethodTest.java | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/core/StreamingResultSetMethodTest.java b/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/core/StreamingResultSetMethodTest.java index bacb8863..3170ec9b 100644 --- a/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/core/StreamingResultSetMethodTest.java +++ b/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/core/StreamingResultSetMethodTest.java @@ -263,6 +263,18 @@ void getObjectWithSupertypeOrInterfaceReturnsValue() throws Exception { } } + @Test + void getObjectWithNullTypeMapBehavesLikeGetObject() throws Exception { + // JDBC: getObject(int, Map) with a null/empty type map should behave like getObject(int). + try (val rs = createResultSet()) { + rs.next(); + val plain = rs.getObject(1); + assertThat(rs.getObject(1, (java.util.Map>) null)).isEqualTo(plain); + assertThat(rs.getObject(1, java.util.Collections.>emptyMap())) + .isEqualTo(plain); + } + } + @Test void queryId() throws Exception { try (val rs = createResultSet()) { From a538fe64194a1be02ad84aeda13efb87911039fc Mon Sep 17 00:00:00 2001 From: Moritz Kaufmann Date: Mon, 11 May 2026 14:40:49 +0200 Subject: [PATCH 08/24] fix: validate row arity in MetadataResultSets.of Previously a row with the wrong number of elements would silently leave the trailing columns as Arrow null (interpreted as missing values). Today every caller routes through MetadataSchemas so the sizes match by construction, but a future caller bug would surface only inside vector population, far from the boundary. Add an explicit arity check at the of(...) entrypoint: each non-null row must have exactly columns.size() elements. Null rows are accepted as the all-nulls row (matching the legacy coerceRows convention of turning null into emptyList). Empty rows are accepted only when the schema is also empty. Pin behavior with MetadataResultSetsTest covering short, long, correct-arity, null-row, and empty-rows cases. --- .../core/metadata/MetadataResultSets.java | 32 +++++++- .../core/metadata/MetadataResultSetsTest.java | 77 +++++++++++++++++++ 2 files changed, 108 insertions(+), 1 deletion(-) create mode 100644 jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/core/metadata/MetadataResultSetsTest.java diff --git a/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/metadata/MetadataResultSets.java b/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/metadata/MetadataResultSets.java index 3cdc556a..6a389a43 100644 --- a/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/metadata/MetadataResultSets.java +++ b/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/metadata/MetadataResultSets.java @@ -50,9 +50,14 @@ public static StreamingResultSet emptyNoColumns() throws SQLException { /** * Build a result set whose schema is {@code columns} and whose rows are {@code rows}. Each - * inner list in {@code rows} supplies values in column order. + * inner list in {@code rows} supplies values in column order, and must have exactly + * {@code columns.size()} elements — a short row would silently leave the trailing columns + * unset (interpreted as Arrow null), which is almost always a caller bug. Today every caller + * goes through {@link MetadataSchemas} so the sizes match by construction; the precondition + * here makes a future caller bug surface at the boundary instead of in vector population. */ public static StreamingResultSet of(List columns, List> rows) throws SQLException { + validateRowArity(columns, rows); byte[] ipcBytes = writeArrowStream(columns, rows); // Allocator is handed to StreamingResultSet along with the reader; the result set owns // its lifecycle and closes it when close() is called. @@ -105,6 +110,31 @@ private static byte[] writeArrowStream(List columns, List columns, List> rows) throws SQLException { + int expected = columns.size(); + for (int i = 0; i < rows.size(); i++) { + List row = rows.get(i); + if (row == null) { + continue; + } + // The legacy coerceRows path turns a null-row into Collections.emptyList(); accept + // empty as the "all nulls" shape here too. + if (row.isEmpty() && expected > 0) { + continue; + } + if (row.size() != expected) { + throw new SQLException("Metadata row " + i + " has " + row.size() + " elements but schema has " + + expected + " columns"); + } + } + } + @SuppressWarnings("unchecked") private static List> coerceRows(List rawRows) throws SQLException { if (rawRows == null || rawRows.isEmpty()) { diff --git a/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/core/metadata/MetadataResultSetsTest.java b/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/core/metadata/MetadataResultSetsTest.java new file mode 100644 index 00000000..d5d15a38 --- /dev/null +++ b/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/core/metadata/MetadataResultSetsTest.java @@ -0,0 +1,77 @@ +/** + * This file is part of https://github.com/forcedotcom/datacloud-jdbc which is released under the + * Apache 2.0 license. See https://github.com/forcedotcom/datacloud-jdbc/blob/main/LICENSE.txt + */ +package com.salesforce.datacloud.jdbc.core.metadata; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.salesforce.datacloud.jdbc.protocol.data.ColumnMetadata; +import com.salesforce.datacloud.jdbc.protocol.data.HyperType; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import lombok.val; +import org.junit.jupiter.api.Test; + +/** + * Pin the {@link MetadataResultSets#of(List, List)} arity contract: rows must match the schema + * column count, otherwise a short row would silently produce trailing Arrow-null cells (almost + * always a caller bug). A {@code null} row is allowed and is interpreted as an all-nulls row, + * matching the legacy {@code coerceRows} convention. + */ +class MetadataResultSetsTest { + + private static final List THREE_COLUMNS = Arrays.asList( + new ColumnMetadata("a", HyperType.varcharUnlimited(true)), + new ColumnMetadata("b", HyperType.int32(true)), + new ColumnMetadata("c", HyperType.bool(true))); + + @Test + void shortRowRejected() { + val rows = Collections.singletonList(Arrays.asList("only-one")); + assertThatThrownBy(() -> MetadataResultSets.of(THREE_COLUMNS, rows)) + .hasMessageContaining("3 columns") + .hasMessageContaining("1 elements"); + } + + @Test + void longRowRejected() { + val rows = Collections.singletonList(Arrays.asList("a", 1, true, "extra")); + assertThatThrownBy(() -> MetadataResultSets.of(THREE_COLUMNS, rows)) + .hasMessageContaining("3 columns") + .hasMessageContaining("4 elements"); + } + + @Test + void rightArityAccepted() throws Exception { + val rows = Collections.singletonList(Arrays.asList("a", 1, true)); + try (val rs = MetadataResultSets.of(THREE_COLUMNS, rows)) { + assertThat(rs.next()).isTrue(); + assertThat(rs.getString(1)).isEqualTo("a"); + assertThat(rs.getInt(2)).isEqualTo(1); + assertThat(rs.getBoolean(3)).isTrue(); + } + } + + @Test + void nullRowAcceptedAsAllNulls() throws Exception { + val rows = Collections.>singletonList(null); + try (val rs = MetadataResultSets.of(THREE_COLUMNS, rows)) { + assertThat(rs.next()).isTrue(); + assertThat(rs.getString(1)).isNull(); + rs.getInt(2); + assertThat(rs.wasNull()).isTrue(); + rs.getBoolean(3); + assertThat(rs.wasNull()).isTrue(); + } + } + + @Test + void emptyRowsAccepted() throws Exception { + try (val rs = MetadataResultSets.of(THREE_COLUMNS, Collections.emptyList())) { + assertThat(rs.next()).isFalse(); + } + } +} From 272b0f8d2682d2dafcc6d12e1706ce9b170dbf47 Mon Sep 17 00:00:00 2001 From: Moritz Kaufmann Date: Mon, 11 May 2026 15:06:24 +0200 Subject: [PATCH 09/24] refactor: drop empty-batch workaround in MetadataResultSets Now that ArrowStreamReaderCursor.loadNextNonEmptyBatch (introduced earlier on this branch as a pre-unify cursor fix) consumes empty batches at the cursor seam, MetadataResultSets.writeArrowStream no longer needs its own "skip writeBatch when rowCount==0" workaround: the cursor handles the empty-only case correctly. Remove the special case and always emit a batch. Tightens the zeroRowOnlyBatchYieldsNoRows test docstring to match. --- .../datacloud/jdbc/core/metadata/MetadataResultSets.java | 7 +------ .../datacloud/jdbc/core/ArrowStreamReaderCursorTest.java | 4 ++++ 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/metadata/MetadataResultSets.java b/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/metadata/MetadataResultSets.java index 6a389a43..bf08f009 100644 --- a/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/metadata/MetadataResultSets.java +++ b/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/metadata/MetadataResultSets.java @@ -96,12 +96,7 @@ private static byte[] writeArrowStream(List columns, List 0) { - writer.writeBatch(); - } + writer.writeBatch(); writer.end(); } return out.toByteArray(); diff --git a/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/core/ArrowStreamReaderCursorTest.java b/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/core/ArrowStreamReaderCursorTest.java index d5874a7b..1ed20de7 100644 --- a/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/core/ArrowStreamReaderCursorTest.java +++ b/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/core/ArrowStreamReaderCursorTest.java @@ -64,6 +64,10 @@ void incrementsInternalIndexUntilRowsExhaustedThenLoadsNextBatch() { val sut = new ArrowStreamReaderCursor(reader, allocator, ZoneId.systemDefault()); IntStream.range(0, times + 1).forEach(i -> sut.next()); + // Each next() inspects rowCount once on the per-batch index check. loadNextNonEmptyBatch + // is reached on the (times+1)-th call but only inspects rowCount inside its loop body if + // loadNextBatch returns true; here it returns false, so getRowCount is observed times+1 + // times in total. verify(root, times(times + 1)).getRowCount(); verify(reader, times(1)).loadNextBatch(); } From 72d96ee9b69bb799afdd2ec1a43f2b2b7d00737f Mon Sep 17 00:00:00 2001 From: Moritz Kaufmann Date: Mon, 11 May 2026 15:08:34 +0200 Subject: [PATCH 10/24] test: merge DataCloudMetadataResultSetTest into MetadataResultSetsTest DataCloudMetadataResultSet was deleted in this PR, but the test file retained the old name and lived in the wrong package. Merge its empty- result-set JDBC-shape smoke tests into the new MetadataResultSetsTest under the .core.metadata package and delete the legacy file. No behavior change. --- .../core/DataCloudMetadataResultSetTest.java | 107 ------------------ .../core/metadata/MetadataResultSetsTest.java | 107 +++++++++++++++++- 2 files changed, 103 insertions(+), 111 deletions(-) delete mode 100644 jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/core/DataCloudMetadataResultSetTest.java diff --git a/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/core/DataCloudMetadataResultSetTest.java b/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/core/DataCloudMetadataResultSetTest.java deleted file mode 100644 index 9323b050..00000000 --- a/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/core/DataCloudMetadataResultSetTest.java +++ /dev/null @@ -1,107 +0,0 @@ -/** - * This file is part of https://github.com/forcedotcom/datacloud-jdbc which is released under the - * Apache 2.0 license. See https://github.com/forcedotcom/datacloud-jdbc/blob/main/LICENSE.txt - */ -package com.salesforce.datacloud.jdbc.core; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import com.salesforce.datacloud.jdbc.core.metadata.MetadataResultSets; -import java.sql.ResultSet; -import java.sql.ResultSetMetaData; -import java.sql.SQLException; -import lombok.SneakyThrows; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; - -/** - * Smoke-test the Arrow-backed metadata path: empty metadata result sets expose the standard - * JDBC shape (row=0, closeable, forward-only, holdability, etc.). - */ -class DataCloudMetadataResultSetTest { - ResultSet metadataResultSet; - - @BeforeEach - public void init() throws SQLException { - metadataResultSet = MetadataResultSets.empty(MetadataSchemas.COLUMNS); - } - - @Test - void getRow() throws SQLException { - assertThat(metadataResultSet.getRow()).isEqualTo(0); - - metadataResultSet.close(); - assertThrows(SQLException.class, () -> metadataResultSet.next()); - } - - @Test - void next() throws SQLException { - metadataResultSet.close(); - assertThrows(SQLException.class, () -> metadataResultSet.next()); - } - - @Test - void isClosed() throws SQLException { - assertFalse(metadataResultSet.isClosed()); - metadataResultSet.close(); - assertTrue(metadataResultSet.isClosed()); - } - - @Test - void getStatement() throws SQLException { - assertThat(metadataResultSet.getStatement()).isNull(); - } - - @Test - void unwrap() throws SQLException { - assertThrows(SQLException.class, () -> metadataResultSet.unwrap(ResultSetMetaData.class)); - } - - @Test - void isWrapperFor() throws SQLException { - // StreamingResultSet implements DataCloudResultSet / ResultSet; it is not a wrapper for - // arbitrary unrelated types. - assertThat(metadataResultSet.isWrapperFor(ResultSetMetaData.class)).isFalse(); - } - - @Test - void getHoldability() throws SQLException { - assertThat(metadataResultSet.getHoldability()).isEqualTo(ResultSet.HOLD_CURSORS_OVER_COMMIT); - } - - @Test - void getFetchSize() throws SQLException { - assertThat(metadataResultSet.getFetchSize()).isEqualTo(0); - } - - @Test - void setFetchSize() throws SQLException { - // StreamingResultSet controls its own fetch size and ignores caller-supplied hints. - metadataResultSet.setFetchSize(0); - } - - @SneakyThrows - @Test - void getWarnings() { - assertThat((Iterable) metadataResultSet.getWarnings()) - .isNull(); - } - - @Test - void getConcurrency() throws SQLException { - assertThat(metadataResultSet.getConcurrency()).isEqualTo(ResultSet.CONCUR_READ_ONLY); - } - - @Test - void getType() throws SQLException { - assertThat(metadataResultSet.getType()).isEqualTo(ResultSet.TYPE_FORWARD_ONLY); - } - - @Test - void getFetchDirection() throws SQLException { - assertThat(metadataResultSet.getFetchDirection()).isEqualTo(ResultSet.FETCH_FORWARD); - } -} diff --git a/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/core/metadata/MetadataResultSetsTest.java b/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/core/metadata/MetadataResultSetsTest.java index d5d15a38..8bd14436 100644 --- a/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/core/metadata/MetadataResultSetsTest.java +++ b/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/core/metadata/MetadataResultSetsTest.java @@ -6,20 +6,32 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import com.salesforce.datacloud.jdbc.core.MetadataSchemas; import com.salesforce.datacloud.jdbc.protocol.data.ColumnMetadata; import com.salesforce.datacloud.jdbc.protocol.data.HyperType; +import java.sql.ResultSet; +import java.sql.ResultSetMetaData; +import java.sql.SQLException; import java.util.Arrays; import java.util.Collections; import java.util.List; +import lombok.SneakyThrows; import lombok.val; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; /** - * Pin the {@link MetadataResultSets#of(List, List)} arity contract: rows must match the schema - * column count, otherwise a short row would silently produce trailing Arrow-null cells (almost - * always a caller bug). A {@code null} row is allowed and is interpreted as an all-nulls row, - * matching the legacy {@code coerceRows} convention. + * Tests for {@link MetadataResultSets}. Two slices: + *
    + *
  • Arity contract — rows must match the schema column count; null rows are allowed + * as the all-nulls shape (matching the legacy {@code coerceRows} convention). + *
  • JDBC ResultSet shape — empty metadata result sets expose the standard JDBC shape + * (row=0, closeable, forward-only, holdability, etc.). + *
*/ class MetadataResultSetsTest { @@ -28,6 +40,8 @@ class MetadataResultSetsTest { new ColumnMetadata("b", HyperType.int32(true)), new ColumnMetadata("c", HyperType.bool(true))); + // --- Arity contract --- + @Test void shortRowRejected() { val rows = Collections.singletonList(Arrays.asList("only-one")); @@ -74,4 +88,89 @@ void emptyRowsAccepted() throws Exception { assertThat(rs.next()).isFalse(); } } + + // --- JDBC ResultSet shape on an empty metadata result set --- + + private ResultSet emptyMetadataResultSet; + + @BeforeEach + public void initEmptyMetadataResultSet() throws SQLException { + emptyMetadataResultSet = MetadataResultSets.empty(MetadataSchemas.COLUMNS); + } + + @Test + void getRow() throws SQLException { + assertThat(emptyMetadataResultSet.getRow()).isEqualTo(0); + + emptyMetadataResultSet.close(); + assertThrows(SQLException.class, () -> emptyMetadataResultSet.next()); + } + + @Test + void next() throws SQLException { + emptyMetadataResultSet.close(); + assertThrows(SQLException.class, () -> emptyMetadataResultSet.next()); + } + + @Test + void isClosed() throws SQLException { + assertFalse(emptyMetadataResultSet.isClosed()); + emptyMetadataResultSet.close(); + assertTrue(emptyMetadataResultSet.isClosed()); + } + + @Test + void getStatement() throws SQLException { + assertThat(emptyMetadataResultSet.getStatement()).isNull(); + } + + @Test + void unwrap() { + assertThrows(SQLException.class, () -> emptyMetadataResultSet.unwrap(ResultSetMetaData.class)); + } + + @Test + void isWrapperFor() throws SQLException { + // StreamingResultSet implements DataCloudResultSet / ResultSet; it is not a wrapper for + // arbitrary unrelated types. + assertThat(emptyMetadataResultSet.isWrapperFor(ResultSetMetaData.class)).isFalse(); + } + + @Test + void getHoldability() throws SQLException { + assertThat(emptyMetadataResultSet.getHoldability()).isEqualTo(ResultSet.HOLD_CURSORS_OVER_COMMIT); + } + + @Test + void getFetchSize() throws SQLException { + assertThat(emptyMetadataResultSet.getFetchSize()).isEqualTo(0); + } + + @Test + void setFetchSize() throws SQLException { + // StreamingResultSet controls its own fetch size and ignores caller-supplied hints. + emptyMetadataResultSet.setFetchSize(0); + } + + @SneakyThrows + @Test + void getWarnings() { + assertThat((Iterable) emptyMetadataResultSet.getWarnings()) + .isNull(); + } + + @Test + void getConcurrency() throws SQLException { + assertThat(emptyMetadataResultSet.getConcurrency()).isEqualTo(ResultSet.CONCUR_READ_ONLY); + } + + @Test + void getType() throws SQLException { + assertThat(emptyMetadataResultSet.getType()).isEqualTo(ResultSet.TYPE_FORWARD_ONLY); + } + + @Test + void getFetchDirection() throws SQLException { + assertThat(emptyMetadataResultSet.getFetchDirection()).isEqualTo(ResultSet.FETCH_FORWARD); + } } From 1a31efbe81acc2d84d7a2e42022bff9bdcb19e91 Mon Sep 17 00:00:00 2001 From: Moritz Kaufmann Date: Mon, 11 May 2026 16:53:17 +0200 Subject: [PATCH 11/24] refactor: drop StreamingResultSet getObject(Class) bandaid Now that QueryJDBCAccessor.getObject(Class) provides the raw + isInstance fallback as its base-class default, StreamingResultSet no longer needs the catch-and-retry path that worked around accessors which threw "Operation not supported." Collapse getObject(int, Class) to direct dispatch and update the regression test's WHY comment to point at the accessor base class as the load-bearing layer. Addresses: review comment on PR #175 line 388. --- .../jdbc/core/StreamingResultSet.java | 28 ++----------------- 1 file changed, 3 insertions(+), 25 deletions(-) diff --git a/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/StreamingResultSet.java b/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/StreamingResultSet.java index d9d03aa2..99395c7b 100644 --- a/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/StreamingResultSet.java +++ b/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/StreamingResultSet.java @@ -360,32 +360,10 @@ public Object getObject(int columnIndex, Map> map) throws SQLEx @Override public T getObject(int columnIndex, Class type) throws SQLException { - if (type == null) { - throw new SQLException("Target type must not be null"); - } - // Default implementation: get the raw Object and check if it matches the requested type. - // Accessors that need richer conversion (e.g. Arrow Decimal → BigInteger) can still - // override the accessor-level getObject(Class) — in that case we dispatch to them first. val accessor = getAccessor(columnIndex); - try { - val typed = accessor.getObject(type); - updateWasNull(accessor); - return typed; - } catch (SQLException ex) { - // Accessor does not implement typed getObject — fall back to raw + isInstance check. - val raw = accessor.getObject(); - updateWasNull(accessor); - if (raw == null) { - return null; - } - if (type.isInstance(raw)) { - return type.cast(raw); - } - throw new SQLException( - "Cannot convert column value to " + type.getName() + "; actual type is " - + raw.getClass().getName(), - ex); - } + val result = accessor.getObject(type); + updateWasNull(accessor); + return result; } @Override From 8bf45a20f79acc1279e2e8b9f6243a3f8077503c Mon Sep 17 00:00:00 2001 From: Moritz Kaufmann Date: Mon, 11 May 2026 16:53:40 +0200 Subject: [PATCH 12/24] refactor: trim docstrings and prune redundant ResultSet-shape tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three small follow-ups from PR #175 review: - StreamingResultSet.of: drop the paragraph that pointed at the HyperTypeToArrow.JDBC_TYPE_NAME_METADATA_KEY field-metadata key. The docstring spilled implementation detail of the metadata-stamping path into a generic "create a result set from a reader" entry-point; the type-name override is documented at HyperTypeToArrow / ColumnMetadata where it's relevant. - ArrowStreamReaderCursor.loadNextNonEmptyBatch: rewrite the rationale to answer "why does the cursor consume empty batches instead of the caller?" directly. Empty IPC batches are valid Arrow and producers emit them; JDBC's next() only knows rows, so this cursor is the seam that translates batch-level signals into row-level advances. - MetadataResultSetsTest: drop the JDBC ResultSet-shape slice (next / isClosed / getStatement / unwrap / isWrapperFor / getHoldability / getFetchSize / setFetchSize / getWarnings / getConcurrency / getType / getFetchDirection). Those test the StreamingResultSet plumbing shared by every result set on this branch and are already covered by StreamingResultSetMethodTest. Keep the arity-contract slice (short/long/right/null/empty rows) — that is the metadata-result-set-specific behavior. Addresses: review comments on PR #175. --- .../jdbc/core/StreamingResultSet.java | 4 - .../core/metadata/MetadataResultSetsTest.java | 108 +----------------- 2 files changed, 5 insertions(+), 107 deletions(-) diff --git a/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/StreamingResultSet.java b/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/StreamingResultSet.java index 99395c7b..be0709ac 100644 --- a/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/StreamingResultSet.java +++ b/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/StreamingResultSet.java @@ -87,10 +87,6 @@ public static StreamingResultSet of(ArrowStreamReader reader, BufferAllocator al * buffer accounting clears before the allocator's budget check. Callers must not close * either separately. * - *

The column metadata (including any {@link ColumnMetadata#getTypeName()} override - * stamped under {@link com.salesforce.datacloud.jdbc.protocol.data.HyperTypeToArrow#JDBC_TYPE_NAME_METADATA_KEY}) - * is derived from the Arrow schema via {@link ArrowToHyperTypeMapper#toColumnMetadata(org.apache.arrow.vector.types.pojo.Field)}. - * * @param reader The Arrow stream, owned by the result set. * @param allocator The allocator backing the reader, owned by the result set. * @param queryId The query identifier. diff --git a/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/core/metadata/MetadataResultSetsTest.java b/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/core/metadata/MetadataResultSetsTest.java index 8bd14436..d2ea37bf 100644 --- a/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/core/metadata/MetadataResultSetsTest.java +++ b/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/core/metadata/MetadataResultSetsTest.java @@ -6,32 +6,21 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; -import com.salesforce.datacloud.jdbc.core.MetadataSchemas; import com.salesforce.datacloud.jdbc.protocol.data.ColumnMetadata; import com.salesforce.datacloud.jdbc.protocol.data.HyperType; -import java.sql.ResultSet; -import java.sql.ResultSetMetaData; -import java.sql.SQLException; import java.util.Arrays; import java.util.Collections; import java.util.List; -import lombok.SneakyThrows; import lombok.val; -import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; /** - * Tests for {@link MetadataResultSets}. Two slices: - *

    - *
  • Arity contract — rows must match the schema column count; null rows are allowed - * as the all-nulls shape (matching the legacy {@code coerceRows} convention). - *
  • JDBC ResultSet shape — empty metadata result sets expose the standard JDBC shape - * (row=0, closeable, forward-only, holdability, etc.). - *
+ * Tests the {@link MetadataResultSets#of} arity contract: rows must match the schema column + * count; null rows are allowed as the all-nulls shape (matching the legacy {@code coerceRows} + * convention). Generic JDBC {@link java.sql.ResultSet} shape (closeable, forward-only, + * holdability, etc.) is exercised by {@code StreamingResultSetMethodTest} since metadata + * result sets share the {@link com.salesforce.datacloud.jdbc.core.StreamingResultSet} plumbing. */ class MetadataResultSetsTest { @@ -40,8 +29,6 @@ class MetadataResultSetsTest { new ColumnMetadata("b", HyperType.int32(true)), new ColumnMetadata("c", HyperType.bool(true))); - // --- Arity contract --- - @Test void shortRowRejected() { val rows = Collections.singletonList(Arrays.asList("only-one")); @@ -88,89 +75,4 @@ void emptyRowsAccepted() throws Exception { assertThat(rs.next()).isFalse(); } } - - // --- JDBC ResultSet shape on an empty metadata result set --- - - private ResultSet emptyMetadataResultSet; - - @BeforeEach - public void initEmptyMetadataResultSet() throws SQLException { - emptyMetadataResultSet = MetadataResultSets.empty(MetadataSchemas.COLUMNS); - } - - @Test - void getRow() throws SQLException { - assertThat(emptyMetadataResultSet.getRow()).isEqualTo(0); - - emptyMetadataResultSet.close(); - assertThrows(SQLException.class, () -> emptyMetadataResultSet.next()); - } - - @Test - void next() throws SQLException { - emptyMetadataResultSet.close(); - assertThrows(SQLException.class, () -> emptyMetadataResultSet.next()); - } - - @Test - void isClosed() throws SQLException { - assertFalse(emptyMetadataResultSet.isClosed()); - emptyMetadataResultSet.close(); - assertTrue(emptyMetadataResultSet.isClosed()); - } - - @Test - void getStatement() throws SQLException { - assertThat(emptyMetadataResultSet.getStatement()).isNull(); - } - - @Test - void unwrap() { - assertThrows(SQLException.class, () -> emptyMetadataResultSet.unwrap(ResultSetMetaData.class)); - } - - @Test - void isWrapperFor() throws SQLException { - // StreamingResultSet implements DataCloudResultSet / ResultSet; it is not a wrapper for - // arbitrary unrelated types. - assertThat(emptyMetadataResultSet.isWrapperFor(ResultSetMetaData.class)).isFalse(); - } - - @Test - void getHoldability() throws SQLException { - assertThat(emptyMetadataResultSet.getHoldability()).isEqualTo(ResultSet.HOLD_CURSORS_OVER_COMMIT); - } - - @Test - void getFetchSize() throws SQLException { - assertThat(emptyMetadataResultSet.getFetchSize()).isEqualTo(0); - } - - @Test - void setFetchSize() throws SQLException { - // StreamingResultSet controls its own fetch size and ignores caller-supplied hints. - emptyMetadataResultSet.setFetchSize(0); - } - - @SneakyThrows - @Test - void getWarnings() { - assertThat((Iterable) emptyMetadataResultSet.getWarnings()) - .isNull(); - } - - @Test - void getConcurrency() throws SQLException { - assertThat(emptyMetadataResultSet.getConcurrency()).isEqualTo(ResultSet.CONCUR_READ_ONLY); - } - - @Test - void getType() throws SQLException { - assertThat(emptyMetadataResultSet.getType()).isEqualTo(ResultSet.TYPE_FORWARD_ONLY); - } - - @Test - void getFetchDirection() throws SQLException { - assertThat(emptyMetadataResultSet.getFetchDirection()).isEqualTo(ResultSet.FETCH_FORWARD); - } } From c826ae7c8e8684863bf6883023a2557d0bf3a09e Mon Sep 17 00:00:00 2001 From: Moritz Kaufmann Date: Mon, 11 May 2026 21:44:18 +0200 Subject: [PATCH 13/24] refactor: collapse StreamingResultSet.of factories into one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit StreamingResultSet had two public factories — of(reader, allocator, queryId[, zone]) (4 callers) and ofClosingOnFailure(Result, queryId, zone) (5 callers). Every production caller wanted the close-on-failure behavior; only tests and the metadata helper used the bare of(). Two factories with overlapping responsibilities is one too many — a caller hitting the bare of() and not knowing about ofClosingOnFailure would silently leak the 100 MB RootAllocator on construction failure. Collapse to one public factory: - of(QueryResultArrowStream.Result, queryId, sessionZone) — the only factory callers see, always closes both reader and allocator on failure. Name is the unambiguous "of" because there is no other. - create(reader, allocator, queryId, sessionZone) — private; just the construction body the factory wraps. Production call sites (DataCloudConnection, DataCloudStatement) and MetadataResultSets were already passing a (reader, allocator) pair, so the call shape collapses to passing the Result holder. Tests that were building the pair locally now wrap it in a Result the same way. --- .../jdbc/core/DataCloudConnection.java | 4 +- .../jdbc/core/DataCloudStatement.java | 6 +- .../jdbc/core/StreamingResultSet.java | 59 ++++++++----------- .../core/metadata/MetadataResultSets.java | 11 ++-- .../datacloud/jdbc/core/StreamCloseTest.java | 2 +- .../core/StreamingResultSetMethodTest.java | 5 +- 6 files changed, 38 insertions(+), 49 deletions(-) diff --git a/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/DataCloudConnection.java b/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/DataCloudConnection.java index 96dc8de6..31c19306 100644 --- a/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/DataCloudConnection.java +++ b/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/DataCloudConnection.java @@ -221,7 +221,7 @@ public DataCloudResultSet getRowBasedResultSet(String queryId, long offset, long QueryResultArrowStream.OUTPUT_FORMAT); val arrowStream = SQLExceptionQueryResultIterator.createSqlExceptionArrowStreamReader( iterator, connectionProperties.isIncludeCustomerDetailInReason(), queryId, null); - return StreamingResultSet.ofClosingOnFailure(arrowStream, queryId, ZoneId.systemDefault()); + return StreamingResultSet.of(arrowStream, queryId, ZoneId.systemDefault()); } catch (StatusRuntimeException ex) { throw QueryExceptionHandler.createException( connectionProperties.isIncludeCustomerDetailInReason(), null, queryId, ex); @@ -264,7 +264,7 @@ public DataCloudResultSet getChunkBasedResultSet(String queryId, long chunkId, l QueryResultArrowStream.OUTPUT_FORMAT); val arrowStream = SQLExceptionQueryResultIterator.createSqlExceptionArrowStreamReader( iterator, connectionProperties.isIncludeCustomerDetailInReason(), queryId, null); - return StreamingResultSet.ofClosingOnFailure(arrowStream, queryId, ZoneId.systemDefault()); + return StreamingResultSet.of(arrowStream, queryId, ZoneId.systemDefault()); } catch (StatusRuntimeException ex) { throw QueryExceptionHandler.createException( connectionProperties.isIncludeCustomerDetailInReason(), null, queryId, ex); diff --git a/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/DataCloudStatement.java b/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/DataCloudStatement.java index 6dd87623..138d95d3 100644 --- a/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/DataCloudStatement.java +++ b/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/DataCloudStatement.java @@ -196,8 +196,8 @@ public ResultSet executeQuery(String sql) throws SQLException { val iterator = executeAdaptiveQuery(sql); val arrowStream = SQLExceptionQueryResultIterator.createSqlExceptionArrowStreamReader( iterator, includeCustomerDetail, iterator.getQueryStatus().getQueryId(), sql); - resultSet = StreamingResultSet.ofClosingOnFailure( - arrowStream, iterator.getQueryStatus().getQueryId(), sessionZone); + resultSet = + StreamingResultSet.of(arrowStream, iterator.getQueryStatus().getQueryId(), sessionZone); log.info( "executeAdaptiveQuery completed. queryId={}, sessionZone={}", queryHandle.getQueryStatus().getQueryId(), @@ -436,7 +436,7 @@ public ResultSet getResultSet() throws SQLException { includeCustomerDetail, adaptiveIterator.getQueryStatus().getQueryId(), null); - resultSet = StreamingResultSet.ofClosingOnFailure( + resultSet = StreamingResultSet.of( arrowStream, adaptiveIterator.getQueryStatus().getQueryId(), sessionZone); diff --git a/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/StreamingResultSet.java b/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/StreamingResultSet.java index be0709ac..55d415fe 100644 --- a/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/StreamingResultSet.java +++ b/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/StreamingResultSet.java @@ -74,25 +74,38 @@ private StreamingResultSet( this.closed = false; } - public static StreamingResultSet of(ArrowStreamReader reader, BufferAllocator allocator, String queryId) - throws SQLException { - return of(reader, allocator, queryId, ZoneId.systemDefault()); - } - /** - * Creates a StreamingResultSet from an {@link ArrowStreamReader} and its backing allocator. + * Creates a StreamingResultSet from a {@link QueryResultArrowStream.Result} (reader paired + * with its backing allocator). * *

Ownership of both the reader and the allocator transfers to the returned result set — * closing the result set closes the reader and then the allocator, in that order, so Arrow's - * buffer accounting clears before the allocator's budget check. Callers must not close - * either separately. + * buffer accounting clears before the allocator's budget check. If construction itself + * throws (for example a {@link SQLException} wrapping an unsupported Arrow type), this + * method closes both before re-throwing so the 100 MB {@link + * org.apache.arrow.memory.RootAllocator} does not leak. Callers must not close either + * separately on success. * - * @param reader The Arrow stream, owned by the result set. - * @param allocator The allocator backing the reader, owned by the result set. - * @param queryId The query identifier. + * @param arrowStream The Arrow stream + allocator pair, both owned by the result set. + * @param queryId The query identifier (may be {@code null} for synthesized result sets). * @param sessionZone The session timezone used for timestamp conversions. */ - public static StreamingResultSet of( + public static StreamingResultSet of(QueryResultArrowStream.Result arrowStream, String queryId, ZoneId sessionZone) + throws SQLException { + try { + return create(arrowStream.getReader(), arrowStream.getAllocator(), queryId, sessionZone); + } catch (SQLException | RuntimeException ex) { + try { + arrowStream.getReader().close(); + } catch (Exception suppressed) { + ex.addSuppressed(suppressed); + } + arrowStream.getAllocator().close(); + throw ex; + } + } + + private static StreamingResultSet create( ArrowStreamReader reader, BufferAllocator allocator, String queryId, ZoneId sessionZone) throws SQLException { try { @@ -113,28 +126,6 @@ public static StreamingResultSet of( } } - /** - * Hand the reader + allocator pair from {@link QueryResultArrowStream.Result} to {@link - * #of(ArrowStreamReader, BufferAllocator, String, ZoneId)} and close both on construction - * failure. Without this, an {@code of} call that throws (for example {@code SQLException} - * wrapping an unsupported Arrow type) would leak the 100 MB - * {@link org.apache.arrow.memory.RootAllocator} held by {@code Result}. - */ - public static StreamingResultSet ofClosingOnFailure( - QueryResultArrowStream.Result arrowStream, String queryId, ZoneId sessionZone) throws SQLException { - try { - return of(arrowStream.getReader(), arrowStream.getAllocator(), queryId, sessionZone); - } catch (SQLException | RuntimeException ex) { - try { - arrowStream.getReader().close(); - } catch (Exception suppressed) { - ex.addSuppressed(suppressed); - } - arrowStream.getAllocator().close(); - throw ex; - } - } - // --- Core ResultSet navigation --- @Override diff --git a/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/metadata/MetadataResultSets.java b/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/metadata/MetadataResultSets.java index bf08f009..a9f5e6cc 100644 --- a/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/metadata/MetadataResultSets.java +++ b/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/metadata/MetadataResultSets.java @@ -5,6 +5,7 @@ package com.salesforce.datacloud.jdbc.core.metadata; import com.salesforce.datacloud.jdbc.core.StreamingResultSet; +import com.salesforce.datacloud.jdbc.protocol.QueryResultArrowStream; import com.salesforce.datacloud.jdbc.protocol.data.ColumnMetadata; import com.salesforce.datacloud.jdbc.protocol.data.HyperTypeToArrow; import com.salesforce.datacloud.jdbc.protocol.data.VectorPopulator; @@ -62,13 +63,9 @@ public static StreamingResultSet of(List columns, List StreamingResultSet.ofClosingOnFailure(arrowStream, QUERY_ID, ZoneId.systemDefault())) + assertThatThrownBy(() -> StreamingResultSet.of(arrowStream, QUERY_ID, ZoneId.systemDefault())) .isInstanceOf(SQLException.class) .hasMessageContaining("Unsupported column type"); From 7baf67d795c53c175b9e0b495c17bd2d0b7a5536 Mon Sep 17 00:00:00 2001 From: Moritz Kaufmann Date: Mon, 11 May 2026 21:51:57 +0200 Subject: [PATCH 14/24] refactor: rename StreamingResultSet to DataCloudResultSet, drop marker interface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pre-unify there were three result-set implementations: StreamingResultSet (streaming Arrow query results), DataCloudMetadataResultSet (metadata), SimpleResultSet (in-memory rows). The DataCloudResultSet interface — a one-method (getQueryId) extension over java.sql.ResultSet — was the common "implements" the public API surfaced; StreamingResultSet was the only one that ever implemented it as a non-trivial impl. The unify refactor collapsed all three implementations into StreamingResultSet, but kept the interface and the "Streaming" name. Two problems fall out: - The "Streaming" name now lies. Metadata results flow through the same class but they're a one-shot in-memory IPC blob — nothing streaming about them. MetadataResultSets.of even passes /*queryId=*/ null because there is no query. - The DataCloudResultSet interface has one implementer and one method. Layering an interface for one impl is just a reader trap: callers instinctively look for "what other implementations exist" and find none. Collapse the two: - Rename the class StreamingResultSet -> DataCloudResultSet. - Delete the old DataCloudResultSet interface (the public method getQueryId() now lives directly on the class via @Getter). - Update all production and test references; rename the affected test files to match (StreamingResultSet*Test -> DataCloudResultSet*Test). The public API surface is unchanged in source for the common cases: DataCloudConnection.getRowBasedResultSet / getChunkBasedResultSet still return DataCloudResultSet, just as a class instead of an interface. This is binary-incompatible for any caller that ever cast to or implemented the old interface; in practice only StreamingResultSet implemented it on the read side, and no code outside the driver implemented it on the write side. --- .../jdbc/core/ArrowStreamReaderCursor.java | 4 +- .../jdbc/core/DataCloudConnection.java | 4 +- .../jdbc/core/DataCloudResultSet.java | 507 ++++++++++++++++- .../jdbc/core/DataCloudStatement.java | 4 +- .../jdbc/core/StreamingResultSet.java | 515 ------------------ .../core/metadata/MetadataResultSets.java | 16 +- .../jdbc/protocol/QueryResultArrowStream.java | 2 +- ....java => AsyncDataCloudResultSetTest.java} | 4 +- ...java => DataCloudResultSetMethodTest.java} | 22 +- ...tTest.java => DataCloudResultSetTest.java} | 4 +- .../datacloud/jdbc/core/StreamCloseTest.java | 8 +- .../core/metadata/MetadataResultSetsTest.java | 4 +- .../jdbc/examples/RowBasedPaginationTest.java | 3 +- 13 files changed, 542 insertions(+), 555 deletions(-) delete mode 100644 jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/StreamingResultSet.java rename jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/core/{AsyncStreamingResultSetTest.java => AsyncDataCloudResultSetTest.java} (97%) rename jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/core/{StreamingResultSetMethodTest.java => DataCloudResultSetMethodTest.java} (96%) rename jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/core/{StreamingResultSetTest.java => DataCloudResultSetTest.java} (98%) diff --git a/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/ArrowStreamReaderCursor.java b/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/ArrowStreamReaderCursor.java index 37605df9..b43f6853 100644 --- a/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/ArrowStreamReaderCursor.java +++ b/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/ArrowStreamReaderCursor.java @@ -23,12 +23,12 @@ import org.apache.arrow.vector.ipc.ArrowStreamReader; /** - * Row cursor over an {@link ArrowStreamReader} that drives the {@link StreamingResultSet}. + * Row cursor over an {@link ArrowStreamReader} that drives the {@link DataCloudResultSet}. * *

The cursor owns the supplied {@link BufferAllocator} alongside the reader: closing the * cursor closes the reader (which releases ArrowBuf accounting) and then the allocator (which * returns its budget). This is the single place that guarantees root-allocator hygiene for the - * driver; callers of {@link StreamingResultSet#of} hand ownership over and do not close the + * driver; callers of {@link DataCloudResultSet#of} hand ownership over and do not close the * allocator themselves. */ @Slf4j diff --git a/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/DataCloudConnection.java b/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/DataCloudConnection.java index 31c19306..56ca5a5f 100644 --- a/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/DataCloudConnection.java +++ b/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/DataCloudConnection.java @@ -221,7 +221,7 @@ public DataCloudResultSet getRowBasedResultSet(String queryId, long offset, long QueryResultArrowStream.OUTPUT_FORMAT); val arrowStream = SQLExceptionQueryResultIterator.createSqlExceptionArrowStreamReader( iterator, connectionProperties.isIncludeCustomerDetailInReason(), queryId, null); - return StreamingResultSet.of(arrowStream, queryId, ZoneId.systemDefault()); + return DataCloudResultSet.of(arrowStream, queryId, ZoneId.systemDefault()); } catch (StatusRuntimeException ex) { throw QueryExceptionHandler.createException( connectionProperties.isIncludeCustomerDetailInReason(), null, queryId, ex); @@ -264,7 +264,7 @@ public DataCloudResultSet getChunkBasedResultSet(String queryId, long chunkId, l QueryResultArrowStream.OUTPUT_FORMAT); val arrowStream = SQLExceptionQueryResultIterator.createSqlExceptionArrowStreamReader( iterator, connectionProperties.isIncludeCustomerDetailInReason(), queryId, null); - return StreamingResultSet.of(arrowStream, queryId, ZoneId.systemDefault()); + return DataCloudResultSet.of(arrowStream, queryId, ZoneId.systemDefault()); } catch (StatusRuntimeException ex) { throw QueryExceptionHandler.createException( connectionProperties.isIncludeCustomerDetailInReason(), null, queryId, ex); diff --git a/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/DataCloudResultSet.java b/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/DataCloudResultSet.java index 28cd51a1..50d1a1b1 100644 --- a/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/DataCloudResultSet.java +++ b/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/DataCloudResultSet.java @@ -4,8 +4,511 @@ */ package com.salesforce.datacloud.jdbc.core; +import com.salesforce.datacloud.jdbc.core.accessor.QueryJDBCAccessor; +import com.salesforce.datacloud.jdbc.core.metadata.DataCloudResultSetMetaData; +import com.salesforce.datacloud.jdbc.core.resultset.ForwardOnlyResultSet; +import com.salesforce.datacloud.jdbc.core.resultset.ReadOnlyResultSet; +import com.salesforce.datacloud.jdbc.core.resultset.ResultSetWithPositionalGetters; +import com.salesforce.datacloud.jdbc.protocol.QueryResultArrowStream; +import com.salesforce.datacloud.jdbc.protocol.data.ArrowToHyperTypeMapper; +import com.salesforce.datacloud.jdbc.util.ThrowingJdbcSupplier; +import com.salesforce.datacloud.query.v3.QueryStatus; +import java.io.IOException; +import java.io.InputStream; +import java.io.Reader; +import java.math.BigDecimal; +import java.net.URL; +import java.sql.Array; +import java.sql.Blob; +import java.sql.Clob; +import java.sql.Date; +import java.sql.Ref; import java.sql.ResultSet; +import java.sql.ResultSetMetaData; +import java.sql.RowId; +import java.sql.SQLException; +import java.sql.SQLFeatureNotSupportedException; +import java.sql.SQLWarning; +import java.sql.SQLXML; +import java.sql.Statement; +import java.sql.Struct; +import java.sql.Time; +import java.sql.Timestamp; +import java.sql.Types; +import java.time.ZoneId; +import java.util.Calendar; +import java.util.Map; +import java.util.stream.Collectors; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import lombok.val; +import org.apache.arrow.memory.BufferAllocator; +import org.apache.arrow.vector.ipc.ArrowStreamReader; -public interface DataCloudResultSet extends ResultSet { - String getQueryId(); +@Slf4j +public class DataCloudResultSet implements ReadOnlyResultSet, ForwardOnlyResultSet, ResultSetWithPositionalGetters { + + @Getter + private final String queryId; + + private final ArrowStreamReaderCursor cursor; + private final QueryJDBCAccessor[] accessors; + private final DataCloudResultSetMetaData metadata; + private final ColumnNameResolver columnNameResolver; + ThrowingJdbcSupplier getQueryStatus; + private boolean wasNull; + private boolean closed; + + private DataCloudResultSet( + ArrowStreamReaderCursor cursor, + String queryId, + DataCloudResultSetMetaData metadata, + QueryJDBCAccessor[] accessors, + ColumnNameResolver columnNameResolver) { + this.cursor = cursor; + this.queryId = queryId; + this.metadata = metadata; + this.accessors = accessors; + this.columnNameResolver = columnNameResolver; + this.closed = false; + } + + /** + * Creates a DataCloudResultSet from a {@link QueryResultArrowStream.Result} (reader paired + * with its backing allocator). + * + *

Ownership of both the reader and the allocator transfers to the returned result set — + * closing the result set closes the reader and then the allocator, in that order, so Arrow's + * buffer accounting clears before the allocator's budget check. If construction itself + * throws (for example a {@link SQLException} wrapping an unsupported Arrow type), this + * method closes both before re-throwing so the 100 MB {@link + * org.apache.arrow.memory.RootAllocator} does not leak. Callers must not close either + * separately on success. + * + * @param arrowStream The Arrow stream + allocator pair, both owned by the result set. + * @param queryId The query identifier (may be {@code null} for synthesized result sets). + * @param sessionZone The session timezone used for timestamp conversions. + */ + public static DataCloudResultSet of(QueryResultArrowStream.Result arrowStream, String queryId, ZoneId sessionZone) + throws SQLException { + try { + return create(arrowStream.getReader(), arrowStream.getAllocator(), queryId, sessionZone); + } catch (SQLException | RuntimeException ex) { + try { + arrowStream.getReader().close(); + } catch (Exception suppressed) { + ex.addSuppressed(suppressed); + } + arrowStream.getAllocator().close(); + throw ex; + } + } + + private static DataCloudResultSet create( + ArrowStreamReader reader, BufferAllocator allocator, String queryId, ZoneId sessionZone) + throws SQLException { + try { + val schemaRoot = reader.getVectorSchemaRoot(); + val columns = schemaRoot.getSchema().getFields().stream() + .map(ArrowToHyperTypeMapper::toColumnMetadata) + .collect(Collectors.toList()); + val metadata = new DataCloudResultSetMetaData(columns); + val cursor = new ArrowStreamReaderCursor(reader, allocator, sessionZone); + val accessors = cursor.createAccessors().toArray(new QueryJDBCAccessor[0]); + val columnNameResolver = new ColumnNameResolver(columns); + return new DataCloudResultSet(cursor, queryId, metadata, accessors, columnNameResolver); + } catch (IOException ex) { + throw new SQLException("Unexpected error during ResultSet creation", "XX000", ex); + } catch (IllegalArgumentException ex) { + // Thrown by ArrowToHyperTypeMapper for Arrow types the driver does not model. + throw new SQLException("Unsupported column type in query result: " + ex.getMessage(), "0A000", ex); + } + } + + // --- Core ResultSet navigation --- + + @Override + public boolean next() throws SQLException { + checkClosed(); + return cursor.next(); + } + + @Override + public void close() throws SQLException { + if (!closed) { + cursor.close(); + closed = true; + } + } + + @Override + public boolean isClosed() throws SQLException { + return closed; + } + + @Override + public int getRow() throws SQLException { + checkClosed(); + return cursor.getRowsSeen(); + } + + @Override + public ResultSetMetaData getMetaData() throws SQLException { + checkClosed(); + return metadata; + } + + @Override + public int findColumn(String columnLabel) throws SQLException { + checkClosed(); + return columnNameResolver.findColumn(columnLabel); + } + + @Override + public Statement getStatement() throws SQLException { + checkClosed(); + return null; + } + + @Override + public boolean wasNull() throws SQLException { + checkClosed(); + return wasNull; + } + + // --- Accessor dispatch: delegate to QueryJDBCAccessor --- + + private QueryJDBCAccessor getAccessor(int columnIndex) throws SQLException { + checkClosed(); + if (columnIndex <= 0 || columnIndex > accessors.length) { + throw new SQLException( + "Column index " + columnIndex + " out of bounds (" + accessors.length + " columns available)"); + } + return accessors[columnIndex - 1]; + } + + private void updateWasNull(QueryJDBCAccessor accessor) throws SQLException { + wasNull = accessor.wasNull(); + } + + @Override + public String getString(int columnIndex) throws SQLException { + val accessor = getAccessor(columnIndex); + val result = accessor.getString(); + updateWasNull(accessor); + return result; + } + + @Override + public boolean getBoolean(int columnIndex) throws SQLException { + val accessor = getAccessor(columnIndex); + val result = accessor.getBoolean(); + updateWasNull(accessor); + return result; + } + + @Override + public byte getByte(int columnIndex) throws SQLException { + val accessor = getAccessor(columnIndex); + val result = accessor.getByte(); + updateWasNull(accessor); + return result; + } + + @Override + public short getShort(int columnIndex) throws SQLException { + val accessor = getAccessor(columnIndex); + val result = accessor.getShort(); + updateWasNull(accessor); + return result; + } + + @Override + public int getInt(int columnIndex) throws SQLException { + val accessor = getAccessor(columnIndex); + val result = accessor.getInt(); + updateWasNull(accessor); + return result; + } + + @Override + public long getLong(int columnIndex) throws SQLException { + val accessor = getAccessor(columnIndex); + val result = accessor.getLong(); + updateWasNull(accessor); + return result; + } + + @Override + public float getFloat(int columnIndex) throws SQLException { + val accessor = getAccessor(columnIndex); + val result = accessor.getFloat(); + updateWasNull(accessor); + return result; + } + + @Override + public double getDouble(int columnIndex) throws SQLException { + val accessor = getAccessor(columnIndex); + val result = accessor.getDouble(); + updateWasNull(accessor); + return result; + } + + @SuppressWarnings("deprecation") + @Override + public BigDecimal getBigDecimal(int columnIndex, int scale) throws SQLException { + val accessor = getAccessor(columnIndex); + val result = accessor.getBigDecimal(scale); + updateWasNull(accessor); + return result; + } + + @Override + public BigDecimal getBigDecimal(int columnIndex) throws SQLException { + val accessor = getAccessor(columnIndex); + val result = accessor.getBigDecimal(); + updateWasNull(accessor); + return result; + } + + @Override + public byte[] getBytes(int columnIndex) throws SQLException { + val accessor = getAccessor(columnIndex); + val result = accessor.getBytes(); + updateWasNull(accessor); + return result; + } + + @Override + public Date getDate(int columnIndex) throws SQLException { + return getDate(columnIndex, null); + } + + @Override + public Date getDate(int columnIndex, Calendar cal) throws SQLException { + val accessor = getAccessor(columnIndex); + val result = accessor.getDate(cal); + updateWasNull(accessor); + return result; + } + + @Override + public Time getTime(int columnIndex) throws SQLException { + return getTime(columnIndex, null); + } + + @Override + public Time getTime(int columnIndex, Calendar cal) throws SQLException { + val accessor = getAccessor(columnIndex); + val result = accessor.getTime(cal); + updateWasNull(accessor); + return result; + } + + @Override + public Timestamp getTimestamp(int columnIndex) throws SQLException { + return getTimestamp(columnIndex, null); + } + + @Override + public Timestamp getTimestamp(int columnIndex, Calendar cal) throws SQLException { + val accessor = getAccessor(columnIndex); + val result = accessor.getTimestamp(cal); + updateWasNull(accessor); + return result; + } + + @Override + public Object getObject(int columnIndex) throws SQLException { + // For ARRAY columns, dispatch to getArray() to return DataCloudArray + // (matching the behavior of the old AvaticaResultSet type dispatch) + if (metadata.getColumnType(columnIndex) == Types.ARRAY) { + return getArray(columnIndex); + } + val accessor = getAccessor(columnIndex); + val result = accessor.getObject(); + updateWasNull(accessor); + return result; + } + + @Override + public Object getObject(int columnIndex, Map> map) throws SQLException { + if (map == null || map.isEmpty()) { + // JDBC allows a null/empty type map to behave like plain getObject(int). + return getObject(columnIndex); + } + val accessor = getAccessor(columnIndex); + val result = accessor.getObject(map); + updateWasNull(accessor); + return result; + } + + @Override + public T getObject(int columnIndex, Class type) throws SQLException { + val accessor = getAccessor(columnIndex); + val result = accessor.getObject(type); + updateWasNull(accessor); + return result; + } + + @Override + public Array getArray(int columnIndex) throws SQLException { + val accessor = getAccessor(columnIndex); + val result = accessor.getArray(); + updateWasNull(accessor); + return result; + } + + @Override + public InputStream getAsciiStream(int columnIndex) throws SQLException { + val accessor = getAccessor(columnIndex); + val result = accessor.getAsciiStream(); + updateWasNull(accessor); + return result; + } + + @SuppressWarnings("deprecation") + @Override + public InputStream getUnicodeStream(int columnIndex) throws SQLException { + val accessor = getAccessor(columnIndex); + val result = accessor.getUnicodeStream(); + updateWasNull(accessor); + return result; + } + + @Override + public InputStream getBinaryStream(int columnIndex) throws SQLException { + val accessor = getAccessor(columnIndex); + val result = accessor.getBinaryStream(); + updateWasNull(accessor); + return result; + } + + @Override + public Reader getCharacterStream(int columnIndex) throws SQLException { + val accessor = getAccessor(columnIndex); + val result = accessor.getCharacterStream(); + updateWasNull(accessor); + return result; + } + + @Override + public Ref getRef(int columnIndex) throws SQLException { + val accessor = getAccessor(columnIndex); + val result = accessor.getRef(); + updateWasNull(accessor); + return result; + } + + @Override + public Blob getBlob(int columnIndex) throws SQLException { + val accessor = getAccessor(columnIndex); + val result = accessor.getBlob(); + updateWasNull(accessor); + return result; + } + + @Override + public Clob getClob(int columnIndex) throws SQLException { + val accessor = getAccessor(columnIndex); + val result = accessor.getClob(); + updateWasNull(accessor); + return result; + } + + public Struct getStruct(int columnIndex) throws SQLException { + throw new SQLFeatureNotSupportedException("getStruct is not supported"); + } + + @Override + public URL getURL(int columnIndex) throws SQLException { + val accessor = getAccessor(columnIndex); + val result = accessor.getURL(); + updateWasNull(accessor); + return result; + } + + @Override + public RowId getRowId(int columnIndex) throws SQLException { + throw new SQLFeatureNotSupportedException("getRowId is not supported"); + } + + @Override + public SQLXML getSQLXML(int columnIndex) throws SQLException { + val accessor = getAccessor(columnIndex); + val result = accessor.getSQLXML(); + updateWasNull(accessor); + return result; + } + + @Override + public String getNString(int columnIndex) throws SQLException { + val accessor = getAccessor(columnIndex); + val result = accessor.getNString(); + updateWasNull(accessor); + return result; + } + + @Override + public Reader getNCharacterStream(int columnIndex) throws SQLException { + val accessor = getAccessor(columnIndex); + val result = accessor.getNCharacterStream(); + updateWasNull(accessor); + return result; + } + + // --- Miscellaneous ResultSet methods --- + + @Override + public int getHoldability() throws SQLException { + checkClosed(); + return ResultSet.HOLD_CURSORS_OVER_COMMIT; + } + + @Override + public void setFetchSize(int rows) throws SQLException { + // no-op: streaming result set controls its own fetch size + } + + @Override + public int getFetchSize() throws SQLException { + checkClosed(); + return 0; + } + + @Override + public SQLWarning getWarnings() throws SQLException { + checkClosed(); + return null; + } + + @Override + public void clearWarnings() throws SQLException { + // no-op + } + + @Override + public String getCursorName() throws SQLException { + throw new SQLFeatureNotSupportedException("getCursorName is not supported"); + } + + @Override + public T unwrap(Class iface) throws SQLException { + if (iface.isInstance(this)) { + return iface.cast(this); + } + throw new SQLException("Cannot unwrap to " + iface.getName()); + } + + @Override + public boolean isWrapperFor(Class iface) throws SQLException { + return iface.isInstance(this); + } + + private void checkClosed() throws SQLException { + if (closed) { + throw new SQLException("ResultSet is closed"); + } + } } diff --git a/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/DataCloudStatement.java b/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/DataCloudStatement.java index 138d95d3..6c544700 100644 --- a/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/DataCloudStatement.java +++ b/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/DataCloudStatement.java @@ -197,7 +197,7 @@ public ResultSet executeQuery(String sql) throws SQLException { val arrowStream = SQLExceptionQueryResultIterator.createSqlExceptionArrowStreamReader( iterator, includeCustomerDetail, iterator.getQueryStatus().getQueryId(), sql); resultSet = - StreamingResultSet.of(arrowStream, iterator.getQueryStatus().getQueryId(), sessionZone); + DataCloudResultSet.of(arrowStream, iterator.getQueryStatus().getQueryId(), sessionZone); log.info( "executeAdaptiveQuery completed. queryId={}, sessionZone={}", queryHandle.getQueryStatus().getQueryId(), @@ -436,7 +436,7 @@ public ResultSet getResultSet() throws SQLException { includeCustomerDetail, adaptiveIterator.getQueryStatus().getQueryId(), null); - resultSet = StreamingResultSet.of( + resultSet = DataCloudResultSet.of( arrowStream, adaptiveIterator.getQueryStatus().getQueryId(), sessionZone); diff --git a/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/StreamingResultSet.java b/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/StreamingResultSet.java deleted file mode 100644 index 55d415fe..00000000 --- a/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/StreamingResultSet.java +++ /dev/null @@ -1,515 +0,0 @@ -/** - * This file is part of https://github.com/forcedotcom/datacloud-jdbc which is released under the - * Apache 2.0 license. See https://github.com/forcedotcom/datacloud-jdbc/blob/main/LICENSE.txt - */ -package com.salesforce.datacloud.jdbc.core; - -import com.salesforce.datacloud.jdbc.core.accessor.QueryJDBCAccessor; -import com.salesforce.datacloud.jdbc.core.metadata.DataCloudResultSetMetaData; -import com.salesforce.datacloud.jdbc.core.resultset.ForwardOnlyResultSet; -import com.salesforce.datacloud.jdbc.core.resultset.ReadOnlyResultSet; -import com.salesforce.datacloud.jdbc.core.resultset.ResultSetWithPositionalGetters; -import com.salesforce.datacloud.jdbc.protocol.QueryResultArrowStream; -import com.salesforce.datacloud.jdbc.protocol.data.ArrowToHyperTypeMapper; -import com.salesforce.datacloud.jdbc.util.ThrowingJdbcSupplier; -import com.salesforce.datacloud.query.v3.QueryStatus; -import java.io.IOException; -import java.io.InputStream; -import java.io.Reader; -import java.math.BigDecimal; -import java.net.URL; -import java.sql.Array; -import java.sql.Blob; -import java.sql.Clob; -import java.sql.Date; -import java.sql.Ref; -import java.sql.ResultSet; -import java.sql.ResultSetMetaData; -import java.sql.RowId; -import java.sql.SQLException; -import java.sql.SQLFeatureNotSupportedException; -import java.sql.SQLWarning; -import java.sql.SQLXML; -import java.sql.Statement; -import java.sql.Struct; -import java.sql.Time; -import java.sql.Timestamp; -import java.sql.Types; -import java.time.ZoneId; -import java.util.Calendar; -import java.util.Map; -import java.util.stream.Collectors; -import lombok.Getter; -import lombok.extern.slf4j.Slf4j; -import lombok.val; -import org.apache.arrow.memory.BufferAllocator; -import org.apache.arrow.vector.ipc.ArrowStreamReader; - -@Slf4j -public class StreamingResultSet - implements DataCloudResultSet, ReadOnlyResultSet, ForwardOnlyResultSet, ResultSetWithPositionalGetters { - - @Getter - private final String queryId; - - private final ArrowStreamReaderCursor cursor; - private final QueryJDBCAccessor[] accessors; - private final DataCloudResultSetMetaData metadata; - private final ColumnNameResolver columnNameResolver; - ThrowingJdbcSupplier getQueryStatus; - private boolean wasNull; - private boolean closed; - - private StreamingResultSet( - ArrowStreamReaderCursor cursor, - String queryId, - DataCloudResultSetMetaData metadata, - QueryJDBCAccessor[] accessors, - ColumnNameResolver columnNameResolver) { - this.cursor = cursor; - this.queryId = queryId; - this.metadata = metadata; - this.accessors = accessors; - this.columnNameResolver = columnNameResolver; - this.closed = false; - } - - /** - * Creates a StreamingResultSet from a {@link QueryResultArrowStream.Result} (reader paired - * with its backing allocator). - * - *

Ownership of both the reader and the allocator transfers to the returned result set — - * closing the result set closes the reader and then the allocator, in that order, so Arrow's - * buffer accounting clears before the allocator's budget check. If construction itself - * throws (for example a {@link SQLException} wrapping an unsupported Arrow type), this - * method closes both before re-throwing so the 100 MB {@link - * org.apache.arrow.memory.RootAllocator} does not leak. Callers must not close either - * separately on success. - * - * @param arrowStream The Arrow stream + allocator pair, both owned by the result set. - * @param queryId The query identifier (may be {@code null} for synthesized result sets). - * @param sessionZone The session timezone used for timestamp conversions. - */ - public static StreamingResultSet of(QueryResultArrowStream.Result arrowStream, String queryId, ZoneId sessionZone) - throws SQLException { - try { - return create(arrowStream.getReader(), arrowStream.getAllocator(), queryId, sessionZone); - } catch (SQLException | RuntimeException ex) { - try { - arrowStream.getReader().close(); - } catch (Exception suppressed) { - ex.addSuppressed(suppressed); - } - arrowStream.getAllocator().close(); - throw ex; - } - } - - private static StreamingResultSet create( - ArrowStreamReader reader, BufferAllocator allocator, String queryId, ZoneId sessionZone) - throws SQLException { - try { - val schemaRoot = reader.getVectorSchemaRoot(); - val columns = schemaRoot.getSchema().getFields().stream() - .map(ArrowToHyperTypeMapper::toColumnMetadata) - .collect(Collectors.toList()); - val metadata = new DataCloudResultSetMetaData(columns); - val cursor = new ArrowStreamReaderCursor(reader, allocator, sessionZone); - val accessors = cursor.createAccessors().toArray(new QueryJDBCAccessor[0]); - val columnNameResolver = new ColumnNameResolver(columns); - return new StreamingResultSet(cursor, queryId, metadata, accessors, columnNameResolver); - } catch (IOException ex) { - throw new SQLException("Unexpected error during ResultSet creation", "XX000", ex); - } catch (IllegalArgumentException ex) { - // Thrown by ArrowToHyperTypeMapper for Arrow types the driver does not model. - throw new SQLException("Unsupported column type in query result: " + ex.getMessage(), "0A000", ex); - } - } - - // --- Core ResultSet navigation --- - - @Override - public boolean next() throws SQLException { - checkClosed(); - return cursor.next(); - } - - @Override - public void close() throws SQLException { - if (!closed) { - cursor.close(); - closed = true; - } - } - - @Override - public boolean isClosed() throws SQLException { - return closed; - } - - @Override - public int getRow() throws SQLException { - checkClosed(); - return cursor.getRowsSeen(); - } - - @Override - public ResultSetMetaData getMetaData() throws SQLException { - checkClosed(); - return metadata; - } - - @Override - public int findColumn(String columnLabel) throws SQLException { - checkClosed(); - return columnNameResolver.findColumn(columnLabel); - } - - @Override - public Statement getStatement() throws SQLException { - checkClosed(); - return null; - } - - @Override - public boolean wasNull() throws SQLException { - checkClosed(); - return wasNull; - } - - // --- Accessor dispatch: delegate to QueryJDBCAccessor --- - - private QueryJDBCAccessor getAccessor(int columnIndex) throws SQLException { - checkClosed(); - if (columnIndex <= 0 || columnIndex > accessors.length) { - throw new SQLException( - "Column index " + columnIndex + " out of bounds (" + accessors.length + " columns available)"); - } - return accessors[columnIndex - 1]; - } - - private void updateWasNull(QueryJDBCAccessor accessor) throws SQLException { - wasNull = accessor.wasNull(); - } - - @Override - public String getString(int columnIndex) throws SQLException { - val accessor = getAccessor(columnIndex); - val result = accessor.getString(); - updateWasNull(accessor); - return result; - } - - @Override - public boolean getBoolean(int columnIndex) throws SQLException { - val accessor = getAccessor(columnIndex); - val result = accessor.getBoolean(); - updateWasNull(accessor); - return result; - } - - @Override - public byte getByte(int columnIndex) throws SQLException { - val accessor = getAccessor(columnIndex); - val result = accessor.getByte(); - updateWasNull(accessor); - return result; - } - - @Override - public short getShort(int columnIndex) throws SQLException { - val accessor = getAccessor(columnIndex); - val result = accessor.getShort(); - updateWasNull(accessor); - return result; - } - - @Override - public int getInt(int columnIndex) throws SQLException { - val accessor = getAccessor(columnIndex); - val result = accessor.getInt(); - updateWasNull(accessor); - return result; - } - - @Override - public long getLong(int columnIndex) throws SQLException { - val accessor = getAccessor(columnIndex); - val result = accessor.getLong(); - updateWasNull(accessor); - return result; - } - - @Override - public float getFloat(int columnIndex) throws SQLException { - val accessor = getAccessor(columnIndex); - val result = accessor.getFloat(); - updateWasNull(accessor); - return result; - } - - @Override - public double getDouble(int columnIndex) throws SQLException { - val accessor = getAccessor(columnIndex); - val result = accessor.getDouble(); - updateWasNull(accessor); - return result; - } - - @SuppressWarnings("deprecation") - @Override - public BigDecimal getBigDecimal(int columnIndex, int scale) throws SQLException { - val accessor = getAccessor(columnIndex); - val result = accessor.getBigDecimal(scale); - updateWasNull(accessor); - return result; - } - - @Override - public BigDecimal getBigDecimal(int columnIndex) throws SQLException { - val accessor = getAccessor(columnIndex); - val result = accessor.getBigDecimal(); - updateWasNull(accessor); - return result; - } - - @Override - public byte[] getBytes(int columnIndex) throws SQLException { - val accessor = getAccessor(columnIndex); - val result = accessor.getBytes(); - updateWasNull(accessor); - return result; - } - - @Override - public Date getDate(int columnIndex) throws SQLException { - return getDate(columnIndex, null); - } - - @Override - public Date getDate(int columnIndex, Calendar cal) throws SQLException { - val accessor = getAccessor(columnIndex); - val result = accessor.getDate(cal); - updateWasNull(accessor); - return result; - } - - @Override - public Time getTime(int columnIndex) throws SQLException { - return getTime(columnIndex, null); - } - - @Override - public Time getTime(int columnIndex, Calendar cal) throws SQLException { - val accessor = getAccessor(columnIndex); - val result = accessor.getTime(cal); - updateWasNull(accessor); - return result; - } - - @Override - public Timestamp getTimestamp(int columnIndex) throws SQLException { - return getTimestamp(columnIndex, null); - } - - @Override - public Timestamp getTimestamp(int columnIndex, Calendar cal) throws SQLException { - val accessor = getAccessor(columnIndex); - val result = accessor.getTimestamp(cal); - updateWasNull(accessor); - return result; - } - - @Override - public Object getObject(int columnIndex) throws SQLException { - // For ARRAY columns, dispatch to getArray() to return DataCloudArray - // (matching the behavior of the old AvaticaResultSet type dispatch) - if (metadata.getColumnType(columnIndex) == Types.ARRAY) { - return getArray(columnIndex); - } - val accessor = getAccessor(columnIndex); - val result = accessor.getObject(); - updateWasNull(accessor); - return result; - } - - @Override - public Object getObject(int columnIndex, Map> map) throws SQLException { - if (map == null || map.isEmpty()) { - // JDBC allows a null/empty type map to behave like plain getObject(int). - return getObject(columnIndex); - } - val accessor = getAccessor(columnIndex); - val result = accessor.getObject(map); - updateWasNull(accessor); - return result; - } - - @Override - public T getObject(int columnIndex, Class type) throws SQLException { - val accessor = getAccessor(columnIndex); - val result = accessor.getObject(type); - updateWasNull(accessor); - return result; - } - - @Override - public Array getArray(int columnIndex) throws SQLException { - val accessor = getAccessor(columnIndex); - val result = accessor.getArray(); - updateWasNull(accessor); - return result; - } - - @Override - public InputStream getAsciiStream(int columnIndex) throws SQLException { - val accessor = getAccessor(columnIndex); - val result = accessor.getAsciiStream(); - updateWasNull(accessor); - return result; - } - - @SuppressWarnings("deprecation") - @Override - public InputStream getUnicodeStream(int columnIndex) throws SQLException { - val accessor = getAccessor(columnIndex); - val result = accessor.getUnicodeStream(); - updateWasNull(accessor); - return result; - } - - @Override - public InputStream getBinaryStream(int columnIndex) throws SQLException { - val accessor = getAccessor(columnIndex); - val result = accessor.getBinaryStream(); - updateWasNull(accessor); - return result; - } - - @Override - public Reader getCharacterStream(int columnIndex) throws SQLException { - val accessor = getAccessor(columnIndex); - val result = accessor.getCharacterStream(); - updateWasNull(accessor); - return result; - } - - @Override - public Ref getRef(int columnIndex) throws SQLException { - val accessor = getAccessor(columnIndex); - val result = accessor.getRef(); - updateWasNull(accessor); - return result; - } - - @Override - public Blob getBlob(int columnIndex) throws SQLException { - val accessor = getAccessor(columnIndex); - val result = accessor.getBlob(); - updateWasNull(accessor); - return result; - } - - @Override - public Clob getClob(int columnIndex) throws SQLException { - val accessor = getAccessor(columnIndex); - val result = accessor.getClob(); - updateWasNull(accessor); - return result; - } - - public Struct getStruct(int columnIndex) throws SQLException { - throw new SQLFeatureNotSupportedException("getStruct is not supported"); - } - - @Override - public URL getURL(int columnIndex) throws SQLException { - val accessor = getAccessor(columnIndex); - val result = accessor.getURL(); - updateWasNull(accessor); - return result; - } - - @Override - public RowId getRowId(int columnIndex) throws SQLException { - throw new SQLFeatureNotSupportedException("getRowId is not supported"); - } - - @Override - public SQLXML getSQLXML(int columnIndex) throws SQLException { - val accessor = getAccessor(columnIndex); - val result = accessor.getSQLXML(); - updateWasNull(accessor); - return result; - } - - @Override - public String getNString(int columnIndex) throws SQLException { - val accessor = getAccessor(columnIndex); - val result = accessor.getNString(); - updateWasNull(accessor); - return result; - } - - @Override - public Reader getNCharacterStream(int columnIndex) throws SQLException { - val accessor = getAccessor(columnIndex); - val result = accessor.getNCharacterStream(); - updateWasNull(accessor); - return result; - } - - // --- Miscellaneous ResultSet methods --- - - @Override - public int getHoldability() throws SQLException { - checkClosed(); - return ResultSet.HOLD_CURSORS_OVER_COMMIT; - } - - @Override - public void setFetchSize(int rows) throws SQLException { - // no-op: streaming result set controls its own fetch size - } - - @Override - public int getFetchSize() throws SQLException { - checkClosed(); - return 0; - } - - @Override - public SQLWarning getWarnings() throws SQLException { - checkClosed(); - return null; - } - - @Override - public void clearWarnings() throws SQLException { - // no-op - } - - @Override - public String getCursorName() throws SQLException { - throw new SQLFeatureNotSupportedException("getCursorName is not supported"); - } - - @Override - public T unwrap(Class iface) throws SQLException { - if (iface.isInstance(this)) { - return iface.cast(this); - } - throw new SQLException("Cannot unwrap to " + iface.getName()); - } - - @Override - public boolean isWrapperFor(Class iface) throws SQLException { - return iface.isInstance(this); - } - - private void checkClosed() throws SQLException { - if (closed) { - throw new SQLException("ResultSet is closed"); - } - } -} diff --git a/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/metadata/MetadataResultSets.java b/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/metadata/MetadataResultSets.java index a9f5e6cc..5c370dd4 100644 --- a/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/metadata/MetadataResultSets.java +++ b/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/metadata/MetadataResultSets.java @@ -4,7 +4,7 @@ */ package com.salesforce.datacloud.jdbc.core.metadata; -import com.salesforce.datacloud.jdbc.core.StreamingResultSet; +import com.salesforce.datacloud.jdbc.core.DataCloudResultSet; import com.salesforce.datacloud.jdbc.protocol.QueryResultArrowStream; import com.salesforce.datacloud.jdbc.protocol.data.ColumnMetadata; import com.salesforce.datacloud.jdbc.protocol.data.HyperTypeToArrow; @@ -27,7 +27,7 @@ /** * Factory for Arrow-backed metadata result sets. Materialises a row-oriented list of metadata * values into the Arrow IPC format used by every other driver result set, so streaming query - * results and materialised metadata results both flow through {@link StreamingResultSet}. + * results and materialised metadata results both flow through {@link DataCloudResultSet}. * *

Each call builds a fresh single-batch Arrow stream: a writer-side {@link VectorSchemaRoot} * is populated via {@link VectorPopulator} (the same code path the JDBC parameter encoder uses), @@ -40,12 +40,12 @@ private MetadataResultSets() { } /** Empty result set with the given column schema. */ - public static StreamingResultSet empty(List columns) throws SQLException { + public static DataCloudResultSet empty(List columns) throws SQLException { return of(columns, Collections.emptyList()); } /** Empty result set with no columns — used as a placeholder by unsupported metadata methods. */ - public static StreamingResultSet emptyNoColumns() throws SQLException { + public static DataCloudResultSet emptyNoColumns() throws SQLException { return of(Collections.emptyList(), Collections.emptyList()); } @@ -57,14 +57,14 @@ public static StreamingResultSet emptyNoColumns() throws SQLException { * goes through {@link MetadataSchemas} so the sizes match by construction; the precondition * here makes a future caller bug surface at the boundary instead of in vector population. */ - public static StreamingResultSet of(List columns, List> rows) throws SQLException { + public static DataCloudResultSet of(List columns, List> rows) throws SQLException { validateRowArity(columns, rows); byte[] ipcBytes = writeArrowStream(columns, rows); - // Allocator is handed to StreamingResultSet along with the reader; the result set owns + // Allocator is handed to DataCloudResultSet along with the reader; the result set owns // its lifecycle and closes it when close() is called. RootAllocator allocator = new RootAllocator(Long.MAX_VALUE); ArrowStreamReader reader = new ArrowStreamReader(new ByteArrayInputStream(ipcBytes), allocator); - return StreamingResultSet.of( + return DataCloudResultSet.of( new QueryResultArrowStream.Result(reader, allocator), /*queryId=*/ null, ZoneId.systemDefault()); } @@ -73,7 +73,7 @@ public static StreamingResultSet of(List columns, List} row. Mirrors the old * {@code DataCloudMetadataResultSet.of(..., List data)} signature. */ - public static StreamingResultSet ofRawRows(List columns, List rawRows) throws SQLException { + public static DataCloudResultSet ofRawRows(List columns, List rawRows) throws SQLException { return of(columns, coerceRows(rawRows)); } diff --git a/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/protocol/QueryResultArrowStream.java b/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/protocol/QueryResultArrowStream.java index cac6297f..bd32cbdc 100644 --- a/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/protocol/QueryResultArrowStream.java +++ b/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/protocol/QueryResultArrowStream.java @@ -25,7 +25,7 @@ public class QueryResultArrowStream { /** * Pair of the {@link ArrowStreamReader} that decodes gRPC chunks and the {@link RootAllocator} * that backs it. Callers hand ownership to {@link - * com.salesforce.datacloud.jdbc.core.StreamingResultSet#of} which closes both; the pair is + * com.salesforce.datacloud.jdbc.core.DataCloudResultSet#of} which closes both; the pair is * never closed directly. */ @Value diff --git a/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/core/AsyncStreamingResultSetTest.java b/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/core/AsyncDataCloudResultSetTest.java similarity index 97% rename from jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/core/AsyncStreamingResultSetTest.java rename to jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/core/AsyncDataCloudResultSetTest.java index b0e43bc5..56a52133 100644 --- a/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/core/AsyncStreamingResultSetTest.java +++ b/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/core/AsyncDataCloudResultSetTest.java @@ -22,7 +22,7 @@ import org.junit.jupiter.api.extension.ExtendWith; @ExtendWith(LocalHyperTestBase.class) -public class AsyncStreamingResultSetTest { +public class AsyncDataCloudResultSetTest { private static final int size = 64; private static final String sql = String.format( @@ -55,7 +55,7 @@ public void testNoDataIsLostAsync() { val rs = statement.getResultSet(); assertThat(status.allResultsProduced()).isTrue(); - assertThat(rs).isInstanceOf(StreamingResultSet.class); + assertThat(rs).isInstanceOf(DataCloudResultSet.class); val expected = new AtomicInteger(0); diff --git a/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/core/StreamingResultSetMethodTest.java b/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/core/DataCloudResultSetMethodTest.java similarity index 96% rename from jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/core/StreamingResultSetMethodTest.java rename to jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/core/DataCloudResultSetMethodTest.java index 09a3545f..89bf729b 100644 --- a/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/core/StreamingResultSetMethodTest.java +++ b/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/core/DataCloudResultSetMethodTest.java @@ -40,7 +40,7 @@ import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; -class StreamingResultSetMethodTest { +class DataCloudResultSetMethodTest { @RegisterExtension static RootAllocatorTestExtension ext = new RootAllocatorTestExtension(); @@ -48,17 +48,17 @@ class StreamingResultSetMethodTest { private static final String QUERY_ID = "test-query-id"; @SneakyThrows - private StreamingResultSet createResultSet() { + private DataCloudResultSet createResultSet() { return createSingleVarCharResultSet(false); } @SneakyThrows - private StreamingResultSet createResultSetWithNullValue() { + private DataCloudResultSet createResultSetWithNullValue() { return createSingleVarCharResultSet(true); } @SneakyThrows - private StreamingResultSet createSingleVarCharResultSet(boolean nullValue) { + private DataCloudResultSet createSingleVarCharResultSet(boolean nullValue) { // Build a single-row VARCHAR batch, serialise to IPC bytes, and wrap in an // ArrowStreamReader. Using a fresh RootAllocator so the result set owns its own // allocator lifecycle (independent of the shared test extension allocator). @@ -84,7 +84,7 @@ private StreamingResultSet createSingleVarCharResultSet(boolean nullValue) { RootAllocator readerAllocator = new RootAllocator(Long.MAX_VALUE); ArrowStreamReader reader = new ArrowStreamReader(new ByteArrayInputStream(out.toByteArray()), readerAllocator); - return StreamingResultSet.of( + return DataCloudResultSet.of( new QueryResultArrowStream.Result(reader, readerAllocator), QUERY_ID, ZoneId.systemDefault()); } @@ -92,7 +92,7 @@ private StreamingResultSet createSingleVarCharResultSet(boolean nullValue) { @FunctionalInterface interface ResultSetMethod { - void invoke(StreamingResultSet rs) throws SQLException; + void invoke(DataCloudResultSet rs) throws SQLException; } static Stream unsupportedMethods() { @@ -110,7 +110,7 @@ static Stream unsupportedMethods() { Arguments.of(Named.of("getSQLXML", (ResultSetMethod) rs -> rs.getSQLXML(1))), Arguments.of(Named.of("getNString", (ResultSetMethod) rs -> rs.getNString(1))), Arguments.of(Named.of("getNCharacterStream", (ResultSetMethod) rs -> rs.getNCharacterStream(1))), - Arguments.of(Named.of("getCursorName", (ResultSetMethod) StreamingResultSet::getCursorName))); + Arguments.of(Named.of("getCursorName", (ResultSetMethod) DataCloudResultSet::getCursorName))); } @ParameterizedTest @@ -150,7 +150,7 @@ void getAccessorThrowsOnTooLargeIndex() throws Exception { @SneakyThrows void ofClosingOnFailureClosesAllocatorWhenSchemaIsUnsupported() { // Build an Arrow IPC stream containing one column of LargeUtf8, which - // ArrowToHyperTypeMapper does not model — StreamingResultSet.of will throw SQLException. + // ArrowToHyperTypeMapper does not model — DataCloudResultSet.of will throw SQLException. // Without the leak fix, the RootAllocator passed in would never be closed. val unsupportedField = new Field("col", new FieldType(true, new ArrowType.LargeUtf8(), null), null); val schema = new Schema(Collections.singletonList(unsupportedField)); @@ -168,7 +168,7 @@ void ofClosingOnFailureClosesAllocatorWhenSchemaIsUnsupported() { val reader = spy(new ArrowStreamReader(new ByteArrayInputStream(out.toByteArray()), readerAllocator)); val arrowStream = new QueryResultArrowStream.Result(reader, readerAllocator); - assertThatThrownBy(() -> StreamingResultSet.of(arrowStream, QUERY_ID, ZoneId.systemDefault())) + assertThatThrownBy(() -> DataCloudResultSet.of(arrowStream, QUERY_ID, ZoneId.systemDefault())) .isInstanceOf(SQLException.class) .hasMessageContaining("Unsupported column type"); @@ -394,11 +394,11 @@ void getWarningsReturnsNull() throws Exception { @Test void unwrapAndIsWrapperFor() throws Exception { try (val rs = createResultSet()) { - assertThat(rs.isWrapperFor(StreamingResultSet.class)).isTrue(); + assertThat(rs.isWrapperFor(DataCloudResultSet.class)).isTrue(); assertThat(rs.isWrapperFor(DataCloudResultSet.class)).isTrue(); assertThat(rs.isWrapperFor(String.class)).isFalse(); - assertThat(rs.unwrap(StreamingResultSet.class)).isSameAs(rs); + assertThat(rs.unwrap(DataCloudResultSet.class)).isSameAs(rs); assertThatThrownBy(() -> rs.unwrap(String.class)) .isInstanceOf(SQLException.class) .hasMessageContaining("Cannot unwrap"); diff --git a/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/core/StreamingResultSetTest.java b/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/core/DataCloudResultSetTest.java similarity index 98% rename from jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/core/StreamingResultSetTest.java rename to jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/core/DataCloudResultSetTest.java index 75288403..a3c6518e 100644 --- a/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/core/StreamingResultSetTest.java +++ b/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/core/DataCloudResultSetTest.java @@ -23,7 +23,7 @@ @Slf4j @ExtendWith(LocalHyperTestBase.class) -public class StreamingResultSetTest { +public class DataCloudResultSetTest { public static String query(String arg) { return String.format( "select cast(a as numeric(38,18)) a, cast(a as numeric(38,18)) b, cast(a as numeric(38,18)) c from generate_series(1, %s) as s(a) order by a asc", @@ -95,7 +95,7 @@ private void withPrepared(String sql, ThrowingBiConsumerThe test wraps a QueryResultIterator in a close-tracking decorator, passes it through the * standard driver path (SQLExceptionQueryResultIterator → QueryResultArrowStream → - * ByteStringReadableByteChannel → ArrowStreamReader → StreamingResultSet), then verifies that + * ByteStringReadableByteChannel → ArrowStreamReader → DataCloudResultSet), then verifies that * closing the ResultSet propagates all the way down to the iterator.

*/ @Test @@ -81,7 +81,7 @@ void closingResultSetClosesUnderlyingIterator() { // ByteStringReadableByteChannel(iterator, resource) → ArrowStreamReader val arrowStream = SQLExceptionQueryResultIterator.createSqlExceptionArrowStreamReader( tracked, false, "test-query", null); - val resultSet = StreamingResultSet.of(arrowStream, "test-query", java.time.ZoneId.systemDefault()); + val resultSet = DataCloudResultSet.of(arrowStream, "test-query", java.time.ZoneId.systemDefault()); // Read one row — stream is still open with remaining rows assertThat(resultSet.next()).isTrue(); diff --git a/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/core/metadata/MetadataResultSetsTest.java b/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/core/metadata/MetadataResultSetsTest.java index d2ea37bf..5bf407b1 100644 --- a/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/core/metadata/MetadataResultSetsTest.java +++ b/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/core/metadata/MetadataResultSetsTest.java @@ -19,8 +19,8 @@ * Tests the {@link MetadataResultSets#of} arity contract: rows must match the schema column * count; null rows are allowed as the all-nulls shape (matching the legacy {@code coerceRows} * convention). Generic JDBC {@link java.sql.ResultSet} shape (closeable, forward-only, - * holdability, etc.) is exercised by {@code StreamingResultSetMethodTest} since metadata - * result sets share the {@link com.salesforce.datacloud.jdbc.core.StreamingResultSet} plumbing. + * holdability, etc.) is exercised by {@code DataCloudResultSetMethodTest} since metadata + * result sets share the {@link com.salesforce.datacloud.jdbc.core.DataCloudResultSet} plumbing. */ class MetadataResultSetsTest { diff --git a/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/examples/RowBasedPaginationTest.java b/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/examples/RowBasedPaginationTest.java index 803542d3..87390c83 100644 --- a/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/examples/RowBasedPaginationTest.java +++ b/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/examples/RowBasedPaginationTest.java @@ -10,7 +10,6 @@ import com.salesforce.datacloud.jdbc.core.DataCloudConnection; import com.salesforce.datacloud.jdbc.core.DataCloudResultSet; import com.salesforce.datacloud.jdbc.core.DataCloudStatement; -import com.salesforce.datacloud.jdbc.core.StreamingResultSet; import com.salesforce.datacloud.jdbc.hyper.HyperServerManager; import com.salesforce.datacloud.jdbc.hyper.HyperServerManager.ConfigFile; import com.salesforce.datacloud.jdbc.hyper.HyperServerProcess; @@ -55,7 +54,7 @@ public void testRowBasedPagination() throws SQLException { final DataCloudStatement stmt = conn.createStatement().unwrap(DataCloudStatement.class)) { // Set the initial page size stmt.setResultSetConstraints(pageSize); - final StreamingResultSet rs = stmt.executeQuery(sql).unwrap(StreamingResultSet.class); + final DataCloudResultSet rs = stmt.executeQuery(sql).unwrap(DataCloudResultSet.class); // Save the queryId for retrieving subsequent pages queryId = stmt.getQueryId(); From 4c529109abe158cd25384dfc9630d2d8bfa7237d Mon Sep 17 00:00:00 2001 From: Moritz Kaufmann Date: Fri, 15 May 2026 16:34:23 +0200 Subject: [PATCH 15/24] fix: declare TYPE_INFO boolean columns as BOOL and tighten VarCharVectorSetter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DatabaseMetaData.getTypeInfo declared CASE_SENSITIVE, UNSIGNED_ATTRIBUTE, FIXED_PREC_SCALE, and AUTO_INCREMENT as VARCHAR while the row producer in HyperTypes.buildTypeInfoRow wrote Boolean values into them. The mismatch worked only because VarCharVectorSetter accepted Object and silently called value.toString(), so the four columns surfaced as "true"/"false" strings instead of the boolean payload JDBC 4.2 (DatabaseMetaData.getTypeInfo) and pgjdbc both define for these positions. Declare the four columns with a new bool(...) helper in MetadataSchemas that produces a HyperType.bool(true) / Constants.BOOL ColumnMetadata. The existing BitVectorSetter already accepts Boolean, so the row producer is unchanged. Tighten VarCharVectorSetter from BaseVectorSetter to so non-String payloads fail fast at the BaseVectorSetter type guard instead of being toString-coerced — the byte[] arm was dead (setBytes / setBinaryStream / setUnicodeStream / setAsciiStream all throw FEATURE_NOT_SUPPORTED in DataCloudPreparedStatement). Both fixes land together because tightening the setter without the schema fix would make getTypeInfo throw IllegalArgumentException on the Boolean payload. Behavior change: getObject on the four columns now returns Boolean (per JDBC spec), not String. Callers that previously cast (String) rs.getObject(...) will get a ClassCastException; rs.getBoolean(...) starts working where it previously threw on the VARCHAR path, and rs.getString(...) keeps returning the same lowercase "true"/"false" via BooleanVectorAccessor.getString. Pin the schema with three new MetadataSchemasTest methods mirroring the existing COLUMNS coverage (names / typeNames / JdbcTypeIds), add a strict- type regression test for VarCharVectorSetter modeled on IntegerVectorSetterRangeCheckTest so a future re-widening trips CI, and exercise rs.getBoolean on the four boolean columns end-to-end in DataCloudDatabaseMetadataTest.testGetTypeInfo. --- .../datacloud/jdbc/core/MetadataSchemas.java | 13 ++- .../jdbc/protocol/data/VectorPopulator.java | 10 +-- .../datacloud/jdbc/util/Constants.java | 1 + .../core/DataCloudDatabaseMetadataTest.java | 6 ++ .../jdbc/core/MetadataSchemasTest.java | 73 +++++++++++++++++ .../VarCharVectorSetterStrictTypeTest.java | 81 +++++++++++++++++++ 6 files changed, 174 insertions(+), 10 deletions(-) create mode 100644 jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/protocol/data/VarCharVectorSetterStrictTypeTest.java diff --git a/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/MetadataSchemas.java b/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/MetadataSchemas.java index 10f6f1db..f140ddfc 100644 --- a/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/MetadataSchemas.java +++ b/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/MetadataSchemas.java @@ -4,6 +4,7 @@ */ package com.salesforce.datacloud.jdbc.core; +import static com.salesforce.datacloud.jdbc.util.Constants.BOOL; import static com.salesforce.datacloud.jdbc.util.Constants.INTEGER; import static com.salesforce.datacloud.jdbc.util.Constants.SHORT; import static com.salesforce.datacloud.jdbc.util.Constants.TEXT; @@ -46,11 +47,11 @@ public final class MetadataSchemas { text("LITERAL_SUFFIX"), text("CREATE_PARAMS"), shortColumn("NULLABLE"), - text("CASE_SENSITIVE"), + bool("CASE_SENSITIVE"), shortColumn("SEARCHABLE"), - text("UNSIGNED_ATTRIBUTE"), - text("FIXED_PREC_SCALE"), - text("AUTO_INCREMENT"), + bool("UNSIGNED_ATTRIBUTE"), + bool("FIXED_PREC_SCALE"), + bool("AUTO_INCREMENT"), text("LOCAL_TYPE_NAME"), shortColumn("MINIMUM_SCALE"), shortColumn("MAXIMUM_SCALE"), @@ -96,6 +97,10 @@ private static ColumnMetadata shortColumn(String name) { return new ColumnMetadata(name, HyperType.int16(true), SHORT); } + private static ColumnMetadata bool(String name) { + return new ColumnMetadata(name, HyperType.bool(true), BOOL); + } + private MetadataSchemas() { throw new UnsupportedOperationException("This is a utility class and cannot be instantiated"); } diff --git a/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/protocol/data/VectorPopulator.java b/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/protocol/data/VectorPopulator.java index e053e59f..85d68535 100644 --- a/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/protocol/data/VectorPopulator.java +++ b/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/protocol/data/VectorPopulator.java @@ -171,16 +171,14 @@ public void setValue(T vector, int index, Object value) { } /** Setter implementation for VarCharVector. */ -class VarCharVectorSetter extends BaseVectorSetter { +class VarCharVectorSetter extends BaseVectorSetter { VarCharVectorSetter() { - super(Object.class); // accept String, Number, Boolean, byte[] — coerce to UTF-8 bytes + super(String.class); } @Override - protected void setValueInternal(VarCharVector vector, int index, Object value) { - byte[] bytes = - value instanceof byte[] ? (byte[]) value : value.toString().getBytes(StandardCharsets.UTF_8); - vector.setSafe(index, bytes); + protected void setValueInternal(VarCharVector vector, int index, String value) { + vector.setSafe(index, value.getBytes(StandardCharsets.UTF_8)); } @Override diff --git a/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/util/Constants.java b/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/util/Constants.java index d08fcf47..c6c69ac6 100644 --- a/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/util/Constants.java +++ b/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/util/Constants.java @@ -9,6 +9,7 @@ public final class Constants { public static final String INTEGER = "INTEGER"; public static final String TEXT = "TEXT"; public static final String SHORT = "SHORT"; + public static final String BOOL = "BOOL"; // Date Time constants public static final String ISO_TIME_FORMAT = "HH:mm:ss"; diff --git a/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/core/DataCloudDatabaseMetadataTest.java b/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/core/DataCloudDatabaseMetadataTest.java index b3fd8c60..8231b63c 100644 --- a/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/core/DataCloudDatabaseMetadataTest.java +++ b/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/core/DataCloudDatabaseMetadataTest.java @@ -1251,6 +1251,12 @@ public void testGetTypeInfo() throws SQLException { int dataType = resultSet.getInt("DATA_TYPE"); assertThat(dataType).isNotEqualTo(0); assertThat(resultSet.getShort("NULLABLE")).isEqualTo((short) java.sql.DatabaseMetaData.typeNullable); + // The four boolean columns are now declared as BIT/BOOLEAN per JDBC spec, so + // getBoolean returns the actual flag instead of throwing on the old VARCHAR path. + resultSet.getBoolean("CASE_SENSITIVE"); + resultSet.getBoolean("UNSIGNED_ATTRIBUTE"); + resultSet.getBoolean("FIXED_PREC_SCALE"); + resultSet.getBoolean("AUTO_INCREMENT"); } assertThat(rowCount) .as("getTypeInfo should return one row per HyperTypeKind") diff --git a/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/core/MetadataSchemasTest.java b/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/core/MetadataSchemasTest.java index 65fe9e9a..fe28b579 100644 --- a/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/core/MetadataSchemasTest.java +++ b/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/core/MetadataSchemasTest.java @@ -72,6 +72,50 @@ class MetadataSchemasTest { Types.VARCHAR, Types.VARCHAR); + private static final List TYPE_INFO_NAMES = Arrays.asList( + "TYPE_NAME", + "DATA_TYPE", + "PRECISION", + "LITERAL_PREFIX", + "LITERAL_SUFFIX", + "CREATE_PARAMS", + "NULLABLE", + "CASE_SENSITIVE", + "SEARCHABLE", + "UNSIGNED_ATTRIBUTE", + "FIXED_PREC_SCALE", + "AUTO_INCREMENT", + "LOCAL_TYPE_NAME", + "MINIMUM_SCALE", + "MAXIMUM_SCALE", + "SQL_DATA_TYPE", + "SQL_DATETIME_SUB", + "NUM_PREC_RADIX"); + + private static final List TYPE_INFO_TYPES = Arrays.asList( + "TEXT", "INTEGER", "INTEGER", "TEXT", "TEXT", "TEXT", "SHORT", "BOOL", "SHORT", "BOOL", "BOOL", "BOOL", + "TEXT", "SHORT", "SHORT", "INTEGER", "INTEGER", "INTEGER"); + + private static final List TYPE_INFO_TYPE_IDS = Arrays.asList( + Types.VARCHAR, + Types.INTEGER, + Types.INTEGER, + Types.VARCHAR, + Types.VARCHAR, + Types.VARCHAR, + Types.SMALLINT, + Types.BOOLEAN, + Types.SMALLINT, + Types.BOOLEAN, + Types.BOOLEAN, + Types.BOOLEAN, + Types.VARCHAR, + Types.SMALLINT, + Types.SMALLINT, + Types.INTEGER, + Types.INTEGER, + Types.INTEGER); + @Test void columnsSchemaHasExpectedNames() { List names = @@ -100,4 +144,33 @@ void columnsSchemaHasExpectedJdbcTypeIds() { assertThat(typeIds).hasSize(24); assertThat(typeIds.get(0)).isEqualTo(Types.VARCHAR); } + + @Test + void typeInfoSchemaHasExpectedNames() { + List names = + MetadataSchemas.TYPE_INFO.stream().map(ColumnMetadata::getName).collect(Collectors.toList()); + assertThat(names).isEqualTo(TYPE_INFO_NAMES); + assertThat(names).hasSize(18); + assertThat(names.get(0)).isEqualTo("TYPE_NAME"); + } + + @Test + void typeInfoSchemaHasExpectedTypeNames() { + List typeNames = MetadataSchemas.TYPE_INFO.stream() + .map(ColumnMetadata::getTypeName) + .collect(Collectors.toList()); + assertThat(typeNames).isEqualTo(TYPE_INFO_TYPES); + assertThat(typeNames).hasSize(18); + assertThat(typeNames.get(7)).isEqualTo("BOOL"); + } + + @Test + void typeInfoSchemaHasExpectedJdbcTypeIds() { + List typeIds = MetadataSchemas.TYPE_INFO.stream() + .map(c -> HyperTypes.toJdbcTypeCode(c.getType())) + .collect(Collectors.toList()); + assertThat(typeIds).isEqualTo(TYPE_INFO_TYPE_IDS); + assertThat(typeIds).hasSize(18); + assertThat(typeIds.get(7)).isEqualTo(Types.BOOLEAN); + } } diff --git a/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/protocol/data/VarCharVectorSetterStrictTypeTest.java b/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/protocol/data/VarCharVectorSetterStrictTypeTest.java new file mode 100644 index 00000000..f312641f --- /dev/null +++ b/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/protocol/data/VarCharVectorSetterStrictTypeTest.java @@ -0,0 +1,81 @@ +/** + * This file is part of https://github.com/forcedotcom/datacloud-jdbc which is released under the + * Apache 2.0 license. See https://github.com/forcedotcom/datacloud-jdbc/blob/main/LICENSE.txt + */ +package com.salesforce.datacloud.jdbc.protocol.data; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.nio.charset.StandardCharsets; +import lombok.val; +import org.apache.arrow.memory.RootAllocator; +import org.apache.arrow.vector.VarCharVector; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** + * Pin the strict-String contract on VarCharVectorSetter: non-String / non-null payloads are + * rejected by the BaseVectorSetter type guard rather than silently coerced via toString. Without + * this test, a future widening of the generic from String back to Object would slip past CI and + * re-introduce the typeInfoRows Boolean-as-VARCHAR regression that motivated the strict typing. + */ +class VarCharVectorSetterStrictTypeTest { + + private RootAllocator allocator; + + @BeforeEach + void setUp() { + allocator = new RootAllocator(Long.MAX_VALUE); + } + + @AfterEach + void tearDown() { + allocator.close(); + } + + @Test + void varCharSetterAcceptsString() { + try (val vector = new VarCharVector("col", allocator)) { + vector.allocateNew(1); + val setter = new VarCharVectorSetter(); + setter.setValue(vector, 0, "hello"); + vector.setValueCount(1); + assertThat(new String(vector.get(0), StandardCharsets.UTF_8)).isEqualTo("hello"); + } + } + + @Test + void varCharSetterRejectsBoolean() { + try (val vector = new VarCharVector("col", allocator)) { + vector.allocateNew(1); + val setter = new VarCharVectorSetter(); + assertThatThrownBy(() -> setter.setValue(vector, 0, Boolean.TRUE)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("must be of type String"); + } + } + + @Test + void varCharSetterRejectsByteArray() { + try (val vector = new VarCharVector("col", allocator)) { + vector.allocateNew(1); + val setter = new VarCharVectorSetter(); + assertThatThrownBy(() -> setter.setValue(vector, 0, new byte[] {1, 2, 3})) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("must be of type String"); + } + } + + @Test + void varCharSetterRejectsNumber() { + try (val vector = new VarCharVector("col", allocator)) { + vector.allocateNew(1); + val setter = new VarCharVectorSetter(); + assertThatThrownBy(() -> setter.setValue(vector, 0, 42)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("must be of type String"); + } + } +} From 4029987b233eb01f84e2db74819abe4ff9e261ff Mon Sep 17 00:00:00 2001 From: Moritz Kaufmann Date: Fri, 15 May 2026 16:45:58 +0200 Subject: [PATCH 16/24] fix: chain reader and allocator close exceptions via addSuppressed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ArrowStreamReaderCursor.close used a plain try/finally that closed reader first, allocator second. When both threw — the most likely failure mode because the allocator's leak detector fires on close when buffers are still outstanding, which is exactly what an exception during reader.close produces — Java's finally semantics replaced the reader's exception with the allocator's. The reader exception is the diagnostically interesting one (the leak detector firing on allocator.close is usually a symptom); silently dropping it left only the symptom in the stack trace. Switch the cursor to try-with-resources over the (allocator, reader) pair so reader closes first and the allocator's exception attaches as suppressed onto the reader's instead of replacing it. Same fix on the construction-failure cleanup in DataCloudResultSet.of: the reader.close was already wrapped with addSuppressed but the immediately-following allocator.close was bare and could replace the original construction SQLException; wrap it the same way. Pin the new behavior with a Mockito-based test that throws from both reader.close and allocator.close and asserts the reader's exception is primary with the allocator's attached as suppressed. --- .../jdbc/core/ArrowStreamReaderCursor.java | 13 +++++----- .../jdbc/core/DataCloudResultSet.java | 6 ++++- .../core/ArrowStreamReaderCursorTest.java | 26 +++++++++++++++++++ 3 files changed, 38 insertions(+), 7 deletions(-) diff --git a/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/ArrowStreamReaderCursor.java b/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/ArrowStreamReaderCursor.java index b43f6853..71942000 100644 --- a/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/ArrowStreamReaderCursor.java +++ b/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/ArrowStreamReaderCursor.java @@ -103,12 +103,13 @@ public boolean next() { @SneakyThrows @Override public void close() { - // Close the reader first: it releases the buffers accounted against the allocator, so the - // allocator's closing budget check passes. Reversing the order trips a leak detector. - try { - reader.close(); - } finally { - allocator.close(); + // try-with-resources closes in reverse declaration order: reader first (releases the + // buffers accounted against the allocator so its closing budget check passes), then + // allocator. If both throw, Java attaches the second as suppressed onto the first + // instead of dropping the reader exception via the standard try/finally semantics. + try (BufferAllocator a = allocator; + ArrowStreamReader r = reader) { + // resource cleanup happens at exit } } } diff --git a/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/DataCloudResultSet.java b/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/DataCloudResultSet.java index 50d1a1b1..d8b2efb8 100644 --- a/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/DataCloudResultSet.java +++ b/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/DataCloudResultSet.java @@ -99,7 +99,11 @@ public static DataCloudResultSet of(QueryResultArrowStream.Result arrowStream, S } catch (Exception suppressed) { ex.addSuppressed(suppressed); } - arrowStream.getAllocator().close(); + try { + arrowStream.getAllocator().close(); + } catch (Exception suppressed) { + ex.addSuppressed(suppressed); + } throw ex; } } diff --git a/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/core/ArrowStreamReaderCursorTest.java b/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/core/ArrowStreamReaderCursorTest.java index 1ed20de7..4a2f94d7 100644 --- a/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/core/ArrowStreamReaderCursorTest.java +++ b/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/core/ArrowStreamReaderCursorTest.java @@ -5,12 +5,15 @@ package com.salesforce.datacloud.jdbc.core; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; +import java.io.IOException; import java.time.ZoneId; import java.util.Collections; import java.util.stream.IntStream; @@ -50,6 +53,29 @@ void closesReaderAndAllocator() { verify(allocator, times(1)).close(); } + /** + * When both reader.close() and allocator.close() throw, the cursor must close the allocator + * even after the reader's close raised, and surface the reader's exception as primary with + * the allocator's exception attached as suppressed. The reader exception is the + * diagnostically interesting one (the leak detector firing on allocator.close is usually a + * symptom of the reader's failure to release buffers); plain try/finally would silently + * replace it with the allocator exception. + */ + @Test + @SneakyThrows + void closeAttachesAllocatorErrorAsSuppressedWhenReaderCloseAlsoThrows() { + val readerError = new IOException("reader close failed"); + val allocatorError = new IllegalStateException("allocator leak detected"); + doThrow(readerError).when(reader).close(); + doThrow(allocatorError).when(allocator).close(); + + val sut = new ArrowStreamReaderCursor(reader, allocator, ZoneId.systemDefault()); + + assertThatThrownBy(sut::close).isSameAs(readerError).hasSuppressedException(allocatorError); + verify(reader, times(1)).close(); + verify(allocator, times(1)).close(); + } + @Test @SneakyThrows void incrementsInternalIndexUntilRowsExhaustedThenLoadsNextBatch() { From 974efa76eed71eca50865d3d4a11d0629b1fc669 Mon Sep 17 00:00:00 2001 From: Moritz Kaufmann Date: Fri, 15 May 2026 16:56:20 +0200 Subject: [PATCH 17/24] fix: hoist queryId before allocator construction in DataCloudStatement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DataCloudStatement.executeQuery and getResultSet both fetched iterator.getQueryStatus().getQueryId() twice — once when constructing arrowStream and once when calling DataCloudResultSet.of with it. The second call sat between arrowStream creation (which puts a 100 MB RootAllocator on the field) and the of(...) call that takes ownership of that allocator. If the second getQueryId() throws — e.g. a future refactor makes getQueryStatus async, or a transient gRPC failure surfaces through the cached proto — the allocator escapes both DataCloudResultSet.of's own try/catch (never entered) and the surrounding catch (StatusRuntimeException) (which doesn't close arrowStream). The PR explicitly claims every code path closes its allocator; this hoist closes the window without changing any observable behavior. --- .../jdbc/core/DataCloudStatement.java | 21 +++++++++---------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/DataCloudStatement.java b/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/DataCloudStatement.java index 6c544700..5b29cdfa 100644 --- a/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/DataCloudStatement.java +++ b/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/DataCloudStatement.java @@ -194,10 +194,12 @@ public ResultSet executeQuery(String sql) throws SQLException { try { val sessionZone = resolveSessionTimeZone(); val iterator = executeAdaptiveQuery(sql); + // Resolve queryId once before allocator construction so a throw between arrowStream + // creation and DataCloudResultSet.of can't strand the allocator outside its try/catch. + val queryId = iterator.getQueryStatus().getQueryId(); val arrowStream = SQLExceptionQueryResultIterator.createSqlExceptionArrowStreamReader( - iterator, includeCustomerDetail, iterator.getQueryStatus().getQueryId(), sql); - resultSet = - DataCloudResultSet.of(arrowStream, iterator.getQueryStatus().getQueryId(), sessionZone); + iterator, includeCustomerDetail, queryId, sql); + resultSet = DataCloudResultSet.of(arrowStream, queryId, sessionZone); log.info( "executeAdaptiveQuery completed. queryId={}, sessionZone={}", queryHandle.getQueryStatus().getQueryId(), @@ -431,15 +433,12 @@ public ResultSet getResultSet() throws SQLException { return logTimedValue( () -> { if (resultSet == null && adaptiveIterator != null) { + // Resolve queryId once before allocator construction; see executeQuery + // above for the same hoist. + val queryId = adaptiveIterator.getQueryStatus().getQueryId(); val arrowStream = SQLExceptionQueryResultIterator.createSqlExceptionArrowStreamReader( - adaptiveIterator, - includeCustomerDetail, - adaptiveIterator.getQueryStatus().getQueryId(), - null); - resultSet = DataCloudResultSet.of( - arrowStream, - adaptiveIterator.getQueryStatus().getQueryId(), - sessionZone); + adaptiveIterator, includeCustomerDetail, queryId, null); + resultSet = DataCloudResultSet.of(arrowStream, queryId, sessionZone); } else if (resultSet == null) { log.warn( "Prefer acquiring async result sets from helper methods DataCloudConnection::getChunkBasedResultSet and DataCloudConnection::getRowBasedResultSet. We will wait for the query's results to be produced in their entirety before returning a result set."); From dd484f2db7402f2a7a28387c6da9aab65d61fb13 Mon Sep 17 00:00:00 2001 From: Moritz Kaufmann Date: Fri, 15 May 2026 17:08:44 +0200 Subject: [PATCH 18/24] fix: close allocator on ArrowStreamReader constructor failure and unify cap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both Result-holder construction sites — QueryResultArrowStream.toArrowStreamReader and MetadataResultSets.of — built the RootAllocator first and then handed it to a new ArrowStreamReader. If the reader's constructor throws (today benign, but a future Arrow upgrade could add constructor-side validation), the allocator escapes both DataCloudResultSet.of's own try/catch (never entered) and the caller's catch. Wrap the construction in try/catch that closes the allocator on the way out and attaches any close failure as suppressed. Unify the per-allocator cap: MetadataResultSets used Long.MAX_VALUE while the gRPC path was capped at 100 MB. The cap exists because Arrow allocators are accounted memory, so hitting the cap throws a clean OutOfMemoryException instead of letting the JVM OOM. A getColumns(...) against a tenant with thousands of tables silently bypassed the cap on the metadata path. Promote the constant to public ROOT_ALLOCATOR_BUDGET_BYTES on QueryResultArrowStream and reuse it from MetadataResultSets. No new test: the failure mode requires ArrowStreamReader's constructor to throw, which doesn't happen with ByteArrayInputStream or the gRPC channel today. Pure code-shape fix; existing suite stays green. --- .../core/metadata/MetadataResultSets.java | 22 +++++++++++++---- .../jdbc/protocol/QueryResultArrowStream.java | 24 ++++++++++++++++--- 2 files changed, 39 insertions(+), 7 deletions(-) diff --git a/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/metadata/MetadataResultSets.java b/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/metadata/MetadataResultSets.java index 5c370dd4..abe7b22b 100644 --- a/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/metadata/MetadataResultSets.java +++ b/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/metadata/MetadataResultSets.java @@ -60,10 +60,24 @@ public static DataCloudResultSet emptyNoColumns() throws SQLException { public static DataCloudResultSet of(List columns, List> rows) throws SQLException { validateRowArity(columns, rows); byte[] ipcBytes = writeArrowStream(columns, rows); - // Allocator is handed to DataCloudResultSet along with the reader; the result set owns - // its lifecycle and closes it when close() is called. - RootAllocator allocator = new RootAllocator(Long.MAX_VALUE); - ArrowStreamReader reader = new ArrowStreamReader(new ByteArrayInputStream(ipcBytes), allocator); + // Reuse the query-path allocator budget so a future caller materialising a multi-MB + // metadata response trips the cap cleanly instead of letting the JVM OOM. + RootAllocator allocator = new RootAllocator(QueryResultArrowStream.ROOT_ALLOCATOR_BUDGET_BYTES); + ArrowStreamReader reader; + try { + reader = new ArrowStreamReader(new ByteArrayInputStream(ipcBytes), allocator); + } catch (Throwable t) { + // Constructor-time leak guard: if ArrowStreamReader fails before DataCloudResultSet.of + // can take ownership, close the allocator on the way out. + try { + allocator.close(); + } catch (Throwable s) { + t.addSuppressed(s); + } + throw t; + } + // Allocator and reader are now handed to DataCloudResultSet, which owns their lifecycle + // and closes both on close() — including the construction-failure path inside of(...). return DataCloudResultSet.of( new QueryResultArrowStream.Result(reader, allocator), /*queryId=*/ null, ZoneId.systemDefault()); } diff --git a/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/protocol/QueryResultArrowStream.java b/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/protocol/QueryResultArrowStream.java index bd32cbdc..bc578583 100644 --- a/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/protocol/QueryResultArrowStream.java +++ b/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/protocol/QueryResultArrowStream.java @@ -20,7 +20,13 @@ public class QueryResultArrowStream { */ public static final OutputFormat OUTPUT_FORMAT = OutputFormat.ARROW_IPC; - private static final int ROOT_ALLOCATOR_MB_FROM_V2 = 100 * 1024 * 1024; + /** + * Per-result-set allocator budget. Hitting this threshold trips a clean + * {@link org.apache.arrow.memory.OutOfMemoryException} from the allocator instead of letting + * the JVM OOM. Reused for the metadata-side allocator in + * {@link com.salesforce.datacloud.jdbc.core.metadata.MetadataResultSets}. + */ + public static final int ROOT_ALLOCATOR_BUDGET_BYTES = 100 * 1024 * 1024; /** * Pair of the {@link ArrowStreamReader} that decodes gRPC chunks and the {@link RootAllocator} @@ -60,7 +66,19 @@ public void close() throws Exception { } }; val channel = new ByteStringReadableByteChannel(closeable); - RootAllocator allocator = new RootAllocator(ROOT_ALLOCATOR_MB_FROM_V2); - return new Result(new ArrowStreamReader(channel, allocator), allocator); + RootAllocator allocator = new RootAllocator(ROOT_ALLOCATOR_BUDGET_BYTES); + try { + return new Result(new ArrowStreamReader(channel, allocator), allocator); + } catch (Throwable t) { + // ArrowStreamReader's constructor is benign today, but a future Arrow upgrade could + // add constructor-side validation. Close the allocator on the way out so the budget + // is reclaimed. + try { + allocator.close(); + } catch (Throwable s) { + t.addSuppressed(s); + } + throw t; + } } } From 006cbda3edc77f203c9b9049c117e6cdd05245bb Mon Sep 17 00:00:00 2001 From: Moritz Kaufmann Date: Sat, 16 May 2026 00:10:13 +0200 Subject: [PATCH 19/24] =?UTF-8?q?fix:=20implement=20INTEGER=20=E2=86=92=20?= =?UTF-8?q?boolean=20coercion=20in=20BaseIntVectorAccessor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit JDBC 4.2 Table B-6 lists INTEGER → boolean as a recommended conversion: 0 maps to false, non-zero maps to true. pgjdbc and other major drivers do this. BaseIntVectorAccessor inherited the abstract default from QueryJDBCAccessor, which throws SQLFeatureNotSupportedException — so rs.getBoolean("NULLABLE") on a metadata int column failed where every spec-respecting client expects it to work. Add the override (one line, getLong() != 0). Restore the assertThat(getBoolean("NULLABLE")).isFalse() assertion that was deleted in the metadata-unify rebase, add a positive case for ORDINAL_POSITION, and correct the comment that previously claimed the coercion already happened. This is the second half of the JDBC-spec compliance pair started in 4c52910 (TYPE_INFO boolean columns are now declared as BIT, so VARCHAR → boolean isn't needed there). After this commit, every metadata int column that BI tools read as boolean (NULLABLE, columnNoNulls/columnNullable values, ORDINAL_POSITION) returns the spec-correct coercion. --- .../jdbc/core/accessor/impl/BaseIntVectorAccessor.java | 8 ++++++++ .../jdbc/core/DataCloudDatabaseMetadataTest.java | 7 +++++-- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/accessor/impl/BaseIntVectorAccessor.java b/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/accessor/impl/BaseIntVectorAccessor.java index 99f59e30..0d779ba4 100644 --- a/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/accessor/impl/BaseIntVectorAccessor.java +++ b/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/accessor/impl/BaseIntVectorAccessor.java @@ -86,6 +86,14 @@ public String getString() { } } + @Override + public boolean getBoolean() { + // JDBC 4.2 Table B-6 recommends INTEGER → boolean: 0 → false, non-zero → true. + // pgjdbc and other major drivers do this; clients reading metadata int columns like + // NULLABLE / IS_AUTOINCREMENT as boolean rely on the coercion. + return getLong() != 0; + } + @Override public byte getByte() { return (byte) getLong(); diff --git a/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/core/DataCloudDatabaseMetadataTest.java b/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/core/DataCloudDatabaseMetadataTest.java index 8231b63c..3b58e13c 100644 --- a/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/core/DataCloudDatabaseMetadataTest.java +++ b/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/core/DataCloudDatabaseMetadataTest.java @@ -1087,10 +1087,13 @@ public void testTestTest() throws SQLException { // JDBC name ("VARCHAR" for varchar columns). assertThat(columnResultSet.getString("TYPE_NAME")).isEqualTo("VARCHAR"); assertThat(columnResultSet.getInt("DATA_TYPE")).isEqualTo(12); - // NULLABLE is an INTEGER column. Arrow-backed getInt reports the nullability enum; - // 0 (columnNoNulls) for NOT NULL rows, which coerces to false via long→boolean. + // NULLABLE is an INTEGER column. Arrow-backed getInt reports the nullability enum: + // 0 (columnNoNulls) for NOT NULL rows, which coerces to false via the JDBC-spec + // INTEGER → boolean recommendation (BaseIntVectorAccessor.getBoolean). assertThat(columnResultSet.getInt("NULLABLE")).isEqualTo(0); + assertThat(columnResultSet.getBoolean("NULLABLE")).isFalse(); assertThat(columnResultSet.getInt("ORDINAL_POSITION")).isEqualTo(ordinalValue); + assertThat(columnResultSet.getBoolean("ORDINAL_POSITION")).isTrue(); assertThat(columnResultSet.getByte("ORDINAL_POSITION")).isEqualTo(ordinalValue.byteValue()); } } From f9633a1f9be5046a2ae147e9579cbd5d9c1a886d Mon Sep 17 00:00:00 2001 From: Moritz Kaufmann Date: Sat, 16 May 2026 00:19:13 +0200 Subject: [PATCH 20/24] fix: make DataCloudResultSet.close idempotent across cursor.close failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DataCloudResultSet.close set the closed flag *after* delegating to cursor.close. If cursor.close threw — e.g. allocator's leak detector trips an IllegalStateException, or the addSuppressed chain surfaces a reader exception — the flag stayed false. A defensive caller that catches the close failure and retries (JDBC connection pools and driver wrappers sometimes do) re-entered cursor.close, calling allocator.close on an already-closed RootAllocator. Arrow throws on second close. Flip the flag before delegating, matching the standard JDK AutoCloseable idempotence pattern. The caller still gets the cleanup exception on the first close; subsequent closes become no-ops. Pin the new contract with a Mockito-based test that throws from allocator.close on first cursor.close and asserts the second close is a no-op (allocator.close called exactly once across both attempts). --- .../jdbc/core/DataCloudResultSet.java | 11 ++++-- .../core/DataCloudResultSetMethodTest.java | 38 +++++++++++++++++++ 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/DataCloudResultSet.java b/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/DataCloudResultSet.java index d8b2efb8..d7fbd521 100644 --- a/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/DataCloudResultSet.java +++ b/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/DataCloudResultSet.java @@ -139,10 +139,15 @@ public boolean next() throws SQLException { @Override public void close() throws SQLException { - if (!closed) { - cursor.close(); - closed = true; + if (closed) { + return; } + // Mark closed before delegating: if cursor.close throws (e.g. allocator leak detector + // trips an IllegalStateException), the caller still gets the cleanup exception but a + // retried close becomes a no-op instead of double-closing the allocator. Standard + // AutoCloseable idempotence pattern. + closed = true; + cursor.close(); } @Override diff --git a/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/core/DataCloudResultSetMethodTest.java b/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/core/DataCloudResultSetMethodTest.java index 89bf729b..3bcf19cc 100644 --- a/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/core/DataCloudResultSetMethodTest.java +++ b/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/core/DataCloudResultSetMethodTest.java @@ -7,7 +7,9 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import com.salesforce.datacloud.jdbc.protocol.QueryResultArrowStream; @@ -188,6 +190,42 @@ void closeAndIsClosed() throws Exception { assertThat(rs.isClosed()).isTrue(); } + /** + * Pin AutoCloseable idempotence: if cursor.close throws (e.g. allocator leak detector trips + * an IllegalStateException), the result set must still be marked closed so a defensive + * caller's retry of close() becomes a no-op rather than re-entering cursor.close and + * double-closing the allocator. {@code closed} is flipped before delegating. + */ + @Test + @SneakyThrows + void closeIsIdempotentEvenIfFirstCloseThrows() { + // Build a real result set whose allocator will throw on close (simulating the leak + // detector firing). The first close should rethrow; the second close should be a no-op. + val readerAllocator = spy(new RootAllocator(Long.MAX_VALUE)); + val out = new ByteArrayOutputStream(); + val schema = new Schema( + Collections.singletonList(new Field("col1", new FieldType(true, new ArrowType.Utf8(), null), null))); + try (VectorSchemaRoot writeRoot = VectorSchemaRoot.create(schema, ext.getRootAllocator())) { + writeRoot.setRowCount(0); + try (ArrowStreamWriter writer = new ArrowStreamWriter(writeRoot, null, out)) { + writer.start(); + writer.end(); + } + } + val reader = new ArrowStreamReader(new ByteArrayInputStream(out.toByteArray()), readerAllocator); + val rs = DataCloudResultSet.of( + new QueryResultArrowStream.Result(reader, readerAllocator), QUERY_ID, ZoneId.systemDefault()); + doThrow(new IllegalStateException("simulated leak detector")) + .when(readerAllocator) + .close(); + + assertThatThrownBy(rs::close).isInstanceOf(IllegalStateException.class); + assertThat(rs.isClosed()).isTrue(); + // Retry: should be a no-op, not re-enter cursor.close and double-close the allocator. + rs.close(); + verify(readerAllocator, times(1)).close(); + } + @Test void methodsThrowAfterClose() throws Exception { val rs = createResultSet(); From a9bb1979b6374e0a134e2bd0b67f58106dab782b Mon Sep 17 00:00:00 2001 From: Moritz Kaufmann Date: Sat, 16 May 2026 00:26:52 +0200 Subject: [PATCH 21/24] fix: range-check unscaled value in DecimalVectorSetter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DecimalVectorSetter.setValueInternal called value.unscaledValue().longValue() unconditionally. BigInteger.longValue() silently truncates to the low 64 bits when the magnitude exceeds Long.MAX_VALUE — a BigDecimal("1E40") wrote garbage and the caller had no way to know. The integer setters in this same file already throw IllegalArgumentException for analogous out-of-range narrowing (lines 240, 261, 433); decimal was the asymmetry. Add the same guard via BigInteger.bitLength() > 63: catches both directions of overflow and throws IllegalArgumentException with the unscaled value in the message. Pin the new behavior with three tests mirroring the integer-setter range-check style — accepts values within long range, rejects above, rejects below. The separate scale-mismatch issue (DecimalVectorSetter ignores the vector's declared scale and writes whatever scale the caller's BigDecimal happens to have) is out of scope for this finding. --- .../jdbc/protocol/data/VectorPopulator.java | 7 ++++ .../IntegerVectorSetterRangeCheckTest.java | 41 +++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/protocol/data/VectorPopulator.java b/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/protocol/data/VectorPopulator.java index 85d68535..d65947a1 100644 --- a/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/protocol/data/VectorPopulator.java +++ b/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/protocol/data/VectorPopulator.java @@ -314,6 +314,13 @@ class DecimalVectorSetter extends BaseVectorSetter { @Override protected void setValueInternal(DecimalVector vector, int index, BigDecimal value) { + // longValue() on a BigInteger silently truncates to the low 64 bits — refuse oversized + // values up front rather than write garbage. Matches the IllegalArgumentException pattern + // the integer setters use for analogous narrowing. + if (value.unscaledValue().bitLength() > 63) { + throw new IllegalArgumentException("BigDecimal unscaled value " + value.unscaledValue() + + " exceeds 64 bits — DECIMAL supports up to 18-digit unscaled longs"); + } vector.setSafe(index, value.unscaledValue().longValue()); } diff --git a/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/protocol/data/IntegerVectorSetterRangeCheckTest.java b/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/protocol/data/IntegerVectorSetterRangeCheckTest.java index ce779e98..32de38e0 100644 --- a/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/protocol/data/IntegerVectorSetterRangeCheckTest.java +++ b/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/protocol/data/IntegerVectorSetterRangeCheckTest.java @@ -7,9 +7,12 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import java.math.BigDecimal; +import java.math.BigInteger; import lombok.val; import org.apache.arrow.memory.RootAllocator; import org.apache.arrow.vector.BigIntVector; +import org.apache.arrow.vector.DecimalVector; import org.apache.arrow.vector.IntVector; import org.apache.arrow.vector.SmallIntVector; import org.apache.arrow.vector.TinyIntVector; @@ -111,4 +114,42 @@ void bigIntVectorSetterAcceptsFullLongRange() { assertThat(vector.get(1)).isEqualTo(Long.MIN_VALUE); } } + + @Test + void decimalVectorSetterAcceptsValuesWithinLongRange() { + try (val vector = new DecimalVector("col", allocator, 18, 0)) { + vector.allocateNew(2); + val setter = new DecimalVectorSetter(); + setter.setValueInternal(vector, 0, BigDecimal.valueOf(Long.MAX_VALUE)); + setter.setValueInternal(vector, 1, BigDecimal.valueOf(Long.MIN_VALUE)); + vector.setValueCount(2); + assertThat(vector.getObject(0)).isEqualTo(BigDecimal.valueOf(Long.MAX_VALUE)); + assertThat(vector.getObject(1)).isEqualTo(BigDecimal.valueOf(Long.MIN_VALUE)); + } + } + + @Test + void decimalVectorSetterRejectsUnscaledValueAboveLongRange() { + try (val vector = new DecimalVector("col", allocator, 38, 0)) { + vector.allocateNew(1); + val setter = new DecimalVectorSetter(); + // unscaled value exceeds 64 bits — longValue() would silently truncate. + val oversized = new BigDecimal(BigInteger.valueOf(Long.MAX_VALUE).add(BigInteger.ONE)); + assertThatThrownBy(() -> setter.setValueInternal(vector, 0, oversized)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("exceeds 64 bits"); + } + } + + @Test + void decimalVectorSetterRejectsUnscaledValueBelowLongRange() { + try (val vector = new DecimalVector("col", allocator, 38, 0)) { + vector.allocateNew(1); + val setter = new DecimalVectorSetter(); + val oversized = new BigDecimal(BigInteger.valueOf(Long.MIN_VALUE).subtract(BigInteger.ONE)); + assertThatThrownBy(() -> setter.setValueInternal(vector, 0, oversized)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("exceeds 64 bits"); + } + } } From 32ad2d93fa26cb273bd379e8971b73b7e262d98e Mon Sep 17 00:00:00 2001 From: Moritz Kaufmann Date: Mon, 18 May 2026 09:28:46 +0200 Subject: [PATCH 22/24] test: pin reader-before-allocator close order in cursor test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit closesReaderAndAllocator only counted invocations on reader.close and allocator.close — a regression that flipped the order would slip past CI. Add an InOrder assertion so the load-bearing ordering documented on ArrowStreamReaderCursor.close (reader first, then allocator, so the allocator's closing budget check sees no outstanding ArrowBufs) is explicit at the test level rather than inferred indirectly from the throw-during-close test's primary/suppressed invariant. --- .../datacloud/jdbc/core/ArrowStreamReaderCursorTest.java | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/core/ArrowStreamReaderCursorTest.java b/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/core/ArrowStreamReaderCursorTest.java index 4a2f94d7..d9c8a698 100644 --- a/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/core/ArrowStreamReaderCursorTest.java +++ b/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/core/ArrowStreamReaderCursorTest.java @@ -7,6 +7,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -49,8 +50,12 @@ class ArrowStreamReaderCursorTest { void closesReaderAndAllocator() { val sut = new ArrowStreamReaderCursor(reader, allocator, ZoneId.systemDefault()); sut.close(); - verify(reader, times(1)).close(); - verify(allocator, times(1)).close(); + // Reader-before-allocator ordering is load-bearing: reader.close releases the buffers + // accounted against the allocator so the allocator's closing budget check passes. + // Reversing the order would trip the leak detector at runtime. + val order = inOrder(reader, allocator); + order.verify(reader).close(); + order.verify(allocator).close(); } /** From 93568055e02f955d971a46766cc04afb4242a441 Mon Sep 17 00:00:00 2001 From: Moritz Kaufmann Date: Mon, 18 May 2026 12:43:51 +0200 Subject: [PATCH 23/24] refactor: drop the typeName override channel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The driver carried an Arrow field-metadata key (`datacloud-jdbc:type_name`), a `ColumnMetadata.typeName` field, four `Constants` tag strings, and a `bool(...)` schema helper to override `ResultSetMetaData.getColumnTypeName(int)` with Hyper-flavored labels — `"TEXT"` instead of `"VARCHAR"`, `"SHORT"` instead of `"SMALLINT"`, `"BOOL"` instead of `"BOOLEAN"`, `"INTEGER"` instead of `"INTEGER"` (no-op). Worth checking whether this was actually buying anything. JDBC 4.2 spec on `getColumnTypeName`: "Retrieves the database-specific type name for the designated column." No specific strings required. pgjdbc returns lowercase Postgres-native names (`text`, `int4`, `bool`, `varchar`, `bpchar`, `numeric`). The driver's own JDBCReferenceTest already normalizes both forms by mapping pgjdbc's `TEXT` to `JDBCType.VARCHAR.getName()` and `BPCHAR` to `JDBCType.CHAR.getName()` before comparison — so even internally the JDBC names are the canonical form. Spark `TypeMapping.scala` branches only on the int `getColumnType` code, never on the type-name string. Drop the override channel entirely. `getColumnTypeName` now returns `HyperTypes.toJdbcTypeName(col.getType())` for every column. Every metadata result-set column reports its JDBC-spec name. Removes ~125 lines of plumbing across `HyperTypeToArrow` (the metadata key write path), `ArrowToHyperTypeMapper` (read path), `ColumnMetadata` (the `typeName` field and 3-arg constructor), `MetadataSchemas` (the third arg on every helper), `Constants` (TEXT/INTEGER/SHORT/BOOL fields), `DataCloudResultSetMetaData.getColumnTypeName` (the override-or-fallback dispatch), and `MetadataResultSets.writeArrowStream` (the only stamp site). Test updates: `ArrowToHyperTypeMapperTest` deleted (it pinned the override read path); 63 assertions in `DataCloudDatabaseMetadataTest` flipped from `"TEXT"` / `"SHORT"` to `"VARCHAR"` / `"SMALLINT"` (the JDBC defaults); `MetadataSchemasTest` re-pins the four BOOLEAN positions in TYPE_INFO at `"BOOLEAN"` / `Types.BOOLEAN`. Behavior on `getColumnType` (the int code), `Types.BOOLEAN` for boolean metadata columns, accessor coercion — all unchanged. --- .../datacloud/jdbc/core/MetadataSchemas.java | 13 +-- .../metadata/DataCloudResultSetMetaData.java | 4 +- .../core/metadata/MetadataResultSets.java | 2 +- .../protocol/data/ArrowToHyperTypeMapper.java | 12 +- .../jdbc/protocol/data/ColumnMetadata.java | 35 +----- .../jdbc/protocol/data/HyperTypeToArrow.java | 44 +------ .../datacloud/jdbc/util/Constants.java | 6 - .../jdbc/core/ColumnNameResolverTest.java | 6 +- .../core/DataCloudDatabaseMetadataTest.java | 110 +++++++++--------- .../jdbc/core/MetadataSchemasTest.java | 55 +++++++-- .../DataCloudResultSetMetaDataTest.java | 12 +- .../data/ArrowToHyperTypeMapperTest.java | 67 ----------- .../jdbc/protocol/data/ArrowUtilsTest.java | 3 - 13 files changed, 122 insertions(+), 247 deletions(-) delete mode 100644 jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/protocol/data/ArrowToHyperTypeMapperTest.java diff --git a/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/MetadataSchemas.java b/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/MetadataSchemas.java index f140ddfc..d49b0b6d 100644 --- a/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/MetadataSchemas.java +++ b/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/MetadataSchemas.java @@ -4,11 +4,6 @@ */ package com.salesforce.datacloud.jdbc.core; -import static com.salesforce.datacloud.jdbc.util.Constants.BOOL; -import static com.salesforce.datacloud.jdbc.util.Constants.INTEGER; -import static com.salesforce.datacloud.jdbc.util.Constants.SHORT; -import static com.salesforce.datacloud.jdbc.util.Constants.TEXT; - import com.google.common.collect.ImmutableList; import com.salesforce.datacloud.jdbc.protocol.data.ColumnMetadata; import com.salesforce.datacloud.jdbc.protocol.data.HyperType; @@ -86,19 +81,19 @@ public final class MetadataSchemas { text("IS_GENERATEDCOLUMN")); private static ColumnMetadata text(String name) { - return new ColumnMetadata(name, HyperType.varcharUnlimited(true), TEXT); + return new ColumnMetadata(name, HyperType.varcharUnlimited(true)); } private static ColumnMetadata integer(String name) { - return new ColumnMetadata(name, HyperType.int32(true), INTEGER); + return new ColumnMetadata(name, HyperType.int32(true)); } private static ColumnMetadata shortColumn(String name) { - return new ColumnMetadata(name, HyperType.int16(true), SHORT); + return new ColumnMetadata(name, HyperType.int16(true)); } private static ColumnMetadata bool(String name) { - return new ColumnMetadata(name, HyperType.bool(true), BOOL); + return new ColumnMetadata(name, HyperType.bool(true)); } private MetadataSchemas() { diff --git a/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/metadata/DataCloudResultSetMetaData.java b/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/metadata/DataCloudResultSetMetaData.java index 214bd4d2..b813a798 100644 --- a/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/metadata/DataCloudResultSetMetaData.java +++ b/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/metadata/DataCloudResultSetMetaData.java @@ -65,9 +65,7 @@ public int getColumnType(int column) throws SQLException { @Override public String getColumnTypeName(int column) throws SQLException { - ColumnMetadata col = getColumn(column); - String override = col.getTypeName(); - return override != null ? override : HyperTypes.toJdbcTypeName(col.getType()); + return HyperTypes.toJdbcTypeName(getColumn(column).getType()); } @Override diff --git a/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/metadata/MetadataResultSets.java b/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/metadata/MetadataResultSets.java index abe7b22b..ae5e439c 100644 --- a/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/metadata/MetadataResultSets.java +++ b/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/metadata/MetadataResultSets.java @@ -97,7 +97,7 @@ public static DataCloudResultSet ofRawRows(List columns, List columns, List> rows) throws SQLException { Schema schema = new Schema(columns.stream() - .map(c -> HyperTypeToArrow.toField(c.getName(), c.getType(), c.getTypeName())) + .map(c -> HyperTypeToArrow.toField(c.getName(), c.getType())) .collect(Collectors.toList())); try (RootAllocator writeAllocator = new RootAllocator(Long.MAX_VALUE); VectorSchemaRoot root = VectorSchemaRoot.create(schema, writeAllocator)) { diff --git a/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/protocol/data/ArrowToHyperTypeMapper.java b/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/protocol/data/ArrowToHyperTypeMapper.java index 53eeafed..7d4f5a2f 100644 --- a/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/protocol/data/ArrowToHyperTypeMapper.java +++ b/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/protocol/data/ArrowToHyperTypeMapper.java @@ -30,17 +30,9 @@ public static HyperType toHyperType(Field field) { return field.getType().accept(new ArrowTypeVisitor(field)); } - /** - * Translate an Arrow {@link Field} to a full {@link ColumnMetadata}, picking up the optional - * JDBC type-name override stamped under - * {@link HyperTypeToArrow#JDBC_TYPE_NAME_METADATA_KEY} when present. - */ + /** Translate an Arrow {@link Field} to a {@link ColumnMetadata}. */ public static ColumnMetadata toColumnMetadata(Field field) { - HyperType type = toHyperType(field); - String override = field.getMetadata() == null - ? null - : field.getMetadata().get(HyperTypeToArrow.JDBC_TYPE_NAME_METADATA_KEY); - return new ColumnMetadata(field.getName(), type, override); + return new ColumnMetadata(field.getName(), toHyperType(field)); } /** Arrow visitor that produces a {@link HyperType} for each supported Arrow type. */ diff --git a/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/protocol/data/ColumnMetadata.java b/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/protocol/data/ColumnMetadata.java index 2d08b8ea..d91ab918 100644 --- a/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/protocol/data/ColumnMetadata.java +++ b/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/protocol/data/ColumnMetadata.java @@ -5,42 +5,11 @@ package com.salesforce.datacloud.jdbc.protocol.data; /** - * Represents a field / column in a result set. - * - *

The {@link HyperType} carries the full internal type model. {@link #getTypeName()} is an - * optional caller-provided override for {@link java.sql.ResultSetMetaData#getColumnTypeName(int)}; - * when {@code null}, the JDBC layer falls back to the default name derived from the - * {@link HyperType}. + * Represents a field / column in a result set. The {@link HyperType} carries the full internal + * type model; {@link java.sql.ResultSetMetaData#getColumnTypeName(int)} is derived from it. */ @lombok.Value public class ColumnMetadata { String name; HyperType type; - /** - * Optional override for {@code ResultSetMetaData.getColumnTypeName}; {@code null} means the - * JDBC layer should use the default derived from {@link #type}. Used for metadata result sets - * where the JDBC spec pins a specific column-type label that differs from the underlying - * Hyper type. - * - *

Example: the columns of {@link java.sql.DatabaseMetaData#getTables} are spec'd as - * {@code TEXT} and {@code SHORT} (see JDBC 4.2 §28.12), not {@code VARCHAR} / {@code SMALLINT}. - * {@link com.salesforce.datacloud.jdbc.core.MetadataSchemas} constructs those columns as - * {@code new ColumnMetadata("TABLE_NAME", HyperType.varcharUnlimited(true), "TEXT")} so that - * {@code ResultSetMetaData.getColumnTypeName} returns {@code "TEXT"} while the accessor - * machinery still sees a {@code VARCHAR}. Regular query result columns pass {@code null} - * (via the two-arg constructor) and get {@code "VARCHAR"} from {@link HyperType}. - */ - String typeName; - - /** Shorthand for a column with no caller-supplied type-name override. */ - public ColumnMetadata(String name, HyperType type) { - this(name, type, null); - } - - /** Full constructor (kept for callers that need to override the JDBC type-name label). */ - public ColumnMetadata(String name, HyperType type, String typeName) { - this.name = name; - this.type = type; - this.typeName = typeName; - } } diff --git a/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/protocol/data/HyperTypeToArrow.java b/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/protocol/data/HyperTypeToArrow.java index ee8d8f02..626163a3 100644 --- a/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/protocol/data/HyperTypeToArrow.java +++ b/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/protocol/data/HyperTypeToArrow.java @@ -30,19 +30,7 @@ private HyperTypeToArrow() { /** Build an Arrow {@link Field} with the given name and the mapped {@link FieldType}. */ public static Field toField(String name, HyperType type) { - return toField(name, type, null); - } - - /** - * Build an Arrow {@link Field} and stamp an optional JDBC type-name override into the field - * metadata under {@link #JDBC_TYPE_NAME_METADATA_KEY}. The override is how {@link - * ColumnMetadata#getTypeName()} round-trips through Arrow — it lets JDBC-spec labels (e.g. - * {@code "TEXT"} for metadata columns) survive serialisation, which is the only way to - * carry them through an {@link org.apache.arrow.vector.ipc.ArrowStreamReader}-backed code - * path. - */ - public static Field toField(String name, HyperType type, String jdbcTypeName) { - FieldType fieldType = toFieldType(type, jdbcTypeName); + FieldType fieldType = toFieldType(type); if (type.getKind() == HyperTypeKind.ARRAY) { Field childField = toField("$element", type.getElement()); return new Field(name, fieldType, Collections.singletonList(childField)); @@ -50,15 +38,6 @@ public static Field toField(String name, HyperType type, String jdbcTypeName) { return new Field(name, fieldType, null); } - /** - * Arrow field-metadata key under which the JDBC-spec {@link ColumnMetadata#getTypeName() - * typeName} override is round-tripped. The {@code datacloud-jdbc:} prefix namespaces the - * key so it cannot collide with anything Hyper, query-federator, or another Arrow producer - * might stamp on its own field metadata; the unprefixed {@code jdbc:} namespace is not - * reserved by the Arrow spec. - */ - public static final String JDBC_TYPE_NAME_METADATA_KEY = "datacloud-jdbc:type_name"; - /** * Map a {@link HyperType} to an Arrow {@link FieldType}. * @@ -69,26 +48,7 @@ public static Field toField(String name, HyperType type, String jdbcTypeName) { * without loss. */ public static FieldType toFieldType(HyperType type) { - return toFieldType(type, null); - } - - /** - * Overload that stamps an optional JDBC type-name override into the field metadata under - * {@link #JDBC_TYPE_NAME_METADATA_KEY} so {@link ColumnMetadata#getTypeName()} round-trips - * through Arrow without needing a parallel metadata channel. - */ - public static FieldType toFieldType(HyperType type, String jdbcTypeName) { - ArrowType arrowType = toArrowType(type); - Map metadata = metadataFor(type); - if (jdbcTypeName != null) { - if (metadata == null) { - metadata = new HashMap<>(); - } else { - metadata = new HashMap<>(metadata); - } - metadata.put(JDBC_TYPE_NAME_METADATA_KEY, jdbcTypeName); - } - return new FieldType(type.isNullable(), arrowType, null, metadata); + return new FieldType(type.isNullable(), toArrowType(type), null, metadataFor(type)); } /** Hyper-compatible field metadata for types whose length is not carried in the ArrowType. */ diff --git a/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/util/Constants.java b/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/util/Constants.java index c6c69ac6..354fae37 100644 --- a/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/util/Constants.java +++ b/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/util/Constants.java @@ -5,12 +5,6 @@ package com.salesforce.datacloud.jdbc.util; public final class Constants { - // Column Types - public static final String INTEGER = "INTEGER"; - public static final String TEXT = "TEXT"; - public static final String SHORT = "SHORT"; - public static final String BOOL = "BOOL"; - // Date Time constants public static final String ISO_TIME_FORMAT = "HH:mm:ss"; diff --git a/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/core/ColumnNameResolverTest.java b/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/core/ColumnNameResolverTest.java index 41ec5236..a6f8c0be 100644 --- a/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/core/ColumnNameResolverTest.java +++ b/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/core/ColumnNameResolverTest.java @@ -22,7 +22,7 @@ public class ColumnNameResolverTest { private ColumnMetadata createColumn(String name) { - return new ColumnMetadata(name, HyperType.varcharUnlimited(true), "VARCHAR"); + return new ColumnMetadata(name, HyperType.varcharUnlimited(true)); } @Test @@ -75,7 +75,7 @@ public void testFindColumnNotFound() { @Test public void testFindColumnWithNullLabels() throws SQLException { List columns = new ArrayList<>(); - columns.add(new ColumnMetadata(null, HyperType.varcharUnlimited(true), "VARCHAR")); + columns.add(new ColumnMetadata(null, HyperType.varcharUnlimited(true))); columns.add(createColumn("Col2")); ColumnNameResolver resolver = new ColumnNameResolver(columns); @@ -123,7 +123,7 @@ public void testMultipleColumnsWithSameLowercase() throws SQLException { public void testDuplicateColumnNames() throws SQLException { List columns = new ArrayList<>(); columns.add(createColumn("Duplicate")); - columns.add(new ColumnMetadata("Other", HyperType.int32(true), "INTEGER")); + columns.add(new ColumnMetadata("Other", HyperType.int32(true))); columns.add(createColumn("Duplicate")); ColumnNameResolver resolver = new ColumnNameResolver(columns); diff --git a/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/core/DataCloudDatabaseMetadataTest.java b/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/core/DataCloudDatabaseMetadataTest.java index 3b58e13c..92692a28 100644 --- a/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/core/DataCloudDatabaseMetadataTest.java +++ b/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/core/DataCloudDatabaseMetadataTest.java @@ -752,16 +752,16 @@ public void testGetTables() { assertThat(resultSet.getMetaData().getColumnName(9)).isEqualTo("SELF_REFERENCING_COL_NAME"); assertThat(resultSet.getMetaData().getColumnName(10)).isEqualTo("REF_GENERATION"); - assertThat(resultSet.getMetaData().getColumnTypeName(1)).isEqualTo("TEXT"); - assertThat(resultSet.getMetaData().getColumnTypeName(2)).isEqualTo("TEXT"); - assertThat(resultSet.getMetaData().getColumnTypeName(3)).isEqualTo("TEXT"); - assertThat(resultSet.getMetaData().getColumnTypeName(4)).isEqualTo("TEXT"); - assertThat(resultSet.getMetaData().getColumnTypeName(5)).isEqualTo("TEXT"); - assertThat(resultSet.getMetaData().getColumnTypeName(6)).isEqualTo("TEXT"); - assertThat(resultSet.getMetaData().getColumnTypeName(7)).isEqualTo("TEXT"); - assertThat(resultSet.getMetaData().getColumnTypeName(8)).isEqualTo("TEXT"); - assertThat(resultSet.getMetaData().getColumnTypeName(9)).isEqualTo("TEXT"); - assertThat(resultSet.getMetaData().getColumnTypeName(10)).isEqualTo("TEXT"); + assertThat(resultSet.getMetaData().getColumnTypeName(1)).isEqualTo("VARCHAR"); + assertThat(resultSet.getMetaData().getColumnTypeName(2)).isEqualTo("VARCHAR"); + assertThat(resultSet.getMetaData().getColumnTypeName(3)).isEqualTo("VARCHAR"); + assertThat(resultSet.getMetaData().getColumnTypeName(4)).isEqualTo("VARCHAR"); + assertThat(resultSet.getMetaData().getColumnTypeName(5)).isEqualTo("VARCHAR"); + assertThat(resultSet.getMetaData().getColumnTypeName(6)).isEqualTo("VARCHAR"); + assertThat(resultSet.getMetaData().getColumnTypeName(7)).isEqualTo("VARCHAR"); + assertThat(resultSet.getMetaData().getColumnTypeName(8)).isEqualTo("VARCHAR"); + assertThat(resultSet.getMetaData().getColumnTypeName(9)).isEqualTo("VARCHAR"); + assertThat(resultSet.getMetaData().getColumnTypeName(10)).isEqualTo("VARCHAR"); } @Test @@ -796,16 +796,16 @@ public void testGetTablesNullValues() { assertThat(resultSet.getMetaData().getColumnName(9)).isEqualTo("SELF_REFERENCING_COL_NAME"); assertThat(resultSet.getMetaData().getColumnName(10)).isEqualTo("REF_GENERATION"); - assertThat(resultSet.getMetaData().getColumnTypeName(1)).isEqualTo("TEXT"); - assertThat(resultSet.getMetaData().getColumnTypeName(2)).isEqualTo("TEXT"); - assertThat(resultSet.getMetaData().getColumnTypeName(3)).isEqualTo("TEXT"); - assertThat(resultSet.getMetaData().getColumnTypeName(4)).isEqualTo("TEXT"); - assertThat(resultSet.getMetaData().getColumnTypeName(5)).isEqualTo("TEXT"); - assertThat(resultSet.getMetaData().getColumnTypeName(6)).isEqualTo("TEXT"); - assertThat(resultSet.getMetaData().getColumnTypeName(7)).isEqualTo("TEXT"); - assertThat(resultSet.getMetaData().getColumnTypeName(8)).isEqualTo("TEXT"); - assertThat(resultSet.getMetaData().getColumnTypeName(9)).isEqualTo("TEXT"); - assertThat(resultSet.getMetaData().getColumnTypeName(10)).isEqualTo("TEXT"); + assertThat(resultSet.getMetaData().getColumnTypeName(1)).isEqualTo("VARCHAR"); + assertThat(resultSet.getMetaData().getColumnTypeName(2)).isEqualTo("VARCHAR"); + assertThat(resultSet.getMetaData().getColumnTypeName(3)).isEqualTo("VARCHAR"); + assertThat(resultSet.getMetaData().getColumnTypeName(4)).isEqualTo("VARCHAR"); + assertThat(resultSet.getMetaData().getColumnTypeName(5)).isEqualTo("VARCHAR"); + assertThat(resultSet.getMetaData().getColumnTypeName(6)).isEqualTo("VARCHAR"); + assertThat(resultSet.getMetaData().getColumnTypeName(7)).isEqualTo("VARCHAR"); + assertThat(resultSet.getMetaData().getColumnTypeName(8)).isEqualTo("VARCHAR"); + assertThat(resultSet.getMetaData().getColumnTypeName(9)).isEqualTo("VARCHAR"); + assertThat(resultSet.getMetaData().getColumnTypeName(10)).isEqualTo("VARCHAR"); } @Test @@ -841,16 +841,16 @@ public void testGetTablesEmptyValues() { assertThat(resultSet.getMetaData().getColumnName(9)).isEqualTo("SELF_REFERENCING_COL_NAME"); assertThat(resultSet.getMetaData().getColumnName(10)).isEqualTo("REF_GENERATION"); - assertThat(resultSet.getMetaData().getColumnTypeName(1)).isEqualTo("TEXT"); - assertThat(resultSet.getMetaData().getColumnTypeName(2)).isEqualTo("TEXT"); - assertThat(resultSet.getMetaData().getColumnTypeName(3)).isEqualTo("TEXT"); - assertThat(resultSet.getMetaData().getColumnTypeName(4)).isEqualTo("TEXT"); - assertThat(resultSet.getMetaData().getColumnTypeName(5)).isEqualTo("TEXT"); - assertThat(resultSet.getMetaData().getColumnTypeName(6)).isEqualTo("TEXT"); - assertThat(resultSet.getMetaData().getColumnTypeName(7)).isEqualTo("TEXT"); - assertThat(resultSet.getMetaData().getColumnTypeName(8)).isEqualTo("TEXT"); - assertThat(resultSet.getMetaData().getColumnTypeName(9)).isEqualTo("TEXT"); - assertThat(resultSet.getMetaData().getColumnTypeName(10)).isEqualTo("TEXT"); + assertThat(resultSet.getMetaData().getColumnTypeName(1)).isEqualTo("VARCHAR"); + assertThat(resultSet.getMetaData().getColumnTypeName(2)).isEqualTo("VARCHAR"); + assertThat(resultSet.getMetaData().getColumnTypeName(3)).isEqualTo("VARCHAR"); + assertThat(resultSet.getMetaData().getColumnTypeName(4)).isEqualTo("VARCHAR"); + assertThat(resultSet.getMetaData().getColumnTypeName(5)).isEqualTo("VARCHAR"); + assertThat(resultSet.getMetaData().getColumnTypeName(6)).isEqualTo("VARCHAR"); + assertThat(resultSet.getMetaData().getColumnTypeName(7)).isEqualTo("VARCHAR"); + assertThat(resultSet.getMetaData().getColumnTypeName(8)).isEqualTo("VARCHAR"); + assertThat(resultSet.getMetaData().getColumnTypeName(9)).isEqualTo("VARCHAR"); + assertThat(resultSet.getMetaData().getColumnTypeName(10)).isEqualTo("VARCHAR"); } @SneakyThrows @@ -906,7 +906,7 @@ public void testGetTableTypes() { assertThat(resultSet.getMetaData().getColumnCount()).isEqualTo(NUM_TABLE_TYPES_METADATA_COLUMNS); assertThat(resultSet.getMetaData().getColumnName(1)).isEqualTo("TABLE_TYPE"); - assertThat(resultSet.getMetaData().getColumnTypeName(1)).isEqualTo("TEXT"); + assertThat(resultSet.getMetaData().getColumnTypeName(1)).isEqualTo("VARCHAR"); } @Test @@ -949,22 +949,22 @@ public void testGetColumnsContainsCorrectMetadata() { assertThat(resultSet.getMetaData().getColumnCount()).isEqualTo(NUM_COLUMN_METADATA_COLUMNS); assertThat(resultSet.getMetaData().getColumnName(1)).isEqualTo("TABLE_CAT"); - assertThat(resultSet.getMetaData().getColumnTypeName(1)).isEqualTo("TEXT"); + assertThat(resultSet.getMetaData().getColumnTypeName(1)).isEqualTo("VARCHAR"); assertThat(resultSet.getMetaData().getColumnName(2)).isEqualTo("TABLE_SCHEM"); - assertThat(resultSet.getMetaData().getColumnTypeName(2)).isEqualTo("TEXT"); + assertThat(resultSet.getMetaData().getColumnTypeName(2)).isEqualTo("VARCHAR"); assertThat(resultSet.getMetaData().getColumnName(3)).isEqualTo("TABLE_NAME"); - assertThat(resultSet.getMetaData().getColumnTypeName(3)).isEqualTo("TEXT"); + assertThat(resultSet.getMetaData().getColumnTypeName(3)).isEqualTo("VARCHAR"); assertThat(resultSet.getMetaData().getColumnName(4)).isEqualTo("COLUMN_NAME"); - assertThat(resultSet.getMetaData().getColumnTypeName(4)).isEqualTo("TEXT"); + assertThat(resultSet.getMetaData().getColumnTypeName(4)).isEqualTo("VARCHAR"); assertThat(resultSet.getMetaData().getColumnName(5)).isEqualTo("DATA_TYPE"); assertThat(resultSet.getMetaData().getColumnTypeName(5)).isEqualTo("INTEGER"); assertThat(resultSet.getMetaData().getColumnName(6)).isEqualTo("TYPE_NAME"); - assertThat(resultSet.getMetaData().getColumnTypeName(6)).isEqualTo("TEXT"); + assertThat(resultSet.getMetaData().getColumnTypeName(6)).isEqualTo("VARCHAR"); assertThat(resultSet.getMetaData().getColumnName(7)).isEqualTo("COLUMN_SIZE"); assertThat(resultSet.getMetaData().getColumnTypeName(7)).isEqualTo("INTEGER"); @@ -982,10 +982,10 @@ public void testGetColumnsContainsCorrectMetadata() { assertThat(resultSet.getMetaData().getColumnTypeName(11)).isEqualTo("INTEGER"); assertThat(resultSet.getMetaData().getColumnName(12)).isEqualTo("REMARKS"); - assertThat(resultSet.getMetaData().getColumnTypeName(12)).isEqualTo("TEXT"); + assertThat(resultSet.getMetaData().getColumnTypeName(12)).isEqualTo("VARCHAR"); assertThat(resultSet.getMetaData().getColumnName(13)).isEqualTo("COLUMN_DEF"); - assertThat(resultSet.getMetaData().getColumnTypeName(13)).isEqualTo("TEXT"); + assertThat(resultSet.getMetaData().getColumnTypeName(13)).isEqualTo("VARCHAR"); assertThat(resultSet.getMetaData().getColumnName(14)).isEqualTo("SQL_DATA_TYPE"); assertThat(resultSet.getMetaData().getColumnTypeName(14)).isEqualTo("INTEGER"); @@ -1000,25 +1000,25 @@ public void testGetColumnsContainsCorrectMetadata() { assertThat(resultSet.getMetaData().getColumnTypeName(17)).isEqualTo("INTEGER"); assertThat(resultSet.getMetaData().getColumnName(18)).isEqualTo("IS_NULLABLE"); - assertThat(resultSet.getMetaData().getColumnTypeName(18)).isEqualTo("TEXT"); + assertThat(resultSet.getMetaData().getColumnTypeName(18)).isEqualTo("VARCHAR"); assertThat(resultSet.getMetaData().getColumnName(19)).isEqualTo("SCOPE_CATALOG"); - assertThat(resultSet.getMetaData().getColumnTypeName(19)).isEqualTo("TEXT"); + assertThat(resultSet.getMetaData().getColumnTypeName(19)).isEqualTo("VARCHAR"); assertThat(resultSet.getMetaData().getColumnName(20)).isEqualTo("SCOPE_SCHEMA"); - assertThat(resultSet.getMetaData().getColumnTypeName(20)).isEqualTo("TEXT"); + assertThat(resultSet.getMetaData().getColumnTypeName(20)).isEqualTo("VARCHAR"); assertThat(resultSet.getMetaData().getColumnName(21)).isEqualTo("SCOPE_TABLE"); - assertThat(resultSet.getMetaData().getColumnTypeName(21)).isEqualTo("TEXT"); + assertThat(resultSet.getMetaData().getColumnTypeName(21)).isEqualTo("VARCHAR"); assertThat(resultSet.getMetaData().getColumnName(22)).isEqualTo("SOURCE_DATA_TYPE"); - assertThat(resultSet.getMetaData().getColumnTypeName(22)).isEqualTo("SHORT"); + assertThat(resultSet.getMetaData().getColumnTypeName(22)).isEqualTo("SMALLINT"); assertThat(resultSet.getMetaData().getColumnName(23)).isEqualTo("IS_AUTOINCREMENT"); - assertThat(resultSet.getMetaData().getColumnTypeName(23)).isEqualTo("TEXT"); + assertThat(resultSet.getMetaData().getColumnTypeName(23)).isEqualTo("VARCHAR"); assertThat(resultSet.getMetaData().getColumnName(24)).isEqualTo("IS_GENERATEDCOLUMN"); - assertThat(resultSet.getMetaData().getColumnTypeName(24)).isEqualTo("TEXT"); + assertThat(resultSet.getMetaData().getColumnTypeName(24)).isEqualTo("VARCHAR"); } @Test @@ -1082,9 +1082,7 @@ public void testTestTest() throws SQLException { ResultSet columnResultSet = QueryMetadataUtil.createColumnResultSet( StringUtils.EMPTY, StringUtils.EMPTY, StringUtils.EMPTY, connection); while (columnResultSet.next()) { - // The metadata result set is Arrow-backed; TYPE_NAME carries "TEXT" (preserved from - // the JDBC-spec MetadataSchemas override), while TYPE_NAME's *value* is the HyperType's - // JDBC name ("VARCHAR" for varchar columns). + // TYPE_NAME row value is the JDBC-derived type name for the column's HyperType. assertThat(columnResultSet.getString("TYPE_NAME")).isEqualTo("VARCHAR"); assertThat(columnResultSet.getInt("DATA_TYPE")).isEqualTo(12); // NULLABLE is an INTEGER column. Arrow-backed getInt reports the nullability enum: @@ -1456,8 +1454,8 @@ public void testGetSchemas() { assertThat(resultSet.getString("TABLE_CATALOG")).isEqualTo(null); } - assertThat(resultSet.getMetaData().getColumnTypeName(1)).isEqualTo("TEXT"); - assertThat(resultSet.getMetaData().getColumnTypeName(2)).isEqualTo("TEXT"); + assertThat(resultSet.getMetaData().getColumnTypeName(1)).isEqualTo("VARCHAR"); + assertThat(resultSet.getMetaData().getColumnTypeName(2)).isEqualTo("VARCHAR"); } @Test @@ -1480,8 +1478,8 @@ public void testGetSchemasCatalogAndSchemaPattern() { assertThat(resultSet.getString("TABLE_CATALOG")).isEqualTo(tableCatalog); } - assertThat(resultSet.getMetaData().getColumnTypeName(1)).isEqualTo("TEXT"); - assertThat(resultSet.getMetaData().getColumnTypeName(2)).isEqualTo("TEXT"); + assertThat(resultSet.getMetaData().getColumnTypeName(1)).isEqualTo("VARCHAR"); + assertThat(resultSet.getMetaData().getColumnTypeName(2)).isEqualTo("VARCHAR"); } @Test @@ -1503,8 +1501,8 @@ public void testGetSchemasCatalogAndSchemaPatternNullValues() { assertThat(resultSet.getString("TABLE_CATALOG")).isEqualTo(null); } - assertThat(resultSet.getMetaData().getColumnTypeName(1)).isEqualTo("TEXT"); - assertThat(resultSet.getMetaData().getColumnTypeName(2)).isEqualTo("TEXT"); + assertThat(resultSet.getMetaData().getColumnTypeName(1)).isEqualTo("VARCHAR"); + assertThat(resultSet.getMetaData().getColumnTypeName(2)).isEqualTo("VARCHAR"); } @Test @@ -1525,8 +1523,8 @@ public void testGetSchemasEmptyValues() { assertThat(resultSet.getString("TABLE_CATALOG")).isEqualTo(null); } - assertThat(resultSet.getMetaData().getColumnTypeName(1)).isEqualTo("TEXT"); - assertThat(resultSet.getMetaData().getColumnTypeName(2)).isEqualTo("TEXT"); + assertThat(resultSet.getMetaData().getColumnTypeName(1)).isEqualTo("VARCHAR"); + assertThat(resultSet.getMetaData().getColumnTypeName(2)).isEqualTo("VARCHAR"); } @Test diff --git a/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/core/MetadataSchemasTest.java b/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/core/MetadataSchemasTest.java index fe28b579..527f0e4e 100644 --- a/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/core/MetadataSchemasTest.java +++ b/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/core/MetadataSchemasTest.java @@ -42,9 +42,30 @@ class MetadataSchemasTest { "IS_GENERATEDCOLUMN"); private static final List COLUMN_TYPES = Arrays.asList( - "TEXT", "TEXT", "TEXT", "TEXT", "INTEGER", "TEXT", "INTEGER", "INTEGER", "INTEGER", "INTEGER", "INTEGER", - "TEXT", "TEXT", "INTEGER", "INTEGER", "INTEGER", "INTEGER", "TEXT", "TEXT", "TEXT", "TEXT", "SHORT", "TEXT", - "TEXT"); + "VARCHAR", + "VARCHAR", + "VARCHAR", + "VARCHAR", + "INTEGER", + "VARCHAR", + "INTEGER", + "INTEGER", + "INTEGER", + "INTEGER", + "INTEGER", + "VARCHAR", + "VARCHAR", + "INTEGER", + "INTEGER", + "INTEGER", + "INTEGER", + "VARCHAR", + "VARCHAR", + "VARCHAR", + "VARCHAR", + "SMALLINT", + "VARCHAR", + "VARCHAR"); private static final List COLUMN_TYPE_IDS = Arrays.asList( Types.VARCHAR, @@ -93,8 +114,24 @@ class MetadataSchemasTest { "NUM_PREC_RADIX"); private static final List TYPE_INFO_TYPES = Arrays.asList( - "TEXT", "INTEGER", "INTEGER", "TEXT", "TEXT", "TEXT", "SHORT", "BOOL", "SHORT", "BOOL", "BOOL", "BOOL", - "TEXT", "SHORT", "SHORT", "INTEGER", "INTEGER", "INTEGER"); + "VARCHAR", + "INTEGER", + "INTEGER", + "VARCHAR", + "VARCHAR", + "VARCHAR", + "SMALLINT", + "BOOLEAN", + "SMALLINT", + "BOOLEAN", + "BOOLEAN", + "BOOLEAN", + "VARCHAR", + "SMALLINT", + "SMALLINT", + "INTEGER", + "INTEGER", + "INTEGER"); private static final List TYPE_INFO_TYPE_IDS = Arrays.asList( Types.VARCHAR, @@ -128,11 +165,11 @@ void columnsSchemaHasExpectedNames() { @Test void columnsSchemaHasExpectedTypeNames() { List typeNames = MetadataSchemas.COLUMNS.stream() - .map(ColumnMetadata::getTypeName) + .map(c -> HyperTypes.toJdbcTypeName(c.getType())) .collect(Collectors.toList()); assertThat(typeNames).isEqualTo(COLUMN_TYPES); assertThat(typeNames).hasSize(24); - assertThat(typeNames.get(0)).isEqualTo("TEXT"); + assertThat(typeNames.get(0)).isEqualTo("VARCHAR"); } @Test @@ -157,11 +194,11 @@ void typeInfoSchemaHasExpectedNames() { @Test void typeInfoSchemaHasExpectedTypeNames() { List typeNames = MetadataSchemas.TYPE_INFO.stream() - .map(ColumnMetadata::getTypeName) + .map(c -> HyperTypes.toJdbcTypeName(c.getType())) .collect(Collectors.toList()); assertThat(typeNames).isEqualTo(TYPE_INFO_TYPES); assertThat(typeNames).hasSize(18); - assertThat(typeNames.get(7)).isEqualTo("BOOL"); + assertThat(typeNames.get(7)).isEqualTo("BOOLEAN"); } @Test diff --git a/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/core/metadata/DataCloudResultSetMetaDataTest.java b/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/core/metadata/DataCloudResultSetMetaDataTest.java index f83ba615..9a2c06f9 100644 --- a/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/core/metadata/DataCloudResultSetMetaDataTest.java +++ b/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/core/metadata/DataCloudResultSetMetaDataTest.java @@ -7,6 +7,7 @@ import static org.assertj.core.api.Assertions.assertThat; import com.salesforce.datacloud.jdbc.core.MetadataSchemas; +import com.salesforce.datacloud.jdbc.core.types.HyperTypes; import com.salesforce.datacloud.jdbc.protocol.data.ColumnMetadata; import com.salesforce.datacloud.jdbc.protocol.data.HyperType; import java.sql.ResultSetMetaData; @@ -79,7 +80,7 @@ public void testGetColumnLabel() throws SQLException { @Test public void testGetColumnLabelWithNullColumnNameReturnsDefaultValue() throws SQLException { - ColumnMetadata columnMetadata = new ColumnMetadata(null, HyperType.varcharUnlimited(true), "TEXT"); + ColumnMetadata columnMetadata = new ColumnMetadata(null, HyperType.varcharUnlimited(true)); resultSetMetaData = new DataCloudResultSetMetaData(new ColumnMetadata[] {columnMetadata}); assertThat(resultSetMetaData.getColumnLabel(1)).isEqualTo("C0"); } @@ -125,7 +126,8 @@ public void testGetCatalogName() throws SQLException { public void getColumnTypeName() throws SQLException { for (int i = 1; i <= COLUMNS_SCHEMA.size(); i++) { assertThat(resultSetMetaData.getColumnTypeName(i)) - .isEqualTo(COLUMNS_SCHEMA.get(i - 1).getTypeName()); + .isEqualTo( + HyperTypes.toJdbcTypeName(COLUMNS_SCHEMA.get(i - 1).getType())); } } @@ -182,16 +184,16 @@ public void nullableVsNonNullableColumn() throws SQLException { HyperType nonNullable = HyperType.varcharUnlimited(false); HyperType nullable = HyperType.int32(true); DataCloudResultSetMetaData metaNonNullable = - new DataCloudResultSetMetaData(new ColumnMetadata[] {new ColumnMetadata("col", nonNullable, "TEXT")}); + new DataCloudResultSetMetaData(new ColumnMetadata[] {new ColumnMetadata("col", nonNullable)}); DataCloudResultSetMetaData metaNullable = - new DataCloudResultSetMetaData(new ColumnMetadata[] {new ColumnMetadata("col", nullable, "INTEGER")}); + new DataCloudResultSetMetaData(new ColumnMetadata[] {new ColumnMetadata("col", nullable)}); assertThat(metaNonNullable.isNullable(1)).isEqualTo(ResultSetMetaData.columnNoNulls); assertThat(metaNullable.isNullable(1)).isEqualTo(ResultSetMetaData.columnNullable); } private static DataCloudResultSetMetaData metaDataWithType(HyperType type) { - return new DataCloudResultSetMetaData(new ColumnMetadata[] {new ColumnMetadata("col", type, "TEXT")}); + return new DataCloudResultSetMetaData(new ColumnMetadata[] {new ColumnMetadata("col", type)}); } @Test diff --git a/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/protocol/data/ArrowToHyperTypeMapperTest.java b/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/protocol/data/ArrowToHyperTypeMapperTest.java deleted file mode 100644 index f6ec6200..00000000 --- a/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/protocol/data/ArrowToHyperTypeMapperTest.java +++ /dev/null @@ -1,67 +0,0 @@ -/** - * This file is part of https://github.com/forcedotcom/datacloud-jdbc which is released under the - * Apache 2.0 license. See https://github.com/forcedotcom/datacloud-jdbc/blob/main/LICENSE.txt - */ -package com.salesforce.datacloud.jdbc.protocol.data; - -import static org.assertj.core.api.Assertions.assertThat; - -import java.util.Collections; -import lombok.val; -import org.apache.arrow.vector.types.pojo.ArrowType; -import org.apache.arrow.vector.types.pojo.Field; -import org.apache.arrow.vector.types.pojo.FieldType; -import org.junit.jupiter.api.Test; - -/** - * Pin the {@link ArrowToHyperTypeMapper#toColumnMetadata(Field)} contract around the - * {@link HyperTypeToArrow#JDBC_TYPE_NAME_METADATA_KEY} field-metadata override. - * - *

Two paths must work: - *

    - *
  • An Arrow field that does stamp the override (the metadata path) returns a - * {@code ColumnMetadata} whose {@code typeName} matches the override exactly. - *
  • An Arrow field that does not stamp the override (every real-Hyper query stream) - * returns a {@code ColumnMetadata} whose {@code typeName} is {@code null}, so the JDBC - * layer falls back to the type-derived default. The fallback is implicit in the rest of - * the test suite — every functional test against local Hyper goes through this code - * path — but no assertion pinned it. This test does. - *
- */ -class ArrowToHyperTypeMapperTest { - - @Test - void typeNameOverrideIsPickedUpWhenStamped() { - val metadata = Collections.singletonMap(HyperTypeToArrow.JDBC_TYPE_NAME_METADATA_KEY, "TEXT"); - val field = new Field("c", new FieldType(true, new ArrowType.Utf8(), null, metadata), null); - - val column = ArrowToHyperTypeMapper.toColumnMetadata(field); - - assertThat(column.getName()).isEqualTo("c"); - assertThat(column.getType()).isEqualTo(HyperType.varcharUnlimited(true)); - assertThat(column.getTypeName()).isEqualTo("TEXT"); - } - - @Test - void typeNameOverrideIsNullWhenAbsent() { - // Mirrors what a real Hyper Arrow stream looks like: no datacloud-jdbc:type_name key. - val field = new Field("c", new FieldType(true, new ArrowType.Utf8(), null), null); - - val column = ArrowToHyperTypeMapper.toColumnMetadata(field); - - assertThat(column.getName()).isEqualTo("c"); - assertThat(column.getType()).isEqualTo(HyperType.varcharUnlimited(true)); - // Null override means the JDBC layer falls back to HyperType-derived "VARCHAR". - assertThat(column.getTypeName()).isNull(); - } - - @Test - void typeNameOverrideIsNullWhenMetadataIsEmptyButPresent() { - val field = - new Field("c", new FieldType(true, new ArrowType.Int(32, true), null, Collections.emptyMap()), null); - - val column = ArrowToHyperTypeMapper.toColumnMetadata(field); - - assertThat(column.getTypeName()).isNull(); - } -} diff --git a/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/protocol/data/ArrowUtilsTest.java b/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/protocol/data/ArrowUtilsTest.java index 2da6132d..96c03447 100644 --- a/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/protocol/data/ArrowUtilsTest.java +++ b/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/protocol/data/ArrowUtilsTest.java @@ -53,9 +53,6 @@ void testConvertArrowFieldsToColumnMetaData() { val actual = actualColumnMetadata.get(0); softly.assertThat(actual.getName()).isEqualTo("id"); - // toColumnMetaData leaves the JDBC type-name override unset; the JDBC layer derives the - // default from the HyperType at query time. - softly.assertThat(actual.getTypeName()).isNull(); softly.assertThat(HyperTypes.toJdbcTypeName(actual.getType())) .isEqualTo(JDBCType.valueOf(Types.VARCHAR).getName()); } From 96925fdd8f861ae7b8eea13f04e57f8caab1d760 Mon Sep 17 00:00:00 2001 From: Moritz Kaufmann Date: Mon, 18 May 2026 14:20:55 +0200 Subject: [PATCH 24/24] fix: tighten BaseIntVectorAccessor.getBoolean to spec-strict 0/1 only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous implementation returned `getLong() != 0`, silently coercing any non-zero integer (2, -1, MAX_VALUE) to `true`. ResultSet.getBoolean's own Javadoc only defines the conversion for exactly 0 and 1 — the spec is silent on other values. pgjdbc throws CANNOT_COERCE on anything else (BooleanTypeUtil.fromNumber). Match that strict behavior: 0 → false, 1 → true, anything else throws SQLException with SQLState 22018. The "non-zero → true" extrapolation was the same flavor of silent coercion the rest of this PR sets out to remove (VarCharVectorSetter accepting arbitrary Object via toString, integer setters silently truncating out-of-range Numbers). Catching here too rather than letting a real integer column lose its value when bounced through getBoolean. Update the metadata-test assertion: ORDINAL_POSITION = 500 used to coerce to true under the permissive path; now asserts the SQLException with the expected message. --- .../accessor/impl/BaseIntVectorAccessor.java | 21 ++++++++++++++----- .../core/DataCloudDatabaseMetadataTest.java | 10 +++++---- 2 files changed, 22 insertions(+), 9 deletions(-) diff --git a/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/accessor/impl/BaseIntVectorAccessor.java b/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/accessor/impl/BaseIntVectorAccessor.java index 0d779ba4..3b69fa2c 100644 --- a/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/accessor/impl/BaseIntVectorAccessor.java +++ b/jdbc-core/src/main/java/com/salesforce/datacloud/jdbc/core/accessor/impl/BaseIntVectorAccessor.java @@ -87,11 +87,22 @@ public String getString() { } @Override - public boolean getBoolean() { - // JDBC 4.2 Table B-6 recommends INTEGER → boolean: 0 → false, non-zero → true. - // pgjdbc and other major drivers do this; clients reading metadata int columns like - // NULLABLE / IS_AUTOINCREMENT as boolean rely on the coercion. - return getLong() != 0; + public boolean getBoolean() throws SQLException { + // ResultSet.getBoolean's Javadoc spells out the INTEGER → boolean conversion only for + // exactly 0 and 1; the spec is silent on other values. pgjdbc throws CANNOT_COERCE on + // anything else (BooleanTypeUtil.fromNumber). Match that strict behaviour rather than + // silently returning true for any non-zero — clients reading e.g. NULLABLE from + // getColumns() pass through the legitimate 0/1 path; anyone bouncing a real integer + // column off getBoolean would otherwise lose the original value silently. + long value = getLong(); + if (value == 0L) { + return false; + } + if (value == 1L) { + return true; + } + throw new SQLException( + "Cannot coerce integer value " + value + " to boolean (spec defines only 0 and 1)", "22018"); } @Override diff --git a/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/core/DataCloudDatabaseMetadataTest.java b/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/core/DataCloudDatabaseMetadataTest.java index 92692a28..9b31fd20 100644 --- a/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/core/DataCloudDatabaseMetadataTest.java +++ b/jdbc-core/src/test/java/com/salesforce/datacloud/jdbc/core/DataCloudDatabaseMetadataTest.java @@ -1085,13 +1085,15 @@ public void testTestTest() throws SQLException { // TYPE_NAME row value is the JDBC-derived type name for the column's HyperType. assertThat(columnResultSet.getString("TYPE_NAME")).isEqualTo("VARCHAR"); assertThat(columnResultSet.getInt("DATA_TYPE")).isEqualTo(12); - // NULLABLE is an INTEGER column. Arrow-backed getInt reports the nullability enum: - // 0 (columnNoNulls) for NOT NULL rows, which coerces to false via the JDBC-spec - // INTEGER → boolean recommendation (BaseIntVectorAccessor.getBoolean). + // NULLABLE is an INTEGER column. Arrow-backed getInt reports the nullability enum + // (0 = columnNoNulls). BaseIntVectorAccessor.getBoolean follows ResultSet.getBoolean's + // spec text strictly — only 0 and 1 coerce; any other integer throws. assertThat(columnResultSet.getInt("NULLABLE")).isEqualTo(0); assertThat(columnResultSet.getBoolean("NULLABLE")).isFalse(); assertThat(columnResultSet.getInt("ORDINAL_POSITION")).isEqualTo(ordinalValue); - assertThat(columnResultSet.getBoolean("ORDINAL_POSITION")).isTrue(); + assertThatThrownBy(() -> columnResultSet.getBoolean("ORDINAL_POSITION")) + .isInstanceOf(SQLException.class) + .hasMessageContaining("Cannot coerce integer value"); assertThat(columnResultSet.getByte("ORDINAL_POSITION")).isEqualTo(ordinalValue.byteValue()); } }