diff --git a/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlPreparedStatement.java b/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlPreparedStatement.java index a3fb301..4496eac 100644 --- a/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlPreparedStatement.java +++ b/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlPreparedStatement.java @@ -27,7 +27,6 @@ import java.sql.SQLXML; import java.sql.Time; import java.sql.Timestamp; -import java.sql.Types; import java.util.ArrayList; import java.util.Calendar; import java.util.List; @@ -40,16 +39,16 @@ * so there is no post-close execute that could expose a result set. sqlite-jdbc 3.53.4.0 instead * returns generated keys, and rejects {@code addBatch()} with a different message; see issue #87. * - *

Parameter metadata is derived locally. The placeholder count comes from the SQL text, but the - * parameter type, precision, and scale stay unknown because Hrana has no describe request. The - * stream and {@link Blob}/{@link Clob} setters convert to a TEXT or BLOB value locally. An unset - * parameter, including one released by {@link #clearParameters()}, binds SQL NULL. + *

The stream and {@link Blob}/{@link Clob} setters convert to a TEXT or BLOB value locally. An + * unset parameter, including one released by {@link #clearParameters()}, binds SQL NULL. Parameter + * metadata lives in {@link ParameterMetaDataView}. */ public class LibsqlPreparedStatement extends LibsqlStatement implements PreparedStatement, ParameterMetaData { private final String sql; private final boolean returnsResultSet; private final int parameterCount; + private final ParameterMetaDataView parameterMetaData; // Sequential list indexed by JDBC parameter position; no map or boxing overhead. private final List parameters = new ArrayList<>(); @@ -58,6 +57,7 @@ public LibsqlPreparedStatement(LibsqlConnection connection, String sql) { this.sql = sql; this.returnsResultSet = returnsResultSet(sql); this.parameterCount = SqlTokenizer.placeholderCount(sql); + this.parameterMetaData = new ParameterMetaDataView(this, parameterCount); } private void setParam(int parameterIndex, Value value) throws SQLException { @@ -363,73 +363,18 @@ public void setURL(int parameterIndex, URL x) throws SQLException { setString(parameterIndex, x != null ? x.toString() : null); } - @Override - public ParameterMetaData getParameterMetaData() throws SQLException { - checkClosed(); - return this; - } - - @Override - public int getParameterCount() throws SQLException { - checkClosed(); - return parameterCount; - } - - @Override - public int isNullable(int param) throws SQLException { - checkParameterIndex(param); - return ParameterMetaData.parameterNullableUnknown; - } - - @Override - public boolean isSigned(int param) throws SQLException { - checkParameterIndex(param); - return true; - } + @Override public ParameterMetaData getParameterMetaData() throws SQLException { checkClosed(); return parameterMetaData; } - @Override - public int getPrecision(int param) throws SQLException { - checkParameterIndex(param); - return 0; - } - - @Override - public int getScale(int param) throws SQLException { - checkParameterIndex(param); - return 0; - } - - @Override - public int getParameterType(int param) throws SQLException { - checkParameterIndex(param); - // Hrana has no describe statement, so a parameter type is unknown until it is executed. - return Types.OTHER; - } - - @Override - public String getParameterTypeName(int param) throws SQLException { - checkParameterIndex(param); - return "OTHER"; - } - - @Override - public String getParameterClassName(int param) throws SQLException { - checkParameterIndex(param); - return Object.class.getName(); - } - - @Override - public int getParameterMode(int param) throws SQLException { - checkParameterIndex(param); - return ParameterMetaData.parameterModeIn; - } - - private void checkParameterIndex(int param) throws SQLException { - checkClosed(); - if (param < 1 || param > parameterCount) { - throw new SQLException("Parameter index out of range: " + param); - } - } + // ParameterMetaData stays on the statement for instanceof compatibility; the view holds the checks. + @Override public int getParameterCount() throws SQLException { return parameterMetaData.getParameterCount(); } + @Override public int isNullable(int param) throws SQLException { return parameterMetaData.isNullable(param); } + @Override public boolean isSigned(int param) throws SQLException { return parameterMetaData.isSigned(param); } + @Override public int getPrecision(int param) throws SQLException { return parameterMetaData.getPrecision(param); } + @Override public int getScale(int param) throws SQLException { return parameterMetaData.getScale(param); } + @Override public int getParameterType(int param) throws SQLException { return parameterMetaData.getParameterType(param); } + @Override public String getParameterTypeName(int param) throws SQLException { return parameterMetaData.getParameterTypeName(param); } + @Override public String getParameterClassName(int param) throws SQLException { return parameterMetaData.getParameterClassName(param); } + @Override public int getParameterMode(int param) throws SQLException { return parameterMetaData.getParameterMode(param); } @Override public void setRowId(int parameterIndex, RowId x) throws SQLException { diff --git a/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/ParameterMetaDataView.java b/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/ParameterMetaDataView.java new file mode 100644 index 0000000..c984c82 --- /dev/null +++ b/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/ParameterMetaDataView.java @@ -0,0 +1,98 @@ +package com.github.magnusp.libsql.jdbc; + +import java.sql.ParameterMetaData; +import java.sql.SQLException; +import java.sql.Types; + +/** + * Parameter metadata for a prepared statement over the Hrana transport. + * + *

The placeholder count is derived locally from the SQL text, but the parameter type, precision, + * and scale stay unknown because Hrana has no describe request. Every call checks the owning + * statement first, so metadata obtained before {@link LibsqlStatement#close()} still throws once + * the statement is closed. + */ +final class ParameterMetaDataView implements ParameterMetaData { + + private final LibsqlStatement statement; + private final int parameterCount; + + ParameterMetaDataView(LibsqlStatement statement, int parameterCount) { + this.statement = statement; + this.parameterCount = parameterCount; + } + + @Override + public int getParameterCount() throws SQLException { + statement.checkClosed(); + return parameterCount; + } + + @Override + public int isNullable(int param) throws SQLException { + checkParameterIndex(param); + return parameterNullableUnknown; + } + + @Override + public boolean isSigned(int param) throws SQLException { + checkParameterIndex(param); + return true; + } + + @Override + public int getPrecision(int param) throws SQLException { + checkParameterIndex(param); + return 0; + } + + @Override + public int getScale(int param) throws SQLException { + checkParameterIndex(param); + return 0; + } + + @Override + public int getParameterType(int param) throws SQLException { + checkParameterIndex(param); + // Hrana has no describe statement, so a parameter type is unknown until it is executed. + return Types.OTHER; + } + + @Override + public String getParameterTypeName(int param) throws SQLException { + checkParameterIndex(param); + return "OTHER"; + } + + @Override + public String getParameterClassName(int param) throws SQLException { + checkParameterIndex(param); + return Object.class.getName(); + } + + @Override + public int getParameterMode(int param) throws SQLException { + checkParameterIndex(param); + return parameterModeIn; + } + + private void checkParameterIndex(int param) throws SQLException { + statement.checkClosed(); + if (param < 1 || param > parameterCount) { + throw new SQLException("Parameter index out of range: " + param); + } + } + + // The statement, not the view, owns the Wrapper surface, so unwrap still reaches the statement. + + @Override + public T unwrap(Class iface) throws SQLException { + return statement.unwrap(iface); + } + + @Override + public boolean isWrapperFor(Class iface) throws SQLException { + return statement.isWrapperFor(iface); + } +} diff --git a/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/LibsqlPreparedStatementTest.java b/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/LibsqlPreparedStatementTest.java index aba6cf0..8546e2c 100644 --- a/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/LibsqlPreparedStatementTest.java +++ b/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/LibsqlPreparedStatementTest.java @@ -283,6 +283,92 @@ void parameterTypesAreUnknownWithoutADescription() throws SQLException { } } + @Test + void getParameterMetaDataReturnsAViewWithThePlaceholderCount() throws SQLException { + RecordingConnection connection = new RecordingConnection(NO_ROWS); + try (LibsqlPreparedStatement statement = new LibsqlPreparedStatement( + connection, "select id from t where id = ? and name = ?")) { + ParameterMetaData meta = statement.getParameterMetaData(); + + assertThat(meta).isNotNull(); + assertThat(meta.getParameterCount()).isEqualTo(2); + } + } + + @Test + void getParameterMetaDataAfterCloseThrows() throws SQLException { + RecordingConnection connection = new RecordingConnection(NO_ROWS); + LibsqlPreparedStatement statement = + new LibsqlPreparedStatement(connection, "select ?"); + statement.close(); + + assertThatThrownBy(statement::getParameterMetaData) + .isInstanceOf(SQLException.class) + .hasMessage("Statement is closed"); + } + + @Test + void parameterMetadataViewTakenBeforeCloseStillThrowsAfterClose() throws SQLException { + RecordingConnection connection = new RecordingConnection(NO_ROWS); + LibsqlPreparedStatement statement = + new LibsqlPreparedStatement(connection, "select ?"); + ParameterMetaData meta = statement.getParameterMetaData(); + statement.close(); + + assertThatThrownBy(meta::getParameterCount) + .isInstanceOf(SQLException.class) + .hasMessage("Statement is closed"); + assertThatThrownBy(() -> meta.getParameterType(1)) + .isInstanceOf(SQLException.class) + .hasMessage("Statement is closed"); + } + + @Test + void parameterMetadataRejectsAnOutOfRangeIndex() throws SQLException { + RecordingConnection connection = new RecordingConnection(NO_ROWS); + try (LibsqlPreparedStatement statement = + new LibsqlPreparedStatement(connection, "select ?")) { + ParameterMetaData meta = statement.getParameterMetaData(); + + assertThatThrownBy(() -> meta.getParameterType(0)) + .isInstanceOf(SQLException.class) + .hasMessage("Parameter index out of range: 0"); + } + } + + @Test + void preparedStatementRemainsAParameterMetaData() throws SQLException { + RecordingConnection connection = new RecordingConnection(NO_ROWS); + try (LibsqlPreparedStatement statement = + new LibsqlPreparedStatement(connection, "select ?")) { + assertThat(statement).isInstanceOf(ParameterMetaData.class); + } + } + + @Test + void getParameterMetaDataReturnsTheSameCachedView() throws SQLException { + RecordingConnection connection = new RecordingConnection(NO_ROWS); + try (LibsqlPreparedStatement statement = + new LibsqlPreparedStatement(connection, "select ?")) { + ParameterMetaData first = statement.getParameterMetaData(); + ParameterMetaData second = statement.getParameterMetaData(); + + assertThat(first).isSameAs(second); + } + } + + @Test + void parameterMetadataUnwrapReachesTheStatement() throws SQLException { + RecordingConnection connection = new RecordingConnection(NO_ROWS); + try (LibsqlPreparedStatement statement = + new LibsqlPreparedStatement(connection, "select ?")) { + ParameterMetaData meta = statement.getParameterMetaData(); + + assertThat(meta.isWrapperFor(ParameterMetaData.class)).isTrue(); + assertThat(meta.unwrap(ParameterMetaData.class)).isSameAs(statement); + } + } + @Test void clearParametersBindsNullArguments() throws SQLException { RecordingConnection connection = new RecordingConnection(NO_ROWS);