From 47bef1e0c068536d4ad47b99532124ae506b777c Mon Sep 17 00:00:00 2001 From: Magnus Persson <1431685+magnusp@users.noreply.github.com> Date: Thu, 22 Oct 2026 01:08:06 +0200 Subject: [PATCH] fix(jdbc): match xerial result set navigation boundaries (#45) --- .../magnusp/libsql/jdbc/LibsqlResultSet.java | 49 ++++-- .../jdbc/LibsqlResultSetNavigationTest.java | 155 ++++++++++++++++++ 2 files changed, 191 insertions(+), 13 deletions(-) create mode 100644 libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/LibsqlResultSetNavigationTest.java diff --git a/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlResultSet.java b/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlResultSet.java index 69a58a2..030190b 100644 --- a/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlResultSet.java +++ b/libsql-jdbc/src/main/java/com/github/magnusp/libsql/jdbc/LibsqlResultSet.java @@ -36,6 +36,18 @@ import java.util.Locale; import java.util.Map; +/** + * Result set over a materialized Hrana statement result. + * + *
Navigation matches sqlite-jdbc 3.53.4.0 rather than strict JDBC, because the driver is a + * drop-in replacement for that driver. Before the first {@link #next()} call the column getters + * read the first row and {@link #getRow()} returns 0. Column getters after the last row return + * SQL NULL (numeric getters return 0), while {@link #getRow()} keeps returning the number of the + * last row. {@link #isLast()} always throws {@link SQLFeatureNotSupportedException}, matching + * xerial. {@link #wasNull()} reports the last column read and throws while no column has been + * read in the current row, including immediately before the first row and immediately after the + * last row. When labels repeat, {@link #findColumn(String)} returns the first match. + */ public class LibsqlResultSet implements ResultSet { private final Statement statement; @@ -45,7 +57,7 @@ public class LibsqlResultSet implements ResultSet { private final LibsqlConnection connection; private final boolean closeStatementOnClose; private int currentRowIndex = -1; - private boolean wasNull = false; + private int lastColumnIndex = 0; private boolean closed = false; private LibsqlResultSetMetaData metadata; @@ -73,18 +85,21 @@ private void checkClosed() throws SQLException { } } + private Value valueAtCursor(int columnIndex) { + int rowIndex = currentRowIndex < 0 ? 0 : currentRowIndex; + if (rowIndex >= rows.size()) { + return Value.ofNull(); + } + return rows.get(rowIndex).values().get(columnIndex - 1); + } + private Value getCurrentValue(int columnIndex) throws SQLException { checkClosed(); - if (currentRowIndex < 0 || currentRowIndex >= rows.size()) { - throw new SQLException("Cursor is not pointing to a valid row"); - } if (columnIndex < 1 || columnIndex > cols.size()) { throw new SQLException("Column index out of bounds: " + columnIndex); } - Row row = rows.get(currentRowIndex); - Value val = row.values().get(columnIndex - 1); - this.wasNull = (val instanceof Value.Null); - return val; + lastColumnIndex = columnIndex; + return valueAtCursor(columnIndex); } Value valueForMetadata(int columnIndex) { @@ -103,6 +118,10 @@ Value valueForMetadata(int columnIndex) { @Override public boolean next() throws SQLException { checkClosed(); + if (rows.isEmpty() || currentRowIndex >= rows.size()) { + return false; + } + lastColumnIndex = -1; currentRowIndex++; return currentRowIndex < rows.size(); } @@ -123,7 +142,11 @@ public void close() throws SQLException { @Override public boolean wasNull() throws SQLException { checkClosed(); - return wasNull; + if (lastColumnIndex < 1 || lastColumnIndex > cols.size()) { + throw new SQLException( + "column " + lastColumnIndex + " out of bounds [1," + cols.size() + "]"); + } + return valueAtCursor(lastColumnIndex) instanceof Value.Null; } /** @@ -682,7 +705,7 @@ public int findColumn(String columnLabel) throws SQLException { for (int i = 0; i < cols.size(); i++) { String name = cols.get(i).name(); if (name != null) { - columnNameToIndex.put(name.toLowerCase(Locale.ROOT), i + 1); + columnNameToIndex.putIfAbsent(name.toLowerCase(Locale.ROOT), i + 1); } } } @@ -690,7 +713,7 @@ public int findColumn(String columnLabel) throws SQLException { if (idx != null) { return idx; } - throw new SQLException("Column not found: " + columnLabel); + throw new SQLException("no such column: '" + columnLabel + "'"); } @Override @@ -801,7 +824,7 @@ public int getConcurrency() throws SQLException { @Override public int getRow() throws SQLException { - return currentRowIndex >= 0 && currentRowIndex < rows.size() ? currentRowIndex + 1 : 0; + return Math.max(0, Math.min(currentRowIndex + 1, rows.size())); } @Override @@ -821,7 +844,7 @@ public boolean isFirst() throws SQLException { @Override public boolean isLast() throws SQLException { - return currentRowIndex == rows.size() - 1 && !rows.isEmpty(); + throw new SQLFeatureNotSupportedException("not supported by sqlite"); } @Override public InputStream getAsciiStream(int columnIndex) throws SQLException { throw new UnsupportedOperationException(); } diff --git a/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/LibsqlResultSetNavigationTest.java b/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/LibsqlResultSetNavigationTest.java new file mode 100644 index 0000000..aeda4b5 --- /dev/null +++ b/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/LibsqlResultSetNavigationTest.java @@ -0,0 +1,155 @@ +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.StmtResult; +import com.github.magnusp.libsql.hrana.codec.Value; +import org.junit.jupiter.api.Test; + +import java.sql.SQLException; +import java.sql.SQLFeatureNotSupportedException; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class LibsqlResultSetNavigationTest { + + private static final StmtResult DUPLICATE_LABELS = new StmtResult( + List.of(new Col("a", null), new Col("a", null), new Col("n", null)), + List.of(new Row(List.of(Value.of(1L), Value.of(2L), Value.ofNull()))), + 1, + null); + + private static final StmtResult TWO_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)))), + 2, + null); + + private static final StmtResult NO_ROWS = new StmtResult( + List.of(new Col("value", null)), + List.of(), + 0, + null); + + @Test + void findColumnReturnsTheFirstMatchingDuplicateLabel() throws SQLException { + LibsqlResultSet resultSet = resultSet(DUPLICATE_LABELS); + + assertThat(resultSet.findColumn("a")).isEqualTo(1); + assertThat(resultSet.findColumn("A")).isEqualTo(1); + } + + @Test + void findColumnRejectsUnknownLabelsWithXerialMessage() throws SQLException { + LibsqlResultSet resultSet = resultSet(DUPLICATE_LABELS); + + assertThatThrownBy(() -> resultSet.findColumn("missing")) + .isInstanceOf(SQLException.class) + .hasMessage("no such column: 'missing'"); + } + + @Test + void gettersReadTheFirstRowBeforeNext() throws SQLException { + LibsqlResultSet resultSet = resultSet(DUPLICATE_LABELS); + + assertThat(resultSet.getInt(1)).isEqualTo(1); + assertThat(resultSet.getString(1)).isEqualTo("1"); + assertThat(resultSet.getObject(2)).isEqualTo(2); + } + + @Test + void gettersReturnNullAndZeroAfterTheLastRow() throws SQLException { + LibsqlResultSet resultSet = resultSet(DUPLICATE_LABELS); + assertThat(resultSet.next()).isTrue(); + assertThat(resultSet.next()).isFalse(); + + assertThat(resultSet.getInt(1)).isZero(); + assertThat(resultSet.getString(1)).isNull(); + assertThat(resultSet.getObject(1)).isNull(); + assertThat(resultSet.getBytes(1)).isNull(); + assertThat(resultSet.getBoolean(1)).isFalse(); + } + + @Test + void gettersReturnNullAndZeroWhenTheResultSetHasNoRows() throws SQLException { + LibsqlResultSet resultSet = resultSet(NO_ROWS); + + assertThat(resultSet.getInt(1)).isZero(); + assertThat(resultSet.getString(1)).isNull(); + assertThat(resultSet.getObject(1)).isNull(); + } + + @Test + void getRowIsZeroBeforeTheFirstRowAndStaysOnTheLastRowAfterTheEnd() throws SQLException { + LibsqlResultSet resultSet = resultSet(TWO_ROWS); + + assertThat(resultSet.getRow()).isZero(); + assertThat(resultSet.next()).isTrue(); + assertThat(resultSet.getRow()).isEqualTo(1); + assertThat(resultSet.next()).isTrue(); + assertThat(resultSet.getRow()).isEqualTo(2); + assertThat(resultSet.next()).isFalse(); + assertThat(resultSet.getRow()).isEqualTo(2); + assertThat(resultSet.next()).isFalse(); + assertThat(resultSet.getRow()).isEqualTo(2); + } + + @Test + void getRowIsZeroForAnEmptyResultSet() throws SQLException { + LibsqlResultSet resultSet = resultSet(NO_ROWS); + + assertThat(resultSet.getRow()).isZero(); + assertThat(resultSet.next()).isFalse(); + assertThat(resultSet.getRow()).isZero(); + } + + @Test + void isLastAlwaysThrowsLikeXerial() throws SQLException { + LibsqlResultSet resultSet = resultSet(DUPLICATE_LABELS); + + assertThatThrownBy(resultSet::isLast) + .isInstanceOf(SQLFeatureNotSupportedException.class) + .hasMessage("not supported by sqlite"); + assertThat(resultSet.next()).isTrue(); + assertThatThrownBy(resultSet::isLast) + .isInstanceOf(SQLFeatureNotSupportedException.class) + .hasMessage("not supported by sqlite"); + } + + @Test + void wasNullThrowsBeforeAnyGetterAndAfterTheEndWithoutAGetter() throws SQLException { + LibsqlResultSet beforeFirst = resultSet(DUPLICATE_LABELS); + assertThatThrownBy(beforeFirst::wasNull) + .isInstanceOf(SQLException.class) + .hasMessage("column 0 out of bounds [1,3]"); + + LibsqlResultSet afterLast = resultSet(DUPLICATE_LABELS); + assertThat(afterLast.next()).isTrue(); + assertThat(afterLast.next()).isFalse(); + assertThatThrownBy(afterLast::wasNull) + .isInstanceOf(SQLException.class) + .hasMessage("column -1 out of bounds [1,3]"); + } + + @Test + void wasNullTracksTheLastGetterRead() throws SQLException { + LibsqlResultSet resultSet = resultSet(DUPLICATE_LABELS); + + assertThat(resultSet.getInt(1)).isEqualTo(1); + assertThat(resultSet.wasNull()).isFalse(); + + assertThat(resultSet.getObject(3)).isNull(); + assertThat(resultSet.wasNull()).isTrue(); + + assertThat(resultSet.next()).isTrue(); + assertThat(resultSet.next()).isFalse(); + assertThat(resultSet.getObject(1)).isNull(); + assertThat(resultSet.wasNull()).isTrue(); + } + + private static LibsqlResultSet resultSet(StmtResult result) { + return new LibsqlResultSet(null, result); + } +}