diff --git a/libsql-jdbc/pom.xml b/libsql-jdbc/pom.xml index da3d350..8ab30f9 100644 --- a/libsql-jdbc/pom.xml +++ b/libsql-jdbc/pom.xml @@ -13,6 +13,10 @@ libSQL JDBC Driver JDBC 4.3 driver for libSQL / Turso + + ${project.build.directory}/docker-config + + io.github.magnusp.libsql @@ -28,10 +32,71 @@ assertj-core test + + org.jooq + jooq + ${jooq.version} + test + + + org.jooq + jooq-codegen + ${jooq.version} + test + + + org.jooq + jooq-meta + ${jooq.version} + test + + + org.testcontainers + testcontainers-junit-jupiter + test + + + org.slf4j + slf4j-simple + 2.0.20 + test + + + org.apache.maven.plugins + maven-resources-plugin + 3.5.0 + + + copy-docker-config + pre-integration-test + + copy-resources + + + ${docker.config.dir} + + + ${project.basedir}/../libsql-conformance/src/test/docker-config + false + + + + + + + + org.apache.maven.plugins + maven-failsafe-plugin + + + ${docker.config.dir} + + + 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