diff --git a/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/LibsqlResultSetMetaDataTest.java b/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/LibsqlResultSetMetaDataTest.java index bf0c0d4..f296424 100644 --- a/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/LibsqlResultSetMetaDataTest.java +++ b/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/LibsqlResultSetMetaDataTest.java @@ -54,6 +54,34 @@ void resolvesTableNameNullabilityAndAutoIncrement() throws SQLException { assertThat(metadata.isAutoIncrement(2)).isFalse(); } + @Test + void resolvesAttachedTableMetadataThroughPragmaDatabaseList() throws SQLException { + LibsqlResultSetMetaData metadata = new LibsqlResultSetMetaData( + List.of(new Col("id", "INTEGER"), new Col("name", "TEXT")), + List.of(), + "SELECT id, name FROM aux.People", + connectionForAttachedSchema("main", "temp", "Aux")); + + assertThat(metadata.getTableName(1)).isEqualTo("People"); + assertThat(metadata.getTableName(2)).isEqualTo("People"); + assertThat(metadata.isNullable(1)).isEqualTo(ResultSetMetaData.columnNoNulls); + assertThat(metadata.isNullable(2)).isEqualTo(ResultSetMetaData.columnNoNulls); + assertThat(metadata.isAutoIncrement(1)).isTrue(); + assertThat(metadata.isAutoIncrement(2)).isFalse(); + } + + @Test + void reportsNoTableMetadataForASchemaMissingFromPragmaDatabaseList() throws SQLException { + LibsqlResultSetMetaData metadata = new LibsqlResultSetMetaData( + List.of(new Col("id", "INTEGER")), + List.of(), + "SELECT id FROM ghost.People", + connectionForAttachedSchema("main", "temp", "Aux")); + + assertThat(metadata.getTableName(1)).isEmpty(); + assertThat(metadata.isNullable(1)).isEqualTo(ResultSetMetaData.columnNullableUnknown); + } + @Test void resolvesNonAsciiTableName() throws SQLException { LibsqlResultSetMetaData metadata = new LibsqlResultSetMetaData( @@ -752,4 +780,59 @@ public StmtResult executeStatement(Stmt stmt) throws SQLException { } }; } + + /** + * Answers {@code pragma_database_list} with the given schema names and every per-schema table + * lookup from a fixed catalogue, so the caller sees metadata only for schemas that the database + * list actually reports. + */ + private static LibsqlConnection connectionForAttachedSchema(String... databaseNames) { + Map tables = new LinkedHashMap<>(); + tables.put( + "Aux", + new FakeTable( + "People", + "CREATE TABLE People (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL)", + List.of(new FakeColumn("id", true, true), new FakeColumn("name", true, false)))); + tables.put( + "main", + new FakeTable( + "People", + "CREATE TABLE People (id INTEGER, name TEXT)", + List.of(new FakeColumn("id", false, false), new FakeColumn("name", false, false)))); + tables.put( + "ghost", + new FakeTable( + "People", + "CREATE TABLE People (id INTEGER)", + List.of(new FakeColumn("id", false, false)))); + List databases = new ArrayList<>(); + for (String name : databaseNames) { + databases.add(new Row(List.of(Value.of(name)))); + } + StmtResult databaseList = new StmtResult(List.of(), databases, 0, null); + + return new LibsqlConnection(null, null) { + @Override + public StmtResult executeStatement(Stmt stmt) throws SQLException { + if (stmt.sql().contains("pragma_database_list")) { + return databaseList; + } + FakeTable table = tables.get(argument(stmt, 1)); + if (table == null) { + return new StmtResult(List.of(), List.of(), 0, null); + } + List rows = new ArrayList<>(); + for (FakeColumn column : table.columns()) { + rows.add(new Row(List.of( + Value.of(table.name()), + Value.of(table.sql()), + Value.of(column.name()), + Value.of(column.notNull() ? 1L : 0L), + Value.of(column.primaryKey() ? 1L : 0L)))); + } + return new StmtResult(List.of(), rows, 0, null); + } + }; + } } diff --git a/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/LibsqlResultSetTest.java b/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/LibsqlResultSetTest.java index 43fcc5e..800b2ea 100644 --- a/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/LibsqlResultSetTest.java +++ b/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/LibsqlResultSetTest.java @@ -24,6 +24,7 @@ import java.time.LocalDateTime; import java.time.LocalTime; import java.time.ZoneId; +import java.time.ZoneOffset; import java.util.List; import java.util.Map; import java.util.stream.IntStream; @@ -276,6 +277,63 @@ void realJulianDayFeedsEveryDateAndTimeGetter() throws SQLException { assertDateTimeGetters(rs, 1_704_153_600_000L); } + @Test + void textDateTimeWithAnOffsetSuffixIsConvertedToTheSystemZone() throws SQLException { + LibsqlResultSet rs = resultSet(new Value.Text("2024-03-15 10:30:00+02:00")); + assertThat(rs.next()).isTrue(); + + LocalDateTime expected = LocalDateTime.of(2024, 3, 15, 10, 30) + .atOffset(ZoneOffset.ofHours(2)) + .atZoneSameInstant(ZoneId.systemDefault()) + .toLocalDateTime(); + + assertThat(rs.getObject(1, LocalDateTime.class)).isEqualTo(expected); + assertThat(rs.getObject(1, LocalDate.class)).isEqualTo(expected.toLocalDate()); + assertThat(rs.getObject(1, LocalTime.class)).isEqualTo(expected.toLocalTime()); + } + + @Test + void textDateTimeWithAZSuffixIsReadAsUtc() throws SQLException { + LibsqlResultSet rs = resultSet(new Value.Text("2024-03-15T10:30:00Z")); + assertThat(rs.next()).isTrue(); + + LocalDateTime expected = LocalDateTime.of(2024, 3, 15, 10, 30) + .atOffset(ZoneOffset.UTC) + .atZoneSameInstant(ZoneId.systemDefault()) + .toLocalDateTime(); + + assertThat(rs.getObject(1, LocalDateTime.class)).isEqualTo(expected); + assertThat(rs.getObject(1, LocalDate.class)).isEqualTo(expected.toLocalDate()); + assertThat(rs.getObject(1, LocalTime.class)).isEqualTo(expected.toLocalTime()); + } + + @Test + void bareTimeTextGetsTheYear2000DefaultDate() throws SQLException { + LibsqlResultSet rs = resultSet(new Value.Text("10:30:45")); + assertThat(rs.next()).isTrue(); + + assertThat(rs.getObject(1, LocalDateTime.class)) + .isEqualTo(LocalDateTime.of(2000, 1, 1, 10, 30, 45)); + assertThat(rs.getObject(1, LocalDate.class)).isEqualTo(LocalDate.of(2000, 1, 1)); + assertThat(rs.getObject(1, LocalTime.class)).isEqualTo(LocalTime.of(10, 30, 45)); + } + + @Test + void blobRejectsDateAndTimeGettersWithTheTargetTypeName() throws SQLException { + LibsqlResultSet rs = resultSet(new Value.Blob(new byte[] {1, 2, 3})); + assertThat(rs.next()).isTrue(); + + assertThatThrownBy(() -> rs.getObject(1, LocalDate.class)) + .isInstanceOf(SQLException.class) + .hasMessage("Bad value for type LocalDate"); + assertThatThrownBy(() -> rs.getObject(1, LocalTime.class)) + .isInstanceOf(SQLException.class) + .hasMessage("Bad value for type LocalTime"); + assertThatThrownBy(() -> rs.getObject(1, LocalDateTime.class)) + .isInstanceOf(SQLException.class) + .hasMessage("Bad value for type LocalDateTime"); + } + @Test void binaryStreamContentMatchesGetBytesByteForByte() throws Exception { byte[] payload = {0, 1, 2, 65, 127, -128, -1}; diff --git a/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/LibsqlSavepointTest.java b/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/LibsqlSavepointTest.java index 883152b..c9aab60 100644 --- a/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/LibsqlSavepointTest.java +++ b/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/LibsqlSavepointTest.java @@ -208,6 +208,52 @@ void releasingTheOutermostSavepointInsideAnExplicitBeginKeepsTheTransaction() "INSERT INTO sample VALUES (2)"); } + @Test + void releasingTheLaterOfTwoSavepointsWithTheSameNameKeepsTheEarlierOneActive() + throws SQLException { + ScriptedClient client = successClient(); + LibsqlConnection connection = newConnection(client); + Savepoint first = connection.setSavepoint("dup"); + Savepoint second = connection.setSavepoint("dup"); + + connection.releaseSavepoint(second); + connection.executeStatement(new Stmt("INSERT INTO sample VALUES (1)")); + connection.rollback(first); + connection.executeStatement(new Stmt("INSERT INTO sample VALUES (2)")); + + assertThat(client.executedStatements()) + .containsExactly( + "SAVEPOINT \"dup\"", + "SAVEPOINT \"dup\"", + "RELEASE SAVEPOINT \"dup\"", + "INSERT INTO sample VALUES (1)", + "ROLLBACK TO SAVEPOINT \"dup\"", + "INSERT INTO sample VALUES (2)"); + assertThat(client.requests.stream() + .flatMap(request -> request.requests().stream()) + .filter(StreamRequest.Close.class::isInstance)) + .isEmpty(); + } + + @Test + void releasingTheOnlySavepointOfAnAutoCommitTransactionLeavesCommitWithNothingToSend() + throws SQLException { + ScriptedClient client = successClient(); + LibsqlConnection connection = newConnection(client); + Savepoint savepoint = connection.setSavepoint("only"); + + connection.releaseSavepoint(savepoint); + connection.commit(); + + assertThat(connection.getAutoCommit()).isFalse(); + assertThat(client.executedStatements()) + .containsExactly("SAVEPOINT \"only\"", "RELEASE SAVEPOINT \"only\""); + assertThat(client.requests.stream() + .flatMap(request -> request.requests().stream()) + .filter(StreamRequest.Close.class::isInstance)) + .hasSize(1); + } + @Test void rollbackAndReleaseThrowInAutoCommitModeWithoutSendingAnything() throws SQLException { ScriptedClient client = successClient(); diff --git a/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/LibsqlStatementConcurrencyTest.java b/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/LibsqlStatementConcurrencyTest.java index e375fb5..116bd7d 100644 --- a/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/LibsqlStatementConcurrencyTest.java +++ b/libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/LibsqlStatementConcurrencyTest.java @@ -34,6 +34,7 @@ import java.util.concurrent.atomic.AtomicReference; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; class LibsqlStatementConcurrencyTest { @@ -181,6 +182,32 @@ void closeDoesNotInterleaveWithExecuteAndLeavesNoStreamOpen() throws Exception { } } + @Test + void executeBatchThatLosesTheRaceToCloseFailsWithStatementClosed() throws Exception { + Pause pause = new Pause(); + LibsqlStatement statement = new LibsqlStatement(new PausingBatchConnection(pause)); + statement.addBatch("insert into t(value) values (1)"); + ExecutorService executor = Executors.newSingleThreadExecutor(); + try { + Future batch = executor.submit(statement::executeBatch); + + assertThat(pause.awaitReached()).isTrue(); + AtomicReference closeFailure = new AtomicReference<>(); + joinAndCheck(closeInThread(statement, closeFailure), closeFailure); + assertThat(statement.isClosed()).isTrue(); + pause.resume(); + + assertThatThrownBy(() -> batch.get(5, TimeUnit.SECONDS)) + .isInstanceOf(ExecutionException.class) + .cause() + .isInstanceOf(SQLException.class) + .hasMessageContaining("Statement is closed"); + } finally { + pause.resume(); + executor.shutdownNow(); + } + } + private static Void executeRepeatedly(LibsqlStatement statement, int count) throws SQLException { for (int i = 0; i < count; i++) { statement.execute("SELECT " + i); @@ -300,6 +327,27 @@ protected void beforeResultSetAssignment() throws SQLException { } } + /** + * Holds the batch inside the connection round trip so a close can land before the statement + * reaches its own cleanup. + */ + private static final class PausingBatchConnection extends LibsqlConnection { + private final Pause pause; + + private PausingBatchConnection(Pause pause) { + super(null, null); + this.pause = pause; + } + + @Override + public List executeBatchResults(List statements) throws SQLException { + pause.await(); + return statements.stream() + .map(statement -> new StmtResult(List.of(), List.of(), 1, null)) + .toList(); + } + } + /** * Models the baton rules the connection must uphold: one open stream per connection, one * request on a stream at a time, and a baton that is consumed by its request. Every rule break