Skip to content
Closed
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
11 changes: 11 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,17 @@ DOCKER_CONFIG="$PWD/libsql-conformance/src/test/docker-config" \

Use the absolute path shown. A relative `DOCKER_CONFIG` is resolved against the fork's working directory, not the shell's.

### JDBC 4.3 Appendix B policy

JDBC 4.3 Appendix B is the oracle for type mapping. The source is [`vendor/JDBC4-TYPE-MAPPING.md`](vendor/JDBC4-TYPE-MAPPING.md).

- When Appendix B and sqlite-jdbc disagree, Appendix B wins. A conformance test that encodes the xerial behavior is quarantined in `libsql-conformance`, not copied.
- Keep sqlite-jdbc parity only where Appendix B is silent. Parity never overrides a defined Appendix B conversion.
- `ARRAY`, `STRUCT`, `REF`, `JAVA_OBJECT`, and `DISTINCT` are N/A. The driver throws `SQLFeatureNotSupportedException` and does not emulate them.
- An expression column has no declared type. Its storage-class fallback is a documented exception in [`docs/jdbc-appendix-b-compliance.md`](docs/jdbc-appendix-b-compliance.md), not a supported Appendix B row.

The compliance profile is [`docs/jdbc-appendix-b-compliance.md`](docs/jdbc-appendix-b-compliance.md). Run the commands in [`README.md`](README.md) before changing a type mapping.

---

## 5. Coding Standards & Conventions
Expand Down
35 changes: 35 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,41 @@ mise exec -- ./mvnw clean verify
mise exec -- ./mvnw spotless:apply
```

### JDBC 4.3 Appendix B checks

Run the Appendix B unit suite.

```bash
./mvnw -q -pl libsql-jdbc -Dtest='Jdbc4AppendixB*' -Dsurefire.failIfNoSpecifiedTests=false test
```

Run the full `libsql-jdbc` unit suite.

```bash
./mvnw -q -pl libsql-jdbc test
```

Run the Appendix B server suite. The install step puts the current driver in the local repository. Under the nono sandbox, the Docker config path is required.

```bash
./mvnw -q -DskipTests install -pl libsql-hrana-codec,libsql-client,libsql-jdbc
DOCKER_CONFIG="$PWD/libsql-conformance/src/test/docker-config" ./mvnw -pl libsql-itests -Dit.test=Jdbc4AppendixBTypeMappingIT -Dtest=NoSuchTest -Dsurefire.failIfNoSpecifiedTests=false verify
```

Run the xerial conformance suite.

```bash
./mvnw -pl libsql-conformance -am verify
```

Run the full gate.

```bash
DOCKER_CONFIG="$PWD/libsql-conformance/src/test/docker-config" ./mvnw clean verify
```

The compliance profile is in [`docs/jdbc-appendix-b-compliance.md`](docs/jdbc-appendix-b-compliance.md).

## Contributing

We welcome contributions! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for contribution guidelines and development workflow, and [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md) for community standards.
Expand Down
245 changes: 245 additions & 0 deletions docs/jdbc-appendix-b-compliance.md

Large diffs are not rendered by default.

28 changes: 26 additions & 2 deletions libsql-conformance/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,32 @@ with:
./mvnw -pl libsql-conformance -am verify
```

