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
37 changes: 37 additions & 0 deletions libsql-conformance/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# libSQL xerial Conformance Tests

This module runs a selected subset of the xerial `sqlite-jdbc` test sources against
libSQL over the Hrana protocol. It is a compatibility check for the JDBC driver, not a
claim that a `jdbc:sqlite:` URL is served by a local SQLite file.

## URL mapping

The conformance driver maps `jdbc:sqlite:` URLs to sqld namespaces so the vendored
tests can run unchanged:

| URL | Namespace |
|---|---|
| `jdbc:sqlite:` | A new in-memory namespace per connection |
| `jdbc:sqlite::memory:` | A new in-memory namespace per connection |
| `jdbc:sqlite:<path>` | One namespace derived from `<path>` |

A file path is resolved against the process working directory and normalized before
it is hashed. A relative path and the equivalent absolute path therefore address the
same namespace; for example, `data/example.db` and `/work/data/example.db` match when
the process working directory is `/work`. Normalization resolves `.` and `..` but does
not resolve symbolic links.

The query string is ignored when deriving the namespace, so URL pragmas such as
`?journal_mode=WAL` have no effect on the conformance server. No local SQLite file is
created; the data lives in the sqld namespace.

## Persistence

A path namespace is retained until the suite shuts down, so closing every connection
and reopening the same path observes the data written earlier. The retained namespaces
are deleted during suite shutdown and the shared sqld container is stopped. In-memory
namespaces are not retained.

The conformance copy of an upstream test diverges from xerial where Hrana connection
lifecycle makes the original behavior impossible; each divergence is recorded in
`../upstream-xerial_sqlite-jdbc.manifest`.
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.sql.Connection;
import java.sql.Driver;
import java.sql.DriverManager;
Expand All @@ -19,6 +20,16 @@
import java.util.function.Consumer;
import java.util.logging.Logger;

