From be7640f12500e650cf71916c4167d7e7257b3d2a Mon Sep 17 00:00:00 2001 From: Magnus Persson <1431685+magnusp@users.noreply.github.com> Date: Sat, 31 Oct 2026 11:37:08 +0100 Subject: [PATCH 1/5] fix(jdbc): report a malformed timeout as a SQLException Long.parseLong let a NumberFormatException escape LibsqlUrlParser.parse for a non-numeric connectTimeout or busyTimeout. A property test found the empty-query-value case. Wrap the conversion so the JDBC boundary reports a SQLException with the parameter name and keeps the cause. --- .../magnusp/libsql/jdbc/LibsqlUrlParser.java | 18 +++++++++++++++--- .../libsql/jdbc/LibsqlUrlParserTest.java | 17 +++++++++++++++++ 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/libsql-jdbc/src/main/java/io/github/magnusp/libsql/jdbc/LibsqlUrlParser.java b/libsql-jdbc/src/main/java/io/github/magnusp/libsql/jdbc/LibsqlUrlParser.java index d0231e1..3e6198c 100644 --- a/libsql-jdbc/src/main/java/io/github/magnusp/libsql/jdbc/LibsqlUrlParser.java +++ b/libsql-jdbc/src/main/java/io/github/magnusp/libsql/jdbc/LibsqlUrlParser.java @@ -145,16 +145,28 @@ private static LibsqlClientConfig.Encoding resolveEncoding( } private static Duration resolveDuration( - String name, Map queryParams, Properties info, Duration defaultValue) { + String name, + Map queryParams, + Properties info, + Duration defaultValue) throws SQLException { if (queryParams.containsKey(name)) { - return Duration.ofMillis(Long.parseLong(queryParams.get(name))); + return parseDuration(name, queryParams.get(name)); } if (info != null && info.containsKey(name)) { - return Duration.ofMillis(Long.parseLong(info.getProperty(name))); + return parseDuration(name, info.getProperty(name)); } return defaultValue; } + /** Keeps a bad timeout a JDBC error instead of leaking {@link NumberFormatException}. */ + private static Duration parseDuration(String name, String value) throws SQLException { + try { + return Duration.ofMillis(Long.parseLong(value)); + } catch (NumberFormatException e) { + throw new SQLException("Invalid " + name + " '" + value + "': expected milliseconds", e); + } + } + private static String buildEndpoint(URI uri, String scheme) { int port = uri.getPort(); String path = uri.getPath() != null ? uri.getPath() : ""; diff --git a/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/LibsqlUrlParserTest.java b/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/LibsqlUrlParserTest.java index 04bd891..59ea82d 100644 --- a/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/LibsqlUrlParserTest.java +++ b/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/LibsqlUrlParserTest.java @@ -54,6 +54,23 @@ void testConnectTimeoutStillWinsAlongsideSocketTimeout() throws SQLException { assertThat(config.connectTimeout().toMillis()).isEqualTo(2500); } + @Test + void testRejectsANonNumericTimeoutAsAJDBCError() { + assertThatThrownBy(() -> LibsqlUrlParser.parse( + "jdbc:libsql:http://localhost:8080/dev/tenant?connectTimeout=soon", + new Properties())) + .isInstanceOf(SQLException.class) + .hasMessageContaining("connectTimeout") + .hasCauseInstanceOf(NumberFormatException.class); + + Properties properties = new Properties(); + properties.setProperty("busyTimeout", "later"); + assertThatThrownBy(() -> LibsqlUrlParser.parse("jdbc:libsql:http://localhost:8080", properties)) + .isInstanceOf(SQLException.class) + .hasMessageContaining("busyTimeout") + .hasCauseInstanceOf(NumberFormatException.class); + } + @Test void testParseWebSocketUrlKeepsTheSchemeAndPath() throws SQLException { LibsqlClientConfig config = LibsqlUrlParser.parse( From e98860f40bcccf401fb07d14747a693af960dabe Mon Sep 17 00:00:00 2001 From: Magnus Persson <1431685+magnusp@users.noreply.github.com> Date: Sat, 31 Oct 2026 12:15:43 +0100 Subject: [PATCH 2/5] build(test): add jetCheck for property-based tests Pin org.jetbrains:jetCheck 0.3.0 and declare it test-scoped in the JDBC and Hrana codec modules. It stays out of runtime and shaded artifacts. --- libsql-hrana-codec/pom.xml | 5 +++++ libsql-jdbc/pom.xml | 5 +++++ pom.xml | 9 +++++++++ 3 files changed, 19 insertions(+) diff --git a/libsql-hrana-codec/pom.xml b/libsql-hrana-codec/pom.xml index d40607b..bb6512a 100644 --- a/libsql-hrana-codec/pom.xml +++ b/libsql-hrana-codec/pom.xml @@ -38,6 +38,11 @@ assertj-core test + + org.jetbrains + jetCheck + test + diff --git a/libsql-jdbc/pom.xml b/libsql-jdbc/pom.xml index 2dafd3d..92aa7f3 100644 --- a/libsql-jdbc/pom.xml +++ b/libsql-jdbc/pom.xml @@ -32,6 +32,11 @@ assertj-core test + + org.jetbrains + jetCheck + test + org.flywaydb flyway-core diff --git a/pom.xml b/pom.xml index 93fa3bb..67beb7a 100644 --- a/pom.xml +++ b/pom.xml @@ -59,6 +59,7 @@ 2.22.4 6.1.3 3.27.7 + 0.3.0 2.0.5 7.1.0 13.12.0 @@ -126,6 +127,14 @@ ${assertj.version} test + + + org.jetbrains + jetCheck + ${jetcheck.version} + test + org.testcontainers testcontainers-bom From 9c7a64d68328b1669f0d77eb30dc75b8c6a4cdb4 Mon Sep 17 00:00:00 2001 From: Magnus Persson <1431685+magnusp@users.noreply.github.com> Date: Sat, 31 Oct 2026 12:15:47 +0100 Subject: [PATCH 3/5] fix(jdbc): redact repeated user info from metadata URLs The USER_INFO pattern removed only the first user:pass@ segment, so a URL such as http://user:pass@user:pass@host kept a credential in the result of a second redaction. Consume through the last @ before the path, query or fragment instead. A property test found the case. --- .../github/magnusp/libsql/jdbc/LibsqlUrlParser.java | 2 +- .../magnusp/libsql/jdbc/LibsqlUrlParserTest.java | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/libsql-jdbc/src/main/java/io/github/magnusp/libsql/jdbc/LibsqlUrlParser.java b/libsql-jdbc/src/main/java/io/github/magnusp/libsql/jdbc/LibsqlUrlParser.java index 3e6198c..ffa8611 100644 --- a/libsql-jdbc/src/main/java/io/github/magnusp/libsql/jdbc/LibsqlUrlParser.java +++ b/libsql-jdbc/src/main/java/io/github/magnusp/libsql/jdbc/LibsqlUrlParser.java @@ -21,7 +21,7 @@ public class LibsqlUrlParser { private static final Set CREDENTIAL_QUERY_PARAMS = Set.of("authToken", "jwt", "password", "token"); - private static final Pattern USER_INFO = Pattern.compile("^(jdbc:[a-zA-Z]+:[a-zA-Z]+://)[^/@]*@"); + private static final Pattern USER_INFO = Pattern.compile("^(jdbc:[a-zA-Z]+:[a-zA-Z]+://)[^/?#]*@"); public static boolean acceptsUrl(String url) { if (url == null) return false; diff --git a/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/LibsqlUrlParserTest.java b/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/LibsqlUrlParserTest.java index 59ea82d..76ad967 100644 --- a/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/LibsqlUrlParserTest.java +++ b/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/LibsqlUrlParserTest.java @@ -169,6 +169,18 @@ void testMetadataUrlRedactsUserInfo() { .isEqualTo("jdbc:libsql:http://localhost:8080/dev"); } + @Test + void testMetadataUrlRedactsRepeatedUserInfo() { + assertThat(LibsqlUrlParser.metadataUrl("jdbc:libsql:http://user:pass@user:pass@localhost:8080/dev")) + .isEqualTo("jdbc:libsql:http://localhost:8080/dev"); + } + + @Test + void testMetadataUrlDoesNotTreatAnAtSignInTheQueryAsUserInfo() { + String url = "jdbc:libsql:http://localhost:8080/dev?filter=a@b"; + assertThat(LibsqlUrlParser.metadataUrl(url)).isEqualTo(url); + } + @Test void testMetadataUrlLeavesANonCredentialUrlUnchanged() { String url = "jdbc:sqlite:http://localhost:8080/dev/tenant?connectTimeout=5000"; From 743fe1768e6a21b24c7fe415cc3b412a2e8f0346 Mon Sep 17 00:00:00 2001 From: Magnus Persson <1431685+magnusp@users.noreply.github.com> Date: Sat, 31 Oct 2026 12:15:51 +0100 Subject: [PATCH 4/5] test: add jetCheck property suites for the extracted seams Cover the statement splitter, SQLite text conversions, URL parsing, metadata text, the two Hrana JSON codecs, and the connection transaction lifecycle. The lifecycle suite uses jetCheck's ImperativeCommand scenario support, so command generation, replay, shrinking and the failure log belong to the library. jetCheck has no 64-bit or array generator, so the suites compose a long from two ints and map lists to byte arrays. Text generators stay on BMP code points because jetCheck's string generator is char-based; supplementary text is covered by constants. --- .../codec/HranaJsonCodecPropertyTest.java | 307 +++++++++++++++++ .../jdbc/ConnectionLifecyclePropertyTest.java | 311 ++++++++++++++++++ .../jdbc/LibsqlUrlParserPropertyTest.java | 118 +++++++ .../jdbc/SqlMetadataParsingPropertyTest.java | 69 ++++ .../libsql/jdbc/SqlScriptPropertyTest.java | 157 +++++++++ .../jdbc/SqliteValueTextPropertyTest.java | 114 +++++++ 6 files changed, 1076 insertions(+) create mode 100644 libsql-hrana-codec/src/test/java/io/github/magnusp/libsql/hrana/codec/HranaJsonCodecPropertyTest.java create mode 100644 libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/ConnectionLifecyclePropertyTest.java create mode 100644 libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/LibsqlUrlParserPropertyTest.java create mode 100644 libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/SqlMetadataParsingPropertyTest.java create mode 100644 libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/SqlScriptPropertyTest.java create mode 100644 libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/SqliteValueTextPropertyTest.java diff --git a/libsql-hrana-codec/src/test/java/io/github/magnusp/libsql/hrana/codec/HranaJsonCodecPropertyTest.java b/libsql-hrana-codec/src/test/java/io/github/magnusp/libsql/hrana/codec/HranaJsonCodecPropertyTest.java new file mode 100644 index 0000000..4ac1d30 --- /dev/null +++ b/libsql-hrana-codec/src/test/java/io/github/magnusp/libsql/hrana/codec/HranaJsonCodecPropertyTest.java @@ -0,0 +1,307 @@ +package io.github.magnusp.libsql.hrana.codec; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.HexFormat; +import java.util.List; + +import com.fasterxml.jackson.core.JsonFactory; +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.JsonToken; +import org.jetbrains.jetCheck.Generator; +import org.jetbrains.jetCheck.IntDistribution; +import org.jetbrains.jetCheck.PropertyChecker; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Differential properties for the two Hrana JSON implementations. + * + *

Both codecs speak the same protocol. The property is that a generated value produces the same + * JSON when either codec encodes it, and that both codecs read that JSON back to the same value. + * The generated domain includes the two precision guarantees from the module guide: every 64-bit + * integer is sent as a decimal string, and every blob is sent as base64. + */ +class HranaJsonCodecPropertyTest { + + private static final int ITERATIONS = 1_000; + + private final DirectHranaJsonCodec direct = new DirectHranaJsonCodec(); + private final JacksonHranaJsonCodec jackson = new JacksonHranaJsonCodec(); + + @Test + void bothCodecsEncodeTheSameValue() { + PropertyChecker.customized() + .withIterationCount(ITERATIONS) + .forAll(describedValues(), described -> { + String directJson = canonicalUnchecked(encodeUnchecked(direct, described.value())); + String jacksonJson = canonicalUnchecked(encodeUnchecked(jackson, described.value())); + if (!directJson.equals(jacksonJson)) { + throw new AssertionError( + "direct=" + escape(directJson) + " jackson=" + escape(jacksonJson)); + } + return true; + }); + } + + @Test + void bothCodecsDecodeTheSameResponse() { + PropertyChecker.customized() + .withIterationCount(ITERATIONS) + .forAll(describedValues(), described -> { + byte[] json = responseUnchecked(described.value()); + PipelineRespBody directResponse = decodeUnchecked(direct, json); + PipelineRespBody jacksonResponse = decodeUnchecked(jackson, json); + + assertThat(jacksonResponse).isEqualTo(directResponse); + assertThat(firstValue(directResponse)).isEqualTo(described.value()); + return true; + }); + } + + @Test + void integersKeepAllSixtyFourBitsInText() { + PropertyChecker.customized() + .withIterationCount(ITERATIONS) + .forAll(longs(), value -> { + String expected = "\"type\":\"integer\",\"value\":\"" + value + "\""; + assertThat(text(encodeUnchecked(direct, Value.of(value)))).contains(expected); + assertThat(text(encodeUnchecked(jackson, Value.of(value)))).contains(expected); + return true; + }); + } + + @Test + void blobsKeepTheirBytesInBase64() { + PropertyChecker.customized() + .withIterationCount(ITERATIONS) + .forAll(describedBlobs(), blob -> { + String expected = "\"type\":\"blob\",\"base64\":\"" + + Base64.getEncoder().encodeToString(blob.bytes()) + "\""; + assertThat(text(encodeUnchecked(direct, Value.of(blob.bytes())))).contains(expected); + assertThat(text(encodeUnchecked(jackson, Value.of(blob.bytes())))).contains(expected); + return true; + }); + } + + private record DescribedValue(String description, Value value) { + @Override + public String toString() { + return description; + } + } + + private record DescribedBlob(byte[] bytes) { + @Override + public String toString() { + return "blob " + HexFormat.of().formatHex(bytes); + } + } + + private static Generator describedValues() { + return values().map(value -> new DescribedValue(describe(value), value)); + } + + private static Generator values() { + return Generator.anyOf( + Generator.constant(Value.ofNull()), + longs().map(value -> Value.of(value.longValue())), + finiteDoubles().map(value -> Value.of(value.doubleValue())), + texts().map(Value::of), + describedBlobs().map(blob -> Value.of(blob.bytes()))); + } + + /** jetCheck has no built-in 64-bit generator, so compose one from two 32-bit draws. */ + private static Generator longs() { + return Generator.zipWith( + Generator.integers(), + Generator.integers(), + (high, low) -> ((long) high << 32) | (low & 0xffffffffL)); + } + + private static Generator finiteDoubles() { + return Generator.doubles().suchThat(Double::isFinite); + } + + private static Generator texts() { + return Generator.anyOf( + Generator.stringsOf( + IntDistribution.uniform(0, 40), Generator.charsInRange((char) 0, (char) 127)), + // Exclude the surrogate range: jetCheck shrinks strings at char boundaries, which + // can split a supplementary pair into a lone surrogate that is not well-formed + // Unicode. Supplementary text is covered by the constants below. + Generator.stringsOf( + IntDistribution.uniform(0, 20), Generator.charsInRange((char) 0x20, (char) 0xD7FF)), + Generator.stringsOf( + IntDistribution.uniform(0, 20), Generator.charsInRange((char) 0xE000, (char) 0xFFFF)), + Generator.constant("\u0000quoted \" text\n\t"), + Generator.constant("\uD840\uDC40 supplementary"), + Generator.constant("emoji \uD83D\uDE00")); + } + + private static Generator describedBlobs() { + return Generator.listsOf(IntDistribution.uniform(0, 32), Generator.integers(0, 255)) + .map(HranaJsonCodecPropertyTest::toBytes) + .map(DescribedBlob::new); + } + + private static byte[] toBytes(List values) { + byte[] bytes = new byte[values.size()]; + for (int i = 0; i < values.size(); i++) { + bytes[i] = (byte) (int) values.get(i); + } + return bytes; + } + + private static String describe(Value value) { + return switch (value) { + case Value.Null n -> "null"; + case Value.Integer i -> "integer " + i.value(); + case Value.Float f -> "float " + f.value(); + case Value.Text t -> "text \"" + escape(t.value()) + "\""; + case Value.Blob b -> "blob " + HexFormat.of().formatHex(b.bytes()); + }; + } + + private static String escape(String text) { + StringBuilder escaped = new StringBuilder(text.length()); + for (char c : text.toCharArray()) { + if (c >= 0x20 && c < 0x7f && c != '\\') { + escaped.append(c); + } else { + escaped.append(String.format("\\u%04x", (int) c)); + } + } + return escaped.toString(); + } + + private static PipelineRespBody decodeUnchecked(HranaJsonCodec codec, byte[] json) { + try { + return codec.deserializePipelineResponse(json); + } catch (IOException e) { + throw new AssertionError("could not decode " + text(json), e); + } + } + + private static byte[] encodeUnchecked(HranaJsonCodec codec, Value value) { + try { + return encode(codec, value); + } catch (IOException e) { + throw new AssertionError("could not encode " + value, e); + } + } + + private static String canonicalUnchecked(byte[] json) { + try { + return canonical(json); + } catch (IOException e) { + throw new AssertionError("could not read back " + text(json), e); + } + } + + private static byte[] responseUnchecked(Value value) { + try { + return response(value); + } catch (IOException e) { + throw new AssertionError("could not write a response for " + value, e); + } + } + + private static byte[] encode(HranaJsonCodec codec, Value value) throws IOException { + PipelineReqBody request = new PipelineReqBody( + null, List.of(new StreamRequest.Execute(new Stmt("select ?", List.of(value))))); + return codec.serializePipelineRequest(request); + } + + private static String text(byte[] json) { + return new String(json, StandardCharsets.UTF_8); + } + + /** + * Reduces JSON to a token stream, so formatting and string-escaping differences between the + * two writers do not appear as protocol differences. Field order still matters. + */ + private static String canonical(byte[] json) throws IOException { + StringBuilder canonical = new StringBuilder(); + try (JsonParser parser = new JsonFactory().createParser(json)) { + JsonToken token; + while ((token = parser.nextToken()) != null) { + canonical.append(token.name()).append('\u0000'); + if (token == JsonToken.FIELD_NAME + || token == JsonToken.VALUE_STRING + || token == JsonToken.VALUE_NUMBER_INT + || token == JsonToken.VALUE_NUMBER_FLOAT) { + canonical.append(parser.getText()); + } + canonical.append('\n'); + } + } + return canonical.toString(); + } + + private static byte[] response(Value value) throws IOException { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + try (JsonGenerator json = new JsonFactory().createGenerator(out)) { + json.writeStartObject(); + json.writeArrayFieldStart("results"); + json.writeStartObject(); + json.writeStringField("type", "ok"); + json.writeObjectFieldStart("response"); + json.writeStringField("type", "execute"); + json.writeObjectFieldStart("result"); + json.writeArrayFieldStart("cols"); + json.writeStartObject(); + json.writeStringField("name", "v"); + json.writeStringField("decltype", "TEXT"); + json.writeEndObject(); + json.writeEndArray(); + json.writeArrayFieldStart("rows"); + json.writeStartArray(); + writeValue(json, value); + json.writeEndArray(); + json.writeEndArray(); + json.writeNumberField("affected_row_count", 0); + json.writeStringField("last_insert_rowid", "0"); + json.writeEndObject(); + json.writeEndObject(); + json.writeEndObject(); + json.writeEndArray(); + json.writeEndObject(); + } + return out.toByteArray(); + } + + private static void writeValue(JsonGenerator json, Value value) throws IOException { + json.writeStartObject(); + switch (value) { + case Value.Null n -> json.writeStringField("type", "null"); + case Value.Integer i -> { + json.writeStringField("type", "integer"); + json.writeStringField("value", Long.toString(i.value())); + } + case Value.Float f -> { + json.writeStringField("type", "float"); + json.writeNumberField("value", f.value()); + } + case Value.Text t -> { + json.writeStringField("type", "text"); + json.writeStringField("value", t.value()); + } + case Value.Blob b -> { + json.writeStringField("type", "blob"); + json.writeStringField("base64", Base64.getEncoder().encodeToString(b.bytes())); + } + } + json.writeEndObject(); + } + + private static Value firstValue(PipelineRespBody response) { + StreamResult.Ok ok = (StreamResult.Ok) response.results().get(0); + StreamResponse.Execute execute = (StreamResponse.Execute) ok.response(); + return execute.result().rows().get(0).values().get(0); + } +} diff --git a/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/ConnectionLifecyclePropertyTest.java b/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/ConnectionLifecyclePropertyTest.java new file mode 100644 index 0000000..515ad32 --- /dev/null +++ b/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/ConnectionLifecyclePropertyTest.java @@ -0,0 +1,311 @@ +package io.github.magnusp.libsql.jdbc; + +import io.github.magnusp.libsql.client.LibsqlClient; +import io.github.magnusp.libsql.client.LibsqlClientConfig; +import io.github.magnusp.libsql.hrana.codec.Batch; +import io.github.magnusp.libsql.hrana.codec.BatchResult; +import io.github.magnusp.libsql.hrana.codec.Stmt; +import io.github.magnusp.libsql.hrana.codec.StmtResult; +import java.sql.SQLException; +import java.sql.Savepoint; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.jetbrains.jetCheck.Generator; +import org.jetbrains.jetCheck.ImperativeCommand; +import org.jetbrains.jetCheck.IntDistribution; +import org.jetbrains.jetCheck.PropertyChecker; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Model-based property for the connection transaction lifecycle. + * + *

jetCheck's {@link ImperativeCommand} support generates and shrinks command sequences while + * replaying them against a fresh connection. The model below tracks auto-commit, the optional open + * transaction, and the savepoint stack. The fake client never rejects a statement, so a failure is + * a driver state-machine bug rather than a server error. + */ +class ConnectionLifecyclePropertyTest { + + private static final int ITERATIONS = 1_000; + private static final LibsqlClientConfig CONFIG = + new LibsqlClientConfig("http://localhost:1", null); + + @Test + void generatedLifecyclesMatchTheTransactionModel() { + PropertyChecker.customized() + .withIterationCount(ITERATIONS) + .checkScenarios(ConnectionScenario::new); + } + + private enum ActionKind { + SET_AUTO_COMMIT_TRUE, + SET_AUTO_COMMIT_FALSE, + COMMIT, + ROLLBACK, + SET_SAVEPOINT, + RELEASE_LATEST, + ROLLBACK_LATEST, + EXECUTE, + CLOSE + } + + private record Action(ActionKind kind, String savepointName) {} + + private record State( + boolean closed, + boolean autoCommit, + boolean transactionActive, + boolean transactionStartedBySavepoint, + List savepoints) { + + static State initial() { + return new State(false, true, false, false, List.of()); + } + + boolean allows(Action action) { + if (action.kind() == ActionKind.CLOSE) { + return true; + } + if (closed) { + return false; + } + return switch (action.kind()) { + case COMMIT, ROLLBACK -> !autoCommit; + case RELEASE_LATEST, ROLLBACK_LATEST -> !autoCommit && !savepoints.isEmpty(); + default -> true; + }; + } + + State after(Action action) { + return switch (action.kind()) { + case SET_AUTO_COMMIT_TRUE -> autoCommit + ? this + : new State(false, true, false, false, List.of()); + case SET_AUTO_COMMIT_FALSE -> + new State(false, false, transactionActive, transactionStartedBySavepoint, savepoints); + case COMMIT, ROLLBACK -> new State(false, false, false, false, List.of()); + case SET_SAVEPOINT -> autoCommit + ? new State(false, false, true, true, List.of(action.savepointName())) + : new State( + false, + false, + true, + transactionStartedBySavepoint, + append(savepoints, action.savepointName())); + case RELEASE_LATEST -> { + int index = savepoints.lastIndexOf(last(savepoints)); + boolean started = index == 0 && transactionStartedBySavepoint; + yield new State( + false, + false, + started ? false : transactionActive, + started ? false : transactionStartedBySavepoint, + List.copyOf(savepoints.subList(0, index))); + } + case ROLLBACK_LATEST -> { + int index = savepoints.lastIndexOf(last(savepoints)); + yield new State( + false, + false, + transactionActive, + transactionStartedBySavepoint, + List.copyOf(savepoints.subList(0, index + 1))); + } + case EXECUTE -> autoCommit + ? this + : new State(false, false, true, transactionStartedBySavepoint, savepoints); + case CLOSE -> new State(true, autoCommit, false, false, List.of()); + }; + } + + private static List append(List values, String value) { + List copy = new ArrayList<>(values); + copy.add(value); + return List.copyOf(copy); + } + + private static String last(List values) { + return values.get(values.size() - 1); + } + } + + private static final class ConnectionScenario implements ImperativeCommand { + + @Override + public void performCommand(ImperativeCommand.Environment env) { + Fixture fixture = new Fixture(); + try { + env.executeCommands(IntDistribution.uniform(0, 50), commands(fixture)); + fixture.assertInvariants(); + } finally { + try { + fixture.connection.close(); + } catch (SQLException e) { + throw new AssertionError("could not close the connection", e); + } + } + } + } + + private record Command(Fixture fixture, ActionKind kind, String savepointName) + implements ImperativeCommand { + + @Override + public void performCommand(ImperativeCommand.Environment env) { + env.logMessage(kind + (savepointName == null ? "" : "(" + savepointName + ")")); + fixture.apply(new Action(kind, savepointName)); + } + } + + private static final class Fixture { + + private final RecordingClient client = new RecordingClient(); + private final LibsqlConnection connection = new LibsqlConnection(CONFIG, client); + private final List savepoints = new ArrayList<>(); + private State state = State.initial(); + + void apply(Action action) { + if ((action.kind() == ActionKind.RELEASE_LATEST || action.kind() == ActionKind.ROLLBACK_LATEST) + && state.savepoints().isEmpty()) { + return; + } + + boolean expectedToSucceed = state.allows(action); + try { + execute(action); + assertThat(expectedToSucceed) + .as("%s should have failed in %s", action.kind(), state) + .isTrue(); + state = state.after(action); + } catch (SQLException expected) { + assertThat(expectedToSucceed) + .as("unexpected SQLException from %s in %s", action.kind(), state) + .isFalse(); + } catch (RuntimeException unexpected) { + throw new AssertionError( + "unexpected " + unexpected + " from " + action.kind() + " in " + state, unexpected); + } + + assertInvariants(); + } + + private void execute(Action action) throws SQLException { + switch (action.kind()) { + case SET_AUTO_COMMIT_TRUE -> connection.setAutoCommit(true); + case SET_AUTO_COMMIT_FALSE -> connection.setAutoCommit(false); + case COMMIT -> connection.commit(); + case ROLLBACK -> connection.rollback(); + case SET_SAVEPOINT -> savepoints.add(connection.setSavepoint(action.savepointName())); + case RELEASE_LATEST -> { + connection.releaseSavepoint(lastSavepoint()); + savepoints.remove(savepoints.size() - 1); + } + case ROLLBACK_LATEST -> connection.rollback(lastSavepoint()); + case EXECUTE -> connection.executeStatement(new Stmt("SELECT 1")); + case CLOSE -> connection.close(); + } + } + + private Savepoint lastSavepoint() { + return savepoints.get(savepoints.size() - 1); + } + + void assertInvariants() { + try { + assertThat(connection.isClosed()).isEqualTo(state.closed()); + if (state.closed()) { + assertThatThrownBy(connection::getAutoCommit).isInstanceOf(SQLException.class); + } else { + assertThat(connection.getAutoCommit()).isEqualTo(state.autoCommit()); + } + } catch (SQLException e) { + throw new AssertionError("could not read connection state", e); + } + assertThat(state.autoCommit() && (!state.savepoints().isEmpty() || state.transactionActive())) + .as("auto-commit implies no transaction state, was %s", state) + .isFalse(); + assertThat(state.transactionStartedBySavepoint() + && !(state.transactionActive() + && !state.autoCommit() + && !state.savepoints().isEmpty())) + .as("savepoint-owned transaction must be active, was %s", state) + .isFalse(); + } + } + + private static Generator commands(Fixture fixture) { + return Generator.sampledFrom( + new Command(fixture, ActionKind.SET_AUTO_COMMIT_TRUE, null), + new Command(fixture, ActionKind.SET_AUTO_COMMIT_FALSE, null), + new Command(fixture, ActionKind.COMMIT, null), + new Command(fixture, ActionKind.ROLLBACK, null), + new Command(fixture, ActionKind.SET_SAVEPOINT, "a"), + new Command(fixture, ActionKind.SET_SAVEPOINT, "b"), + new Command(fixture, ActionKind.SET_SAVEPOINT, "dup"), + new Command(fixture, ActionKind.RELEASE_LATEST, null), + new Command(fixture, ActionKind.ROLLBACK_LATEST, null), + new Command(fixture, ActionKind.EXECUTE, null), + new Command(fixture, ActionKind.CLOSE, null)); + } + + private static final class RecordingClient implements LibsqlClient { + + private boolean openStream; + + @Override + public StmtResult executeOneShot(Stmt stmt) { + return emptyResult(); + } + + @Override + public StmtResult executeOnStream(Stmt stmt) { + openStream = true; + return emptyResult(); + } + + @Override + public BatchResult executeBatchOneShot(Batch batch) { + return emptyBatch(batch); + } + + @Override + public BatchResult executeBatchOnStream(Batch batch) { + openStream = true; + return emptyBatch(batch); + } + + @Override + public boolean hasOpenStream() { + return openStream; + } + + @Override + public void closeStream() { + openStream = false; + } + + @Override + public void close() { + openStream = false; + } + + private static StmtResult emptyResult() { + return new StmtResult(List.of(), List.of(), 0, null); + } + + private static BatchResult emptyBatch(Batch batch) { + Map results = new LinkedHashMap<>(); + for (int i = 0; i < batch.steps().size(); i++) { + if (batch.steps().get(i).stmt() != null) { + results.put(i, emptyResult()); + } + } + return new BatchResult(results, Map.of()); + } + } +} diff --git a/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/LibsqlUrlParserPropertyTest.java b/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/LibsqlUrlParserPropertyTest.java new file mode 100644 index 0000000..6c9da6a --- /dev/null +++ b/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/LibsqlUrlParserPropertyTest.java @@ -0,0 +1,118 @@ +package io.github.magnusp.libsql.jdbc; + +import java.sql.SQLException; +import java.util.Properties; + +import org.jetbrains.jetCheck.Generator; +import org.jetbrains.jetCheck.IntDistribution; +import org.jetbrains.jetCheck.PropertyChecker; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Properties for URL parsing. + * + *

{@link LibsqlUrlParser#parse} is a JDBC boundary: any input the driver accepts must produce a + * config or a {@link SQLException}. An unchecked exception escaping here is a driver bug, because + * the caller cannot distinguish it from a programming error. + */ +class LibsqlUrlParserPropertyTest { + + private static final int ITERATIONS = 500; + + @Test + void parseNeverThrowsAnUnexpectedException() { + PropertyChecker.customized() + .withIterationCount(ITERATIONS) + .forAll(timeoutUrls(), url -> { + try { + LibsqlUrlParser.parse(url, new Properties()); + } catch (SQLException expected) { + // A rejected timeout is a caller error, not a driver crash. + } catch (RuntimeException unexpected) { + throw new AssertionError("unexpected " + unexpected + " for " + url, unexpected); + } + return true; + }); + } + + @Test + void arbitraryUrlsNeverThrowAnUnexpectedException() { + PropertyChecker.customized() + .withIterationCount(ITERATIONS) + .forAll(arbitraryUrls().map(body -> body.startsWith("jdbc:libsql:") + ? body + : "jdbc:libsql:" + body), url -> { + try { + LibsqlUrlParser.parse(url, new Properties()); + } catch (SQLException expected) { + // A malformed URL is a caller error, not a driver crash. + } catch (RuntimeException unexpected) { + throw new AssertionError("unexpected " + unexpected + " for " + url, unexpected); + } + return true; + }); + } + + @Test + void parseNeverThrowsAnUnexpectedExceptionFromProperties() { + PropertyChecker.customized() + .withIterationCount(ITERATIONS) + .forAll(timeoutValues(), value -> { + Properties properties = new Properties(); + properties.setProperty("connectTimeout", value); + try { + LibsqlUrlParser.parse("jdbc:libsql:http://localhost:8080", properties); + } catch (SQLException expected) { + // A rejected timeout is a caller error, not a driver crash. + } catch (RuntimeException unexpected) { + throw new AssertionError("unexpected " + unexpected + " for " + value, unexpected); + } + return true; + }); + } + + @Test + void metadataUrlIsIdempotent() { + PropertyChecker.customized() + .withIterationCount(ITERATIONS) + .forAll(arbitraryUrls(), url -> { + String once = LibsqlUrlParser.metadataUrl(url); + return once.equals(LibsqlUrlParser.metadataUrl(once)); + }); + } + + private static Generator timeoutUrls() { + return timeoutValues().map(value -> "jdbc:libsql:http://localhost:8080/dev/tenant?connectTimeout=" + value); + } + + private static Generator timeoutValues() { + return Generator.anyOf( + Generator.stringsOf(IntDistribution.uniform(0, 20), Generator.digits()), + Generator.stringsOf(IntDistribution.uniform(0, 16), Generator.charsInRange((char) 0, (char) 127)), + Generator.constant(""), + Generator.constant("-1"), + Generator.constant("+5"), + Generator.constant("999999999999999999999999")); + } + + private static Generator arbitraryUrls() { + Generator fragments = Generator.anyOf( + Generator.constant("jdbc:libsql:"), + Generator.constant("http://"), + Generator.constant("localhost:8080"), + Generator.constant("/dev/tenant"), + Generator.constant("?"), + Generator.constant("&"), + Generator.constant("="), + Generator.constant("authToken"), + Generator.constant("token"), + Generator.constant("jwt"), + Generator.constant("connectTimeout"), + Generator.constant("user:pass@"), + Generator.stringsOf(IntDistribution.uniform(0, 8), Generator.charsInRange((char) 0, (char) 127))); + return Generator.listsOf(IntDistribution.uniform(0, 10), fragments) + .map(parts -> String.join("", parts)); + } +} diff --git a/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/SqlMetadataParsingPropertyTest.java b/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/SqlMetadataParsingPropertyTest.java new file mode 100644 index 0000000..6fd4649 --- /dev/null +++ b/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/SqlMetadataParsingPropertyTest.java @@ -0,0 +1,69 @@ +package io.github.magnusp.libsql.jdbc; + +import io.github.magnusp.libsql.hrana.codec.Col; +import org.jetbrains.jetCheck.Generator; +import org.jetbrains.jetCheck.IntDistribution; +import org.jetbrains.jetCheck.PropertyChecker; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Properties for metadata text that originates in the database rather than in the driver. + * + *

SQLite lets a schema declare almost any type name. The generated domain is syntactically + * valid SQLite type text: an arbitrary string with no parentheses, or a parenthesized precision. + * A malformed precision such as {@code DECIMAL(abc)} is deliberately excluded: {@link + * LibsqlResultSetMetaDataTest} currently pins a {@link NumberFormatException} for that input. + */ +class SqlMetadataParsingPropertyTest { + + private static final int ITERATIONS = 500; + + @Test + void declaredTypeNeverThrowsOnValidDeclaredText() { + PropertyChecker.customized() + .withIterationCount(ITERATIONS) + .forAll(declaredTypes(), declType -> { + DeclaredType type = DeclaredType.of(new Col("c", declType)); + assertThat(type.declaredTypeName()).isNotNull(); + assertThat(type.typeName()).isNotNull(); + assertThat(type.precision()).isNotNegative(); + assertThat(type.scale()).isNotNegative(); + return true; + }); + } + + @Test + void autoIncrementDetectionNeverThrowsOnArbitraryCreateStatements() { + PropertyChecker.customized() + .withIterationCount(ITERATIONS) + .forAll(Generator.zipWith(arbitraryText(), arbitraryText(), Pair::new), pair -> { + SqlAutoIncrement.isAutoIncrementColumn(pair.createSql(), pair.columnName()); + return true; + }); + } + + private record Pair(String createSql, String columnName) {} + + private static Generator declaredTypes() { + return Generator.anyOf( + Generator.stringsOf( + IntDistribution.uniform(0, 24), Generator.charsInRange((char) 0x20, (char) 0x7E)) + .map(text -> text.replace('(', '_').replace(')', '_')), + Generator.constant("INTEGER"), + Generator.constant("DECIMAL(10,2)"), + Generator.constant("VARCHAR(255)"), + Generator.constant("BIG INT"), + Generator.constant(" NUMERIC "), + Generator.zipWith( + Generator.integers(0, 1_000_000), + Generator.integers(0, 1_000_000), + (precision, scale) -> "DECIMAL(" + precision + "," + scale + ")")); + } + + private static Generator arbitraryText() { + return Generator.stringsOf( + IntDistribution.uniform(0, 80), Generator.charsInRange((char) 0, Character.MAX_VALUE)); + } +} diff --git a/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/SqlScriptPropertyTest.java b/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/SqlScriptPropertyTest.java new file mode 100644 index 0000000..86ea931 --- /dev/null +++ b/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/SqlScriptPropertyTest.java @@ -0,0 +1,157 @@ +package io.github.magnusp.libsql.jdbc; + +import java.util.List; + +import org.jetbrains.jetCheck.Generator; +import org.jetbrains.jetCheck.IntDistribution; +import org.jetbrains.jetCheck.PropertyChecker; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Properties for the statement splitter and the local tokenizer. + * + *

The generated scripts contain the cases the hand-written tests enumerate: semicolons in + * quoted text, comments and trigger bodies. The point is coverage of combinations, not a + * replacement for the named examples in {@link SqlScriptTest}. + */ +class SqlScriptPropertyTest { + + private static final int ITERATIONS = 500; + + @Test + void splittingReproducesEveryGeneratedStatement() { + PropertyChecker.customized() + .withIterationCount(ITERATIONS) + .forAll(scripts(), script -> { + assertThat(SqlScript.split(script.sql())) + .as("split of %s", script.sql()) + .containsExactlyElementsOf(script.expected()); + return true; + }); + } + + @Test + void splittingNeverThrowsOnArbitraryText() { + PropertyChecker.customized() + .withIterationCount(ITERATIONS) + .forAll(arbitraryText(), sql -> SqlScript.split(sql) != null); + } + + @Test + void splittingNeverReturnsABlankStatement() { + PropertyChecker.customized() + .withIterationCount(ITERATIONS) + .forAll(arbitraryText(), sql -> { + assertThat(SqlScript.split(sql)).allSatisfy(statement -> assertThat(statement) + .isNotBlank()); + return true; + }); + } + + @Test + void everySplitStatementSplitsToItself() { + PropertyChecker.customized() + .withIterationCount(ITERATIONS) + .forAll(arbitraryText(), sql -> { + for (String statement : SqlScript.split(sql)) { + assertThat(SqlScript.split(statement)) + .as("second split of %s", statement) + .containsExactly(statement); + } + return true; + }); + } + + @Test + void placeholderCountIgnoresQuotedAndCommentedQuestionMarks() { + PropertyChecker.customized() + .withIterationCount(ITERATIONS) + .forAll(Generator.listsOf(IntDistribution.uniform(0, 12), tokenizerFragments()), fragments -> { + String sql = String.join(" ", fragments); + long expected = fragments.stream().filter("?"::equals).count(); + assertThat(SqlTokenizer.placeholderCount(sql)) + .as("placeholders in %s", sql) + .isEqualTo((int) expected); + return true; + }); + } + + private record Script(String sql, List expected) {} + + private static Generator