Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 8 additions & 5 deletions docs/property-based-testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,18 +32,21 @@ shaded artifact.
| `LibsqlUrlParserPropertyTest` | `libsql-jdbc` | only `SQLException` escapes parsing, metadata redaction idempotence | 500 |
| `SqlMetadataParsingPropertyTest` | `libsql-jdbc` | declared-type parsing and AUTOINCREMENT detection | 500 |
| `ConnectionLifecyclePropertyTest` | `libsql-jdbc` | model-based transaction and savepoint state machine using `ImperativeCommand` | 1 000 scenarios, up to 50 commands |
| `HranaJsonCodecPropertyTest` | `libsql-hrana-codec` | both codecs agree on encode and decode, 64-bit integers, base64 blobs | 1 000 |
| `HranaJsonCodecPropertyTest` | `libsql-hrana-codec` | both codecs agree on encode and decode of the whole response, 64-bit integers, base64 blobs | 1 000 |

The suites add well under a second to the unit lane. They do not start a container.

The statement splitter was the first target because a wrong split silently changes migration
behavior. The generated scripts combine text literals, quoted identifiers, line and block comments,
and trigger bodies that contain inner semicolons. The properties assert that:
empty and comment-only statements, and trigger bodies that contain inner semicolons. The properties
assert that:

- `split` returns exactly the statements that were joined, in order;
- `split` returns exactly the statements that were joined, in order, dropping empty and
comment-only ones;
- arbitrary text never makes `split` throw;
- every returned statement is non-blank;
- splitting any returned statement again returns that same statement;
- an unterminated quote stays inside one statement;
- `?` inside quoted text, bracket identifiers, blob literals and comments is not a placeholder.

The lifecycle suite is a small model, not a replay of named examples. The model tracks `closed`,
Expand Down Expand Up @@ -136,8 +139,8 @@ and savepoint work has more value in generated command sequences than in more pr

## Conventions and limits

- Property tests complement the named examples. They do not replace the Appendix B profile or the
xerial conformance suite.
- Property tests are the primary check for the pure seams they cover; they do not replace the
Appendix B profile or the xerial conformance suite.
- Use AssertJ inside the property and finish with `return true`. The assertion failure is caught and
shrunk by jetCheck.
- Keep generators described and deterministic. Do not depend on outside state.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,54 +48,4 @@ void testSerializeSupplementaryCharactersAsUtf8() throws IOException {
assertThat(json).doesNotContain("?");
}

