diff --git a/libsql-client/src/main/java/io/github/magnusp/libsql/client/LibsqlClient.java b/libsql-client/src/main/java/io/github/magnusp/libsql/client/LibsqlClient.java index 8f70a62..87ce215 100644 --- a/libsql-client/src/main/java/io/github/magnusp/libsql/client/LibsqlClient.java +++ b/libsql-client/src/main/java/io/github/magnusp/libsql/client/LibsqlClient.java @@ -11,11 +11,12 @@ *

Both transports expose the same two execution styles. A one-shot call opens a stream, * runs the work and closes the stream in as few round trips as possible, so it leaves no server-side * state behind and needs no explicit cleanup. An on-stream call reuses a lazily opened - * interactive stream, which is what a transaction needs: the server keeps the transaction open - * between calls. + * interactive stream. The JDBC driver uses the on-stream style for every statement, so auto-commit + * retains SQLite session state such as {@code last_insert_rowid()} and {@code PRAGMA} settings, and + * manual transactions stay open between calls. * - *

Implementations track their own stream state. {@link #hasOpenStream()} is the single source of - * truth for whether a transaction begin is still pending. + *

Implementations track their own stream state. {@link #hasOpenStream()} tells a caller whether + * the lazily opened stream still exists, which matters after the server expires an idle stream. */ public interface LibsqlClient extends AutoCloseable { diff --git a/libsql-conformance/README.md b/libsql-conformance/README.md index 4b67036..2f6c716 100644 --- a/libsql-conformance/README.md +++ b/libsql-conformance/README.md @@ -122,34 +122,33 @@ categories above, these occur in selected tests. They are driver behavior rather than edits to a vendored test, so they are described here and are not recorded as `# divergence:` lines. -#### Auto-commit holds no stream - -An auto-commit statement runs as a one-shot pipeline that executes the -statement and closes the stream in the same round trip. The driver keeps no -session stream open between statements, so an idle connection consumes no -server stream slot and holds no baton for the idle expiry to invalidate. -Keeping one open only to preserve session state would reintroduce stale-baton -failures on idle connections. - -Because `StmtResult.last_insert_rowid` describes the insert on one stream and -nothing else, a later `SELECT last_insert_rowid()` runs on a fresh stream and -returns `0`, even when the same `Statement` inserted a row immediately before. -xerial returns that insert's rowid instead. Use `Statement.getGeneratedKeys()` -after the insert, which the driver builds from `StmtResult.last_insert_rowid` -with no extra round trip, or run the insert and the query inside one explicit -transaction so both run on the same stream. `LibsqlGeneratedKeysTest` pins the -generated-keys path. +#### Auto-commit holds one connection-scoped stream + +An auto-commit connection lazily opens one interactive Hrana stream and +pipelines every statement on it, the same machinery explicit transactions +use. `last_insert_rowid()`, `PRAGMA` settings, and other session state +therefore behave as they do on a SQLite connection: they survive between +statements on the same connection and do not cross connections. +`Statement.getGeneratedKeys()` still reads `StmtResult.last_insert_rowid` +without an extra round trip. + +sqld expires idle streams (around 10 seconds). When it rejects a baton, the +driver drops the stream and retries the statement once on a fresh one. +Auto-commit has no transaction to lose, but session state set before the +expiry is reset. A pool larger than sqld's stream limit can hit that limit, +just as idle non-auto-commit connections already can. #### Transactions begin lazily `setAutoCommit(false)` sends nothing. The next statement begins the -transaction on a new stream with `BEGIN`, `BEGIN IMMEDIATE`, or +transaction on the connection's stream with `BEGIN`, `BEGIN IMMEDIATE`, or `BEGIN EXCLUSIVE` according to `LibsqlConnection.setTransactionMode(TransactionMode)` (default `DEFERRED`), sent as its own pipeline so a failed begin cannot be followed by the -statement running outside a transaction. `commit()` and `rollback()` close -the stream and do not begin the next transaction, so an idle non-auto-commit -connection likewise holds no stream. +statement running outside a transaction. A connection that used auto-commit +first keeps that stream, so its session state carries into the transaction. +`commit()` and `rollback()` close the stream and do not begin the next +transaction. The divergence is timing only: an `IMMEDIATE` or `EXCLUSIVE` transaction takes its write lock when the first statement begins it, not at diff --git a/libsql-jdbc/src/main/java/io/github/magnusp/libsql/jdbc/LibsqlConnection.java b/libsql-jdbc/src/main/java/io/github/magnusp/libsql/jdbc/LibsqlConnection.java index b7599ec..b919943 100644 --- a/libsql-jdbc/src/main/java/io/github/magnusp/libsql/jdbc/LibsqlConnection.java +++ b/libsql-jdbc/src/main/java/io/github/magnusp/libsql/jdbc/LibsqlConnection.java @@ -71,6 +71,7 @@ public class LibsqlConnection implements Connection { private TransactionMode transactionMode = TransactionMode.DEFERRED; private LibsqlDatabaseMetaData meta; private int nextSavepointId = 0; + private boolean transactionActive = false; private boolean transactionStartedBySavepoint = false; private final List savepointNames = new ArrayList<>(); @@ -102,7 +103,9 @@ public static TransactionMode from(String mode) { } } - // Interactive stream state lives in the client; see LibsqlClient.hasOpenStream(). + // The client owns the Hrana stream. Auto-commit keeps one open between statements so session + // state survives; a manual transaction tracks its begin explicitly because the stream may + // already be open for auto-commit when the transaction starts. public LibsqlConnection(LibsqlClientConfig config, LibsqlClient client) { this(config, client, null); @@ -135,11 +138,12 @@ public StmtResult executeStatement(Stmt stmt) throws SQLException { synchronized (stateLock) { checkClosed(); if (autoCommit) { - // One-shot: single-round-trip close, so no idle stream is left for the server to reap. - return sending(() -> client.executeOneShot(stmt)); + return sendAutoCommit(stmt); } ensureTransactionStarted(); - return sendOnStream(stmt); + StmtResult result = sendOnStream(stmt); + requireTransactionStream(); + return result; } } @@ -162,10 +166,11 @@ List executeBatchResults(List statements) throws SQLException Batch batch = new Batch(steps); BatchResult result; if (autoCommit) { - result = sending(() -> client.executeBatchOneShot(batch)); + result = sendBatchAutoCommit(batch); } else { ensureTransactionStarted(); result = sendBatchOnStream(batch); + requireTransactionStream(); } return extractBatchResults(result, statements.size()); } @@ -215,6 +220,62 @@ private T sending(Supplier call) throws SQLException { } } + /** + * Sends an auto-commit statement on the connection's session stream. sqld expires idle streams + * (around 10 seconds); when it rejects the baton, the statement did not run, so the driver drops + * the stale stream and retries once on a fresh one. Auto-commit has no transaction to lose. + * Session state set before the expiry is gone, by design. + * + *

Callers hold {@link #stateLock}. + */ + private StmtResult sendAutoCommit(Stmt stmt) throws SQLException { + try { + return client.executeOnStream(stmt); + } catch (LibsqlException e) { + if (!isExpiredStream(e)) { + throw LibsqlErrorMapper.toSQLException(e); + } + closeStreamQuietly(); + try { + return client.executeOnStream(stmt); + } catch (LibsqlException retry) { + throw LibsqlErrorMapper.toSQLException(retry); + } + } + } + + /** Auto-commit counterpart of {@link #sendAutoCommit(Stmt)}, with the same one retry. */ + private BatchResult sendBatchAutoCommit(Batch batch) throws SQLException { + try { + return client.executeBatchOnStream(batch); + } catch (LibsqlException e) { + if (!isExpiredStream(e)) { + throw LibsqlErrorMapper.toSQLException(e); + } + closeStreamQuietly(); + try { + return client.executeBatchOnStream(batch); + } catch (LibsqlException retry) { + throw LibsqlErrorMapper.toSQLException(retry); + } + } + } + + private static boolean isExpiredStream(LibsqlException e) { + String code = e.getCode(); + if (code != null) { + String upper = code.toUpperCase(java.util.Locale.ROOT); + if (upper.equals("STREAM_EXPIRED") + || upper.equals("BATON_EXPIRED") + || upper.equals("STALE_BATON")) { + return true; + } + } + String message = e.getMessage(); + return message != null + && message.toLowerCase(java.util.Locale.ROOT).contains("stream has expired"); + } + /** Stream sends. Callers hold {@link #stateLock}. */ private StmtResult sendOnStream(Stmt stmt) throws SQLException { return sending(() -> client.executeOnStream(stmt)); @@ -224,11 +285,43 @@ private BatchResult sendBatchOnStream(Batch batch) throws SQLException { return sending(() -> client.executeBatchOnStream(batch)); } - /** Callers hold {@link #stateLock}. */ + /** + * Fails the transaction when the server closed its stream anyway. Executing the next statement + * on a fresh stream would silently run it outside the transaction the caller asked for. + * Callers hold {@link #stateLock}. + */ + private void requireTransactionStream() throws SQLException { + if (!client.hasOpenStream()) { + transactionActive = false; + clearSavepoints(); + throw new SQLException("The server closed the transaction stream"); + } + } + + /** + * Begins the manual transaction lazily. An auto-commit stream may already be open when the + * first transactional statement arrives, and that stream may have expired while the connection + * was idle. The begin did not run in that case, so drop the stale stream and retry once. + * + *

Callers hold {@link #stateLock}. + */ private void ensureTransactionStarted() throws SQLException { - if (!beginPending()) return; + if (transactionActive) return; + Stmt begin = new Stmt(transactionMode.beginSql()); try { - sendOnStream(new Stmt(transactionMode.beginSql())); + sendBegin(begin); + } catch (LibsqlException e) { + if (!isExpiredStream(e)) { + closeStreamQuietly(); + throw LibsqlErrorMapper.toSQLException(e); + } + closeStreamQuietly(); + try { + sendBegin(begin); + } catch (LibsqlException retry) { + closeStreamQuietly(); + throw LibsqlErrorMapper.toSQLException(retry); + } } catch (SQLException e) { // The begin failed, so the fresh stream holds no transaction state. closeStreamQuietly(); @@ -236,15 +329,13 @@ private void ensureTransactionStarted() throws SQLException { } } - /** - * The begin-pending state is derived rather than stored: auto-commit is off and the client has - * no open stream, so the next statement must begin the transaction. A separate boolean would be - * a second source of truth for a fact the client's stream state already carries. - * - *

Callers hold {@link #stateLock}. - */ - private boolean beginPending() { - return !autoCommit && !client.hasOpenStream(); + /** Callers hold {@link #stateLock}. */ + private void sendBegin(Stmt begin) throws SQLException { + client.executeOnStream(begin); + if (!client.hasOpenStream()) { + throw new SQLException("The server closed the stream before the transaction began"); + } + transactionActive = true; } /** Callers hold {@link #stateLock}. */ @@ -293,13 +384,14 @@ public String nativeSQL(String sql) throws SQLException { * *

* *

This diverges from xerial only in when the server sees the begin: an {@code IMMEDIATE} or @@ -351,7 +443,7 @@ public void rollback() throws SQLException { /** Callers hold {@link #stateLock}. */ private void endTransaction(String sql) throws SQLException { - if (beginPending()) { + if (!transactionActive) { // No transaction has started, so there is nothing to send and nothing to close. return; } @@ -361,6 +453,7 @@ private void endTransaction(String sql) throws SQLException { // The server committed or rolled back, so the stream has no state left worth holding. // Closing it lets an idle connection drop its server-side stream. On an error above the // stream stays, so the caller can retry or roll back on the same stream. + transactionActive = false; clearSavepoints(); closeStreamQuietly(); } @@ -370,6 +463,7 @@ public void close() throws SQLException { synchronized (stateLock) { if (!closed) { closed = true; + transactionActive = false; clearSavepoints(); closeStreamQuietly(); if (client != null) { @@ -563,12 +657,14 @@ private Savepoint setSavepointInternal(String name) throws SQLException { private void beginSavepointTransaction(Stmt stmt, String sqlName) throws SQLException { try { sendOnStream(stmt); + requireTransactionStream(); } catch (SQLException e) { // No transaction was started, so the fresh stream holds no state. closeStreamQuietly(); throw e; } autoCommit = false; + transactionActive = true; transactionStartedBySavepoint = true; savepointNames.clear(); savepointNames.add(sqlName); @@ -617,6 +713,7 @@ private void closeTransactionOwnedBySavepoint(String sqlName) { savepointNames.subList(index, savepointNames.size()).clear(); if (index > 0 || !transactionStartedBySavepoint) return; transactionStartedBySavepoint = false; + transactionActive = false; closeStreamQuietly(); } diff --git a/libsql-jdbc/src/main/java/io/github/magnusp/libsql/jdbc/LibsqlPreparedStatement.java b/libsql-jdbc/src/main/java/io/github/magnusp/libsql/jdbc/LibsqlPreparedStatement.java index 4572885..3f9c5cf 100644 --- a/libsql-jdbc/src/main/java/io/github/magnusp/libsql/jdbc/LibsqlPreparedStatement.java +++ b/libsql-jdbc/src/main/java/io/github/magnusp/libsql/jdbc/LibsqlPreparedStatement.java @@ -68,11 +68,20 @@ public class LibsqlPreparedStatement extends LibsqlStatement implements Prepared public LibsqlPreparedStatement(LibsqlConnection connection, String sql) { super(connection); this.sql = sql; - this.returnsResultSet = returnsResultSet(sql); + this.returnsResultSet = returnsResultSet(finalStatement(sql)); this.parameterCount = SqlTokenizer.placeholderCount(sql); this.parameterMetaData = new ParameterMetaDataView(this, parameterCount); } + /** + * A multi-statement script exposes the final statement's result, so that statement decides + * whether the prepared execute returns a result set. + */ + private static String finalStatement(String sql) { + List statements = SqlScript.split(sql); + return statements.isEmpty() ? sql : statements.get(statements.size() - 1); + } + private void setParam(int parameterIndex, Value value) throws SQLException { checkClosed(); int idx = parameterIndex - 1; @@ -184,7 +193,7 @@ private StmtResult executeCurrent() throws SQLException { checkEscapeProcessing(sql); checkClosed(); beginExecution(); - return connection.executeStatement(buildCurrentStmt()); + return executePrepared(buildCurrentStmt(), !parameters.isEmpty()); } @Override diff --git a/libsql-jdbc/src/main/java/io/github/magnusp/libsql/jdbc/LibsqlStatement.java b/libsql-jdbc/src/main/java/io/github/magnusp/libsql/jdbc/LibsqlStatement.java index 024072f..ad5a10a 100644 --- a/libsql-jdbc/src/main/java/io/github/magnusp/libsql/jdbc/LibsqlStatement.java +++ b/libsql-jdbc/src/main/java/io/github/magnusp/libsql/jdbc/LibsqlStatement.java @@ -203,9 +203,63 @@ private void discardCurrentResult(LibsqlResultSet resultSet, long updateCountWhe private StmtResult executeSql(String sql) throws SQLException { checkEscapeProcessing(sql); beginExecution(); + List script = SqlScript.split(sql); + if (script.size() > 1) { + rejectScriptParameters(sql, false); + return executeScript(script); + } return connection.executeStatement(statement(sql)); } + /** + * Executes an already-built prepared statement, splitting a multi-statement script the same way + * {@link #executeSql(String)} does. Callers have already run {@link #beginExecution()}. + */ + protected StmtResult executePrepared(Stmt stmt, boolean hasBoundArguments) throws SQLException { + List script = SqlScript.split(stmt.sql()); + if (script.size() > 1) { + rejectScriptParameters(stmt.sql(), hasBoundArguments); + return executeScript(script); + } + return connection.executeStatement(stmt); + } + + /** + * Rejects parameters in a multi-statement script. Binding the same argument list to every + * statement would be ambiguous, so the driver asks the caller to execute the statements + * separately instead. A single statement keeps the normal parameter behavior. + */ + private static void rejectScriptParameters(String sql, boolean hasBoundArguments) + throws SQLException { + if (hasBoundArguments || SqlTokenizer.placeholderCount(sql) > 0) { + throw new SQLFeatureNotSupportedException( + "Parameters are not supported in multi-statement SQL; execute each statement" + + " separately"); + } + } + + /** + * Executes the statements in order on the connection's stream and stops at the first failure. + * The update count is the total affected rows, matching xerial. A final statement that + * produces a result set exposes that result, so intermediate results are discarded. + */ + private StmtResult executeScript(List statements) throws SQLException { + long affectedRows = 0; + StmtResult last = null; + for (String statement : statements) { + last = connection.executeStatement(new Stmt(statement)); + affectedRows += last.affectedRowCount(); + } + if (last == null) { + return new StmtResult(List.of(), List.of(), 0, null); + } + if (last.cols() != null && !last.cols().isEmpty()) { + return last; + } + return new StmtResult( + last.cols(), last.rows(), affectedRows, last.lastInsertRowid()); + } + /** * Builds a statement with SQL NULL for every placeholder a plain {@code Statement} leaves * unbound, matching SQLite, where an unset parameter reads as NULL. Hrana rejects an argument @@ -351,7 +405,9 @@ protected void clearGeneratedKeys() throws SQLException { } private static boolean isInsertSql(String sql) { - List tokens = SqlTokenizer.tokenize(sql); + List script = SqlScript.split(sql); + String statementSql = script.isEmpty() ? sql : script.get(script.size() - 1); + List tokens = SqlTokenizer.tokenize(statementSql); if (tokens == null) { return false; } diff --git a/libsql-jdbc/src/main/java/io/github/magnusp/libsql/jdbc/SqlScript.java b/libsql-jdbc/src/main/java/io/github/magnusp/libsql/jdbc/SqlScript.java new file mode 100644 index 0000000..8ab0c27 --- /dev/null +++ b/libsql-jdbc/src/main/java/io/github/magnusp/libsql/jdbc/SqlScript.java @@ -0,0 +1,194 @@ +package io.github.magnusp.libsql.jdbc; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; + +/** + * Splits a SQL string into individual SQLite statements. + * + *

sqld accepts one statement per Hrana {@code Stmt}. A JDBC caller may hand the driver a + * script, so the driver splits it locally and sends each statement in order on the connection's + * stream. Semicolons inside quoted text and comments do not terminate a statement, and a trigger + * body ({@code CREATE TRIGGER ... BEGIN ... END}) is one statement even though SQLite terminates + * its inner statements with semicolons. + * + *

The splitter mirrors SQLite's lexical rules closely enough for statements the driver + * executes itself. A malformed input is left to sqld to reject; the splitter never throws. + */ +final class SqlScript { + + private static final String CREATE = "CREATE"; + private static final String TRIGGER = "TRIGGER"; + + private SqlScript() {} + + /** Returns the non-empty statements in {@code sql}, in order. */ + static List split(String sql) { + List statements = new ArrayList<>(); + if (sql == null || sql.isEmpty()) { + return statements; + } + + int start = 0; + int i = 0; + boolean triggerCandidate = false; + boolean triggerBody = false; + boolean triggerEnd = false; + int caseDepth = 0; + List leadingWords = new ArrayList<>(3); + + while (i < sql.length()) { + char c = sql.charAt(i); + + if (Character.isWhitespace(c)) { + i++; + continue; + } + if (c == '-' && i + 1 < sql.length() && sql.charAt(i + 1) == '-') { + i += 2; + while (i < sql.length() && sql.charAt(i) != '\n' && sql.charAt(i) != '\r') { + i++; + } + continue; + } + if (c == '/' && i + 1 < sql.length() && sql.charAt(i + 1) == '*') { + int end = sql.indexOf("*/", i + 2); + i = end < 0 ? sql.length() : end + 2; + continue; + } + if (c == '\'') { + i = skipQuoted(sql, i, '\''); + continue; + } + if (c == '"' || c == '`') { + i = skipQuoted(sql, i, c); + continue; + } + if (c == '[') { + int end = sql.indexOf(']', i + 1); + i = end < 0 ? sql.length() : end + 1; + continue; + } + if (isWordChar(c)) { + int wordStart = i; + while (i < sql.length() && isWordChar(sql.charAt(i))) { + i++; + } + String word = sql.substring(wordStart, i).toUpperCase(Locale.ROOT); + if (leadingWords.size() < 3) { + leadingWords.add(word); + } + if (!triggerCandidate && isCreateTriggerPrefix(leadingWords)) { + triggerCandidate = true; + } + if (triggerCandidate) { + if (TRIGGER.equals(word)) { + // The TRIGGER keyword itself carries no body state. + } else if ("BEGIN".equals(word) && !triggerBody) { + triggerBody = true; + } else if ("CASE".equals(word)) { + caseDepth++; + } else if ("END".equals(word)) { + if (caseDepth > 0) { + caseDepth--; + } else if (triggerBody) { + triggerEnd = true; + } + } + } + continue; + } + if (c == ';') { + if (triggerBody && !triggerEnd) { + i++; + continue; + } + addStatement(statements, sql, start, i); + start = i + 1; + triggerCandidate = false; + triggerBody = false; + triggerEnd = false; + caseDepth = 0; + leadingWords.clear(); + i++; + continue; + } + i++; + } + + addStatement(statements, sql, start, sql.length()); + return statements; + } + + private static boolean isCreateTriggerPrefix(List leadingWords) { + if (leadingWords.size() < 2 || !CREATE.equals(leadingWords.get(0))) { + return false; + } + String second = leadingWords.get(1); + if (TRIGGER.equals(second)) { + return true; + } + return ("TEMP".equals(second) || "TEMPORARY".equals(second)) + && leadingWords.size() >= 3 + && TRIGGER.equals(leadingWords.get(2)); + } + + private static void addStatement(List statements, String sql, int start, int end) { + String statement = sql.substring(start, end).strip(); + if (!statement.isEmpty() && hasSql(statement)) { + statements.add(statement); + } + } + + /** True when {@code text} has more than whitespace and comments. */ + private static boolean hasSql(String text) { + int i = 0; + while (i < text.length()) { + char c = text.charAt(i); + if (Character.isWhitespace(c)) { + i++; + continue; + } + if (c == '-' && i + 1 < text.length() && text.charAt(i + 1) == '-') { + i += 2; + while (i < text.length() && text.charAt(i) != '\n' && text.charAt(i) != '\r') { + i++; + } + continue; + } + if (c == '/' && i + 1 < text.length() && text.charAt(i + 1) == '*') { + int end = text.indexOf("*/", i + 2); + i = end < 0 ? text.length() : end + 2; + continue; + } + return true; + } + return false; + } + + /** Returns the index just past the closing quote, or the end of the text. */ + private static int skipQuoted(String sql, int start, char quote) { + int i = start + 1; + while (i < sql.length()) { + if (sql.charAt(i) == quote) { + if (i + 1 < sql.length() && sql.charAt(i + 1) == quote) { + i += 2; + continue; + } + return i + 1; + } + i++; + } + return sql.length(); + } + + private static boolean isWordChar(char c) { + return c >= 0x80 + || (c >= 'a' && c <= 'z') + || (c >= 'A' && c <= 'Z') + || (c >= '0' && c <= '9') + || c == '_' + || c == '$'; + } +} diff --git a/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/AutoCommitSessionIT.java b/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/AutoCommitSessionIT.java new file mode 100644 index 0000000..964293a --- /dev/null +++ b/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/AutoCommitSessionIT.java @@ -0,0 +1,112 @@ +package io.github.magnusp.libsql.jdbc; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; +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.sql.Connection; +import java.sql.DriverManager; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import java.time.Duration; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Pins the SQLite session behavior an auto-commit JDBC connection must expose. + * + *

Every auto-commit statement runs on the connection's session stream, so + * connection-scoped state survives between statements. sqld expires idle streams; the + * driver recovers with a fresh stream, which resets that state. + */ +@Testcontainers +class AutoCommitSessionIT { + + private static final int PORT = 8080; + private static final String LIBSQL_IMAGE = requireLibsqlImage(); + + @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))); + + @Test + void lastInsertRowidIsVisibleToTheNextAutoCommitStatement() throws SQLException { + try (Connection connection = connect(); Statement statement = connection.createStatement()) { + statement.execute("CREATE TABLE session_probe (id INTEGER PRIMARY KEY AUTOINCREMENT, v TEXT)"); + statement.executeUpdate("INSERT INTO session_probe (v) VALUES ('a')"); + + try (ResultSet resultSet = statement.executeQuery("SELECT last_insert_rowid()")) { + assertThat(resultSet.next()).isTrue(); + assertThat(resultSet.getLong(1)).isPositive(); + } + } + } + + @Test + void sessionStateSurvivesAutoCommitStatementsOnOneConnection() throws SQLException { + try (Connection connection = connect(); Statement statement = connection.createStatement()) { + statement.execute("PRAGMA foreign_keys = OFF"); + + try (ResultSet resultSet = statement.executeQuery("PRAGMA foreign_keys")) { + assertThat(resultSet.next()).isTrue(); + assertThat(resultSet.getInt(1)).isZero(); + } + } + } + + @Test + @Timeout(40) + void anIdleExpiredStreamIsRecoveredAndSessionStateResets() throws Exception { + try (Connection connection = connect(); Statement statement = connection.createStatement()) { + statement.execute("PRAGMA foreign_keys = OFF"); + try (ResultSet resultSet = statement.executeQuery("PRAGMA foreign_keys")) { + assertThat(resultSet.next()).isTrue(); + assertThat(resultSet.getInt(1)).isZero(); + } + + // sqld expires an idle Hrana stream after about ten seconds. + Thread.sleep(12_000); + + try (ResultSet resultSet = statement.executeQuery("SELECT 1")) { + assertThat(resultSet.next()) + .as("the driver retries the statement on a fresh stream") + .isTrue(); + assertThat(resultSet.getInt(1)).isEqualTo(1); + } + + try (ResultSet resultSet = statement.executeQuery("PRAGMA foreign_keys")) { + assertThat(resultSet.next()).isTrue(); + assertThat(resultSet.getInt(1)) + .as("state set before the idle expiry does not survive the fresh stream") + .isEqualTo(1); + } + } + } + + private static Connection connect() throws SQLException { + return DriverManager.getConnection( + "jdbc:libsql:http://" + LIBSQL.getHost() + ":" + LIBSQL.getMappedPort(PORT)); + } + + 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/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/LibsqlConnectionTest.java b/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/LibsqlConnectionTest.java index 93f1b90..9942306 100644 --- a/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/LibsqlConnectionTest.java +++ b/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/LibsqlConnectionTest.java @@ -555,6 +555,103 @@ void aConstraintErrorWithoutACodeFallsBackToMessageCategorization() throws SQLEx .satisfies(thrown -> assertThat(((SQLException) thrown).getSQLState()).isEqualTo("23000")); } + @Test + void autoCommitStatementsShareOneSessionStream() throws SQLException { + ScriptedClient client = new ScriptedClient((request, index) -> successResponse(request)); + LibsqlConnection connection = new LibsqlConnection(CONFIG, client); + + connection.executeStatement(new Stmt("SELECT 1")); + connection.executeStatement(new Stmt("SELECT 2")); + + assertThat(client.executedStatements()).containsExactly("SELECT 1", "SELECT 2"); + assertThat(client.requests).hasSize(2); + assertThat(client.requests.get(0).baton()).isNull(); + assertThat(client.requests.get(1).baton()) + .as("the second auto-commit statement reuses the first statement's stream") + .isEqualTo(BATON); + assertThat(client.requests) + .as("auto-commit keeps the stream open between statements") + .noneSatisfy(request -> assertThat(request.requests()) + .containsExactly(StreamRequest.Close.INSTANCE)); + } + + @Test + void closeClosesTheAutoCommitSessionStream() throws SQLException { + ScriptedClient client = new ScriptedClient((request, index) -> successResponse(request)); + LibsqlConnection connection = new LibsqlConnection(CONFIG, client); + connection.executeStatement(new Stmt("SELECT 1")); + + connection.close(); + + assertThat(client.requests.get(1).requests()).containsExactly(StreamRequest.Close.INSTANCE); + } + + @Test + void anExpiredAutoCommitStreamIsRetriedOnceOnAFreshStream() throws SQLException { + AtomicInteger executes = new AtomicInteger(); + ScriptedClient client = new ScriptedClient((request, index) -> { + if (executedSql(request) != null && executes.incrementAndGet() == 2) { + return errorResponse(new HranaError( + "The stream has expired due to inactivity", "STREAM_EXPIRED")); + } + return successResponse(request); + }); + LibsqlConnection connection = new LibsqlConnection(CONFIG, client); + connection.executeStatement(new Stmt("SELECT 1")); + + connection.executeStatement(new Stmt("SELECT 2")); + + assertThat(client.executedStatements()) + .as("the statement did not run on the expired stream, so the retry is safe") + .containsExactly("SELECT 1", "SELECT 2", "SELECT 2"); + assertThat(client.requests.get(2).requests()).containsExactly(StreamRequest.Close.INSTANCE); + assertThat(client.requests.get(3).baton()) + .as("the retry opens a fresh stream") + .isNull(); + } + + @Test + void aTransactionBegunAfterAutoCommitWorkReusesTheSessionStream() throws SQLException { + ScriptedClient client = new ScriptedClient((request, index) -> successResponse(request)); + LibsqlConnection connection = new LibsqlConnection(CONFIG, client); + connection.executeStatement(new Stmt("SELECT 1")); + + connection.setAutoCommit(false); + connection.executeStatement(new Stmt("SELECT 2")); + + assertThat(client.executedStatements()) + .containsExactly("SELECT 1", "BEGIN", "SELECT 2"); + assertThat(client.requests.get(1).baton()) + .as("BEGIN runs on the open auto-commit stream instead of a new one") + .isEqualTo(BATON); + } + + @Test + void anExpiredSessionStreamIsRetriedWhenTheTransactionBegins() throws SQLException { + AtomicInteger begins = new AtomicInteger(); + ScriptedClient client = new ScriptedClient((request, index) -> { + if ("BEGIN".equals(executedSql(request)) && begins.incrementAndGet() == 1) { + return errorResponse(new HranaError( + "The stream has expired due to inactivity", "STREAM_EXPIRED")); + } + return successResponse(request); + }); + LibsqlConnection connection = new LibsqlConnection(CONFIG, client); + connection.executeStatement(new Stmt("SELECT 1")); + connection.setAutoCommit(false); + + connection.executeStatement(new Stmt("INSERT INTO sample VALUES (1)")); + + assertThat(client.executedStatements()) + .containsExactly("SELECT 1", "BEGIN", "BEGIN", "INSERT INTO sample VALUES (1)"); + assertThat(client.requests.get(2).requests()) + .as("the expired auto-commit stream is closed before the begin is retried") + .containsExactly(StreamRequest.Close.INSTANCE); + assertThat(client.requests.get(3).baton()) + .as("the retried begin opens a fresh stream") + .isNull(); + } + private static int[] countsOfStepsThatRan(int failAt) { int[] counts = new int[failAt]; for (int i = 0; i < counts.length; i++) { diff --git a/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/LibsqlGeneratedKeysTest.java b/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/LibsqlGeneratedKeysTest.java index f5d5d05..1618e9e 100644 --- a/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/LibsqlGeneratedKeysTest.java +++ b/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/LibsqlGeneratedKeysTest.java @@ -30,8 +30,8 @@ private static StmtResult insertResult(long rowid) { @Test void autoCommitInsertReturnsLastInsertRowidFromStmtResult() throws SQLException { - // Auto-commit runs every statement one-shot, so the only rowid the driver can report is - // the one the server sent on this statement's result. + // The generated keys come from the rowid the server sent on this statement's result, + // without a follow-up last_insert_rowid() query. StmtResult insert = insertResult(42L); RecordingConnection connection = new RecordingConnection(insert); assertThat(connection.getAutoCommit()).isTrue(); diff --git a/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/LibsqlStatementTest.java b/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/LibsqlStatementTest.java index 806e220..bc5c3ee 100644 --- a/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/LibsqlStatementTest.java +++ b/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/LibsqlStatementTest.java @@ -11,6 +11,7 @@ import java.sql.ResultSet; import java.sql.SQLException; import java.sql.SQLFeatureNotSupportedException; +import java.sql.SQLIntegrityConstraintViolationException; import java.util.ArrayList; import java.util.List; @@ -191,6 +192,106 @@ void plainStatementBindsNullForUnboundPlaceholders() throws SQLException { assertThat(connection.executedStatements.get(0).args()).containsExactly(Value.ofNull()); } + @Test + void multiStatementUpdateSumsAffectedRowsAndSendsEachStatementInOrder() throws SQLException { + ScriptedConnection connection = new ScriptedConnection( + List.of(updateResult(1), updateResult(1))); + LibsqlStatement statement = new LibsqlStatement(connection); + + assertThat(statement.executeUpdate( + "insert into t values (1); insert into t values (2)")) + .isEqualTo(2); + + assertThat(connection.executedStatements) + .containsExactly("insert into t values (1)", "insert into t values (2)"); + assertThat(statement.getUpdateCount()).isEqualTo(2); + } + + @Test + void multiStatementExecuteExposesTheFinalResultSet() throws SQLException { + StmtResult select = new StmtResult( + List.of(new Col("value", null)), + List.of(new Row(List.of(Value.of(2L)))), + 0, + null); + ScriptedConnection connection = new ScriptedConnection( + List.of(updateResult(1), select)); + LibsqlStatement statement = new LibsqlStatement(connection); + + assertThat(statement.execute("insert into t values (1); select 2")).isTrue(); + + try (ResultSet resultSet = statement.getResultSet()) { + assertThat(resultSet.next()).isTrue(); + assertThat(resultSet.getLong(1)).isEqualTo(2L); + } + } + + @Test + void multiStatementStopsAtTheFirstFailureAndRunsNoLaterStatement() { + ScriptedConnection connection = new ScriptedConnection(List.of(updateResult(1))); + connection.failOnStatement = 2; + LibsqlStatement statement = new LibsqlStatement(connection); + + assertThatThrownBy(() -> statement.execute( + "insert into t values (1); insert into t values (2); insert into t values (3)")) + .isInstanceOf(SQLIntegrityConstraintViolationException.class) + .hasMessageContaining("constraint failed"); + + assertThat(connection.executedStatements) + .containsExactly("insert into t values (1)", "insert into t values (2)"); + } + + @Test + void multiStatementWithPlaceholdersIsRejectedBeforeAnythingRuns() { + ScriptedConnection connection = new ScriptedConnection(List.of(updateResult(1))); + LibsqlStatement statement = new LibsqlStatement(connection); + + assertThatThrownBy(() -> statement.execute( + "insert into t values (?); insert into t values (2)")) + .isInstanceOf(SQLFeatureNotSupportedException.class) + .hasMessageContaining("multi-statement"); + + assertThat(connection.executedStatements).isEmpty(); + } + + @Test + void preparedMultiStatementWithBoundArgumentsIsRejectedBeforeAnythingRuns() throws SQLException { + ScriptedConnection connection = new ScriptedConnection(List.of(updateResult(1))); + LibsqlPreparedStatement statement = new LibsqlPreparedStatement( + connection, "insert into t values (1); insert into t values (2)"); + statement.setInt(1, 1); + + assertThatThrownBy(statement::executeUpdate) + .isInstanceOf(SQLFeatureNotSupportedException.class) + .hasMessageContaining("multi-statement"); + + assertThat(connection.executedStatements).isEmpty(); + } + + private static StmtResult updateResult(long affectedRows) { + return new StmtResult(List.of(), List.of(), affectedRows, null); + } + + private static final class ScriptedConnection extends LibsqlConnection { + private final List results; + private final List executedStatements = new ArrayList<>(); + private int failOnStatement = -1; + + private ScriptedConnection(List results) { + super(null, null); + this.results = results; + } + + @Override + public StmtResult executeStatement(Stmt stmt) throws SQLException { + executedStatements.add(stmt.sql()); + if (executedStatements.size() == failOnStatement) { + throw new SQLIntegrityConstraintViolationException("constraint failed"); + } + return results.get(executedStatements.size() - 1); + } + } + private static final class RecordingConnection extends LibsqlConnection { private final StmtResult result; private final List batchResults; diff --git a/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/SqlScriptTest.java b/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/SqlScriptTest.java new file mode 100644 index 0000000..26fb97d --- /dev/null +++ b/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/SqlScriptTest.java @@ -0,0 +1,80 @@ +package io.github.magnusp.libsql.jdbc; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class SqlScriptTest { + + @Test + void returnsASingleStatementWhenThereIsNoTerminator() { + assertThat(SqlScript.split("select 1")).containsExactly("select 1"); + } + + @Test + void splitsStatementsOnTopLevelSemicolons() { + assertThat(SqlScript.split("insert into t values (1); insert into t values (2)")) + .containsExactly("insert into t values (1)", "insert into t values (2)"); + } + + @Test + void ignoresEmptyStatementsAndTrailingSemicolons() { + assertThat(SqlScript.split(";; select 1;; select 2; ")) + .containsExactly("select 1", "select 2"); + } + + @Test + void ignoresCommentOnlyStatements() { + assertThat(SqlScript.split("-- leading\n; /* block */; select 1")) + .containsExactly("select 1"); + } + + @Test + void keepsSemicolonsInsideQuotedText() { + assertThat(SqlScript.split( + "select ';', \";\", `;`, [;] from t; insert into t values ('a;b')")) + .containsExactly( + "select ';', \";\", `;`, [;] from t", + "insert into t values ('a;b')"); + } + + @Test + void keepsSemicolonsInsideComments() { + assertThat(SqlScript.split( + "select 1 -- ; not a terminator\n; select 2 /* ; neither */; select 3")) + .containsExactly("select 1 -- ; not a terminator", "select 2 /* ; neither */", "select 3"); + } + + @Test + void keepsATriggerBodyAsOneStatement() { + String trigger = "create trigger t_after_insert after insert on t begin" + + " update summary set count = count + 1;" + + " insert into audit (value) values (new.value);" + + " end"; + assertThat(SqlScript.split(trigger + "; select 1")) + .containsExactly(trigger, "select 1"); + } + + @Test + void keepsCaseEndInsideATriggerBody() { + String trigger = "create temp trigger t_guard before insert on t" + + " when case when new.value is null then 1 else 0 end" + + " begin" + + " select case when new.value is null then raise(abort, 'value') end;" + + " end"; + assertThat(SqlScript.split(trigger + "; select 1")) + .containsExactly(trigger, "select 1"); + } + + @Test + void doesNotTreatABeginInAnOrdinaryStatementAsATriggerBody() { + assertThat(SqlScript.split("begin; select 1; commit")) + .containsExactly("begin", "select 1", "commit"); + } + + @Test + void handlesUnterminatedQuotesWithoutThrowing() { + assertThat(SqlScript.split("select 'unterminated; select 2")) + .containsExactly("select 'unterminated; select 2"); + } +} diff --git a/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/flyway/FlywayKnownGapsIT.java b/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/flyway/FlywayKnownGapsIT.java index b645c48..f521850 100644 --- a/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/flyway/FlywayKnownGapsIT.java +++ b/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/flyway/FlywayKnownGapsIT.java @@ -14,11 +14,13 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; /** - * Pins the Flyway-facing gaps the smoke test found. + * Pins the Flyway-facing behavior the smoke test found. * - *

These assertions describe current behavior, not desired behavior. If a test - * starts failing, the driver improved and the workaround in the assertion message - * can be removed. + *

URL-based Flyway configuration cannot select the driver from a + * {@code jdbc:libsql:} URL; the supported configurations are documented in the + * README. {@code clean()} works when foreign keys are disabled on the + * connection before Flyway starts its transaction, as SQLite ignores + * {@code PRAGMA foreign_keys} inside a transaction. */ class FlywayKnownGapsIT extends FlywaySqldSupport { @@ -30,6 +32,21 @@ private static Flyway flyway() { .load(); } + /** + * Flyway wraps {@code clean()} in a transaction. SQLite ignores + * {@code PRAGMA foreign_keys} inside a transaction, so the pragma must run when the + * connection is initialized, before Flyway begins its transaction. The driver's + * connection-scoped auto-commit stream keeps that session state for the later statements. + */ + private static Flyway flywayWithForeignKeysDisabled() { + return Flyway.configure() + .dataSource(dataSource()) + .locations("classpath:flyway/migration") + .cleanDisabled(false) + .initSql("PRAGMA foreign_keys = OFF") + .load(); + } + @BeforeEach void emptyDatabase() throws Exception { resetDatabase(); @@ -74,42 +91,30 @@ void productNameStartsWithSqliteSoProductDetectionWorks() throws SQLException { } @Test - void cleanIsBlockedUntilMultiStatementSupportLands() throws Exception { - Flyway flyway = flyway(); + void cleanDropsEveryUserTableAndTheSchemaHistory() throws Exception { + Flyway flyway = flywayWithForeignKeysDisabled(); flyway.migrate(); + assertThat(userTables()).contains("author", "book", "flyway_schema_history"); - assertThat(scalarInt("PRAGMA foreign_keys")) - .as("sqld enables foreign keys on a fresh connection, unlike vanilla SQLite") - .isEqualTo(1); - - // The decision is to support clean(); see #258. This pin flips when - // multi-statement PRAGMA/DROP scripts are supported. - assertThatThrownBy(flyway::clean) - .as("Flyway sees foreign keys enabled and sends" - + " 'PRAGMA foreign_keys = OFF; DROP TABLE ...; PRAGMA foreign_keys = ON'" - + " as one statement. sqld rejects that string, so clean cannot finish." - + " Supporting clean() is tracked in #258.") - .isInstanceOf(FlywayException.class) - .hasMessageContaining("Unable to drop") - .hasStackTraceContaining("SQL string contains more than one statement"); + flyway.clean(); assertThat(userTables()) - .as("clean stopped at the first table") - .contains("author"); + .as("Flyway sends a PRAGMA/DROP script per table; the driver splits it and" + + " runs every statement on the connection's stream") + .isEmpty(); } @Test - void autoCommitStatementsDoNotShareSessionState() throws SQLException { + void autoCommitStatementsShareSessionState() throws SQLException { try (Connection connection = connect(); Statement statement = connection.createStatement()) { statement.execute("PRAGMA foreign_keys = OFF"); try (ResultSet resultSet = statement.executeQuery("PRAGMA foreign_keys")) { assertThat(resultSet.next()).isTrue(); assertThat(resultSet.getInt(1)) - .as("each auto-commit statement runs as a one-shot Hrana stream, so" - + " connection-scoped state set by PRAGMA is gone by the next" - + " statement. Wrap related statements in a transaction.") - .isEqualTo(1); + .as("auto-commit statements run on the connection's session stream, so" + + " connection-scoped state set by PRAGMA survives") + .isZero(); } } } diff --git a/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/flyway/FlywaySqldSupport.java b/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/flyway/FlywaySqldSupport.java index b55b9b9..23ca150 100644 --- a/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/flyway/FlywaySqldSupport.java +++ b/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/flyway/FlywaySqldSupport.java @@ -16,8 +16,6 @@ import java.sql.Statement; import java.time.Duration; import java.util.ArrayList; -import java.util.LinkedHashMap; -import java.util.LinkedHashSet; import java.util.List; import java.util.logging.Logger; @@ -63,13 +61,13 @@ static Connection connect() throws SQLException { static void resetDatabase() throws SQLException { try (Connection connection = connect(); Statement statement = connection.createStatement()) { + // Auto-commit statements share the connection's stream, so the PRAGMA applies to + // every DROP that follows instead of forcing a foreign-key-aware drop order. + statement.execute("PRAGMA foreign_keys = OFF"); for (String view : names(statement, "view")) { statement.execute("DROP VIEW IF EXISTS \"" + view + "\""); } - // A PRAGMA does not survive into the next auto-commit statement, so the - // drop order has to respect the foreign-key graph instead of disabling - // checks. - for (String table : dropOrder(statement, names(statement, "table"))) { + for (String table : names(statement, "table")) { statement.execute("DROP TABLE IF EXISTS \"" + table + "\""); } } @@ -122,45 +120,6 @@ private static List names(Statement statement, String type) throws SQLEx return names; } - /** Orders children before the parents they reference. */ - private static List dropOrder(Statement statement, List tables) throws SQLException { - var parents = new LinkedHashMap>(); - var childCount = new LinkedHashMap(); - for (String table : tables) { - List references = new ArrayList<>(); - try (ResultSet resultSet = statement.executeQuery("PRAGMA foreign_key_list(\"" + table + "\")")) { - while (resultSet.next()) { - String parent = resultSet.getString("table"); - if (tables.contains(parent) && !references.contains(parent)) { - references.add(parent); - } - } - } - parents.put(table, references); - childCount.put(table, 0); - } - for (List references : parents.values()) { - for (String parent : references) { - childCount.merge(parent, 1, Integer::sum); - } - } - - List order = new ArrayList<>(); - var remaining = new LinkedHashSet<>(tables); - while (!remaining.isEmpty()) { - String leaf = remaining.stream() - .filter(table -> childCount.get(table) == 0) - .findFirst() - .orElseGet(() -> remaining.iterator().next()); - order.add(leaf); - remaining.remove(leaf); - for (String parent : parents.get(leaf)) { - childCount.merge(parent, -1, Integer::sum); - } - } - return order; - } - private static String requireLibsqlImage() { String image = System.getProperty("libsql.image"); if (image == null || image.isBlank()) { diff --git a/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/hibernate/HibernateSqldSupport.java b/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/hibernate/HibernateSqldSupport.java index f31ecdd..a037c9f 100644 --- a/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/hibernate/HibernateSqldSupport.java +++ b/libsql-jdbc/src/test/java/io/github/magnusp/libsql/jdbc/hibernate/HibernateSqldSupport.java @@ -31,8 +31,8 @@ * schema back through the driver's metadata. * *

Hibernate generates SQLite identities with a separate - * {@code select last_insert_rowid()}, so the probes keep auto-commit off. Issue #83 - * records why that query cannot see an insert from an earlier auto-commit statement. + * {@code select last_insert_rowid()}. Auto-commit statements share the connection's + * session stream, so the query sees the insert even without an explicit transaction. */ @Testcontainers abstract class HibernateSqldSupport { @@ -62,7 +62,6 @@ static Configuration configuration() { .addAnnotatedClass(Note.class) .setProperty("jakarta.persistence.jdbc.url", jdbcUrl()) .setProperty("hibernate.dialect", "org.hibernate.community.dialect.SQLiteDialect") - .setProperty("hibernate.connection.autocommit", "false") .setProperty("hibernate.show_sql", "false") .setProperty("hibernate.format_sql", "false") .setProperty("hibernate.highlight_sql", "false"); 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 index 305947a..acc2b53 100644 --- 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 @@ -37,11 +37,11 @@ /** * 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. + *

Identity-dependent queries run inside an explicit transaction and in + * auto-commit. jOOQ's SQLite dialect reads identities with + * {@code SELECT ... WHERE _rowid_ = last_insert_rowid()}; auto-commit + * statements share the connection's session stream, so the follow-up select + * sees the insert. */ class JooqIT extends JooqSqldSupport { @@ -255,7 +255,7 @@ void codeGenerationProducesTypedTablesAndRecords() throws Exception { } @Test - void autoCommitInsertDoesNotExposeGeneratedIdentity() { + void autoCommitInsertExposesGeneratedIdentity() { Record inserted = dsl.insertInto(AUTHOR) .set(AUTHOR_NAME, "Auto Commit") .set(AUTHOR_COUNTRY, "SE") @@ -267,13 +267,15 @@ void autoCommitInsertDoesNotExposeGeneratedIdentity() { .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(); + + " auto-commit statements share the connection's session stream, so the" + + " select sees the insert.") + .isNotNull(); + Integer authorId = inserted.get(AUTHOR_ID); + assertThat(authorId).isNotNull().isPositive(); 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(); + .as("last_insert_rowid() survives between auto-commit statements") + .isEqualTo(authorId); } @Test diff --git a/upstream-xerial_sqlite-jdbc.manifest b/upstream-xerial_sqlite-jdbc.manifest index c3bf1bc..9bb806e 100644 --- a/upstream-xerial_sqlite-jdbc.manifest +++ b/upstream-xerial_sqlite-jdbc.manifest @@ -125,5 +125,5 @@ VERSION # divergence: src/test/java/org/sqlite/InsertQueryTest.java -- upstream misuses the connection factory so the transaction is applied to a different connection and never asserts the result; the conformance copy reuses the transaction connection, asserts rollback and commit visibility, and asserts the final count (#51). # divergence: src/test/java/org/sqlite/PreparedStatementThreadTest.java -- upstream swallows every ExecutionException, which hides assertion failures and runtime exceptions from the close/execute race; Hrana serializes requests per stream, so the conformance copy awaits both futures and tolerates only an SQLException cause, and any runtime failure fails the test as the Java equivalent of the native crash it guards (#51, #118). # divergence: src/test/java/org/sqlite/DBMetaDataTest.java -- sqld rejects CREATE TEMP TABLE with SQL_PARSE_ERROR, so the conformance copy skips only columnOrderOfgetTables on libSQL and leaves the xerial control run intact. -# divergence: src/test/java/org/sqlite/StatementTest.java -- Hrana sends one statement per Stmt so sqld rejects multi-statement strings (SQL_MANY_STATEMENTS), sqld rejects CREATE TEMP TABLE (SQL_PARSE_ERROR), and Hrana streams are independent so DROP succeeds while a materialized result set is open; the conformance copy skips those cases on libSQL. xerial's DATE-millis round trip is replaced on libSQL by the java.sql date-only value, and the copy uses standard JDBC unwrap and closeOnCompletion calls plus drops the sqlite-jdbc-only generated-keys configuration hook so it compiles without the xerial classes. +# divergence: src/test/java/org/sqlite/StatementTest.java -- sqld rejects CREATE TEMP TABLE (SQL_PARSE_ERROR), and Hrana streams are independent so DROP succeeds while a materialized result set is open; the conformance copy skips those cases on libSQL. xerial's DATE-millis round trip is replaced on libSQL by the java.sql date-only value, and the copy uses standard JDBC unwrap and closeOnCompletion calls plus drops the sqlite-jdbc-only generated-keys configuration hook so it compiles without the xerial classes. # divergence: src/test/java/org/sqlite/PrepStmtTest.java -- Hrana has no describe request, so the conformance copy skips the parameter-type, pre-execution metadata, and pre-execution SQL-validation expectations on libSQL, and uses the java.sql date-only value instead of xerial's DATE-millis round trip; it also reassigns the result set after the next execute as java.sql requires and drops the cases that call sqlite-jdbc's own configuration and error-code classes. diff --git a/upstream-xerial_sqlite-jdbc/CHECKSUMS.sha256 b/upstream-xerial_sqlite-jdbc/CHECKSUMS.sha256 index 4b2e9e4..de91d4f 100644 --- a/upstream-xerial_sqlite-jdbc/CHECKSUMS.sha256 +++ b/upstream-xerial_sqlite-jdbc/CHECKSUMS.sha256 @@ -35,6 +35,6 @@ d787e71bdf2b8d12a2669eb06c432cf2a39ac77f678a92459e3dd9b6c4519a43 ./src/test/jav # upstream 0391680df392315cc6ba7a6b91299aa42f80da0a715880696e64adf41dd45daf ./src/test/java/org/sqlite/SavepointTest.java 0391680df392315cc6ba7a6b91299aa42f80da0a715880696e64adf41dd45daf ./src/test/java/org/sqlite/SavepointTest.java # upstream 81125078aa972d55deba801a81841271a659f0f3306060e128850cbe8cc4054b ./src/test/java/org/sqlite/StatementTest.java -8328eec31b4504df729d39e18c978e03d71c455080a17e943e61d30ff926330c ./src/test/java/org/sqlite/StatementTest.java +015cff1af88d242b36442a74271102734121fb99ad60558af7d4e4f1aae10d04 ./src/test/java/org/sqlite/StatementTest.java # upstream bef3eddca2a6b80472050a3a52d5e7246128f0644d29c24ed091709b0ed6f121 ./src/test/java/org/sqlite/Utils.java bef3eddca2a6b80472050a3a52d5e7246128f0644d29c24ed091709b0ed6f121 ./src/test/java/org/sqlite/Utils.java diff --git a/upstream-xerial_sqlite-jdbc/src/test/java/org/sqlite/StatementTest.java b/upstream-xerial_sqlite-jdbc/src/test/java/org/sqlite/StatementTest.java index 693dbf6..b1b6c07 100644 --- a/upstream-xerial_sqlite-jdbc/src/test/java/org/sqlite/StatementTest.java +++ b/upstream-xerial_sqlite-jdbc/src/test/java/org/sqlite/StatementTest.java @@ -52,24 +52,23 @@ public void executeUpdate() throws SQLException { // count_changes_pgrama. truncate_optimization assertThat(stat.executeUpdate("delete from s1;")).isEqualTo(3); - // multiple SQL statements: Hrana carries one statement per Stmt and sqld - // rejects a multi-statement string, so the xerial expectation is control-only. - if (!isLibsql()) { - assertThat(stat.executeUpdate("insert into s1 values (11);" + "insert into s1 values (12)")) - .isEqualTo(2); - assertThat( - stat.executeUpdate( - "update s1 set c1 = 21 where c1 = 11;" - + "update s1 set c1 = 22 where c1 = 12;" - + "update s1 set c1 = 23 where c1 = 13")) - .isEqualTo(2); // c1 = 13 does not exist - assertThat( - stat.executeUpdate( - "delete from s1 where c1 = 21;" - + "delete from s1 where c1 = 22;" - + "delete from s1 where c1 = 23")) - .isEqualTo(2); // c1 = 23 does not exist - } + // Multiple SQL statements: the driver splits the script and sends each + // statement on the connection's session stream. The update count is the + // total affected rows, as in xerial. + assertThat(stat.executeUpdate("insert into s1 values (11);" + "insert into s1 values (12)")) + .isEqualTo(2); + assertThat( + stat.executeUpdate( + "update s1 set c1 = 21 where c1 = 11;" + + "update s1 set c1 = 22 where c1 = 12;" + + "update s1 set c1 = 23 where c1 = 13")) + .isEqualTo(2); // c1 = 13 does not exist + assertThat( + stat.executeUpdate( + "delete from s1 where c1 = 21;" + + "delete from s1 where c1 = 22;" + + "delete from s1 where c1 = 23")) + .isEqualTo(2); // c1 = 23 does not exist assertThat(stat.executeUpdate("drop table s1;")).isEqualTo(0); } @@ -540,8 +539,7 @@ public void noSuchColName() throws SQLException { @Test public void multipleStatements() throws SQLException { - assumeFalse(isLibsql(), "sqld rejects a multi-statement SQL string with SQL_MANY_STATEMENTS"); - // ; insert into person values(1,'leo') + // The driver splits the script into statements and runs them in order. stat.executeUpdate( "create table person (id integer, name string); " + "insert into person values(1, 'leo'); insert into person values(2, 'yui');");