diff --git a/NEXT_CHANGELOG.md b/NEXT_CHANGELOG.md index 8e44c82b1..0f8cbb105 100644 --- a/NEXT_CHANGELOG.md +++ b/NEXT_CHANGELOG.md @@ -3,11 +3,14 @@ ## [Unreleased] ### Added +- Added support for creating mutable JDBC `Clob` values and binding them to prepared statements. ### Updated - `DatabaseMetaData.getColumns(...)` with a `null` catalog now issues a single `SHOW COLUMNS IN ALL CATALOGS` statement (consistent with `getSchemas`/`getTables`) instead of enumerating every catalog and issuing a per-catalog `SHOW COLUMNS`. Older DBR versions that do not support the syntax transparently fall back to the previous enumerate-and-fan-out behavior. ### Fixed +- Fixed ASCII- and character-stream parameter binding to read unbounded streams to EOF and bounded streams without consuming data past the declared length. + - Fixed connections failing when the same parameter is provided in both the JDBC URL and the connection properties, with the JDBC URL taking precedence. - Fixed `IdleConnectionEvictor` thread leak in long-running applications. Driver-side resources (HTTP client, background threads) are now always released when `Connection.close()` is called, even if statement cleanup or server-side session termination fails. diff --git a/docs/JDBC_METHOD_INVENTORY.md b/docs/JDBC_METHOD_INVENTORY.md index 274dfed7e..d3487b378 100644 --- a/docs/JDBC_METHOD_INVENTORY.md +++ b/docs/JDBC_METHOD_INVENTORY.md @@ -124,7 +124,7 @@ | `setCharacterStream(int, Reader, int)` | YES | - | No | OCCASIONAL | Reads character stream | | `setRef(int, Ref)` | THROWS_NOT_SUPPORTED | DatabricksSQLFeatureNotSupportedException | No | RARE | REF type not supported | | `setBlob(int, Blob)` | THROWS_NOT_SUPPORTED | DatabricksSQLFeatureNotSupportedException | No | RARE | BLOB type not supported | -| `setClob(int, Clob)` | THROWS_NOT_SUPPORTED | DatabricksSQLFeatureNotSupportedException | No | RARE | CLOB type not supported | +| `setClob(int, Clob)` | YES | - | No | RARE | Binds CLOB contents as STRING | | `setArray(int, Array)` | THROWS_NOT_SUPPORTED | DatabricksSQLFeatureNotSupportedException | No | RARE | Array parameter not supported | | `getMetaData()` | YES | - | No | OCCASIONAL | Returns ResultSetMetaData | | `setDate(int, Date, Calendar)` | YES | - | No | OCCASIONAL | Sets Date with Calendar | @@ -137,7 +137,7 @@ | `setNString(int, String)` | THROWS_NOT_SUPPORTED | DatabricksSQLFeatureNotSupportedException | No | RARE | NString not supported | | `setNCharacterStream(int, Reader, long)` | THROWS_NOT_SUPPORTED | DatabricksSQLFeatureNotSupportedException | No | RARE | NCharacterStream not supported | | `setNClob(int, NClob)` | THROWS_NOT_SUPPORTED | DatabricksSQLFeatureNotSupportedException | No | RARE | NClob not supported | -| `setClob(int, Reader, long)` | THROWS_NOT_SUPPORTED | DatabricksSQLFeatureNotSupportedException | No | RARE | Clob reader not supported | +| `setClob(int, Reader, long)` | YES | - | No | RARE | Binds reader contents as STRING | | `setBlob(int, InputStream, long)` | THROWS_NOT_SUPPORTED | DatabricksSQLFeatureNotSupportedException | No | RARE | Blob stream not supported | | `setNClob(int, Reader, long)` | THROWS_NOT_SUPPORTED | DatabricksSQLFeatureNotSupportedException | No | RARE | NClob reader not supported | | `setSQLXML(int, SQLXML)` | THROWS_NOT_SUPPORTED | DatabricksSQLFeatureNotSupportedException | No | RARE | SQLXML not supported | @@ -149,7 +149,7 @@ | `setBinaryStream(int, InputStream)` | THROWS_NOT_SUPPORTED | DatabricksSQLFeatureNotSupportedException | No | RARE | Binary stream not supported | | `setCharacterStream(int, Reader)` | YES | - | No | OCCASIONAL | Character stream no length | | `setNCharacterStream(int, Reader)` | THROWS_NOT_SUPPORTED | DatabricksSQLFeatureNotSupportedException | No | RARE | NCharacterStream not supported | -| `setClob(int, Reader)` | THROWS_NOT_SUPPORTED | DatabricksSQLFeatureNotSupportedException | No | RARE | Clob reader not supported | +| `setClob(int, Reader)` | YES | - | No | RARE | Binds reader contents as STRING | | `setBlob(int, InputStream)` | THROWS_NOT_SUPPORTED | DatabricksSQLFeatureNotSupportedException | No | RARE | Blob stream not supported | | `setNClob(int, Reader)` | THROWS_NOT_SUPPORTED | DatabricksSQLFeatureNotSupportedException | No | RARE | NClob reader not supported | | `setObject(int, Object, SQLType, int)` | YES | - | No | OCCASIONAL | JDBC 4.2 - SQLType variant | @@ -514,7 +514,7 @@ OUT/INOUT parameters, named parameters, and return-value syntax (`{? = call ...} | `prepareStatement(String, int)` | PARTIAL | DatabricksSQLFeatureNotSupportedException | No | OCCASIONAL | Only NO_GENERATED_KEYS | | `prepareStatement(String, int[])` | THROWS_NOT_SUPPORTED | DatabricksSQLFeatureNotSupportedException | No | RARE | Generated keys not supported | | `prepareStatement(String, String[])` | THROWS_NOT_SUPPORTED | DatabricksSQLFeatureNotSupportedException | No | RARE | Generated keys not supported | -| `createClob()` | THROWS_NOT_IMPLEMENTED | DatabricksSQLFeatureNotImplementedException | No | RARE | CLOB not supported | +| `createClob()` | YES | - | No | RARE | Returns a mutable in-memory CLOB | | `createBlob()` | THROWS_NOT_IMPLEMENTED | DatabricksSQLFeatureNotImplementedException | No | RARE | BLOB not supported | | `createNClob()` | THROWS_NOT_IMPLEMENTED | DatabricksSQLFeatureNotImplementedException | No | RARE | NCLOB not supported | | `createSQLXML()` | THROWS_NOT_IMPLEMENTED | DatabricksSQLFeatureNotImplementedException | No | RARE | SQLXML not supported | @@ -776,11 +776,11 @@ OUT/INOUT parameters, named parameters, and return-value syntax (`{? = call ...} | Interface | Total Methods | Fully Implemented | Partially Implemented | Throws NOT_SUPPORTED | Throws NOT_IMPLEMENTED | Not Implemented | |-----------|---------------|-------------------|----------------------|---------------------|------------------------|-----------------| | Statement | 54 | 37 (69%) | 5 (9%) | 12 (22%) | 0 | 0 | -| PreparedStatement | 70 | 28 (40%) | 1 (1%) | 24 (34%) | 17 (24%) | 0 | +| PreparedStatement | 70 | 31 (44%) | 1 (1%) | 21 (30%) | 17 (24%) | 0 | | CallableStatement | 100+ | 0 (0%) | 0 | 0 | 0 | 100+ (100%) | | ResultSet | 200+ | 70 (35%) | 2 (1%) | 130+ (65%) | 0 | 0 | | ResultSetMetaData | 23 | 23 (100%) | 0 | 0 | 0 | 0 | -| Connection | 60 | 26 (43%) | 12 (20%) | 8 (13%) | 14 (23%) | 0 | +| Connection | 60 | 27 (45%) | 12 (20%) | 8 (13%) | 13 (22%) | 0 | | DatabaseMetaData | 180+ | 180+ (100%) | 0 | 0 | 0 | 0 | | ParameterMetaData | 11 | 3 (27%) | 8 (73%) | 0 | 0 | 0 | @@ -794,8 +794,8 @@ OUT/INOUT parameters, named parameters, and return-value syntax (`{? = call ...} - `Statement` - 69% fully implemented, mostly core functionality 3. **Partially Implemented:** - - `Connection` - 43% fully implemented, 20% partially (many throw exceptions for advanced features) - - `PreparedStatement` - 40% fully implemented, many LOB/stream methods not supported + - `Connection` - 45% fully implemented, 20% partially (many throw exceptions for advanced features) + - `PreparedStatement` - 44% fully implemented; CLOB binding is supported, while BLOB/NCLOB binding is not - `ResultSet` - 35% fully implemented, most update methods throw NOT_SUPPORTED 4. **Not Implemented:** @@ -819,11 +819,12 @@ OUT/INOUT parameters, named parameters, and return-value syntax (`{? = call ...} - Large update counts (supported) - Streaming (ASCII/Character streams supported, binary not supported) - Calendar-based date/time operations (supported) +- CLOB creation and PreparedStatement binding (mapped to STRING) **NOT SUPPORTED:** - CallableStatement (stored procedures) - Updatable ResultSets (all update methods throw exceptions) -- LOB types (BLOB, CLOB, NCLOB) +- BLOB/NCLOB operations and CLOB retrieval/update - Advanced types (REF, SQLXML, RowId, URL) - Named cursors - Bidirectional ResultSet navigation diff --git a/docs/JDBC_SPEC_COVERAGE_ANALYSIS.md b/docs/JDBC_SPEC_COVERAGE_ANALYSIS.md index 753482924..0d215676e 100644 --- a/docs/JDBC_SPEC_COVERAGE_ANALYSIS.md +++ b/docs/JDBC_SPEC_COVERAGE_ANALYSIS.md @@ -32,19 +32,19 @@ This document provides a comprehensive analysis of JDBC specification compliance ### Key Findings -**Overall Integration Test Coverage: 13.4%** (when considering only applicable methods) +**Overall Integration Test Coverage: 13.3%** (when considering only applicable methods) **Comprehensive JDBC Inventory:** - **Total JDBC Methods Analyzed:** 588+ methods across 8 core interfaces (ALL methods including rare, deprecated, vendor-specific) -- **Methods Applicable to Databricks:** 328 methods (excludes CallableStatement, updatable ResultSet, LOBs) +- **Methods Applicable to Databricks:** 332 methods (excludes CallableStatement, updatable ResultSet, and unsupported LOB operations) - **Methods with Integration Tests:** 44 methods -- **Applicable Coverage:** 44/328 = **13.4%** +- **Applicable Coverage:** 44/332 = **13.3%** **Implementation Status:** -- **Fully Implemented:** 367+ methods (62% of total) +- **Fully Implemented:** 371+ methods (63% of total) - **Partially Implemented:** 28 methods -- **Throws NOT_SUPPORTED:** 174+ methods (intentional - feature not applicable) -- **Not Implemented:** 124+ methods (CallableStatement, etc.) +- **Throws NOT_SUPPORTED:** 171+ methods (intentional - feature not applicable) +- **Not Implemented:** 130+ methods (CallableStatement, etc.) **Core Methods Analysis (Original 156 high-priority methods):** - **Methods with Integration Tests:** 44 methods (28%) @@ -1570,14 +1570,14 @@ This appendix provides a comprehensive inventory of ALL JDBC methods across all | Interface | Total Methods | Fully Implemented | Partially Implemented | Throws NOT_SUPPORTED | Not Implemented | Implementation % | |-----------|--------------|-------------------|----------------------|---------------------|----------------|-----------------| | **Statement** | 54 | 37 | 5 | 12 | 0 | **69%** | -| **PreparedStatement** | 60 | 28 | 1 | 24 | 10 | **47%** | +| **PreparedStatement** | 70 | 31 | 1 | 21 | 17 | **44%** | | **CallableStatement** | 100+ | 0 | 0 | 0 | 100+ | **0%** | | **ResultSet** | 200+ | 70 | 2 | 130+ | 0 | **35%** | | **ResultSetMetaData** | 23 | 23 | 0 | 0 | 0 | **100%** | -| **Connection** | 60 | 26 | 12 | 8 | 14 | **43%** | +| **Connection** | 60 | 27 | 12 | 8 | 13 | **45%** | | **DatabaseMetaData** | 180+ | 180+ | 0 | 0 | 0 | **100%** | | **ParameterMetaData** | 11 | 3 | 8 | 0 | 0 | **27%** | -| **TOTAL** | **588+** | **367+** | **28** | **174+** | **124+** | **62%** | +| **TOTAL** | **588+** | **371+** | **28** | **171+** | **130+** | **63%** | #### D.2 Key Findings from Complete Inventory @@ -1637,13 +1637,14 @@ This appendix provides a comprehensive inventory of ALL JDBC methods across all - **Impact:** Cannot modify data through ResultSet, must use UPDATE statements - **Test Strategy:** Verify exceptions are thrown correctly -3. **LOB Types** (0%) +3. **LOB Types** (Partial) - **BLOB operations:** getBlob(), setBlob(), updateBlob() - Not supported - - **CLOB operations:** getClob(), setClob(), updateClob() - Not supported + - **CLOB operations:** createClob() and PreparedStatement setClob() are supported by mapping contents to STRING; ResultSet getClob() and updateClob() are not supported - **NCLOB operations:** getNClob(), setNClob(), updateNClob() - Not supported - **Reason:** Databricks does not support LOB types natively - **Workaround:** Use String for CLOB-like data, byte[] for BLOB-like data - - **Test Strategy:** Verify NOT_SUPPORTED exceptions are thrown + - **Limitation:** CLOB creation and binding materialize character contents in JVM memory; there is no server-side LOB locator or streaming parameter transport + - **Test Strategy:** Verify supported CLOB creation/binding behavior and NOT_SUPPORTED exceptions for the remaining LOB operations 4. **Bidirectional ResultSet Navigation** (0%) - **Methods:** previous(), first(), last(), absolute(), relative(), beforeFirst(), afterLast() @@ -1676,7 +1677,7 @@ This appendix provides a comprehensive inventory of ALL JDBC methods across all **RARE Methods (0-40% implementation):** ~150 methods - Advanced scrolling - Updatable ResultSets -- LOB operations +- Unsupported LOB retrieval/update and BLOB/NCLOB operations - Named cursors - **Test Priority:** LOW - Verify NOT_SUPPORTED exceptions only @@ -1687,7 +1688,7 @@ This appendix provides a comprehensive inventory of ALL JDBC methods across all #### D.4 Testing Recommendations by Implementation Status -**For Fully Implemented Methods (367+ methods):** +**For Fully Implemented Methods (371+ methods):** - ✅ **Add integration tests for COMMON usage (Priority: HIGH)** - ✅ **Add integration tests for OCCASIONAL usage (Priority: MEDIUM)** - ⚠️ **Optional integration tests for RARE usage (Priority: LOW)** @@ -1698,13 +1699,13 @@ This appendix provides a comprehensive inventory of ALL JDBC methods across all - ✅ **Verify warnings/exceptions for unsupported features** - ✅ **Document limitations clearly** -**For Methods Throwing NOT_SUPPORTED (174+ methods):** +**For Methods Throwing NOT_SUPPORTED (171+ methods):** - ✅ **Verify exception type is correct** (SQLFeatureNotSupportedException) - ✅ **Verify exception message is meaningful** - ❌ **No need to test functionality** (not implemented) - ✅ **Document in driver documentation** -**For Not Implemented Methods (124+ methods):** +**For Not Implemented Methods (130+ methods):** - ❌ **No tests needed** (feature not applicable) - ✅ **Document limitation in driver documentation** - ✅ **Consider adding to FAQ/Known Limitations section** @@ -1714,17 +1715,17 @@ This appendix provides a comprehensive inventory of ALL JDBC methods across all **Revised Coverage Analysis:** When we exclude methods that are intentionally not supported (not applicable to Databricks): -- **Excluded:** CallableStatement (100+ methods), Updatable ResultSet (130+ methods), LOBs (30+ methods) -- **Excluded Total:** ~260 methods not applicable to Databricks +- **Excluded:** CallableStatement (100+ methods), Updatable ResultSet (130+ methods), and unsupported LOB operations (approximately 26 methods) +- **Excluded Total:** ~256 methods not applicable to Databricks **Adjusted Coverage:** -- **Applicable Methods:** 588 - 260 = 328 methods +- **Applicable Methods:** 588 - 256 = 332 methods - **Tested Methods:** 44 integration tests cover ~44 methods -- **Applicable Coverage:** 44/328 = **13.4%** (vs. 28% when including non-applicable methods) -- **Implemented & Applicable:** 367 - 130 (updateXXX) - 100 (CallableStatement) = 137 methods -- **Implementation Coverage:** 367/328 = **112%** (includes partial implementations) +- **Applicable Coverage:** 44/332 = **13.3%** (vs. 28% when including non-applicable methods) +- **Implemented & Applicable:** 371 - 130 (updateXXX) - 100 (CallableStatement) = 141 methods +- **Implementation Coverage:** 371/332 = **112%** (includes partial implementations) -**Key Insight:** We have good *implementation* coverage (62% overall, 100% for applicable features), but poor *integration test* coverage (13.4% of applicable methods). +**Key Insight:** We have good *implementation* coverage (63% overall, 100% for applicable features), but poor *integration test* coverage (13.3% of applicable methods). #### D.6 Detailed Method Inventory @@ -1771,7 +1772,7 @@ Based on the complete inventory, integration tests should focus on: **Tier 3 - NOT_SUPPORTED Verification (Low Priority):** - ⚠️ Verify exceptions for updateable ResultSet methods - ⚠️ Verify exceptions for scrollable ResultSet methods -- ⚠️ Verify exceptions for LOB operations +- ⚠️ Verify exceptions for unsupported LOB retrieval/update and BLOB/NCLOB operations - ⚠️ Verify exceptions for generated keys - **Estimated:** 10-15 integration tests needed diff --git a/src/main/java/com/databricks/jdbc/api/impl/DatabricksClob.java b/src/main/java/com/databricks/jdbc/api/impl/DatabricksClob.java new file mode 100644 index 000000000..bebd3d276 --- /dev/null +++ b/src/main/java/com/databricks/jdbc/api/impl/DatabricksClob.java @@ -0,0 +1,275 @@ +package com.databricks.jdbc.api.impl; + +import com.databricks.jdbc.exception.DatabricksSQLException; +import com.databricks.jdbc.model.telemetry.enums.DatabricksDriverErrorCode; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.Reader; +import java.io.StringReader; +import java.io.Writer; +import java.nio.charset.StandardCharsets; +import java.sql.Clob; +import java.sql.SQLException; + +/** In-memory {@link Clob} used for JDBC CLOB construction and parameter binding. */ +public final class DatabricksClob implements Clob { + private StringBuilder value = new StringBuilder(); + + @Override + public long length() throws SQLException { + ensureOpen(); + return value.length(); + } + + @Override + public String getSubString(long pos, int length) throws SQLException { + ensureOpen(); + if (length < 0) { + throw validationError("Invalid CLOB substring length: " + length); + } + int start = substringPosition(pos, length); + int end = (int) Math.min((long) start + length, value.length()); + return value.substring(start, end); + } + + @Override + public Reader getCharacterStream() throws SQLException { + ensureOpen(); + return new StringReader(value.toString()); + } + + @Override + public InputStream getAsciiStream() throws SQLException { + ensureOpen(); + return new ByteArrayInputStream(value.toString().getBytes(StandardCharsets.US_ASCII)); + } + + @Override + public long position(String searchstr, long start) throws SQLException { + ensureOpen(); + if (searchstr == null) { + throw validationError("CLOB search string cannot be null"); + } + if (start < 1) { + throw validationError("CLOB search position must be at least 1"); + } + if (start > (long) value.length() + 1) { + return -1; + } + int result = value.indexOf(searchstr, (int) start - 1); + return result < 0 ? -1 : result + 1L; + } + + @Override + public long position(Clob searchstr, long start) throws SQLException { + ensureOpen(); + if (searchstr == null) { + throw validationError("CLOB search value cannot be null"); + } + if (start < 1) { + throw validationError("CLOB search position must be at least 1"); + } + long searchLength = searchstr.length(); + if (searchLength > value.length()) { + return -1; + } + if (searchLength == 0) { + return position("", start); + } + return position(searchstr.getSubString(1, (int) searchLength), start); + } + + @Override + public int setString(long pos, String str) throws SQLException { + ensureOpen(); + if (str == null) { + throw validationError("CLOB value cannot be null"); + } + return setString(pos, str, 0, str.length()); + } + + @Override + public int setString(long pos, String str, int offset, int len) throws SQLException { + ensureOpen(); + if (str == null) { + throw validationError("CLOB value cannot be null"); + } + if (offset < 0 || len < 0 || (long) offset + len > str.length()) { + throw validationError("Invalid CLOB string range"); + } + int start = writePosition(pos); + String replacement = str.substring(offset, offset + len); + int end = (int) Math.min((long) start + len, value.length()); + long resultingLength = (long) value.length() - (end - start) + replacement.length(); + if (resultingLength > Integer.MAX_VALUE) { + throw validationError("CLOB value is too large"); + } + value.replace(start, end, replacement); + return len; + } + + @Override + public OutputStream setAsciiStream(long pos) throws SQLException { + ensureOpen(); + writePosition(pos); + return new OutputStream() { + private final ByteArrayOutputStream buffer = new ByteArrayOutputStream(); + private long nextPosition = pos; + private boolean closed; + + @Override + public void write(int b) throws IOException { + ensureNotClosed(); + buffer.write(b); + } + + @Override + public void write(byte[] bytes, int offset, int length) throws IOException { + ensureNotClosed(); + buffer.write(bytes, offset, length); + } + + @Override + public void flush() throws IOException { + ensureNotClosed(); + if (buffer.size() == 0) { + return; + } + String pending = buffer.toString(StandardCharsets.US_ASCII); + try { + DatabricksClob.this.setString(nextPosition, pending); + } catch (SQLException e) { + throw new IOException("Unable to write CLOB ASCII stream", e); + } + nextPosition += pending.length(); + buffer.reset(); + } + + @Override + public void close() throws IOException { + if (closed) { + return; + } + flush(); + closed = true; + } + + private void ensureNotClosed() throws IOException { + if (closed) { + throw new IOException("CLOB ASCII stream is closed"); + } + } + }; + } + + @Override + public Writer setCharacterStream(long pos) throws SQLException { + ensureOpen(); + writePosition(pos); + return new Writer() { + private final StringBuilder buffer = new StringBuilder(); + private long nextPosition = pos; + private boolean closed; + + @Override + public void write(char[] cbuf, int off, int len) throws IOException { + if (closed) { + throw new IOException("CLOB character stream is closed"); + } + buffer.append(cbuf, off, len); + } + + @Override + public void flush() throws IOException { + if (closed) { + throw new IOException("CLOB character stream is closed"); + } + if (buffer.length() == 0) { + return; + } + String pending = buffer.toString(); + try { + DatabricksClob.this.setString(nextPosition, pending); + } catch (SQLException e) { + throw new IOException("Unable to write CLOB character stream", e); + } + nextPosition += pending.length(); + buffer.setLength(0); + } + + @Override + public void close() throws IOException { + if (closed) { + return; + } + flush(); + closed = true; + } + }; + } + + @Override + public void truncate(long len) throws SQLException { + ensureOpen(); + if (len < 0 || len > value.length()) { + throw validationError("Invalid CLOB truncate length: " + len); + } + value.setLength((int) len); + } + + @Override + public void free() { + value = null; + } + + @Override + public Reader getCharacterStream(long pos, long length) throws SQLException { + ensureOpen(); + int start = readPosition(pos); + if (length < 0 || length > Integer.MAX_VALUE || (long) start + length > value.length()) { + throw validationError("Invalid CLOB stream range"); + } + return new StringReader(value.substring(start, start + (int) length)); + } + + private int readPosition(long pos) throws SQLException { + if (pos < 1 || pos > value.length() || pos > Integer.MAX_VALUE) { + throw validationError("Invalid CLOB position: " + pos); + } + return (int) pos - 1; + } + + private int substringPosition(long pos, int length) throws SQLException { + long maximumPosition = (long) value.length() + (length == 0 ? 1 : 0); + if (pos < 1 || pos > maximumPosition || pos > Integer.MAX_VALUE) { + throw validationError("Invalid CLOB position: " + pos); + } + return (int) pos - 1; + } + + private int writePosition(long pos) throws SQLException { + if (pos < 1 || pos > (long) value.length() + 1 || pos > Integer.MAX_VALUE) { + throw validationError("Invalid CLOB position: " + pos); + } + return (int) pos - 1; + } + + private void ensureOpen() throws SQLException { + if (value == null) { + throw new DatabricksSQLException( + "CLOB has been freed", + DatabricksDriverErrorCode.INVALID_STATE.name(), + DatabricksDriverErrorCode.INVALID_STATE); + } + } + + private static DatabricksSQLException validationError(String message) { + return new DatabricksSQLException( + message, + DatabricksDriverErrorCode.INPUT_VALIDATION_ERROR.name(), + DatabricksDriverErrorCode.INPUT_VALIDATION_ERROR); + } +} diff --git a/src/main/java/com/databricks/jdbc/api/impl/DatabricksConnection.java b/src/main/java/com/databricks/jdbc/api/impl/DatabricksConnection.java index cdf133c0a..3313b4b31 100644 --- a/src/main/java/com/databricks/jdbc/api/impl/DatabricksConnection.java +++ b/src/main/java/com/databricks/jdbc/api/impl/DatabricksConnection.java @@ -728,8 +728,13 @@ public PreparedStatement prepareStatement(String sql, String[] columnNames) thro @Override public Clob createClob() throws SQLException { LOGGER.debug("public Clob createClob()"); - throw new DatabricksSQLFeatureNotImplementedException( - "Not implemented in DatabricksConnection - createClob()"); + if (isClosed()) { + throw new DatabricksSQLException( + "Connection is closed", + DatabricksDriverErrorCode.CONNECTION_CLOSED.name(), + DatabricksDriverErrorCode.CONNECTION_CLOSED); + } + return new DatabricksClob(); } @Override diff --git a/src/main/java/com/databricks/jdbc/api/impl/DatabricksPreparedStatement.java b/src/main/java/com/databricks/jdbc/api/impl/DatabricksPreparedStatement.java index 32e856aac..25ab785f0 100644 --- a/src/main/java/com/databricks/jdbc/api/impl/DatabricksPreparedStatement.java +++ b/src/main/java/com/databricks/jdbc/api/impl/DatabricksPreparedStatement.java @@ -278,9 +278,11 @@ public void setTimestamp(int parameterIndex, Timestamp x) throws SQLException { public void setAsciiStream(int parameterIndex, InputStream x, int length) throws SQLException { LOGGER.debug("public void setAsciiStream(int parameterIndex, InputStream x, int length)"); checkIfClosed(); - byte[] bytes = readBytesFromInputStream(x, length); - String asciiString = new String(bytes, StandardCharsets.US_ASCII); - setObject(parameterIndex, asciiString, DatabricksTypeUtil.STRING); + validateStreamLength(length); + setObject( + parameterIndex, + readStringFromInputStream(x, length, StandardCharsets.US_ASCII), + DatabricksTypeUtil.STRING); } @Override @@ -388,18 +390,8 @@ public void setCharacterStream(int parameterIndex, Reader reader, int length) throws SQLException { LOGGER.debug("public void setCharacterStream(int parameterIndex, Reader reader, int length)"); checkIfClosed(); - try { - char[] buffer = new char[length]; - int charsRead = reader.read(buffer); - checkLength(charsRead, length); - String str = new String(buffer); - setObject(parameterIndex, str, DatabricksTypeUtil.STRING); - } catch (IOException e) { - String errorMessage = "Error reading from the Reader"; - LOGGER.error(errorMessage); - throw new DatabricksSQLException( - errorMessage, e, DatabricksDriverErrorCode.INPUT_VALIDATION_ERROR); - } + validateStreamLength(length); + setObject(parameterIndex, readStringFromReader(reader, length), DatabricksTypeUtil.STRING); } @Override @@ -419,8 +411,17 @@ public void setBlob(int parameterIndex, Blob x) throws SQLException { @Override public void setClob(int parameterIndex, Clob x) throws SQLException { LOGGER.debug("public void setClob(int parameterIndex, Clob x)"); - throw new DatabricksSQLFeatureNotSupportedException( - "Not implemented in DatabricksPreparedStatement - setClob(int parameterIndex, Clob x)"); + checkIfClosed(); + if (x == null) { + setObject(parameterIndex, null, DatabricksTypeUtil.STRING); + return; + } + long length = x.length(); + if (length > Integer.MAX_VALUE) { + throw inputValidationError("CLOB value is too large"); + } + String value = length == 0 ? "" : x.getSubString(1, (int) length); + setObject(parameterIndex, value, DatabricksTypeUtil.STRING); } @Override @@ -540,8 +541,11 @@ public void setNClob(int parameterIndex, NClob value) throws SQLException { @Override public void setClob(int parameterIndex, Reader reader, long length) throws SQLException { LOGGER.debug("public void setClob(int parameterIndex, Reader reader, long length)"); - throw new DatabricksSQLFeatureNotSupportedException( - "Not implemented in DatabricksPreparedStatement - setClob(int parameterIndex, Reader reader, long length)"); + checkIfClosed(); + if (length < 0 || length > Integer.MAX_VALUE) { + throw inputValidationError("Invalid CLOB length: " + length); + } + setCharacterStream(parameterIndex, reader, length); } @Override @@ -607,6 +611,7 @@ public void setObject(int parameterIndex, Object x, int targetSqlType, int scale public void setAsciiStream(int parameterIndex, InputStream x, long length) throws SQLException { LOGGER.debug("public void setAsciiStream(int parameterIndex, InputStream x, long length)"); checkIfClosed(); + validateStreamLength(length); setObject( parameterIndex, readStringFromInputStream(x, length, StandardCharsets.US_ASCII), @@ -625,6 +630,7 @@ public void setCharacterStream(int parameterIndex, Reader reader, long length) throws SQLException { LOGGER.debug("public void setCharacterStream(int parameterIndex, Reader reader, long length)"); checkIfClosed(); + validateStreamLength(length); setObject(parameterIndex, readStringFromReader(reader, length), DatabricksTypeUtil.STRING); } @@ -662,8 +668,7 @@ public void setNCharacterStream(int parameterIndex, Reader value) throws SQLExce @Override public void setClob(int parameterIndex, Reader reader) throws SQLException { LOGGER.debug("public void setClob(int parameterIndex, Reader reader)"); - throw new DatabricksSQLFeatureNotSupportedException( - "Not implemented in DatabricksPreparedStatement - setClob(int parameterIndex, Reader reader)"); + setCharacterStream(parameterIndex, reader); } @Override @@ -743,17 +748,6 @@ public void addBatch(String sql) throws SQLException { "Method not supported: addBatch(String sql)"); } - private void checkLength(long targetLength, long sourceLength) throws SQLException { - if (targetLength != sourceLength) { - String errorMessage = - String.format( - "Unexpected number of bytes read from the stream. Expected: %d, got: %d", - targetLength, sourceLength); - LOGGER.error(errorMessage); - throw new DatabricksSQLException(errorMessage, DatabricksDriverErrorCode.INVALID_STATE); - } - } - private void checkIfBatchOperation() throws DatabricksSQLException { if (!this.databricksBatchParameterMetaData.isEmpty()) { String errorMessage = @@ -763,24 +757,6 @@ private void checkIfBatchOperation() throws DatabricksSQLException { } } - private byte[] readBytesFromInputStream(InputStream x, int length) throws SQLException { - if (x == null) { - String errorMessage = "InputStream cannot be null"; - LOGGER.error(errorMessage); - throw new DatabricksSQLException(errorMessage, DatabricksDriverErrorCode.INVALID_STATE); - } - byte[] bytes = new byte[length]; - try { - int bytesRead = x.read(bytes); - checkLength(bytesRead, length); - } catch (IOException e) { - String errorMessage = "Error reading from the InputStream"; - LOGGER.error(errorMessage); - throw new DatabricksSQLException(errorMessage, e, DatabricksDriverErrorCode.INVALID_STATE); - } - return bytes; - } - /** * Reads bytes from the provided {@link InputStream} up to the specified length and returns them * as a {@link String} decoded using the specified {@link Charset}. If the specified length is -1, @@ -798,24 +774,39 @@ private String readStringFromInputStream(InputStream inputStream, long length, C if (inputStream == null) { String message = "InputStream cannot be null"; LOGGER.error(message); - throw new DatabricksValidationException(message); + throw inputValidationError(message); } try (ByteArrayOutputStream buffer = new ByteArrayOutputStream()) { byte[] chunk = new byte[CHUNK_SIZE]; long bytesRead = 0; int nRead; - while ((length != -1 && bytesRead < length) && (nRead = inputStream.read(chunk)) != -1) { + while (length == -1 || bytesRead < length) { + int readLength = + length == -1 ? chunk.length : (int) Math.min(chunk.length, length - bytesRead); + nRead = inputStream.read(chunk, 0, readLength); + if (nRead == -1) { + break; + } buffer.write(chunk, 0, nRead); bytesRead += nRead; } - if (length != -1) { - checkLength(length, bytesRead); + if (length != -1 && length != bytesRead) { + String message = + String.format( + "Unexpected number of bytes read from the InputStream. Expected: %d, got: %d", + length, bytesRead); + LOGGER.error(message); + throw inputValidationError(message); } return buffer.toString(charset); } catch (IOException e) { String message = "Error reading from the InputStream"; LOGGER.error(message); - throw new DatabricksSQLException(message, e, DatabricksDriverErrorCode.INVALID_STATE); + throw new DatabricksSQLException( + message, + DatabricksDriverErrorCode.INPUT_VALIDATION_ERROR.name(), + DatabricksDriverErrorCode.INPUT_VALIDATION_ERROR.getCode(), + e); } } @@ -832,23 +823,38 @@ private String readStringFromReader(Reader reader, long length) throws SQLExcept if (reader == null) { String message = "Reader cannot be null"; LOGGER.error(message); - throw new DatabricksValidationException(message); + throw inputValidationError(message); } try { StringBuilder buffer = new StringBuilder(); char[] chunk = new char[CHUNK_SIZE]; long charsRead = 0; int nRead; - while ((length != -1 && charsRead < length) && (nRead = reader.read(chunk)) != -1) { + while (length == -1 || charsRead < length) { + int readLength = + length == -1 ? chunk.length : (int) Math.min(chunk.length, length - charsRead); + nRead = reader.read(chunk, 0, readLength); + if (nRead == -1) { + break; + } buffer.append(chunk, 0, nRead); charsRead += nRead; } - if (length != -1) { - checkLength(length, charsRead); + if (length != -1 && length != charsRead) { + String message = + String.format( + "Unexpected number of characters read from the Reader. Expected: %d, got: %d", + length, charsRead); + LOGGER.error(message); + throw inputValidationError(message); } return buffer.toString(); } catch (IOException e) { - throw new RuntimeException(e); + throw new DatabricksSQLException( + "Error reading from the Reader", + DatabricksDriverErrorCode.INPUT_VALIDATION_ERROR.name(), + DatabricksDriverErrorCode.INPUT_VALIDATION_ERROR.getCode(), + e); } } @@ -862,6 +868,19 @@ private void setObject(int parameterIndex, Object x, String databricksType) { .build()); } + private static DatabricksSQLException inputValidationError(String message) { + return new DatabricksSQLException( + message, + DatabricksDriverErrorCode.INPUT_VALIDATION_ERROR.name(), + DatabricksDriverErrorCode.INPUT_VALIDATION_ERROR); + } + + private static void validateStreamLength(long length) throws SQLException { + if (length < 0) { + throw inputValidationError("Invalid stream length: " + length); + } + } + private DatabricksResultSet interpolateIfRequiredAndExecute(StatementType statementType) throws SQLException { String interpolatedSql = diff --git a/src/main/java/com/databricks/jdbc/api/impl/DatabricksStatement.java b/src/main/java/com/databricks/jdbc/api/impl/DatabricksStatement.java index d1dd0d30e..676d01dcf 100644 --- a/src/main/java/com/databricks/jdbc/api/impl/DatabricksStatement.java +++ b/src/main/java/com/databricks/jdbc/api/impl/DatabricksStatement.java @@ -971,7 +971,9 @@ DatabricksResultSet getResultFromClient( void checkIfClosed() throws DatabricksSQLException { if (isClosed) { throw new DatabricksSQLException( - "Statement is closed", DatabricksDriverErrorCode.STATEMENT_CLOSED); + "Statement is closed", + DatabricksDriverErrorCode.STATEMENT_CLOSED.name(), + DatabricksDriverErrorCode.STATEMENT_CLOSED); } } diff --git a/src/test/java/com/databricks/jdbc/api/impl/DatabricksClobTest.java b/src/test/java/com/databricks/jdbc/api/impl/DatabricksClobTest.java new file mode 100644 index 000000000..b38050006 --- /dev/null +++ b/src/test/java/com/databricks/jdbc/api/impl/DatabricksClobTest.java @@ -0,0 +1,180 @@ +package com.databricks.jdbc.api.impl; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.databricks.jdbc.exception.DatabricksSQLException; +import com.databricks.jdbc.model.telemetry.enums.DatabricksDriverErrorCode; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.Reader; +import java.io.Writer; +import java.nio.charset.StandardCharsets; +import java.sql.Clob; +import java.sql.SQLException; +import javax.sql.rowset.serial.SerialClob; +import org.junit.jupiter.api.Test; + +class DatabricksClobTest { + + @Test + void supportsStringMutationAndReads() throws Exception { + DatabricksClob clob = new DatabricksClob(); + + assertEquals(0, clob.length()); + assertEquals(5, clob.setString(1, "hello")); + assertEquals(2, clob.setString(2, "XY")); + assertEquals("hXYlo", clob.getSubString(1, 5)); + assertEquals(2, clob.position("XY", 1)); + + clob.truncate(3); + assertEquals("hXY", clob.getSubString(1, 3)); + } + + @Test + void supportsAsciiAndCharacterStreams() throws Exception { + DatabricksClob clob = new DatabricksClob(); + clob.setString(1, "prefix"); + + OutputStream output = clob.setAsciiStream(1); + output.write("ascii".getBytes(StandardCharsets.US_ASCII)); + output.flush(); + assertEquals("asciix", clob.getSubString(1, 6)); + output.close(); + assertThrows(IOException.class, () -> output.write('x')); + + Writer writer = clob.setCharacterStream(6); + writer.write("-chars"); + writer.flush(); + assertEquals("ascii-chars", clob.getSubString(1, 11)); + writer.close(); + assertThrows(IOException.class, () -> writer.write('x')); + + try (InputStream input = clob.getAsciiStream(); + Reader reader = clob.getCharacterStream(6, 6)) { + assertEquals("ascii-chars", new String(input.readAllBytes(), StandardCharsets.US_ASCII)); + assertEquals("-chars", readAll(reader)); + } + } + + @Test + void closingStreamsCommitsBufferedContent() throws Exception { + DatabricksClob clob = new DatabricksClob(); + + try (OutputStream output = clob.setAsciiStream(1)) { + output.write("ascii".getBytes(StandardCharsets.US_ASCII)); + } + assertEquals("ascii", clob.getSubString(1, 5)); + + try (Writer writer = clob.setCharacterStream(6)) { + writer.write("-chars"); + } + assertEquals("ascii-chars", clob.getSubString(1, 11)); + } + + @Test + void flushWritesOnlyNewContent() throws Exception { + DatabricksClob clob = new DatabricksClob(); + clob.setString(1, "0000"); + + OutputStream output = clob.setAsciiStream(1); + output.write("ab".getBytes(StandardCharsets.US_ASCII)); + output.flush(); + clob.setString(1, "XY"); + output.write('c'); + output.flush(); + assertEquals("XYc0", clob.getSubString(1, 4)); + output.close(); + + Writer writer = clob.setCharacterStream(1); + writer.write("mn"); + writer.flush(); + clob.setString(1, "PQ"); + writer.write('o'); + writer.flush(); + assertEquals("PQo0", clob.getSubString(1, 4)); + writer.close(); + } + + @Test + void supportsClobSearchOffsetWritesAndFullCharacterStream() throws Exception { + DatabricksClob clob = new DatabricksClob(); + clob.setString(1, "hello"); + + assertEquals(3, clob.setString(2, "ABCDE", 1, 3)); + assertEquals("hBCDo", clob.getSubString(1, 5)); + + DatabricksClob search = new DatabricksClob(); + search.setString(1, "BCD"); + assertEquals(2, clob.position(search, 1)); + assertEquals(-1, clob.position(search, 3)); + try (Reader reader = clob.getCharacterStream()) { + assertEquals("hBCDo", readAll(reader)); + } + } + + @Test + void rejectsInvalidRanges() throws Exception { + DatabricksClob clob = new DatabricksClob(); + clob.setString(1, "value"); + + DatabricksSQLException invalidPosition = + assertThrows(DatabricksSQLException.class, () -> clob.getSubString(0, 1)); + assertEquals( + DatabricksDriverErrorCode.INPUT_VALIDATION_ERROR.name(), invalidPosition.getSQLState()); + assertEquals( + DatabricksDriverErrorCode.INPUT_VALIDATION_ERROR.getCode(), invalidPosition.getErrorCode()); + assertEquals("", clob.getSubString(6, 0)); + assertThrows(SQLException.class, () -> clob.getSubString(1, -1)); + assertEquals("value", clob.getSubString(1, 6)); + assertEquals("ue", clob.getSubString(4, 99)); + assertThrows(SQLException.class, () -> clob.getCharacterStream(1, 6)); + assertThrows(SQLException.class, () -> clob.setString(1, "value", -1, 1)); + assertThrows(SQLException.class, () -> clob.setString(1, "value", 0, -1)); + assertThrows(SQLException.class, () -> clob.setString(1, "value", 3, 3)); + assertThrows(SQLException.class, () -> clob.setString(7, "x")); + assertThrows(SQLException.class, () -> clob.truncate(6)); + } + + @Test + void supportsEmptyValuesAndRejectsInvalidSearchStarts() throws Exception { + DatabricksClob clob = new DatabricksClob(); + + assertEquals("", clob.getSubString(1, 0)); + + Clob oversizedSearch = mock(Clob.class); + when(oversizedSearch.length()).thenReturn((long) Integer.MAX_VALUE + 1); + assertEquals(-1, clob.position(oversizedSearch, 1)); + assertThrows(SQLException.class, () -> clob.position(oversizedSearch, 0)); + + clob.setString(1, "value"); + assertEquals(3, clob.position(new SerialClob(new char[0]), 3)); + } + + @Test + void freeInvalidatesTheClob() throws Exception { + DatabricksClob clob = new DatabricksClob(); + clob.setString(1, "value"); + + clob.free(); + + DatabricksSQLException exception = assertThrows(DatabricksSQLException.class, clob::length); + assertEquals(DatabricksDriverErrorCode.INVALID_STATE.name(), exception.getSQLState()); + assertEquals(DatabricksDriverErrorCode.INVALID_STATE.getCode(), exception.getErrorCode()); + assertThrows(SQLException.class, clob::getCharacterStream); + assertThrows(SQLException.class, () -> clob.setString(1, "other")); + } + + private static String readAll(Reader reader) throws Exception { + StringBuilder result = new StringBuilder(); + char[] buffer = new char[32]; + int count; + while ((count = reader.read(buffer)) != -1) { + result.append(buffer, 0, count); + } + return result.toString(); + } +} diff --git a/src/test/java/com/databricks/jdbc/api/impl/DatabricksConnectionTest.java b/src/test/java/com/databricks/jdbc/api/impl/DatabricksConnectionTest.java index fefcbeb68..8639b0ebb 100644 --- a/src/test/java/com/databricks/jdbc/api/impl/DatabricksConnectionTest.java +++ b/src/test/java/com/databricks/jdbc/api/impl/DatabricksConnectionTest.java @@ -364,6 +364,12 @@ public void testClosedConnection() throws SQLException { connection.close(); assertFalse(connection.isValid(1)); assertThrows(DatabricksSQLException.class, connection::isReadOnly); + DatabricksSQLException createClobException = + assertThrows(DatabricksSQLException.class, connection::createClob); + assertEquals( + DatabricksDriverErrorCode.CONNECTION_CLOSED.name(), createClobException.getSQLState()); + assertEquals( + DatabricksDriverErrorCode.CONNECTION_CLOSED.getCode(), createClobException.getErrorCode()); } @Test @@ -564,7 +570,10 @@ void testUnsupportedOperations() throws SQLException { // With default IgnoreTransactions=1, savepoint methods return null (no-op) assertNull(connection.setSavepoint("1")); assertNull(connection.setSavepoint()); - assertThrows(DatabricksSQLFeatureNotImplementedException.class, connection::createClob); + Clob clob = connection.createClob(); + assertNotNull(clob); + assertEquals(4, clob.setString(1, "test")); + assertEquals("test", clob.getSubString(1, 4)); assertThrows(DatabricksSQLFeatureNotImplementedException.class, connection::createBlob); assertThrows(DatabricksSQLFeatureNotImplementedException.class, connection::createNClob); assertThrows(DatabricksSQLFeatureNotImplementedException.class, connection::createSQLXML); diff --git a/src/test/java/com/databricks/jdbc/api/impl/DatabricksPreparedStatementTest.java b/src/test/java/com/databricks/jdbc/api/impl/DatabricksPreparedStatementTest.java index 7552e2a93..04e8a9f0e 100644 --- a/src/test/java/com/databricks/jdbc/api/impl/DatabricksPreparedStatementTest.java +++ b/src/test/java/com/databricks/jdbc/api/impl/DatabricksPreparedStatementTest.java @@ -1,6 +1,7 @@ package com.databricks.jdbc.api.impl; import static com.databricks.jdbc.TestConstants.*; +import static com.databricks.jdbc.model.core.ColumnInfoTypeName.STRING; import static java.sql.JDBCType.DECIMAL; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.any; @@ -21,7 +22,9 @@ import com.databricks.jdbc.exception.DatabricksBatchUpdateException; import com.databricks.jdbc.exception.DatabricksSQLException; import com.databricks.jdbc.exception.DatabricksSQLFeatureNotSupportedException; +import com.databricks.jdbc.model.telemetry.enums.DatabricksDriverErrorCode; import java.io.ByteArrayInputStream; +import java.io.IOException; import java.io.InputStream; import java.io.Reader; import java.io.StringReader; @@ -600,44 +603,52 @@ public void testSetTimestampWithNullCalendar() throws DatabricksSQLException { } @Test - public void testSetAsciiStream() throws DatabricksSQLException { + public void testSetAsciiStream() throws SQLException { setupMocks(); DatabricksPreparedStatement preparedStatement = new DatabricksPreparedStatement(connection, STATEMENT); - byte[] bytes = {0x01, 0x02, 0x03, 0x04}; + byte[] bytes = "prefix-trailing".getBytes(StandardCharsets.US_ASCII); InputStream asciiStream = new ByteArrayInputStream(bytes); - assertDoesNotThrow(() -> preparedStatement.setAsciiStream(1, asciiStream, bytes.length)); + preparedStatement.setAsciiStream(1, asciiStream, 6); + + assertEquals("prefix", getBoundValue(preparedStatement, 1)); + assertEquals(STRING, getBoundParameter(preparedStatement, 1).type()); } @Test - public void testSetAsciiStreamWithLong() throws DatabricksSQLException { + public void testSetAsciiStreamWithLong() throws SQLException { setupMocks(); DatabricksPreparedStatement preparedStatement = new DatabricksPreparedStatement(connection, STATEMENT); - byte[] bytes = {0x01, 0x02, 0x03, 0x04}; + byte[] bytes = "prefix-trailing".getBytes(StandardCharsets.US_ASCII); InputStream asciiStream = new ByteArrayInputStream(bytes); - assertDoesNotThrow(() -> preparedStatement.setAsciiStream(1, asciiStream, (long) bytes.length)); + preparedStatement.setAsciiStream(1, asciiStream, 6L); + + assertEquals("prefix", getBoundValue(preparedStatement, 1)); + assertEquals(STRING, getBoundParameter(preparedStatement, 1).type()); } @Test - public void testSetCharacterStream() throws DatabricksSQLException { + public void testSetCharacterStream() throws SQLException { setupMocks(); DatabricksPreparedStatement preparedStatement = new DatabricksPreparedStatement(connection, STATEMENT); - String originalString = "Hello, World!"; + String originalString = "prefix-trailing"; Reader characterStream = new StringReader(originalString); - assertDoesNotThrow( - () -> preparedStatement.setCharacterStream(1, characterStream, originalString.length())); + preparedStatement.setCharacterStream(1, characterStream, 6); + + assertEquals("prefix", getBoundValue(preparedStatement, 1)); + assertEquals(STRING, getBoundParameter(preparedStatement, 1).type()); } @Test - public void testSetCharacterStreamWithLong() throws DatabricksSQLException { + public void testSetCharacterStreamWithLong() throws SQLException { setupMocks(); DatabricksPreparedStatement preparedStatement = new DatabricksPreparedStatement(connection, STATEMENT); @@ -645,14 +656,23 @@ public void testSetCharacterStreamWithLong() throws DatabricksSQLException { String originalString = "Hello, World!"; Reader characterStream = new StringReader(originalString); - assertDoesNotThrow( - () -> - preparedStatement.setCharacterStream( - 1, characterStream, (long) originalString.length())); + preparedStatement.setCharacterStream(1, characterStream, (long) originalString.length()); + + assertEquals(originalString, getBoundValue(preparedStatement, 1)); + assertEquals(STRING, getBoundParameter(preparedStatement, 1).type()); + + DatabricksSQLException invalidLength = + assertThrows( + DatabricksSQLException.class, + () -> preparedStatement.setCharacterStream(1, new StringReader("value"), -1L)); + assertEquals( + DatabricksDriverErrorCode.INPUT_VALIDATION_ERROR.name(), invalidLength.getSQLState()); + assertEquals( + DatabricksDriverErrorCode.INPUT_VALIDATION_ERROR.getCode(), invalidLength.getErrorCode()); } @Test - public void testSetAsciiStreamWithoutLength() throws DatabricksSQLException { + public void testSetAsciiStreamWithoutLength() throws SQLException { setupMocks(); DatabricksPreparedStatement preparedStatement = new DatabricksPreparedStatement(connection, STATEMENT); @@ -660,7 +680,63 @@ public void testSetAsciiStreamWithoutLength() throws DatabricksSQLException { byte[] bytes = "Hello, World!".getBytes(StandardCharsets.US_ASCII); InputStream asciiStream = new ByteArrayInputStream(bytes); - assertDoesNotThrow(() -> preparedStatement.setAsciiStream(1, asciiStream)); + preparedStatement.setAsciiStream(1, asciiStream); + + assertEquals("Hello, World!", getBoundValue(preparedStatement, 1)); + assertEquals(STRING, getBoundParameter(preparedStatement, 1).type()); + } + + @Test + public void testSetAsciiStreamValidation() throws SQLException { + setupMocks(); + DatabricksPreparedStatement preparedStatement = + new DatabricksPreparedStatement(connection, STATEMENT); + + DatabricksSQLException shortStream = + assertThrows( + DatabricksSQLException.class, + () -> + preparedStatement.setAsciiStream( + 1, new ByteArrayInputStream("short".getBytes(StandardCharsets.US_ASCII)), 6L)); + assertEquals( + DatabricksDriverErrorCode.INPUT_VALIDATION_ERROR.name(), shortStream.getSQLState()); + assertEquals( + DatabricksDriverErrorCode.INPUT_VALIDATION_ERROR.getCode(), shortStream.getErrorCode()); + assertEquals( + "Unexpected number of bytes read from the InputStream. Expected: 6, got: 5", + shortStream.getMessage()); + + DatabricksSQLException nullStream = + assertThrows( + DatabricksSQLException.class, + () -> preparedStatement.setAsciiStream(1, (InputStream) null)); + assertEquals(DatabricksDriverErrorCode.INPUT_VALIDATION_ERROR.name(), nullStream.getSQLState()); + assertEquals( + DatabricksDriverErrorCode.INPUT_VALIDATION_ERROR.getCode(), nullStream.getErrorCode()); + + DatabricksSQLException invalidLength = + assertThrows( + DatabricksSQLException.class, + () -> preparedStatement.setAsciiStream(1, new ByteArrayInputStream(new byte[0]), -1L)); + assertEquals( + DatabricksDriverErrorCode.INPUT_VALIDATION_ERROR.name(), invalidLength.getSQLState()); + assertEquals( + DatabricksDriverErrorCode.INPUT_VALIDATION_ERROR.getCode(), invalidLength.getErrorCode()); + + InputStream failingStream = + new InputStream() { + @Override + public int read() throws IOException { + throw new IOException("read failed"); + } + }; + DatabricksSQLException readFailure = + assertThrows( + DatabricksSQLException.class, () -> preparedStatement.setAsciiStream(1, failingStream)); + assertEquals( + DatabricksDriverErrorCode.INPUT_VALIDATION_ERROR.name(), readFailure.getSQLState()); + assertEquals( + DatabricksDriverErrorCode.INPUT_VALIDATION_ERROR.getCode(), readFailure.getErrorCode()); } @Test @@ -686,7 +762,7 @@ public void testSetBytes() throws DatabricksSQLException { } @Test - public void testSetCharacterStreamWithoutLength() throws DatabricksSQLException { + public void testSetCharacterStreamWithoutLength() throws SQLException { setupMocks(); DatabricksPreparedStatement preparedStatement = new DatabricksPreparedStatement(connection, STATEMENT); @@ -695,6 +771,192 @@ public void testSetCharacterStreamWithoutLength() throws DatabricksSQLException Reader characterStream = new StringReader(originalString); assertDoesNotThrow(() -> preparedStatement.setCharacterStream(1, characterStream)); + assertEquals(originalString, getBoundValue(preparedStatement, 1)); + } + + @Test + public void testSetClob() throws Exception { + setupMocks(); + DatabricksPreparedStatement preparedStatement = + new DatabricksPreparedStatement(connection, STATEMENT); + DatabricksClob clob = new DatabricksClob(); + clob.setString(1, "clob value"); + + preparedStatement.setClob(1, clob); + + assertEquals("clob value", getBoundValue(preparedStatement, 1)); + assertEquals(STRING, getBoundParameter(preparedStatement, 1).type()); + } + + @Test + public void testSetEmptyAndFreedClob() throws Exception { + setupMocks(); + DatabricksPreparedStatement preparedStatement = + new DatabricksPreparedStatement(connection, STATEMENT); + DatabricksClob clob = new DatabricksClob(); + + preparedStatement.setClob(1, clob); + assertEquals("", getBoundValue(preparedStatement, 1)); + assertEquals(STRING, getBoundParameter(preparedStatement, 1).type()); + + clob.free(); + DatabricksSQLException exception = + assertThrows(DatabricksSQLException.class, () -> preparedStatement.setClob(1, clob)); + assertEquals(DatabricksDriverErrorCode.INVALID_STATE.name(), exception.getSQLState()); + assertEquals(DatabricksDriverErrorCode.INVALID_STATE.getCode(), exception.getErrorCode()); + } + + @Test + public void testSetClobOverloadsRejectClosedStatement() throws Exception { + setupMocks(); + DatabricksPreparedStatement preparedStatement = + new DatabricksPreparedStatement(connection, STATEMENT); + preparedStatement.close(); + + DatabricksSQLException clobException = + assertThrows( + DatabricksSQLException.class, () -> preparedStatement.setClob(1, new DatabricksClob())); + assertEquals(DatabricksDriverErrorCode.STATEMENT_CLOSED.name(), clobException.getSQLState()); + assertEquals( + DatabricksDriverErrorCode.STATEMENT_CLOSED.getCode(), clobException.getErrorCode()); + + DatabricksSQLException readerWithLengthException = + assertThrows( + DatabricksSQLException.class, + () -> preparedStatement.setClob(1, new StringReader("value"), 5)); + assertEquals( + DatabricksDriverErrorCode.STATEMENT_CLOSED.name(), readerWithLengthException.getSQLState()); + assertEquals( + DatabricksDriverErrorCode.STATEMENT_CLOSED.getCode(), + readerWithLengthException.getErrorCode()); + + DatabricksSQLException invalidLengthException = + assertThrows( + DatabricksSQLException.class, + () -> preparedStatement.setClob(1, new StringReader("value"), -1)); + assertEquals( + DatabricksDriverErrorCode.STATEMENT_CLOSED.name(), invalidLengthException.getSQLState()); + assertEquals( + DatabricksDriverErrorCode.STATEMENT_CLOSED.getCode(), + invalidLengthException.getErrorCode()); + + DatabricksSQLException readerException = + assertThrows( + DatabricksSQLException.class, + () -> preparedStatement.setClob(1, new StringReader("value"))); + assertEquals(DatabricksDriverErrorCode.STATEMENT_CLOSED.name(), readerException.getSQLState()); + assertEquals( + DatabricksDriverErrorCode.STATEMENT_CLOSED.getCode(), readerException.getErrorCode()); + } + + @Test + public void testSetClobReaderOverloads() throws Exception { + setupMocks(); + DatabricksPreparedStatement preparedStatement = + new DatabricksPreparedStatement(connection, STATEMENT); + + preparedStatement.setClob(1, new StringReader("prefix-trailing"), 6); + assertEquals("prefix", getBoundValue(preparedStatement, 1)); + assertEquals(STRING, getBoundParameter(preparedStatement, 1).type()); + + Reader chunkedReader = + new StringReader("prefix-trailing") { + @Override + public int read(char[] buffer, int offset, int length) throws IOException { + return super.read(buffer, offset, Math.min(2, length)); + } + }; + preparedStatement.setClob(1, chunkedReader, 6); + assertEquals("prefix", getBoundValue(preparedStatement, 1)); + assertEquals(STRING, getBoundParameter(preparedStatement, 1).type()); + + preparedStatement.setClob(1, new StringReader("without length")); + assertEquals("without length", getBoundValue(preparedStatement, 1)); + assertEquals(STRING, getBoundParameter(preparedStatement, 1).type()); + + DatabricksSQLException invalidLength = + assertThrows( + DatabricksSQLException.class, + () -> preparedStatement.setClob(1, new StringReader("value"), -1)); + assertEquals( + DatabricksDriverErrorCode.INPUT_VALIDATION_ERROR.name(), invalidLength.getSQLState()); + assertEquals( + DatabricksDriverErrorCode.INPUT_VALIDATION_ERROR.getCode(), invalidLength.getErrorCode()); + assertThrows( + DatabricksSQLException.class, + () -> + preparedStatement.setClob(1, new StringReader("value"), (long) Integer.MAX_VALUE + 1)); + + DatabricksSQLException shortReader = + assertThrows( + DatabricksSQLException.class, + () -> preparedStatement.setClob(1, new StringReader("short"), 6)); + assertEquals( + DatabricksDriverErrorCode.INPUT_VALIDATION_ERROR.name(), shortReader.getSQLState()); + assertEquals( + DatabricksDriverErrorCode.INPUT_VALIDATION_ERROR.getCode(), shortReader.getErrorCode()); + assertEquals( + "Unexpected number of characters read from the Reader. Expected: 6, got: 5", + shortReader.getMessage()); + + DatabricksSQLException nullReader = + assertThrows( + DatabricksSQLException.class, () -> preparedStatement.setClob(1, (Reader) null)); + assertEquals(DatabricksDriverErrorCode.INPUT_VALIDATION_ERROR.name(), nullReader.getSQLState()); + assertEquals( + DatabricksDriverErrorCode.INPUT_VALIDATION_ERROR.getCode(), nullReader.getErrorCode()); + } + + @Test + public void testSetClobReaderFailure() throws Exception { + setupMocks(); + DatabricksPreparedStatement preparedStatement = + new DatabricksPreparedStatement(connection, STATEMENT); + Reader failingReader = + new Reader() { + @Override + public int read(char[] buffer, int offset, int length) throws IOException { + throw new IOException("read failed"); + } + + @Override + public void close() {} + }; + + DatabricksSQLException exception = + assertThrows( + DatabricksSQLException.class, () -> preparedStatement.setClob(1, failingReader)); + + assertEquals(DatabricksDriverErrorCode.INPUT_VALIDATION_ERROR.name(), exception.getSQLState()); + assertEquals( + DatabricksDriverErrorCode.INPUT_VALIDATION_ERROR.getCode(), exception.getErrorCode()); + } + + @Test + public void testSetOversizedClob() throws Exception { + setupMocks(); + DatabricksPreparedStatement preparedStatement = + new DatabricksPreparedStatement(connection, STATEMENT); + Clob clob = mock(Clob.class); + when(clob.length()).thenReturn((long) Integer.MAX_VALUE + 1); + + DatabricksSQLException exception = + assertThrows(DatabricksSQLException.class, () -> preparedStatement.setClob(1, clob)); + assertEquals(DatabricksDriverErrorCode.INPUT_VALIDATION_ERROR.name(), exception.getSQLState()); + assertEquals( + DatabricksDriverErrorCode.INPUT_VALIDATION_ERROR.getCode(), exception.getErrorCode()); + } + + @Test + public void testSetNullClob() throws Exception { + setupMocks(); + DatabricksPreparedStatement preparedStatement = + new DatabricksPreparedStatement(connection, STATEMENT); + + preparedStatement.setClob(1, (Clob) null); + + assertNull(getBoundValue(preparedStatement, 1)); + assertEquals(STRING, getBoundParameter(preparedStatement, 1).type()); } @Test @@ -879,9 +1141,6 @@ void testUnsupportedMethods() throws DatabricksSQLException { assertThrows( DatabricksSQLFeatureNotSupportedException.class, () -> preparedStatement.setBlob(1, (Blob) null)); - assertThrows( - DatabricksSQLFeatureNotSupportedException.class, - () -> preparedStatement.setClob(1, (Clob) null)); assertThrows( DatabricksSQLFeatureNotSupportedException.class, () -> preparedStatement.setRef(1, null)); assertThrows( @@ -897,9 +1156,6 @@ void testUnsupportedMethods() throws DatabricksSQLException { assertThrows( DatabricksSQLFeatureNotSupportedException.class, () -> preparedStatement.setNClob(1, (NClob) null)); - assertThrows( - DatabricksSQLFeatureNotSupportedException.class, - () -> preparedStatement.setClob(1, null, 1)); assertThrows( DatabricksSQLFeatureNotSupportedException.class, () -> preparedStatement.setBlob(1, null, 1)); @@ -927,9 +1183,6 @@ void testUnsupportedMethods() throws DatabricksSQLException { assertThrows( DatabricksSQLFeatureNotSupportedException.class, () -> preparedStatement.setUnicodeStream(1, InputStream.nullInputStream(), 1)); - assertThrows( - DatabricksSQLFeatureNotSupportedException.class, - () -> preparedStatement.setClob(1, Reader.nullReader())); assertThrows( DatabricksSQLFeatureNotSupportedException.class, () -> preparedStatement.setBlob(1, InputStream.nullInputStream())); @@ -972,6 +1225,18 @@ void testUnsupportedMethods() throws DatabricksSQLException { () -> preparedStatement.execute("SELECT * FROM table", new String[] {"column"})); } + private static Object getBoundValue( + DatabricksPreparedStatement preparedStatement, int parameterIndex) throws SQLException { + return getBoundParameter(preparedStatement, parameterIndex).value(); + } + + private static ImmutableSqlParameter getBoundParameter( + DatabricksPreparedStatement preparedStatement, int parameterIndex) throws SQLException { + DatabricksParameterMetaData metadata = + (DatabricksParameterMetaData) preparedStatement.getParameterMetaData(); + return metadata.getParameterBindings().get(parameterIndex); + } + @Test public void testBatchedInsertWithManyParameters() throws Exception { // Test that when supportManyParameters=1, batched inserts can exceed 256 parameters