Last green result: 272 tests, 7 skipped, with `SavepointTest` included. The
parity queue (#54) closed at 268 tests, 7 skipped.
Last green result: 270 tests, 7 skipped, with `SavepointTest` included. The
count excludes the four Appendix B quarantines below and includes the two
harness tests for the quarantine.

### Appendix B quarantines

JDBC 4.3 Appendix B is the oracle. Four selected xerial test methods assert
behavior that Appendix B replaces. They stay in the vendored tree and still
run in the `xerial-control` profile, but `XerialQuarantine` excludes them from
the libSQL run. The full type profile is in
[`../docs/jdbc-appendix-b-compliance.md`](../docs/jdbc-appendix-b-compliance.md).

| Test | Why the xerial expectation is quarantined |
|---|---|
| `org.sqlite.StatementTest#bytesTest` | xerial allows `getBytes` on a declared `BLOB`. Table B-6 lists `getBytes` only for `BINARY`, `VARBINARY`, and `LONGVARBINARY`. |
| `org.sqlite.RSMetaDataTest#columnTypes` | xerial reports a declared `NCHAR` as `Types.CHAR`. Appendix B defines `NCHAR` as its own JDBC type, so the driver reports `Types.NCHAR`. |
| `org.sqlite.RSMetaDataTest#testGetColumnClassName` | xerial reports a declared `BLOB` as `java.lang.Object`. Table B-3 maps `BLOB` to `java.sql.Blob`. |
| `org.sqlite.PrepStmtTest#getObject` | xerial returns an `Integer` for `getObject` on a declared `BIT`. Table B-3 maps `BIT` to `Boolean`. |

The exclusion is method-level, so each method also loses checks that do not
conflict with Appendix B:

- `StatementTest#bytesTest` also loses its BLOB `setBytes`/`getBytes` round trip.
- `RSMetaDataTest#columnTypes` also checks other column types that do not conflict, including the INTEGER family, text, numeric, boolean, date and timestamp types, plus DECIMAL precision and scale.
- `RSMetaDataTest#testGetColumnClassName` also checks the INTEGER, VARCHAR, and DOUBLE class names.
- `PrepStmtTest#getObject` also checks other `getObject` rows (INTEGER, FLOAT, untyped, VARCHAR and BLOB columns) and the typed getters above them.

### Three-layer contract

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
import java.util.concurrent.atomic.AtomicInteger;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.junit.platform.engine.TestDescriptor;
import org.junit.platform.engine.support.descriptor.MethodSource;

/**
* Exercises the conformance harness itself, without a running sqld container where possible.
Expand Down Expand Up @@ -63,6 +65,54 @@ void fileMemoryUriIsNotRetained() {
assertThat(memoryMode.retained()).isFalse();
}

@Test
void quarantineExcludesOnlyTheAppendixBConflicts() {
XerialQuarantine quarantine = new XerialQuarantine();

for (XerialQuarantine.Quarantined quarantined : XerialQuarantine.QUARANTINED) {
assertThat(
quarantine
.apply(
descriptor(
quarantined.className(),
quarantined.methodName()))
.excluded())
.as("%s#%s", quarantined.className(), quarantined.methodName())
.isTrue();
}

assertThat(quarantine.apply(descriptor("org.sqlite.ResultSetTest", "next")).included())
.as("an unconflicted xerial test")
.isTrue();
}

@Test
void quarantineIsInactiveDuringTheXerialControlRun() {
String previous = System.getProperty(XerialControl.ENABLED_PROPERTY);
System.setProperty(XerialControl.ENABLED_PROPERTY, "xerial");
try {
XerialQuarantine quarantine = new XerialQuarantine();
for (XerialQuarantine.Quarantined quarantined : XerialQuarantine.QUARANTINED) {
assertThat(
quarantine
.apply(
descriptor(
quarantined.className(),
quarantined.methodName()))
.included())
.as("control run keeps %s#%s", quarantined.className(),
quarantined.methodName())
.isTrue();
}
} finally {
if (previous == null) {
System.clearProperty(XerialControl.ENABLED_PROPERTY);
} else {
System.setProperty(XerialControl.ENABLED_PROPERTY, previous);
}
}
}

@Test
void queryStringIsPartOfTheFileNamespace() {
XerialConformanceDriver.Target readOnly =
Expand Down Expand Up @@ -163,6 +213,19 @@ void stopReportsASwallowedCleanupFailure() {
}
}

private static TestDescriptor descriptor(String className, String methodName) {
MethodSource source = MethodSource.from(className, methodName);
return (TestDescriptor)
Proxy.newProxyInstance(
TestDescriptor.class.getClassLoader(),
new Class<?>[] {TestDescriptor.class},
(proxy, method, args) ->
switch (method.getName()) {
case "getSource" -> java.util.Optional.of(source);
default -> null;
});
}

private static Connection wrap(Connection connection, String namespace) {
return wrap(connection, namespace, ignored -> {});
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package com.github.magnusp.libsql.conformance;

import java.util.List;
import org.junit.platform.engine.FilterResult;
import org.junit.platform.engine.TestDescriptor;
import org.junit.platform.engine.support.descriptor.MethodSource;
import org.junit.platform.launcher.PostDiscoveryFilter;

/**
* Quarantines the xerial tests whose expectations Appendix B replaces.
*
* <p>These tests assert sqlite-jdbc behavior that JDBC 4.3 Appendix B defines differently. They
* stay in the vendored tree and still run in the {@code xerial-control} profile, where the
* reference driver is the subject. They are excluded only from the libSQL run, and the reason is
* kept here so the exclusion cannot become an unexplained skip.
*/
public final class XerialQuarantine implements PostDiscoveryFilter {

/** One upstream test method whose xerial expectation conflicts with Appendix B. */
record Quarantined(String className, String methodName, String reason) {}

static final List<Quarantined> QUARANTINED =
List.of(
new Quarantined(
"org.sqlite.StatementTest",
"bytesTest",
"xerial allows getBytes on a declared BLOB; Table B-6 lists getBytes"
+ " only for BINARY, VARBINARY and LONGVARBINARY"),
new Quarantined(
"org.sqlite.RSMetaDataTest",
"columnTypes",
"xerial reports a declared NCHAR as Types.CHAR; Appendix B defines"
+ " NCHAR as its own JDBC type, so the driver reports Types.NCHAR"),
new Quarantined(
"org.sqlite.RSMetaDataTest",
"testGetColumnClassName",
"xerial reports a declared BLOB as java.lang.Object; Table B-3 maps"
+ " BLOB to java.sql.Blob"),
new Quarantined(
"org.sqlite.PrepStmtTest",
"getObject",
"xerial returns an Integer for getObject on a declared BIT; Table B-3"
+ " maps BIT to Boolean"));

@Override
public FilterResult apply(TestDescriptor descriptor) {
if (XerialControl.isEnabled()
|| !(descriptor.getSource().orElse(null) instanceof MethodSource source)) {
return FilterResult.included("Appendix B quarantine is inactive for this run");
}
for (Quarantined quarantined : QUARANTINED) {
if (quarantined.className().equals(source.getClassName())
&& quarantined.methodName().equals(source.getMethodName())) {
return FilterResult.excluded(quarantined.reason());
}
}
return FilterResult.included("not quarantined");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
com.github.magnusp.libsql.conformance.XerialQuarantine
Original file line number Diff line number Diff line change
Expand Up @@ -415,11 +415,11 @@ static Expected any(Class<?> type) {
TEXT, ResultSet::getURL, Expected.any(URL.class)),
new WriteRow("Java class", new Marker("abc"), "JAVA_OBJECT", Types.JAVA_OBJECT,
UNSPECIFIED, (rs, i) -> rs.getObject(i), Expected.of(new Marker("abc"))),
new WriteRow("java.sql.RowId", stub(RowId.class), "ROWID", Types.ROWID, UNSPECIFIED,
new WriteRow("java.sql.RowId", rowId(TEXT_ROWID), "ROWID", Types.ROWID, UNSPECIFIED,
ResultSet::getRowId, Expected.any(RowId.class)),
new WriteRow("java.sql.NClob", nclob("abc"), "NCLOB", Types.NCLOB, TEXT,
ResultSet::getNClob, Expected.any(NClob.class)),
new WriteRow("java.sql.SQLXML", stub(SQLXML.class), "SQLXML", Types.SQLXML, UNSPECIFIED,
Jdbc4AppendixBTypeMappingIT::nclobText, Expected.of("abc")),
new WriteRow("java.sql.SQLXML", sqlxml(TEXT_XML), "SQLXML", Types.SQLXML, UNSPECIFIED,
ResultSet::getSQLXML, Expected.any(SQLXML.class)));

@ParameterizedTest(name = "B-2 {0} -> {2}")
Expand Down Expand Up @@ -542,7 +542,7 @@ private static Calendar calendar2024() {
new WriteRow("java.sql.RowId", stub(RowId.class), "ROWID", Types.ROWID, UNSPECIFIED,
ResultSet::getRowId, Expected.any(RowId.class)),
new WriteRow("java.sql.NClob", nclob("abc"), "NCLOB", Types.NCLOB, TEXT,
ResultSet::getNClob, Expected.any(NClob.class)),
Jdbc4AppendixBTypeMappingIT::nclobText, Expected.of("abc")),
new WriteRow("java.sql.SQLXML", stub(SQLXML.class), "SQLXML", Types.SQLXML, UNSPECIFIED,
ResultSet::getSQLXML, Expected.any(SQLXML.class)),
new WriteRow("java.util.Calendar", CALENDAR, "TIMESTAMP", Types.TIMESTAMP, TEXT,
Expand Down Expand Up @@ -881,10 +881,7 @@ private static SqlRead readFor(Target target) {
case "TIMESTAMP" -> ResultSet::getTimestamp;
case "CLOB" -> Jdbc4AppendixBTypeMappingIT::clobText;
case "BLOB" -> Jdbc4AppendixBTypeMappingIT::blobBytes;
case "NCLOB" -> (rs, i) -> {
NClob nclob = rs.getNClob(i);
return nclob == null ? null : nclob.getSubString(1, (int) nclob.length());
};
case "NCLOB" -> Jdbc4AppendixBTypeMappingIT::nclobText;
case "ARRAY" -> ResultSet::getArray;
case "STRUCT" -> (rs, i) -> rs.getObject(i);
case "REF" -> ResultSet::getRef;
Expand Down Expand Up @@ -929,7 +926,7 @@ private static Expected expectedFor(JavaRow row, Target target) {
case "TIME" -> timeExpected(source);
case "TIMESTAMP" -> timestampExpected(source);
case "CLOB" -> Expected.of(clobTextValue(source));
case "NCLOB" -> Expected.any(NClob.class);
case "NCLOB" -> Expected.of(clobTextValue(source));
case "BLOB" -> Expected.of(bytesForm(source));
case "BINARY", "VARBINARY", "LONGVARBINARY" -> Expected.of(bytesForm(source));
case "DATALINK" -> Expected.any(URL.class);
Expand Down Expand Up @@ -1130,6 +1127,14 @@ private static String clobText(ResultSet resultSet, int column) throws SQLExcept
return clob == null ? null : clob.getSubString(1, (int) clob.length());
}

private static String nclobText(ResultSet resultSet, int column) throws SQLException {
NClob nclob = resultSet.getNClob(column);
assertThat(nclob)
.as("NCLOB column must read as an NClob wrapper")
.isInstanceOf(NClob.class);
return nclob.getSubString(1, (int) nclob.length());
}

private static byte[] blobBytes(ResultSet resultSet, int column) throws SQLException {
Blob blob = resultSet.getBlob(column);
if (blob == null) {
Expand Down Expand Up @@ -1219,6 +1224,36 @@ private static NClob nclob(String text) {
});
}

private static RowId rowId(String text) {
byte[] bytes = text.getBytes(StandardCharsets.UTF_8);
return (RowId) Proxy.newProxyInstance(
RowId.class.getClassLoader(),
new Class<?>[] {RowId.class},
(proxy, method, args) -> switch (method.getName()) {
case "toString" -> text;
case "hashCode" -> System.identityHashCode(proxy);
case "equals" -> proxy == args[0];
case "getBytes" -> bytes;
default -> defaultValue(method.getReturnType());
});
}

private static SQLXML sqlxml(String text) {
return (SQLXML) Proxy.newProxyInstance(
SQLXML.class.getClassLoader(),
new Class<?>[] {SQLXML.class},
(proxy, method, args) -> switch (method.getName()) {
case "toString" -> text;
case "hashCode" -> System.identityHashCode(proxy);
case "equals" -> proxy == args[0];
case "getString" -> text;
case "getCharacterStream" -> new StringReader(text);
case "getBinaryStream" ->
new ByteArrayInputStream(text.getBytes(StandardCharsets.UTF_8));
default -> defaultValue(method.getReturnType());
});
}

private static URL url(String value) {
try {
return URI.create(value).toURL();
Expand Down
Loading