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 c4b4bf0..2dcb12d 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 @@ -72,7 +72,7 @@ public ResultSet executeQuery() throws SQLException { if (result.cols() == null || result.cols().isEmpty()) { throw new SQLException("Query does not return results"); } - LibsqlResultSet resultSet = new LibsqlResultSet(this, result, sql, false); + LibsqlResultSet resultSet = new LibsqlResultSet(this, limitRows(result), sql, false); assignResultSet(resultSet, result, sql, true); return resultSet; } @@ -229,6 +229,7 @@ public boolean execute() throws SQLException { } private StmtResult executeCurrent() throws SQLException { + checkEscapeProcessing(sql); checkClosed(); beginExecution(); return connection.executeStatement(buildCurrentStmt()); diff --git a/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlStatement.java b/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlStatement.java index 6b12ac8..271e175 100644 --- a/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlStatement.java +++ b/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlStatement.java @@ -37,6 +37,16 @@ *
  • {@link #closeOnCompletion()} and {@link #isCloseOnCompletion()} throw {@code * SQLException("Statement is closed")} after the statement is closed. *
  • {@link #close()} closes the current result set and generated-key results. + *
  • Optional statement settings are pinned per setter. {@link #setMaxRows(int)} stores a limit + * and drops the excess rows from the result set, matching {@code java.sql} and sqlite-jdbc + * 3.53.4.0. {@link #setEscapeProcessing(boolean)} stores the java.sql flag, which starts + * enabled: while it is enabled, SQL that contains a JDBC escape sequence throws {@link + * SQLFeatureNotSupportedException} rather than reaching sqld untranslated. {@link + * #setMaxFieldSize(int)} and {@link #setQueryTimeout(int)} accept the zero that means no + * limit and throw for a limit the driver cannot enforce. {@link #setCursorName(String)} + * always throws: Hrana has no positioned cursor. sqlite-jdbc 3.53.4.0 instead accepts the + * last three silently, and its query timeout drives SQLite's busy timeout, which is a lock + * wait rather than a query deadline. *
  • Matching xerial's execute-after-close behavior, {@link #execute(String)}, {@link * #executeQuery(String)}, {@link #executeUpdate(String)}, and {@link #getConnection()} keep * working after {@link #close()}. This deliberately copies a likely xerial bug for drop-in @@ -66,6 +76,8 @@ public class LibsqlStatement implements Statement { private boolean resultSetRequested = false; private boolean closing = false; private int fetchSize = 0; + private int maxRows = 0; + private boolean escapeProcessing = true; public LibsqlStatement(LibsqlConnection connection) { this.connection = connection; @@ -94,7 +106,7 @@ public ResultSet executeQuery(String sql) throws SQLException { if (result.cols() == null || result.cols().isEmpty()) { throw new SQLException("Query did not produce a ResultSet: " + sql); } - LibsqlResultSet resultSet = new LibsqlResultSet(this, result, sql, false); + LibsqlResultSet resultSet = new LibsqlResultSet(this, limitRows(result), sql, false); assignResultSet(resultSet, result, sql, true); return resultSet; } @@ -139,7 +151,7 @@ public boolean execute(String sql) throws SQLException { protected boolean executeResult(String sql, StmtResult result) throws SQLException { boolean hasResultSet = result.cols() != null && !result.cols().isEmpty(); LibsqlResultSet resultSet = - hasResultSet ? new LibsqlResultSet(this, result, sql, false) : null; + hasResultSet ? new LibsqlResultSet(this, limitRows(result), sql, false) : null; if (hasResultSet && !isClosed()) { beforeResultSetAssignment(); @@ -165,10 +177,106 @@ protected boolean executeResult(String sql, StmtResult result) throws SQLExcepti } private StmtResult executeSql(String sql) throws SQLException { + checkEscapeProcessing(sql); beginExecution(); return connection.executeStatement(new Stmt(sql)); } + /** + * Rejects SQL that needs JDBC escape substitution while escape processing is on. The driver + * sends SQL to sqld verbatim, so a sequence such as {@code {fn abs(-1)}} would fail as SQLite + * syntax instead of being translated. Turning escape processing off is how a caller states + * that it sends raw SQL. + */ + protected void checkEscapeProcessing(String sql) throws SQLException { + boolean enabled; + synchronized (stateLock) { + enabled = escapeProcessing; + } + if (enabled && hasEscapeSequence(sql)) { + throw new SQLFeatureNotSupportedException("JDBC escape sequences are not supported"); + } + } + + /** + * Whether the SQL contains a JDBC escape sequence. Braces inside string literals, quoted + * identifiers, and comments do not count, and a brace that does not start with an escape code + * is left to the server. + */ + private static boolean hasEscapeSequence(String sql) { + if (sql == null || sql.indexOf('{') < 0) { + return false; + } + int n = sql.length(); + int i = 0; + while (i < n) { + char c = sql.charAt(i); + if (c == '\'' || c == '"' || c == '`') { + i++; + while (i < n) { + if (sql.charAt(i) == c) { + if (i + 1 < n && sql.charAt(i + 1) == c) { + i += 2; + continue; + } + i++; + break; + } + i++; + } + } else if (c == '[') { + i++; + while (i < n && sql.charAt(i) != ']') { + i++; + } + i++; + } else if (c == '-' && i + 1 < n && sql.charAt(i + 1) == '-') { + i += 2; + while (i < n && sql.charAt(i) != '\n') { + i++; + } + } else if (c == '/' && i + 1 < n && sql.charAt(i + 1) == '*') { + i += 2; + while (i + 1 < n && !(sql.charAt(i) == '*' && sql.charAt(i + 1) == '/')) { + i++; + } + i += 2; + } else if (c == '{') { + int code = i + 1; + while (code < n && Character.isWhitespace(sql.charAt(code))) { + code++; + } + if (code < n && "dDtTfFoOeEcC".indexOf(sql.charAt(code)) >= 0) { + return true; + } + i++; + } else { + i++; + } + } + return false; + } + + /** + * Drops rows above the {@link #setMaxRows(int)} limit. java.sql requires the excess rows to be + * dropped silently from the result set, not from the statement that produced it, so the server + * still runs the full query. + */ + protected StmtResult limitRows(StmtResult result) { + int limit; + synchronized (stateLock) { + limit = maxRows; + } + if (limit <= 0 || result == null || result.rows() == null || result.rows().size() <= limit) { + return result; + } + return new StmtResult( + result.cols(), + new ArrayList<>(result.rows().subList(0, limit)), + result.affectedRowCount(), + result.lastInsertRowid()); + } + /** * Resets the state every execution path starts from: the previous result set is closed and the * previous generated keys are discarded. Without this, a path that does not produce keys of @@ -411,6 +519,7 @@ public boolean getMoreResults() throws SQLException { @Override public void addBatch(String sql) throws SQLException { checkClosed(); + checkEscapeProcessing(sql); batchStatements.add(new Stmt(sql)); } @@ -485,16 +594,53 @@ public boolean isClosed() throws SQLException { } @Override public int getMaxFieldSize() throws SQLException { return 0; } - @Override public void setMaxFieldSize(int max) throws SQLException {} - @Override public int getMaxRows() throws SQLException { return 0; } - @Override public void setMaxRows(int max) throws SQLException {} - @Override public void setEscapeProcessing(boolean enable) throws SQLException {} + @Override + public void setMaxFieldSize(int max) throws SQLException { + if (max < 0) { + throw new SQLException("max field size must be >= 0"); + } + if (max > 0) { + throw new SQLFeatureNotSupportedException("Column size limits are not supported"); + } + } + @Override + public int getMaxRows() throws SQLException { + synchronized (stateLock) { + return maxRows; + } + } + @Override + public void setMaxRows(int max) throws SQLException { + if (max < 0) { + throw new SQLException("max row count must be >= 0"); + } + synchronized (stateLock) { + maxRows = max; + } + } + @Override + public void setEscapeProcessing(boolean enable) throws SQLException { + synchronized (stateLock) { + escapeProcessing = enable; + } + } @Override public int getQueryTimeout() throws SQLException { return 0; } - @Override public void setQueryTimeout(int seconds) throws SQLException {} + @Override + public void setQueryTimeout(int seconds) throws SQLException { + if (seconds < 0) { + throw new SQLException("query timeout must be >= 0"); + } + if (seconds > 0) { + throw new SQLFeatureNotSupportedException("Query timeouts are not supported"); + } + } @Override public void cancel() throws SQLException {} @Override public SQLWarning getWarnings() throws SQLException { return null; } @Override public void clearWarnings() throws SQLException {} - @Override public void setCursorName(String name) throws SQLException {} + @Override + public void setCursorName(String name) throws SQLException { + throw new SQLFeatureNotSupportedException("Cursor names are not supported"); + } @Override public void setFetchDirection(int direction) throws SQLException {} @Override public int getFetchDirection() throws SQLException { return ResultSet.FETCH_FORWARD; } @Override 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 2c880bd..ad08f45 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 @@ -9,6 +9,8 @@ import java.sql.ResultSet; import java.sql.SQLException; +import java.sql.SQLFeatureNotSupportedException; +import java.util.ArrayList; import java.util.List; import static org.assertj.core.api.Assertions.assertThat; @@ -24,6 +26,15 @@ class LibsqlPreparedStatementTest { private static final StmtResult NO_ROWS = new StmtResult(List.of(), List.of(), 1, 1L); + private static final StmtResult THREE_ROWS = new StmtResult( + List.of(new Col("id", "INTEGER")), + List.of( + new Row(List.of(Value.of(1L))), + new Row(List.of(Value.of(2L))), + new Row(List.of(Value.of(3L)))), + 0, + null); + @Test void executeQueryOnInsertThrowsWithoutExecuting() throws SQLException { RecordingConnection connection = new RecordingConnection(NO_ROWS); @@ -102,8 +113,44 @@ void executeUpdateOnWithSelectThrowsWithoutExecuting() throws SQLException { assertThat(connection.executeCalls).isZero(); } + @Test + void setMaxRowsLimitsPreparedQueryAndGetMaxRowsReadsItBack() throws SQLException { + RecordingConnection connection = new RecordingConnection(THREE_ROWS); + try (LibsqlPreparedStatement statement = + new LibsqlPreparedStatement(connection, "select id from t")) { + statement.setMaxRows(2); + + assertThat(statement.getMaxRows()).isEqualTo(2); + ResultSet resultSet = statement.executeQuery(); + assertThat(resultSet.next()).isTrue(); + assertThat(resultSet.getLong(1)).isEqualTo(1L); + assertThat(resultSet.next()).isTrue(); + assertThat(resultSet.getLong(1)).isEqualTo(2L); + assertThat(resultSet.next()).isFalse(); + } + } + + @Test + void escapeProcessingAppliesToPreparedSql() throws SQLException { + RecordingConnection connection = new RecordingConnection(THREE_ROWS); + try (LibsqlPreparedStatement statement = + new LibsqlPreparedStatement(connection, "select {fn abs(-1)} from t")) { + assertThatThrownBy(statement::executeQuery) + .isInstanceOf(SQLFeatureNotSupportedException.class) + .hasMessage("JDBC escape sequences are not supported"); + assertThat(connection.executeCalls).isZero(); + + statement.setEscapeProcessing(false); + statement.executeQuery(); + + assertThat(connection.executedSql) + .containsExactly("select {fn abs(-1)} from t"); + } + } + private static final class RecordingConnection extends LibsqlConnection { private final StmtResult result; + private final List executedSql = new ArrayList<>(); private int executeCalls; private RecordingConnection(StmtResult result) { @@ -114,6 +161,7 @@ private RecordingConnection(StmtResult result) { @Override public StmtResult executeStatement(Stmt stmt) { executeCalls++; + executedSql.add(stmt.sql()); return result; } } diff --git a/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/LibsqlStatementTest.java b/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/LibsqlStatementTest.java new file mode 100644 index 0000000..bf313b8 --- /dev/null +++ b/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/LibsqlStatementTest.java @@ -0,0 +1,159 @@ +package com.github.magnusp.libsql.jdbc; + +import com.github.magnusp.libsql.hrana.codec.Col; +import com.github.magnusp.libsql.hrana.codec.Row; +import com.github.magnusp.libsql.hrana.codec.Stmt; +import com.github.magnusp.libsql.hrana.codec.StmtResult; +import com.github.magnusp.libsql.hrana.codec.Value; +import org.junit.jupiter.api.Test; + +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.SQLFeatureNotSupportedException; +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class LibsqlStatementTest { + + private static final StmtResult THREE_ROWS = new StmtResult( + List.of(new Col("value", null)), + List.of( + new Row(List.of(Value.of(1L))), + new Row(List.of(Value.of(2L))), + new Row(List.of(Value.of(3L)))), + 0, + null); + + @Test + void setMaxRowsTruncatesResultRowsAndGetMaxRowsReadsItBack() throws SQLException { + LibsqlStatement statement = new LibsqlStatement(new RecordingConnection(THREE_ROWS)); + + statement.setMaxRows(2); + + assertThat(statement.getMaxRows()).isEqualTo(2); + try (ResultSet resultSet = statement.executeQuery("select value from t")) { + assertThat(resultSet.next()).isTrue(); + assertThat(resultSet.getLong(1)).isEqualTo(1L); + assertThat(resultSet.next()).isTrue(); + assertThat(resultSet.getLong(1)).isEqualTo(2L); + assertThat(resultSet.next()).isFalse(); + } + } + + @Test + void setMaxRowsZeroRemovesTheLimit() throws SQLException { + LibsqlStatement statement = new LibsqlStatement(new RecordingConnection(THREE_ROWS)); + statement.setMaxRows(1); + + statement.setMaxRows(0); + + assertThat(statement.getMaxRows()).isZero(); + int rows = 0; + try (ResultSet resultSet = statement.executeQuery("select value from t")) { + while (resultSet.next()) { + rows++; + } + } + assertThat(rows).isEqualTo(3); + } + + @Test + void setMaxRowsRejectsANegativeLimit() { + LibsqlStatement statement = new LibsqlStatement(new RecordingConnection(THREE_ROWS)); + + assertThatThrownBy(() -> statement.setMaxRows(-1)) + .isInstanceOf(SQLException.class) + .hasMessage("max row count must be >= 0"); + } + + @Test + void setQueryTimeoutAcceptsNoLimitAndRejectsAPositiveTimeout() throws SQLException { + LibsqlStatement statement = new LibsqlStatement(new RecordingConnection(THREE_ROWS)); + + statement.setQueryTimeout(0); + + assertThat(statement.getQueryTimeout()).isZero(); + assertThatThrownBy(() -> statement.setQueryTimeout(1)) + .isInstanceOf(SQLFeatureNotSupportedException.class) + .hasMessage("Query timeouts are not supported"); + assertThatThrownBy(() -> statement.setQueryTimeout(-1)) + .isInstanceOf(SQLException.class) + .hasMessage("query timeout must be >= 0"); + } + + @Test + void setMaxFieldSizeAcceptsNoLimitAndRejectsAPositiveLimit() throws SQLException { + LibsqlStatement statement = new LibsqlStatement(new RecordingConnection(THREE_ROWS)); + + statement.setMaxFieldSize(0); + + assertThat(statement.getMaxFieldSize()).isZero(); + assertThatThrownBy(() -> statement.setMaxFieldSize(8)) + .isInstanceOf(SQLFeatureNotSupportedException.class) + .hasMessage("Column size limits are not supported"); + assertThatThrownBy(() -> statement.setMaxFieldSize(-1)) + .isInstanceOf(SQLException.class) + .hasMessage("max field size must be >= 0"); + } + + @Test + void escapeProcessingStartsEnabledAndRejectsEscapeSequences() { + RecordingConnection connection = new RecordingConnection(THREE_ROWS); + LibsqlStatement statement = new LibsqlStatement(connection); + + assertThatThrownBy(() -> statement.executeQuery("select {fn abs(-1)}")) + .isInstanceOf(SQLFeatureNotSupportedException.class) + .hasMessage("JDBC escape sequences are not supported"); + assertThat(connection.executedSql).isEmpty(); + } + + @Test + void setEscapeProcessingDisabledSendsEscapeLookingSqlVerbatim() throws SQLException { + RecordingConnection connection = new RecordingConnection(THREE_ROWS); + LibsqlStatement statement = new LibsqlStatement(connection); + + statement.setEscapeProcessing(false); + statement.executeQuery("select '{fn abs(-1)}' as text"); + + assertThat(connection.executedSql) + .containsExactly("select '{fn abs(-1)}' as text"); + } + + @Test + void escapeProcessingIgnoresBracesInsideStringLiteralsAndComments() throws SQLException { + RecordingConnection connection = new RecordingConnection(THREE_ROWS); + LibsqlStatement statement = new LibsqlStatement(connection); + + statement.executeQuery("select '{\"a\": 1}' as doc -- {fn abs(-1)}"); + + assertThat(connection.executedSql).hasSize(1); + } + + @Test + void setCursorNameIsNotSupported() { + LibsqlStatement statement = new LibsqlStatement(new RecordingConnection(THREE_ROWS)); + + assertThatThrownBy(() -> statement.setCursorName("c1")) + .isInstanceOf(SQLFeatureNotSupportedException.class) + .hasMessage("Cursor names are not supported"); + } + + private static final class RecordingConnection extends LibsqlConnection { + private final StmtResult result; + private final List executedSql = new ArrayList<>(); + + private RecordingConnection(StmtResult result) { + super(null, null); + this.result = result; + } + + @Override + public StmtResult executeStatement(Stmt stmt) { + executedSql.add(stmt.sql()); + return result; + } + } +}