org.apache.maven.plugins
maven-shade-plugin
diff --git a/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/jooq/JooqIT.java b/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/jooq/JooqIT.java
new file mode 100644
index 0000000..5ed536c
--- /dev/null
+++ b/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/jooq/JooqIT.java
@@ -0,0 +1,287 @@
+package io.github.magnusp.libsql.jdbc.jooq;
+
+import org.jooq.DSLContext;
+import org.jooq.Field;
+import org.jooq.Query;
+import org.jooq.Record;
+import org.jooq.SQLDialect;
+import org.jooq.Table;
+import org.jooq.codegen.GenerationTool;
+import org.jooq.impl.DSL;
+import org.jooq.meta.jaxb.Configuration;
+import org.jooq.meta.jaxb.Database;
+import org.jooq.meta.jaxb.Generate;
+import org.jooq.meta.jaxb.Generator;
+import org.jooq.meta.jaxb.Jdbc;
+import org.jooq.meta.jaxb.Target;
+import org.jooq.tools.jdbc.JDBCUtils;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.sql.Connection;
+import java.sql.DriverManager;
+import java.util.List;
+import java.util.stream.IntStream;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.fail;
+import static org.jooq.impl.DSL.field;
+import static org.jooq.impl.DSL.name;
+import static org.jooq.impl.DSL.table;
+
+/**
+ * Drives the in-tree driver through jOOQ, including code generation.
+ *
+ * Identity-dependent queries run inside an explicit transaction. jOOQ's
+ * SQLite dialect reads identities with
+ * {@code SELECT ... WHERE _rowid_ = last_insert_rowid()}, which only works
+ * while the insert and the follow-up select share one Hrana stream. The
+ * auto-commit gap itself is pinned below.
+ */
+class JooqIT extends JooqSqldSupport {
+
+ private static final Table AUTHOR = table(name("author"));
+ private static final Field AUTHOR_ID = field(name("author", "id"), Integer.class);
+ private static final Field AUTHOR_NAME = field(name("author", "name"), String.class);
+ private static final Field AUTHOR_COUNTRY = field(name("author", "country"), String.class);
+
+ private static final Table BOOK = table(name("book"));
+ private static final Field BOOK_ID = field(name("book", "id"), Integer.class);
+ private static final Field BOOK_AUTHOR_ID = field(name("book", "author_id"), Integer.class);
+ private static final Field BOOK_TITLE = field(name("book", "title"), String.class);
+ private static final Field BOOK_PRICE = field(name("book", "price"), Float.class);
+ private static final Field BOOK_IN_PRINT = field(name("book", "in_print"), Integer.class);
+
+ private static Connection connection;
+ private static DSLContext dsl;
+
+ @TempDir
+ Path codegenOutput;
+
+ @BeforeAll
+ static void connect() throws Exception {
+ createSchema();
+ connection = DriverManager.getConnection(jdbcUrl());
+ dsl = DSL.using(connection, SQLDialect.SQLITE);
+ }
+
+ @AfterAll
+ static void disconnect() throws Exception {
+ if (connection != null) {
+ connection.close();
+ }
+ }
+
+ @BeforeEach
+ void cleanDatabase() {
+ dsl.deleteFrom(BOOK).execute();
+ dsl.deleteFrom(AUTHOR).execute();
+ }
+
+ @Test
+ void crudThroughJooqDsl() {
+ dsl.transaction(configuration -> {
+ DSLContext tx = DSL.using(configuration);
+
+ Record author = tx.insertInto(AUTHOR)
+ .set(AUTHOR_NAME, "Ursula K. Le Guin")
+ .set(AUTHOR_COUNTRY, "US")
+ .returning(AUTHOR_ID)
+ .fetchOne();
+ assertThat(author).isNotNull();
+ Integer authorId = author.get(AUTHOR_ID);
+ assertThat(authorId).as("generated author id").isNotNull();
+
+ Record book = tx.insertInto(BOOK)
+ .set(BOOK_AUTHOR_ID, authorId)
+ .set(BOOK_TITLE, "A Wizard of Earthsea")
+ .set(BOOK_PRICE, 9.99f)
+ .set(BOOK_IN_PRINT, 1)
+ .returning(BOOK_ID)
+ .fetchOne();
+ assertThat(book).isNotNull();
+ Integer bookId = book.get(BOOK_ID);
+ assertThat(bookId).as("generated book id").isNotNull();
+
+ Record row = tx.select(AUTHOR_NAME, BOOK_TITLE, BOOK_PRICE)
+ .from(AUTHOR)
+ .join(BOOK).on(BOOK_AUTHOR_ID.eq(AUTHOR_ID))
+ .where(AUTHOR_ID.eq(authorId))
+ .fetchOne();
+ assertThat(row).isNotNull();
+ assertThat(row.get(AUTHOR_NAME)).isEqualTo("Ursula K. Le Guin");
+ assertThat(row.get(BOOK_TITLE)).isEqualTo("A Wizard of Earthsea");
+ assertThat(row.get(BOOK_PRICE)).isEqualTo(9.99f);
+
+ assertThat(tx.update(AUTHOR)
+ .set(AUTHOR_COUNTRY, "USA")
+ .where(AUTHOR_ID.eq(authorId))
+ .execute()).isEqualTo(1);
+ assertThat(tx.select(AUTHOR_COUNTRY).from(AUTHOR).where(AUTHOR_ID.eq(authorId))
+ .fetchOne(AUTHOR_COUNTRY)).isEqualTo("USA");
+
+ assertThat(tx.deleteFrom(BOOK).where(BOOK_ID.eq(bookId)).execute()).isEqualTo(1);
+ assertThat(tx.deleteFrom(AUTHOR).where(AUTHOR_ID.eq(authorId)).execute()).isEqualTo(1);
+ assertThat(tx.fetchCount(AUTHOR)).isZero();
+ });
+
+ assertThat(dsl.fetchCount(AUTHOR)).isZero();
+ }
+
+ @Test
+ void insertUpdateAndDeleteReturning() {
+ dsl.transaction(configuration -> {
+ DSLContext tx = DSL.using(configuration);
+
+ Record inserted = tx.insertInto(AUTHOR)
+ .set(AUTHOR_NAME, "Octavia E. Butler")
+ .set(AUTHOR_COUNTRY, "US")
+ .returning(AUTHOR_ID, AUTHOR_NAME)
+ .fetchOne();
+ assertThat(inserted).isNotNull();
+ Integer authorId = inserted.get(AUTHOR_ID);
+ assertThat(authorId).as("RETURNING id").isNotNull();
+ assertThat(inserted.get(AUTHOR_NAME)).isEqualTo("Octavia E. Butler");
+
+ Record updated = tx.update(AUTHOR)
+ .set(AUTHOR_COUNTRY, "USA")
+ .where(AUTHOR_ID.eq(authorId))
+ .returning(AUTHOR_ID, AUTHOR_COUNTRY)
+ .fetchOne();
+ assertThat(updated).isNotNull();
+ assertThat(updated.get(AUTHOR_COUNTRY)).isEqualTo("USA");
+
+ Record deleted = tx.deleteFrom(AUTHOR)
+ .where(AUTHOR_ID.eq(authorId))
+ .returning(AUTHOR_ID)
+ .fetchOne();
+ assertThat(deleted).isNotNull();
+ assertThat(deleted.get(AUTHOR_ID)).isEqualTo(authorId);
+ assertThat(tx.fetchCount(AUTHOR)).isZero();
+ });
+
+ assertThat(dsl.fetchCount(AUTHOR)).isZero();
+ }
+
+ @Test
+ void jdbcBatchInsert() {
+ dsl.transaction(configuration -> {
+ DSLContext tx = DSL.using(configuration);
+
+ Record author = tx.insertInto(AUTHOR)
+ .set(AUTHOR_NAME, "Batch Author")
+ .returning(AUTHOR_ID)
+ .fetchOne();
+ assertThat(author).isNotNull();
+ Integer authorId = author.get(AUTHOR_ID);
+ assertThat(authorId).isNotNull();
+
+ List inserts = IntStream.rangeClosed(1, 4)
+ .mapToObj(i -> tx.insertInto(BOOK)
+ .set(BOOK_AUTHOR_ID, authorId)
+ .set(BOOK_TITLE, "Volume " + i)
+ .set(BOOK_PRICE, i * 2.5f)
+ .set(BOOK_IN_PRINT, 1))
+ .map(Query.class::cast)
+ .toList();
+
+ assertThat(tx.batch(inserts).execute()).containsExactly(1, 1, 1, 1);
+ assertThat(tx.fetchCount(BOOK)).isEqualTo(4);
+ assertThat(tx.select(BOOK_TITLE).from(BOOK).orderBy(BOOK_ID).fetch(BOOK_TITLE))
+ .containsExactly("Volume 1", "Volume 2", "Volume 3", "Volume 4");
+ });
+ }
+
+ @Test
+ void transactionRollsBackOnException() {
+ try {
+ dsl.transaction(configuration -> {
+ DSLContext tx = DSL.using(configuration);
+ tx.insertInto(AUTHOR)
+ .set(AUTHOR_NAME, "Rolled Back")
+ .set(AUTHOR_COUNTRY, "XX")
+ .execute();
+ throw new IllegalStateException("force rollback");
+ });
+ fail("transaction should have propagated the exception");
+ } catch (RuntimeException expected) {
+ assertThat(expected).hasStackTraceContaining("force rollback");
+ }
+
+ assertThat(dsl.fetchCount(AUTHOR, AUTHOR_NAME.eq("Rolled Back")))
+ .as("rows visible after rollback")
+ .isZero();
+ }
+
+ @Test
+ void codeGenerationProducesTypedTablesAndRecords() throws Exception {
+ Configuration configuration = new Configuration()
+ .withJdbc(new Jdbc()
+ .withDriver("io.github.magnusp.libsql.jdbc.LibsqlDriver")
+ .withUrl(jdbcUrl()))
+ .withGenerator(new Generator()
+ .withDatabase(new Database()
+ .withName("org.jooq.meta.sqlite.SQLiteDatabase")
+ .withIncludes("author|book")
+ .withIncludeCheckConstraints(false))
+ .withGenerate(new Generate()
+ .withRecords(true)
+ .withPojos(false))
+ .withTarget(new Target()
+ .withPackageName("io.github.magnusp.libsql.jdbc.jooq.generated")
+ .withDirectory(codegenOutput.toString())));
+
+ GenerationTool.generate(configuration);
+
+ Path tables = codegenOutput.resolve("io/github/magnusp/libsql/jdbc/jooq/generated/tables");
+ Path author = tables.resolve("Author.java");
+ Path book = tables.resolve("Book.java");
+ assertThat(author).exists();
+ assertThat(book).exists();
+ assertThat(tables.resolve("records/AuthorRecord.java")).exists();
+ assertThat(tables.resolve("records/BookRecord.java")).exists();
+
+ assertThat(Files.readString(author))
+ .contains("TableField ID")
+ .contains("generatedByDefaultAsIdentity");
+ assertThat(Files.readString(book))
+ .contains("TableField AUTHOR_ID")
+ .contains("nullable(false)");
+ }
+
+ @Test
+ void autoCommitInsertDoesNotExposeGeneratedIdentity() {
+ Record inserted = dsl.insertInto(AUTHOR)
+ .set(AUTHOR_NAME, "Auto Commit")
+ .set(AUTHOR_COUNTRY, "SE")
+ .returning(AUTHOR_ID)
+ .fetchOne();
+
+ assertThat(dsl.fetchCount(AUTHOR, AUTHOR_NAME.eq("Auto Commit")))
+ .as("the row itself is inserted")
+ .isEqualTo(1);
+ assertThat(inserted)
+ .as("jOOQ reads identities with SELECT ... WHERE _rowid_ = last_insert_rowid();"
+ + " in auto-commit the select runs on a fresh Hrana stream, so it sees 0."
+ + " Use an explicit transaction while this is null.")
+ .isNull();
+ assertThat(dsl.select(field("last_insert_rowid()", Integer.class))
+ .fetchOne(0, Integer.class))
+ .as("a new auto-commit stream has no last_insert_rowid state")
+ .isZero();
+ }
+
+ @Test
+ void jdbcUrlIsDetectedAsDefaultDialect() {
+ assertThat(JDBCUtils.dialect(connection))
+ .as("DatabaseMetaData reports 'SQLite (libSQL)', which jOOQ does not map to"
+ + " SQLDialect.SQLITE. Pass SQLDialect.SQLITE explicitly until the"
+ + " product name is exactly 'SQLite'.")
+ .isEqualTo(SQLDialect.DEFAULT);
+ }
+}
diff --git a/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/jooq/JooqSqldSupport.java b/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/jooq/JooqSqldSupport.java
new file mode 100644
index 0000000..958dbad
--- /dev/null
+++ b/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/jooq/JooqSqldSupport.java
@@ -0,0 +1,101 @@
+package io.github.magnusp.libsql.jdbc.jooq;
+
+import org.testcontainers.containers.GenericContainer;
+import org.testcontainers.containers.wait.strategy.Wait;
+import org.testcontainers.junit.jupiter.Container;
+import org.testcontainers.junit.jupiter.Testcontainers;
+import org.testcontainers.utility.DockerImageName;
+
+import java.io.IOException;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.time.Duration;
+import java.util.List;
+import java.util.stream.Collectors;
+
+/**
+ * A throwaway sqld container for the jOOQ integration tests.
+ *
+ * The schema is created over the Hrana HTTP API rather than through the
+ * driver. That keeps a broken metadata implementation from hiding tables from
+ * jOOQ code generation, which would make the test fail for the wrong reason.
+ */
+@Testcontainers
+abstract class JooqSqldSupport {
+
+ private static final int PORT = 8080;
+ private static final String LIBSQL_IMAGE = requireLibsqlImage();
+ private static final List SCHEMA = List.of(
+ "CREATE TABLE author ("
+ + " id INTEGER PRIMARY KEY AUTOINCREMENT,"
+ + " name TEXT NOT NULL,"
+ + " country TEXT)",
+ "CREATE TABLE book ("
+ + " id INTEGER PRIMARY KEY AUTOINCREMENT,"
+ + " author_id INTEGER NOT NULL REFERENCES author (id),"
+ + " title TEXT NOT NULL,"
+ + " price REAL,"
+ + " in_print INTEGER NOT NULL DEFAULT 1)",
+ "CREATE INDEX book_author_id_idx ON book (author_id)");
+
+ @Container
+ static final GenericContainer> LIBSQL = new GenericContainer<>(DockerImageName.parse(LIBSQL_IMAGE))
+ .withExposedPorts(PORT)
+ .withEnv("SQLD_NODE", "primary")
+ .withEnv("SQLD_DISABLE_INTELLIGENT_THROTTLING", "true")
+ .withEnv("SQLD_CONNECTION_CREATION_TIMEOUT_SEC", "30")
+ .waitingFor(Wait.forHttp("/v2/pipeline")
+ .forPort(PORT)
+ .forStatusCode(405)
+ .withStartupTimeout(Duration.ofSeconds(60)));
+
+ static String jdbcUrl() {
+ return "jdbc:libsql:http://" + LIBSQL.getHost() + ":" + LIBSQL.getMappedPort(PORT);
+ }
+
+ static void createSchema() {
+ String baseUrl = "http://" + LIBSQL.getHost() + ":" + LIBSQL.getMappedPort(PORT);
+ String requests = SCHEMA.stream()
+ .map(sql -> "{\"type\":\"execute\",\"stmt\":{\"sql\":\"" + escape(sql) + "\"}}")
+ .collect(Collectors.joining(",", "", ",{\"type\":\"close\"}"));
+ String body = "{\"requests\":[" + requests + "]}";
+
+ HttpResponse response;
+ try {
+ response = HttpClient.newHttpClient().send(
+ HttpRequest.newBuilder(URI.create(baseUrl + "/v3/pipeline"))
+ .version(HttpClient.Version.HTTP_1_1)
+ .timeout(Duration.ofSeconds(30))
+ .header("Content-Type", "application/json")
+ .POST(HttpRequest.BodyPublishers.ofString(body))
+ .build(),
+ HttpResponse.BodyHandlers.ofString());
+ } catch (IOException e) {
+ throw new IllegalStateException("schema bootstrap request failed", e);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new IllegalStateException("schema bootstrap interrupted", e);
+ }
+
+ if (response.statusCode() != 200 || response.body().contains("\"type\":\"error\"")) {
+ throw new IllegalStateException(
+ "schema bootstrap failed: HTTP " + response.statusCode() + " " + response.body());
+ }
+ }
+
+ private static String escape(String sql) {
+ return sql.replace("\\", "\\\\").replace("\"", "\\\"");
+ }
+
+ private static String requireLibsqlImage() {
+ String image = System.getProperty("libsql.image");
+ if (image == null || image.isBlank()) {
+ throw new IllegalStateException(
+ "libsql.image is not set. Run the integration tests with Maven so the"
+ + " pinned sqld image from the root pom is used.");
+ }
+ return image;
+ }
+}
diff --git a/pom.xml b/pom.xml
index 80933e7..527c204 100644
--- a/pom.xml
+++ b/pom.xml
@@ -61,6 +61,7 @@
3.27.7
2.0.5
7.1.0
+ 3.21.9
4.36.2
5.7-beta1