/**
* Maps the {@code jdbc:sqlite:} URLs used by the xerial test sources onto sqld namespaces.
*
* <p>An empty path and {@code :memory:} map to a fresh namespace per connection. Every other path
* is resolved against the process working directory, normalized, and hashed to a namespace that is
* retained until the suite shuts down, so closing every connection and reopening the same path
* observes the same data. A relative path and the equivalent absolute path therefore address the
* same namespace. Query strings are ignored when deriving the namespace, so URL pragmas have no
* effect on the conformance server.
*/
public final class XerialConformanceDriver implements Driver {

private static final String PREFIX = "jdbc:sqlite:";
Expand All @@ -45,22 +56,23 @@ public Connection connect(String url, Properties info) throws SQLException {
try {
connection = delegate.connect(target.url(), info);
} catch (SQLException e) {
releaseAfterConnectFailure(target.namespace(), e);
releaseAfterConnectFailure(target, e);
throw e;
}
if (connection == null) {
XerialTestServer.releaseNamespace(target.namespace());
release(target);
return null;
}
try {
return (Connection)
Proxy.newProxyInstance(
XerialConformanceDriver.class.getClassLoader(),
new Class<?>[] {Connection.class},
new NamespaceClosingHandler(connection, target.namespace()));
new NamespaceClosingHandler(
connection, target.namespace(), releaser(target)));
} catch (RuntimeException e) {
try {
XerialTestServer.releaseNamespace(target.namespace());
release(target);
} catch (RuntimeException cleanup) {
e.addSuppressed(cleanup);
}
Expand Down Expand Up @@ -101,30 +113,46 @@ public Logger getParentLogger() throws SQLFeatureNotSupportedException {
return delegate.getParentLogger();
}

private static void releaseAfterConnectFailure(String namespace, SQLException failure) {
private static void releaseAfterConnectFailure(Target target, SQLException failure) {
try {
XerialTestServer.releaseNamespace(namespace);
release(target);
} catch (RuntimeException cleanup) {
failure.addSuppressed(cleanup);
}
}

private static void release(Target target) {
releaser(target).accept(target.namespace());
}

private static Consumer<String> releaser(Target target) {
return target.retained()
? XerialTestServer::releaseRetainedConnection
: XerialTestServer::releaseNamespace;
}

private static Target rewrite(String url) {
String target = url.substring(PREFIX.length());
String namespace;
if (target.isEmpty() || ":memory:".equals(target)) {
namespace = "mem_" + UUID.randomUUID().toString().replace("-", "");
} else {
namespace =
"file_"
+ UUID.nameUUIDFromBytes(target.getBytes(StandardCharsets.UTF_8))
.toString()
.replace("-", "");
String path = stripQuery(target);
if (path.isEmpty() || ":memory:".equals(path)) {
String namespace = "mem_" + UUID.randomUUID().toString().replace("-", "");
return new Target(XerialTestServer.jdbcUrl(namespace, false), namespace, false);
}
return new Target(XerialTestServer.jdbcUrl(namespace), namespace);
String normalized = Path.of(path).toAbsolutePath().normalize().toString();
String namespace =
"file_"
+ UUID.nameUUIDFromBytes(normalized.getBytes(StandardCharsets.UTF_8))
.toString()
.replace("-", "");
return new Target(XerialTestServer.jdbcUrl(namespace, true), namespace, true);
}

private static String stripQuery(String target) {
int query = target.indexOf('?');
return query < 0 ? target : target.substring(0, query);
}

private record Target(String url, String namespace) {}
private record Target(String url, String namespace, boolean retained) {}

static final class NamespaceClosingHandler implements InvocationHandler {

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package com.github.magnusp.libsql.conformance;

import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assumptions.assumeTrue;

import java.nio.file.Path;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;

class XerialFileUrlPersistenceTest {

@TempDir Path tempDir;

@Test
void fileUrlKeepsDataAfterEveryConnectionClosesAndTheSamePathReopens() throws SQLException {
Path database = tempDir.resolve("persist.db").toAbsolutePath().normalize();
String url = "jdbc:sqlite:" + database;

try (Connection connection = DriverManager.getConnection(url);
Statement statement = connection.createStatement()) {
statement.executeUpdate(
"CREATE TABLE persisted (id INTEGER PRIMARY KEY, value TEXT)");
statement.executeUpdate("INSERT INTO persisted (value) VALUES ('kept')");
}

try (Connection connection = DriverManager.getConnection(url);
Statement statement = connection.createStatement();
ResultSet resultSet =
statement.executeQuery("SELECT value FROM persisted WHERE id = 1")) {
assertThat(resultSet.next()).isTrue();
assertThat(resultSet.getString(1)).isEqualTo("kept");
}
}

@Test
void relativeAndAbsolutePathsAddressTheSameFile() throws SQLException {
Path database = tempDir.resolve("mapped.db").toAbsolutePath().normalize();
Path workingDirectory = Path.of("").toAbsolutePath().normalize();
assumeTrue(
workingDirectory.getRoot().equals(database.getRoot()),
"The working directory and test directory must share a filesystem root");
Path relative = workingDirectory.relativize(database);
assumeTrue(!relative.isAbsolute(), "The test database must be addressable relatively");

try (Connection connection = DriverManager.getConnection("jdbc:sqlite:" + database);
Statement statement = connection.createStatement()) {
statement.executeUpdate(
"CREATE TABLE mapped (id INTEGER PRIMARY KEY, value TEXT)");
statement.executeUpdate("INSERT INTO mapped (value) VALUES ('same file')");
}

try (Connection connection = DriverManager.getConnection("jdbc:sqlite:" + relative);
Statement statement = connection.createStatement();
ResultSet resultSet =
statement.executeQuery("SELECT value FROM mapped WHERE id = 1")) {
assertThat(resultSet.next()).isTrue();
assertThat(resultSet.getString(1)).isEqualTo("same file");
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;

public final class XerialTestServer {
Expand All @@ -32,6 +33,7 @@ public final class XerialTestServer {
return new AdminResponse(response.statusCode(), response.body());
};
private static final Map<String, Integer> NAMESPACES = new ConcurrentHashMap<>();
private static final Set<String> RETAINED_NAMESPACES = ConcurrentHashMap.newKeySet();
private static final Map<String, RuntimeException> FAILED_NAMESPACES =
new ConcurrentHashMap<>();

Expand Down Expand Up @@ -86,27 +88,75 @@ public static synchronized void stop() {
if (container == null) {
return;
}
RuntimeException failure = reapRetainedNamespaces();
try {
container.stop();
} catch (RuntimeException stopFailure) {
if (failure == null) {
failure = stopFailure;
} else {
failure.addSuppressed(stopFailure);
}
} finally {
container = null;
httpBaseUrl = null;
adminBaseUrl = null;
NAMESPACES.clear();
RETAINED_NAMESPACES.clear();
FAILED_NAMESPACES.clear();
}
if (failure != null) {
throw failure;
}
}

private static RuntimeException reapRetainedNamespaces() {
RuntimeException failure = null;
for (String namespace : RETAINED_NAMESPACES) {
try {
deleteNamespace(namespace, adminBaseUrl, DEFAULT_ADMIN);
} catch (RuntimeException e) {
if (failure == null) {
failure = e;
} else {
failure.addSuppressed(e);
}
}
}
RETAINED_NAMESPACES.clear();
return failure;
}

public static String jdbcUrl(String namespace) {
return jdbcUrl(namespace, false);
}

static String jdbcUrl(String namespace, boolean retained) {
start();
ensureNamespace(namespace);
ensureNamespace(namespace, retained, adminBaseUrl, DEFAULT_ADMIN);
return "jdbc:sqlite:" + httpBaseUrl + "/dev/" + namespace;
}

public static synchronized void releaseNamespace(String namespace) {
releaseNamespace(namespace, adminBaseUrl, DEFAULT_ADMIN);
}

public static synchronized void releaseRetainedConnection(String namespace) {
releaseConnection(namespace);
}

static synchronized void releaseConnection(String namespace) {
int active = NAMESPACES.getOrDefault(namespace, 0);
if (active == 0) {
return;
}
if (active > 1) {
NAMESPACES.put(namespace, active - 1);
return;
}
NAMESPACES.remove(namespace);
}

static synchronized void releaseNamespace(
String namespace, String adminUrl, AdminClient admin) {
int active = NAMESPACES.getOrDefault(namespace, 0);
Expand Down Expand Up @@ -140,6 +190,11 @@ static synchronized void ensureNamespace(String namespace) {

static synchronized void ensureNamespace(
String namespace, String adminUrl, AdminClient admin) {
ensureNamespace(namespace, false, adminUrl, admin);
}

static synchronized void ensureNamespace(
String namespace, boolean retained, String adminUrl, AdminClient admin) {
RuntimeException failed = FAILED_NAMESPACES.get(namespace);
if (failed != null) {
throw new IllegalStateException(
Expand All @@ -152,6 +207,14 @@ static synchronized void ensureNamespace(
int active = NAMESPACES.getOrDefault(namespace, 0);
if (active > 0) {
NAMESPACES.put(namespace, active + 1);
if (retained) {
RETAINED_NAMESPACES.add(namespace);
}
return;
}

if (retained && RETAINED_NAMESPACES.contains(namespace)) {
NAMESPACES.put(namespace, 1);
return;
}

Expand Down Expand Up @@ -183,6 +246,9 @@ static synchronized void ensureNamespace(
int status = response.status();
if (status >= 200 && status < 300) {
NAMESPACES.put(namespace, 1);
if (retained) {
RETAINED_NAMESPACES.add(namespace);
}
return;
}

Expand Down
Loading