@Test
void testDeserializePipelineResponse() throws IOException {
String json = """
{
"baton": "baton-12345",
"base_url": null,
"results": [
{
"type": "ok",
"response": {
"type": "execute",
"result": {
"cols": [{"name": "id", "decltype": "INT"}, {"name": "msg", "decltype": "TEXT"}],
"rows": [
[{"type": "integer", "value": "42"}, {"type": "text", "value": "world"}],
[{"type": "null"}, {"type": "blob", "base64": "AQID"}]
],
"affected_row_count": 0,
"last_insert_rowid": "100"
}
}
}
]
}
""";

PipelineRespBody resp = codec.deserializePipelineResponse(json.getBytes(StandardCharsets.UTF_8));

assertThat(resp.baton()).isEqualTo("baton-12345");
assertThat(resp.baseUrl()).isNull();
assertThat(resp.results()).hasSize(1);

StreamResult result = resp.results().get(0);
assertThat(result).isInstanceOf(StreamResult.Ok.class);

StreamResponse streamResponse = ((StreamResult.Ok) result).response();
assertThat(streamResponse).isInstanceOf(StreamResponse.Execute.class);

StmtResult stmtResult = ((StreamResponse.Execute) streamResponse).result();
assertThat(stmtResult.cols()).hasSize(2);
assertThat(stmtResult.cols().get(0).name()).isEqualTo("id");
assertThat(stmtResult.cols().get(0).decltype()).isEqualTo("INT");

assertThat(stmtResult.rows()).hasSize(2);
assertThat(stmtResult.rows().get(0).values().get(0)).isEqualTo(new Value.Integer(42L));
assertThat(stmtResult.rows().get(0).values().get(1)).isEqualTo(new Value.Text("world"));
assertThat(stmtResult.rows().get(1).values().get(0)).isEqualTo(Value.ofNull());
assertThat(stmtResult.rows().get(1).values().get(1)).isEqualTo(new Value.Blob(new byte[]{1, 2, 3}));
assertThat(stmtResult.lastInsertRowid()).isEqualTo(100L);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,11 @@ void bothCodecsDecodeTheSameResponse() {
PipelineRespBody jacksonResponse = decodeUnchecked(jackson, json);

assertThat(jacksonResponse).isEqualTo(directResponse);
assertThat(directResponse.baton()).isEqualTo("baton-1");
StreamResult.Ok ok = (StreamResult.Ok) directResponse.results().get(0);
StreamResponse.Execute execute = (StreamResponse.Execute) ok.response();
assertThat(execute.result().cols()).containsExactly(new Col("v", "TEXT"));
assertThat(execute.result().lastInsertRowid()).isEqualTo(100L);
assertThat(firstValue(directResponse)).isEqualTo(described.value());
return true;
});
Expand Down Expand Up @@ -247,6 +252,8 @@ private static byte[] response(Value value) throws IOException {
ByteArrayOutputStream out = new ByteArrayOutputStream();
try (JsonGenerator json = new JsonFactory().createGenerator(out)) {
json.writeStartObject();
json.writeStringField("baton", "baton-1");
json.writeNullField("base_url");
json.writeArrayFieldStart("results");
json.writeStartObject();
json.writeStringField("type", "ok");
Expand All @@ -265,7 +272,7 @@ private static byte[] response(Value value) throws IOException {
json.writeEndArray();
json.writeEndArray();
json.writeNumberField("affected_row_count", 0);
json.writeStringField("last_insert_rowid", "0");
json.writeStringField("last_insert_rowid", "100");
json.writeEndObject();
json.writeEndObject();
json.writeEndObject();
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
import io.github.magnusp.libsql.hrana.codec.Row;
import io.github.magnusp.libsql.hrana.codec.StmtResult;
import io.github.magnusp.libsql.hrana.codec.Value;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
Expand Down Expand Up @@ -43,30 +42,6 @@ void getBooleanConvertsStorageClasses(String name, Value value, boolean expected
assertThat(rs.getBoolean(1)).isEqualTo(expected);
}

@Test
void getBooleanParsesTextTrueAndFalse() throws SQLException {
LibsqlResultSet rs = resultSet(new Value.Text("true"), new Value.Text("false"));

assertThat(rs.next()).isTrue();

assertThat(rs.getBoolean(1)).isTrue();
assertThat(rs.getBoolean(2)).isFalse();
}

@Test
void textTrueIsTrueAndNumericTextKeepsIntegerCoercion() throws SQLException {
LibsqlResultSet rs = resultSet(
new Value.Text("true"),
new Value.Text("2"),
new Value.Float(0.5));

assertThat(rs.next()).isTrue();

assertThat(rs.getBoolean(1)).isTrue();
assertThat(rs.getBoolean(2)).isTrue();
assertThat(rs.getBoolean(3)).isFalse();
}

private static LibsqlResultSet resultSet(Value... values) {
List<Col> cols = Stream.generate(() -> new Col("c", null)).limit(values.length).toList();
StmtResult result = new StmtResult(cols, List.of(new Row(List.of(values))), 1, null);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,9 @@
/**
* Properties for the statement splitter and the local tokenizer.
*
* <p>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}.
* <p>The generated scripts combine text literals, quoted identifiers, comments, trigger bodies,
* empty statements and malformed quotes. The properties assert conservation, order and the
* split fixpoint, so these cases need no separate named examples.
*/
class SqlScriptPropertyTest {

Expand Down Expand Up @@ -80,17 +80,34 @@ void placeholderCountIgnoresQuotedAndCommentedQuestionMarks() {

private record Script(String sql, List<String> expected) {}

private record Fragment(String sql, boolean kept) {}

private static Generator<Script> scripts() {
return Generator.listsOf(IntDistribution.uniform(0, 6), statements())
.map(statements -> new Script(
String.join(";\n", statements) + (statements.isEmpty() ? "" : ";\n"),
statements.stream().map(String::strip).toList()));
return Generator.listsOf(IntDistribution.uniform(0, 6), fragments())
.map(fragments -> new Script(
String.join(";\n", fragments.stream().map(Fragment::sql).toList())
+ (fragments.isEmpty() ? "" : ";\n"),
fragments.stream()
.filter(Fragment::kept)
.map(fragment -> fragment.sql().strip())
.toList()));
}

private static Generator<Fragment> fragments() {
return Generator.anyOf(
statements().map(statement -> new Fragment(statement, true)),
Generator.constant(new Fragment("", false)),
Generator.constant(new Fragment(" ", false)),
Generator.constant(new Fragment("-- noise\n", false)),
Generator.constant(new Fragment("/* noise */", false)));
}

private static Generator<String> statements() {
return Generator.anyOf(
Generator.constant("select 1"),
Generator.constant("insert into t values (1)"),
Generator.constant("begin"),
Generator.constant("commit"),
textLiteral(),
quotedIdentifier(),
bracketIdentifier(),
Expand All @@ -99,6 +116,26 @@ private static Generator<String> statements() {
triggerBody());
}

/**
* Properties do not need to model a malformed quote. The splitter must leave it inside one
* statement rather than inventing a terminator or throwing.
*/
@Test
void unterminatedQuotesStayInOneStatement() {
PropertyChecker.customized()
.withIterationCount(ITERATIONS)
.forAll(unterminatedScripts(), sql -> {
assertThat(SqlScript.split(sql)).containsExactly(sql.strip());
return true;
});
}

private static Generator<String> unterminatedScripts() {
return Generator.stringsOf(
IntDistribution.uniform(0, 40), Generator.charsInRange((char) 0, (char) 127))
.map(text -> "select '" + text.replace("'", "''") + "; select 2");
}

private static Generator<String> textLiteral() {
return Generator.stringsOf(IntDistribution.uniform(0, 24), Generator.charsInRange((char) 0, (char) 127))
.map(text -> "select '" + text.replace("'", "''") + "' from t");
Expand Down
Loading
Loading