Skip to content

Commit fe91f99

Browse files
authored
test(jdbc): pin attached schema, date time, and savepoint edges (#215)
## What Tests only, for the four behaviors Wave 0 of #195 names. No production code, `pom.xml`, or dependency change; no existing assertion edited. 1. **Attached-schema table metadata** (`LibsqlResultSetMetaDataTest`). `SELECT id, name FROM aux.People` drives `pragma_database_list` and the per-schema lookup, so `findSchema` and `loadAttachedTableInfo` run. The stub answers a *different* `People` in `main` (nullable, no autoincrement) and in the attached schema `Aux` (NOT NULL, `INTEGER PRIMARY KEY AUTOINCREMENT`), so the assertions on `getTableName`/`isNullable`/`isAutoIncrement` only pass when the attached catalogue wins. A second test queries `ghost.People` with `ghost` absent from the database list and asserts no table metadata. 2. **Date/time tails** (`LibsqlResultSetTest`). `+02:00` suffix, `Z` suffix, bare `10:30:45` defaulting to `2000-01-01`, and BLOB rejection with `"Bad value for type LocalDate"` / `LocalTime` / `LocalDateTime` (exact target-type spelling from the code). 3. **Savepoint edges** (`LibsqlSavepointTest`). Two savepoints named `dup`: releasing the later one must keep the earlier active, so the next statement runs on the same stream with no `BEGIN`, and `rollback` to the earlier one still works. Releasing the only savepoint of a transaction started in auto-commit ends the transaction, so the following `commit()` sends nothing and exactly one stream close is issued. 4. **Batch vs `close()`** (`LibsqlStatementConcurrencyTest`). A batch is held inside the connection round trip with the existing `Pause` seam while `close()` lands on the statement, so the interleaving is deterministic; `executeBatch` then fails with `Statement is closed`. ## Mutation probe Each new test was run alone against a scratch break of the behavior it pins, then the break was reverted with `git checkout -- <file>`. All nine bite; none stayed green. Probe lane: `./mvnw -pl libsql-jdbc -am -Dtest='<Class>#<method>' -DfailIfNoTests=false -Dsurefire.failIfNoSpecifiedTests=false test` | Test | Break | Observed failure | |---|---|---| | `LibsqlResultSetMetaDataTest#resolvesAttachedTableMetadataThroughPragmaDatabaseList` | `findSchema`: `name.equalsIgnoreCase(schema)` → `name.equals(schema)` | `:65` expected `"People"` but was `""` | | `LibsqlResultSetMetaDataTest#reportsNoTableMetadataForASchemaMissingFromPragmaDatabaseList` | `findSchema`: `return null` → `return schema` when no database matches | `:81` Expecting empty but was: `"People"` | | `LibsqlResultSetTest#textDateTimeWithAnOffsetSuffixIsConvertedToTheSystemZone` | `parseSqliteDateTime`: guard `if (sign > 9)` → `if (false && sign > 9)` | `:290` `SQLException: Bad value for type LocalDateTime: 2024-03-15 10:30:00+02:00` | | `LibsqlResultSetTest#textDateTimeWithAZSuffixIsReadAsUtc` | `parseSqliteDateTime`: skip the `Z` branch (`if (false && ...)`) | `:305` `SQLException: Bad value for type LocalDateTime: 2024-03-15T10:30:00Z` | | `LibsqlResultSetTest#bareTimeTextGetsTheYear2000DefaultDate` | `LocalDate.of(2000, 1, 1)` → `LocalDate.of(1970, 1, 1)` | `:316` expected `2000-01-01T10:30:45` but was `1970-01-01T10:30:45` | | `LibsqlResultSetTest#blobRejectsDateAndTimeGettersWithTheTargetTypeName` | BLOB branch message → `"Cannot read " + targetType + " from a BLOB"` | `:328` Expecting message `"Bad value for type LocalDate"` | | `LibsqlSavepointTest#releasingTheLaterOfTwoSavepointsWithTheSameNameKeepsTheEarlierOneActive` | `releaseSavepoint`: `savepointNames.lastIndexOf` → `indexOf` | `:225` actual statements contain a `BEGIN` before the first `INSERT` (the transaction was ended early) | | `LibsqlSavepointTest#releasingTheOnlySavepointOfAnAutoCommitTransactionLeavesCommitWithNothingToSend` | `releaseSavepoint`: drop `closeStreamQuietly()` in the outermost branch | `:250` actual statements end with `"COMMIT"` (unexpected) | | `LibsqlStatementConcurrencyTest#executeBatchThatLosesTheRaceToCloseFailsWithStatementClosed` | `executeBatch` finally: `clearBatch()` → `batchStatements.clear()` | `:200` Expecting code to raise a throwable | ## Lanes | Command | Result | |---|---| | `./mvnw -pl libsql-jdbc -am test` | `EXIT=0`, 383 tests, 0 failures, 0 errors (baseline 374 + 9 new) | | `./mvnw spotless:apply` | no changes | | `./mvnw spotless:check` | `BUILD SUCCESS` | | probe lane (9 runs, table above) | each `EXIT=1`, production file reverted after every run | ## Discovery `executeBatch`'s `finally { clearBatch(); }` calls `checkClosed()` through `clearBatch`. When a statement `close()` lands while the batch round trip is in flight, a batch whose steps all succeeded reports `SQLException("Statement is closed")` instead of the update counts. Pinned as the current behavior and **not** fixed here, since a fix changes behavior under a race; it sits next to the `#197` update-count snapshot and `#211` close-path work, so the root can decide. The `socketTimeout` URL case from the wave list is deliberately absent: #196 owns it. Fixes #195
1 parent 404bba6 commit fe91f99

4 files changed

Lines changed: 235 additions & 0 deletions

File tree

‎libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/LibsqlResultSetMetaDataTest.java‎

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,34 @@ void resolvesTableNameNullabilityAndAutoIncrement() throws SQLException {
5454
assertThat(metadata.isAutoIncrement(2)).isFalse();
5555
}
5656

57+
@Test
58+
void resolvesAttachedTableMetadataThroughPragmaDatabaseList() throws SQLException {
59+
LibsqlResultSetMetaData metadata = new LibsqlResultSetMetaData(
60+
List.of(new Col("id", "INTEGER"), new Col("name", "TEXT")),
61+
List.of(),
62+
"SELECT id, name FROM aux.People",
63+
connectionForAttachedSchema("main", "temp", "Aux"));
64+
65+
assertThat(metadata.getTableName(1)).isEqualTo("People");
66+
assertThat(metadata.getTableName(2)).isEqualTo("People");
67+
assertThat(metadata.isNullable(1)).isEqualTo(ResultSetMetaData.columnNoNulls);
68+
assertThat(metadata.isNullable(2)).isEqualTo(ResultSetMetaData.columnNoNulls);
69+
assertThat(metadata.isAutoIncrement(1)).isTrue();
70+
assertThat(metadata.isAutoIncrement(2)).isFalse();
71+
}
72+
73+
@Test
74+
void reportsNoTableMetadataForASchemaMissingFromPragmaDatabaseList() throws SQLException {
75+
LibsqlResultSetMetaData metadata = new LibsqlResultSetMetaData(
76+
List.of(new Col("id", "INTEGER")),
77+
List.of(),
78+
"SELECT id FROM ghost.People",
79+
connectionForAttachedSchema("main", "temp", "Aux"));
80+
81+
assertThat(metadata.getTableName(1)).isEmpty();
82+
assertThat(metadata.isNullable(1)).isEqualTo(ResultSetMetaData.columnNullableUnknown);
83+
}
84+
5785
@Test
5886
void resolvesNonAsciiTableName() throws SQLException {
5987
LibsqlResultSetMetaData metadata = new LibsqlResultSetMetaData(
@@ -752,4 +780,59 @@ public StmtResult executeStatement(Stmt stmt) throws SQLException {
752780
}
753781
};
754782
}
783+
784+
/**
785+
* Answers {@code pragma_database_list} with the given schema names and every per-schema table
786+
* lookup from a fixed catalogue, so the caller sees metadata only for schemas that the database
787+
* list actually reports.
788+
*/
789+
private static LibsqlConnection connectionForAttachedSchema(String... databaseNames) {
790+
Map<String, FakeTable> tables = new LinkedHashMap<>();
791+
tables.put(
792+
"Aux",
793+
new FakeTable(
794+
"People",
795+
"CREATE TABLE People (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL)",
796+
List.of(new FakeColumn("id", true, true), new FakeColumn("name", true, false))));
797+
tables.put(
798+
"main",
799+
new FakeTable(
800+
"People",
801+
"CREATE TABLE People (id INTEGER, name TEXT)",
802+
List.of(new FakeColumn("id", false, false), new FakeColumn("name", false, false))));
803+
tables.put(
804+
"ghost",
805+
new FakeTable(
806+
"People",
807+
"CREATE TABLE People (id INTEGER)",
808+
List.of(new FakeColumn("id", false, false))));
809+
List<Row> databases = new ArrayList<>();
810+
for (String name : databaseNames) {
811+
databases.add(new Row(List.of(Value.of(name))));
812+
}
813+
StmtResult databaseList = new StmtResult(List.of(), databases, 0, null);
814+
815+
return new LibsqlConnection(null, null) {
816+
@Override
817+
public StmtResult executeStatement(Stmt stmt) throws SQLException {
818+
if (stmt.sql().contains("pragma_database_list")) {
819+
return databaseList;
820+
}
821+
FakeTable table = tables.get(argument(stmt, 1));
822+
if (table == null) {
823+
return new StmtResult(List.of(), List.of(), 0, null);
824+
}
825+
List<Row> rows = new ArrayList<>();
826+
for (FakeColumn column : table.columns()) {
827+
rows.add(new Row(List.of(
828+
Value.of(table.name()),
829+
Value.of(table.sql()),
830+
Value.of(column.name()),
831+
Value.of(column.notNull() ? 1L : 0L),
832+
Value.of(column.primaryKey() ? 1L : 0L))));
833+
}
834+
return new StmtResult(List.of(), rows, 0, null);
835+
}
836+
};
837+
}
755838
}

‎libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/LibsqlResultSetTest.java‎

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
import java.time.LocalDateTime;
2525
import java.time.LocalTime;
2626
import java.time.ZoneId;
27+
import java.time.ZoneOffset;
2728
import java.util.List;
2829
import java.util.Map;
2930
import java.util.stream.IntStream;
@@ -276,6 +277,63 @@ void realJulianDayFeedsEveryDateAndTimeGetter() throws SQLException {
276277
assertDateTimeGetters(rs, 1_704_153_600_000L);
277278
}
278279

280+
@Test
281+
void textDateTimeWithAnOffsetSuffixIsConvertedToTheSystemZone() throws SQLException {
282+
LibsqlResultSet rs = resultSet(new Value.Text("2024-03-15 10:30:00+02:00"));
283+
assertThat(rs.next()).isTrue();
284+
285+
LocalDateTime expected = LocalDateTime.of(2024, 3, 15, 10, 30)
286+
.atOffset(ZoneOffset.ofHours(2))
287+
.atZoneSameInstant(ZoneId.systemDefault())
288+
.toLocalDateTime();
289+
290+
assertThat(rs.getObject(1, LocalDateTime.class)).isEqualTo(expected);
291+
assertThat(rs.getObject(1, LocalDate.class)).isEqualTo(expected.toLocalDate());
292+
assertThat(rs.getObject(1, LocalTime.class)).isEqualTo(expected.toLocalTime());
293+
}
294+
295+
@Test
296+
void textDateTimeWithAZSuffixIsReadAsUtc() throws SQLException {
297+
LibsqlResultSet rs = resultSet(new Value.Text("2024-03-15T10:30:00Z"));
298+
assertThat(rs.next()).isTrue();
299+
300+
LocalDateTime expected = LocalDateTime.of(2024, 3, 15, 10, 30)
301+
.atOffset(ZoneOffset.UTC)
302+
.atZoneSameInstant(ZoneId.systemDefault())
303+
.toLocalDateTime();
304+
305+
assertThat(rs.getObject(1, LocalDateTime.class)).isEqualTo(expected);
306+
assertThat(rs.getObject(1, LocalDate.class)).isEqualTo(expected.toLocalDate());
307+
assertThat(rs.getObject(1, LocalTime.class)).isEqualTo(expected.toLocalTime());
308+
}
309+
310+
@Test
311+
void bareTimeTextGetsTheYear2000DefaultDate() throws SQLException {
312+
LibsqlResultSet rs = resultSet(new Value.Text("10:30:45"));
313+
assertThat(rs.next()).isTrue();
314+
315+
assertThat(rs.getObject(1, LocalDateTime.class))
316+
.isEqualTo(LocalDateTime.of(2000, 1, 1, 10, 30, 45));
317+
assertThat(rs.getObject(1, LocalDate.class)).isEqualTo(LocalDate.of(2000, 1, 1));
318+
assertThat(rs.getObject(1, LocalTime.class)).isEqualTo(LocalTime.of(10, 30, 45));
319+
}
320+
321+
@Test
322+
void blobRejectsDateAndTimeGettersWithTheTargetTypeName() throws SQLException {
323+
LibsqlResultSet rs = resultSet(new Value.Blob(new byte[] {1, 2, 3}));
324+
assertThat(rs.next()).isTrue();
325+
326+
assertThatThrownBy(() -> rs.getObject(1, LocalDate.class))
327+
.isInstanceOf(SQLException.class)
328+
.hasMessage("Bad value for type LocalDate");
329+
assertThatThrownBy(() -> rs.getObject(1, LocalTime.class))
330+
.isInstanceOf(SQLException.class)
331+
.hasMessage("Bad value for type LocalTime");
332+
assertThatThrownBy(() -> rs.getObject(1, LocalDateTime.class))
333+
.isInstanceOf(SQLException.class)
334+
.hasMessage("Bad value for type LocalDateTime");
335+
}
336+
279337
@Test
280338
void binaryStreamContentMatchesGetBytesByteForByte() throws Exception {
281339
byte[] payload = {0, 1, 2, 65, 127, -128, -1};

‎libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/LibsqlSavepointTest.java‎

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,52 @@ void releasingTheOutermostSavepointInsideAnExplicitBeginKeepsTheTransaction()
208208
"INSERT INTO sample VALUES (2)");
209209
}
210210

211+
@Test
212+
void releasingTheLaterOfTwoSavepointsWithTheSameNameKeepsTheEarlierOneActive()
213+
throws SQLException {
214+
ScriptedClient client = successClient();
215+
LibsqlConnection connection = newConnection(client);
216+
Savepoint first = connection.setSavepoint("dup");
217+
Savepoint second = connection.setSavepoint("dup");
218+
219+
connection.releaseSavepoint(second);
220+
connection.executeStatement(new Stmt("INSERT INTO sample VALUES (1)"));
221+
connection.rollback(first);
222+
connection.executeStatement(new Stmt("INSERT INTO sample VALUES (2)"));
223+
224+
assertThat(client.executedStatements())
225+
.containsExactly(
226+
"SAVEPOINT \"dup\"",
227+
"SAVEPOINT \"dup\"",
228+
"RELEASE SAVEPOINT \"dup\"",
229+
"INSERT INTO sample VALUES (1)",
230+
"ROLLBACK TO SAVEPOINT \"dup\"",
231+
"INSERT INTO sample VALUES (2)");
232+
assertThat(client.requests.stream()
233+
.flatMap(request -> request.requests().stream())
234+
.filter(StreamRequest.Close.class::isInstance))
235+
.isEmpty();
236+
}
237+
238+
@Test
239+
void releasingTheOnlySavepointOfAnAutoCommitTransactionLeavesCommitWithNothingToSend()
240+
throws SQLException {
241+
ScriptedClient client = successClient();
242+
LibsqlConnection connection = newConnection(client);
243+
Savepoint savepoint = connection.setSavepoint("only");
244+
245+
connection.releaseSavepoint(savepoint);
246+
connection.commit();
247+
248+
assertThat(connection.getAutoCommit()).isFalse();
249+
assertThat(client.executedStatements())
250+
.containsExactly("SAVEPOINT \"only\"", "RELEASE SAVEPOINT \"only\"");
251+
assertThat(client.requests.stream()
252+
.flatMap(request -> request.requests().stream())
253+
.filter(StreamRequest.Close.class::isInstance))
254+
.hasSize(1);
255+
}
256+
211257
@Test
212258
void rollbackAndReleaseThrowInAutoCommitModeWithoutSendingAnything() throws SQLException {
213259
ScriptedClient client = successClient();

‎libsql-jdbc/src/test/java/com/github/magnusp/libsql/jdbc/LibsqlStatementConcurrencyTest.java‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
import java.util.concurrent.atomic.AtomicReference;
3535

3636
import static org.assertj.core.api.Assertions.assertThat;
37+
import static org.assertj.core.api.Assertions.assertThatThrownBy;
3738

3839
class LibsqlStatementConcurrencyTest {
3940

@@ -181,6 +182,32 @@ void closeDoesNotInterleaveWithExecuteAndLeavesNoStreamOpen() throws Exception {
181182
}
182183
}
183184

185+
@Test
186+
void executeBatchThatLosesTheRaceToCloseFailsWithStatementClosed() throws Exception {
187+
Pause pause = new Pause();
188+
LibsqlStatement statement = new LibsqlStatement(new PausingBatchConnection(pause));
189+
statement.addBatch("insert into t(value) values (1)");
190+
ExecutorService executor = Executors.newSingleThreadExecutor();
191+
try {
192+
Future<int[]> batch = executor.submit(statement::executeBatch);
193+
194+
assertThat(pause.awaitReached()).isTrue();
195+
AtomicReference<Throwable> closeFailure = new AtomicReference<>();
196+
joinAndCheck(closeInThread(statement, closeFailure), closeFailure);
197+
assertThat(statement.isClosed()).isTrue();
198+
pause.resume();
199+
200+
assertThatThrownBy(() -> batch.get(5, TimeUnit.SECONDS))
201+
.isInstanceOf(ExecutionException.class)
202+
.cause()
203+
.isInstanceOf(SQLException.class)
204+
.hasMessageContaining("Statement is closed");
205+
} finally {
206+
pause.resume();
207+
executor.shutdownNow();
208+
}
209+
}
210+
184211
private static Void executeRepeatedly(LibsqlStatement statement, int count) throws SQLException {
185212
for (int i = 0; i < count; i++) {
186213
statement.execute("SELECT " + i);
@@ -300,6 +327,27 @@ protected void beforeResultSetAssignment() throws SQLException {
300327
}
301328
}
302329

330+
/**
331+
* Holds the batch inside the connection round trip so a close can land before the statement
332+
* reaches its own cleanup.
333+
*/
334+
private static final class PausingBatchConnection extends LibsqlConnection {
335+
private final Pause pause;
336+
337+
private PausingBatchConnection(Pause pause) {
338+
super(null, null);
339+
this.pause = pause;
340+
}
341+
342+
@Override
343+
public List<StmtResult> executeBatchResults(List<Stmt> statements) throws SQLException {
344+
pause.await();
345+
return statements.stream()
346+
.map(statement -> new StmtResult(List.of(), List.of(), 1, null))
347+
.toList();
348+
}
349+
}
350+
303351
/**
304352
* Models the baton rules the connection must uphold: one open stream per connection, one
305353
* request on a stream at a time, and a baton that is consumed by its request. Every rule break

0 commit comments

Comments
 (0)