Implement a JNI wrapper around SQLite.

This will be used by the implementation of garbage collection for the disk cache, as discussed in https://github.com/bazelbuild/bazel/issues/5139 and the linked design doc. I judge this to be preferred over https://github.com/xerial/sqlite-jdbc for the following reasons:

1. It's a much smaller dependency.
2. The JDBC API is too generic and becomes awkward to use when dealing with the peculiarities of SQLite.
3. We can (more easily) compile it from source for all host platforms, including the BSDs.

PiperOrigin-RevId: 628046749
Change-Id: I17bd0547876df460f48af24944d3f7327069375f
diff --git a/MODULE.bazel b/MODULE.bazel
index 6ca1f04..766e484 100644
--- a/MODULE.bazel
+++ b/MODULE.bazel
@@ -21,6 +21,7 @@
 bazel_dep(name = "stardoc", version = "0.5.6", repo_name = "io_bazel_skydoc")
 bazel_dep(name = "zstd-jni", version = "1.5.2-3.bcr.1")
 bazel_dep(name = "blake3", version = "1.3.3.bcr.1")
+bazel_dep(name = "sqlite3", version = "3.42.0.bcr.1")
 bazel_dep(name = "zlib", version = "1.3")
 bazel_dep(name = "rules_cc", version = "0.0.9")
 bazel_dep(name = "rules_java", version = "7.5.0")
diff --git a/MODULE.bazel.lock b/MODULE.bazel.lock
index 05bef5d..83889a8 100644
--- a/MODULE.bazel.lock
+++ b/MODULE.bazel.lock
@@ -1,6 +1,6 @@
 {
   "lockFileVersion": 6,
-  "moduleFileHash": "6d66ec3a2143e9d841b99698beb6de3ef66d0f3dc0b724afa19e534df8c72833",
+  "moduleFileHash": "41e97ab73eab5fafef6cd8f9b4696823e31384219a6356a052cab52245e1193c",
   "flags": {
     "cmdRegistries": [
       "https://bcr.bazel.build/"
@@ -39,7 +39,7 @@
           "usingModule": "<root>",
           "location": {
             "file": "@@//:MODULE.bazel",
-            "line": 74,
+            "line": 75,
             "column": 22
           },
           "imports": {
@@ -168,7 +168,7 @@
               "devDependency": false,
               "location": {
                 "file": "@@//:MODULE.bazel",
-                "line": 75,
+                "line": 76,
                 "column": 14
               }
             },
@@ -183,7 +183,7 @@
               "devDependency": false,
               "location": {
                 "file": "@@//:MODULE.bazel",
-                "line": 198,
+                "line": 199,
                 "column": 19
               }
             },
@@ -198,7 +198,7 @@
               "devDependency": false,
               "location": {
                 "file": "@@//:MODULE.bazel",
-                "line": 198,
+                "line": 199,
                 "column": 19
               }
             },
@@ -213,7 +213,7 @@
               "devDependency": false,
               "location": {
                 "file": "@@//:MODULE.bazel",
-                "line": 198,
+                "line": 199,
                 "column": 19
               }
             },
@@ -228,7 +228,7 @@
               "devDependency": false,
               "location": {
                 "file": "@@//:MODULE.bazel",
-                "line": 198,
+                "line": 199,
                 "column": 19
               }
             },
@@ -243,7 +243,7 @@
               "devDependency": false,
               "location": {
                 "file": "@@//:MODULE.bazel",
-                "line": 198,
+                "line": 199,
                 "column": 19
               }
             },
@@ -258,7 +258,7 @@
               "devDependency": false,
               "location": {
                 "file": "@@//:MODULE.bazel",
-                "line": 198,
+                "line": 199,
                 "column": 19
               }
             },
@@ -273,7 +273,7 @@
               "devDependency": false,
               "location": {
                 "file": "@@//:MODULE.bazel",
-                "line": 198,
+                "line": 199,
                 "column": 19
               }
             },
@@ -288,7 +288,7 @@
               "devDependency": false,
               "location": {
                 "file": "@@//:MODULE.bazel",
-                "line": 198,
+                "line": 199,
                 "column": 19
               }
             },
@@ -303,7 +303,7 @@
               "devDependency": false,
               "location": {
                 "file": "@@//:MODULE.bazel",
-                "line": 198,
+                "line": 199,
                 "column": 19
               }
             },
@@ -331,7 +331,7 @@
               "devDependency": false,
               "location": {
                 "file": "@@//:MODULE.bazel",
-                "line": 338,
+                "line": 339,
                 "column": 22
               }
             }
@@ -345,7 +345,7 @@
           "usingModule": "<root>",
           "location": {
             "file": "@@//:MODULE.bazel",
-            "line": 219,
+            "line": 220,
             "column": 32
           },
           "imports": {
@@ -385,7 +385,7 @@
           "usingModule": "<root>",
           "location": {
             "file": "@@//:MODULE.bazel",
-            "line": 253,
+            "line": 254,
             "column": 23
           },
           "imports": {},
@@ -399,7 +399,7 @@
               "devDependency": false,
               "location": {
                 "file": "@@//:MODULE.bazel",
-                "line": 254,
+                "line": 255,
                 "column": 17
               }
             }
@@ -413,7 +413,7 @@
           "usingModule": "<root>",
           "location": {
             "file": "@@//:MODULE.bazel",
-            "line": 256,
+            "line": 257,
             "column": 20
           },
           "imports": {
@@ -431,7 +431,7 @@
               "devDependency": false,
               "location": {
                 "file": "@@//:MODULE.bazel",
-                "line": 257,
+                "line": 258,
                 "column": 10
               }
             }
@@ -445,7 +445,7 @@
           "usingModule": "<root>",
           "location": {
             "file": "@@//:MODULE.bazel",
-            "line": 268,
+            "line": 269,
             "column": 33
           },
           "imports": {
@@ -476,7 +476,7 @@
           "usingModule": "<root>",
           "location": {
             "file": "@@//:MODULE.bazel",
-            "line": 289,
+            "line": 290,
             "column": 29
           },
           "imports": {
@@ -493,7 +493,7 @@
           "usingModule": "<root>",
           "location": {
             "file": "@@//:MODULE.bazel",
-            "line": 292,
+            "line": 293,
             "column": 20
           },
           "imports": {
@@ -512,7 +512,7 @@
               "devDependency": false,
               "location": {
                 "file": "@@//:MODULE.bazel",
-                "line": 293,
+                "line": 294,
                 "column": 12
               }
             }
@@ -526,7 +526,7 @@
           "usingModule": "<root>",
           "location": {
             "file": "@@//:MODULE.bazel",
-            "line": 305,
+            "line": 306,
             "column": 32
           },
           "imports": {
@@ -545,7 +545,7 @@
           "usingModule": "<root>",
           "location": {
             "file": "@@//:MODULE.bazel",
-            "line": 313,
+            "line": 314,
             "column": 31
           },
           "imports": {
@@ -562,7 +562,7 @@
           "usingModule": "<root>",
           "location": {
             "file": "@@//:MODULE.bazel",
-            "line": 316,
+            "line": 317,
             "column": 48
           },
           "imports": {
@@ -579,7 +579,7 @@
           "usingModule": "<root>",
           "location": {
             "file": "@@//:MODULE.bazel",
-            "line": 360,
+            "line": 361,
             "column": 35
           },
           "imports": {
@@ -596,7 +596,7 @@
           "usingModule": "<root>",
           "location": {
             "file": "@@//:MODULE.bazel",
-            "line": 363,
+            "line": 364,
             "column": 42
           },
           "imports": {
@@ -614,7 +614,7 @@
           "usingModule": "<root>",
           "location": {
             "file": "@@//:MODULE.bazel",
-            "line": 366,
+            "line": 367,
             "column": 45
           },
           "imports": {
@@ -636,6 +636,7 @@
         "io_bazel_skydoc": "stardoc@0.5.6",
         "zstd-jni": "zstd-jni@1.5.2-3.bcr.1",
         "blake3": "blake3@1.3.3.bcr.1",
+        "sqlite3": "sqlite3@3.42.0.bcr.1",
         "zlib": "zlib@1.3",
         "rules_cc": "rules_cc@0.0.9",
         "rules_java": "rules_java@7.5.0",
@@ -1037,6 +1038,36 @@
         }
       }
     },
+    "sqlite3@3.42.0.bcr.1": {
+      "name": "sqlite3",
+      "version": "3.42.0.bcr.1",
+      "key": "sqlite3@3.42.0.bcr.1",
+      "repoName": "sqlite3",
+      "executionPlatformsToRegister": [],
+      "toolchainsToRegister": [],
+      "extensionUsages": [],
+      "deps": {
+        "platforms": "platforms@0.0.9",
+        "bazel_tools": "bazel_tools@_",
+        "local_config_platform": "local_config_platform@_"
+      },
+      "repoSpec": {
+        "bzlFile": "@@bazel_tools//tools/build_defs/repo:http.bzl",
+        "ruleClassName": "http_archive",
+        "attributes": {
+          "urls": [
+            "https://sqlite.org/2023/sqlite-amalgamation-3420000.zip"
+          ],
+          "integrity": "sha256-HMgk0PXmdYKfo3AYMY/agz6lb36d4rQe3dn3ZDtewp4=",
+          "strip_prefix": "sqlite-amalgamation-3420000",
+          "remote_patches": {
+            "https://bcr.bazel.build/modules/sqlite3/3.42.0.bcr.1/patches/add_build_file.patch": "sha256-jv2puH58Mvf5zg3l26JdJHsAHBjb743lE3uV0FvSAd8=",
+            "https://bcr.bazel.build/modules/sqlite3/3.42.0.bcr.1/patches/module_dot_bazel.patch": "sha256-0nVrZCj8DjLNzeeLpBtVT9X0s4BQXqANYyu++q7Ikv8="
+          },
+          "remote_patch_strip": 0
+        }
+      }
+    },
     "zlib@1.3": {
       "name": "zlib",
       "version": "1.3",
@@ -2704,7 +2735,7 @@
   "moduleExtensions": {
     "//:extensions.bzl%bazel_android_deps": {
       "general": {
-        "bzlTransitiveDigest": "R3xBHm2eNuqBleAB9JHPLRUvv5Eq/3rnrpxiZN2EBKc=",
+        "bzlTransitiveDigest": "tunTSmgwd2uvTzkCLtdbuCp0AI+WR+ftiPNqZ0rmcZk=",
         "recordedFileInputs": {},
         "recordedDirentsInputs": {},
         "envVariables": {},
@@ -2822,6 +2853,11 @@
           ],
           [
             "",
+            "sqlite3",
+            "sqlite3~"
+          ],
+          [
+            "",
             "upb",
             "upb~"
           ],
@@ -2845,9 +2881,9 @@
     },
     "//:extensions.bzl%bazel_build_deps": {
       "general": {
-        "bzlTransitiveDigest": "R3xBHm2eNuqBleAB9JHPLRUvv5Eq/3rnrpxiZN2EBKc=",
+        "bzlTransitiveDigest": "tunTSmgwd2uvTzkCLtdbuCp0AI+WR+ftiPNqZ0rmcZk=",
         "recordedFileInputs": {
-          "@@//MODULE.bazel": "6d66ec3a2143e9d841b99698beb6de3ef66d0f3dc0b724afa19e534df8c72833",
+          "@@//MODULE.bazel": "41e97ab73eab5fafef6cd8f9b4696823e31384219a6356a052cab52245e1193c",
           "@@//src/test/tools/bzlmod/MODULE.bazel.lock": "e6b22f35c7bce99c0677f24a19c7aee829f97e7b1b5cc31e600c7cc9b408a292"
         },
         "recordedDirentsInputs": {},
@@ -2948,6 +2984,7 @@
                 "rules_pkg~",
                 "rules_proto~",
                 "rules_python~",
+                "sqlite3~",
                 "upb~",
                 "zlib~",
                 "zstd-jni~",
@@ -3191,6 +3228,11 @@
           ],
           [
             "",
+            "sqlite3",
+            "sqlite3~"
+          ],
+          [
+            "",
             "upb",
             "upb~"
           ],
@@ -3214,7 +3256,7 @@
     },
     "//:extensions.bzl%bazel_test_deps": {
       "general": {
-        "bzlTransitiveDigest": "R3xBHm2eNuqBleAB9JHPLRUvv5Eq/3rnrpxiZN2EBKc=",
+        "bzlTransitiveDigest": "tunTSmgwd2uvTzkCLtdbuCp0AI+WR+ftiPNqZ0rmcZk=",
         "recordedFileInputs": {},
         "recordedDirentsInputs": {},
         "envVariables": {},
@@ -3342,6 +3384,11 @@
           ],
           [
             "",
+            "sqlite3",
+            "sqlite3~"
+          ],
+          [
+            "",
             "upb",
             "upb~"
           ],
diff --git a/repositories.bzl b/repositories.bzl
index d8fec8a..b3c2c66 100644
--- a/repositories.bzl
+++ b/repositories.bzl
@@ -43,6 +43,7 @@
     "rules_pkg",
     "rules_proto",
     "rules_python",
+    "sqlite3",
     "upb",
     "zlib",
     "zstd-jni",
diff --git a/src/main/java/com/google/devtools/build/lib/remote/disk/BUILD b/src/main/java/com/google/devtools/build/lib/remote/disk/BUILD
index 57271f3..c7ed7da 100644
--- a/src/main/java/com/google/devtools/build/lib/remote/disk/BUILD
+++ b/src/main/java/com/google/devtools/build/lib/remote/disk/BUILD
@@ -16,6 +16,7 @@
     srcs = glob(["*.java"]),
     deps = [
         "//src/main/java/com/google/devtools/build/lib/exec:spawn_runner",
+        "//src/main/java/com/google/devtools/build/lib/jni",
         "//src/main/java/com/google/devtools/build/lib/remote:store",
         "//src/main/java/com/google/devtools/build/lib/remote/common",
         "//src/main/java/com/google/devtools/build/lib/remote/common:cache_not_found_exception",
diff --git a/src/main/java/com/google/devtools/build/lib/remote/disk/Sqlite.java b/src/main/java/com/google/devtools/build/lib/remote/disk/Sqlite.java
new file mode 100644
index 0000000..8f5b1ef
--- /dev/null
+++ b/src/main/java/com/google/devtools/build/lib/remote/disk/Sqlite.java
@@ -0,0 +1,421 @@
+// Copyright 2024 The Bazel Authors. All rights reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//    http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+package com.google.devtools.build.lib.remote.disk;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+import static com.google.common.base.Preconditions.checkState;
+
+import com.google.common.collect.ImmutableList;
+import com.google.devtools.build.lib.jni.JniLoader;
+import com.google.devtools.build.lib.vfs.Path;
+import java.io.IOException;
+import java.util.HashSet;
+import javax.annotation.Nullable;
+
+/**
+ * A wrapper around the SQLite C API.
+ *
+ * <p>Exposes only the subset of the SQLite C API required by Bazel.
+ */
+public final class Sqlite {
+
+  static {
+    JniLoader.loadJni();
+  }
+
+  private Sqlite() {}
+
+  /**
+   * Opens a connection to a database, creating it in an empty state if it doesn't yet exist.
+   *
+   * @param path the path to the database
+   * @throws IOException if an error occurred while opening the connection
+   */
+  public static Connection newConnection(Path path) throws IOException {
+    checkNotNull(path);
+    return new Connection(path);
+  }
+
+  /**
+   * A connection to a database.
+   *
+   * <p>This class is *not* thread-safe. A single connection should be used by a single thread. Use
+   * separate connections to the same database for multithreaded access.
+   */
+  public static final class Connection implements AutoCloseable {
+
+    // C pointer to the `sqlite3` handle. Zero means the connection has been closed.
+    private long connPtr;
+
+    // Tracks open statements so they can also be closed when the connection is closed.
+    private final HashSet<Statement> openStatements = new HashSet<>();
+
+    private Connection(Path path) throws IOException {
+      this.connPtr = openConn(path.getPathString());
+    }
+
+    /**
+     * Closes the connection, rendering it unusable.
+     *
+     * <p>As a convenience, every {@link Statement} associated with the connection is also closed,
+     * with any errors silently ignored.
+     *
+     * <p>Multiple calls have no further effect.
+     *
+     * @throws IOException if an error occurred while closing the connection
+     */
+    @Override
+    public void close() throws IOException {
+      if (connPtr != 0) {
+        // SQLite won't let us close the connection before first closing associated statements.
+        for (Statement stmt : ImmutableList.copyOf(openStatements)) {
+          stmt.close();
+        }
+        closeConn(connPtr);
+        connPtr = 0;
+      }
+    }
+
+    /**
+     * Creates a new {@link Statement}.
+     *
+     * @param sql a string containing a single SQL statement
+     * @throws IOException if the string contains multiple SQL statements, or the single SQL
+     *     statement could not be parsed and validated
+     */
+    public Statement newStatement(String sql) throws IOException {
+      checkState(connPtr != 0, "newStatement() called in invalid state");
+      Statement stmt = new Statement(this, sql);
+      openStatements.add(stmt);
+      return stmt;
+    }
+  }
+
+  /**
+   * A prepared statement.
+   *
+   * <p>Provides a facility to bind values to parameters and execute the statement by calling one of
+   * {@link #executeQuery} or {@link #executeUpdate}. The same statement may be executed multiple
+   * times, with its parameters possibly bound to different values, but there can be at most one
+   * ongoing execution at a time.
+   *
+   * <p>Parameters that haven't been bound or whose binding has been cleared behave as null.
+   */
+  public static final class Statement implements AutoCloseable {
+
+    // The connection owning this statement.
+    private Connection conn;
+
+    // C pointer to te `sqlite3_stmt` handle. Zero means the statement has been closed.
+    private long stmtPtr;
+
+    // The result of current execution, or null if no execution is ongoing.
+    @Nullable private Result currentResult;
+
+    private Statement(Connection conn, String sql) throws IOException {
+      this.conn = conn;
+      stmtPtr = prepareStmt(conn.connPtr, sql);
+      if (stmtPtr == -1) {
+        // Special value returned when a multi-statement string is detected.
+        throw new IOException("unsupported multi-statement string");
+      }
+    }
+
+    /**
+     * Closes the statement, rendering it unusable.
+     *
+     * <p>A {@link Result} previously returned by {@link #executeQuery} is also closed, with any
+     * error silently ignored.
+     *
+     * <p>Multiple calls have no additional effect.
+     */
+    @Override
+    public void close() {
+      if (stmtPtr != 0) {
+        if (currentResult != null) {
+          try {
+            currentResult.close();
+          } catch (IOException e) {
+            // Intentionally ignored: an error always pertains to a particular execution, and should
+            // only be reported when Result#close is called directly.
+          }
+        }
+        try {
+          finalizeStmt(stmtPtr);
+        } catch (IOException e) {
+          // Cannot occur since the statement has been reset by Result#close.
+          throw new IllegalStateException("unexpected exception thrown by finalize", e);
+        }
+        conn.openStatements.remove(this);
+        conn = null;
+        stmtPtr = 0;
+      }
+    }
+
+    /**
+     * Binds a long value to the statement's i-th parameter, counting from 1.
+     *
+     * <p>The binding remains in effect until it is cleared or another value is bound to the same
+     * parameter.
+     *
+     * <p>Must not be called after {@link #executeQuery} returns a {@link Result} and before the
+     * {@link Result} is closed.
+     */
+    public void bindLong(int i, long val) throws IOException {
+      checkState(stmtPtr != 0 && currentResult == null, "bindLong() called in invalid state");
+      bindStmtLong(stmtPtr, i, val);
+    }
+
+    /**
+     * Binds a double value to the statement's i-th parameter, counting from 1.
+     *
+     * <p>The binding remains in effect until it is cleared or another value is bound to the same
+     * parameter.
+     *
+     * <p>Must not be called after {@link #executeQuery} returns a {@link Result} and before the
+     * {@link Result} is closed.
+     */
+    public void bindDouble(int i, double val) throws IOException {
+      checkState(stmtPtr != 0 && currentResult == null, "bindDouble() called in invalid state");
+      bindStmtDouble(stmtPtr, i, val);
+    }
+
+    /**
+     * Binds a non-null string value to the statement's i-th parameter, counting from 1.
+     *
+     * <p>The binding remains in effect until it is cleared or another value is bound to the same
+     * parameter.
+     *
+     * <p>Must not be called after {@link #executeQuery} returns a {@link Result} and before the
+     * {@link Result} is closed.
+     */
+    public void bindString(int i, String val) throws IOException {
+      checkState(stmtPtr != 0 && currentResult == null, "bindString() called in invalid state");
+      checkNotNull(val);
+      bindStmtString(stmtPtr, i, val);
+    }
+
+    /**
+     * Clears the i-th binding.
+     *
+     * <p>Must not be called after {@link #executeQuery} returns a {@link Result} and before the
+     * {@link Result} is closed.
+     */
+    public void clearBinding(int i) throws IOException {
+      checkState(stmtPtr != 0 && currentResult == null, "clearBinding() called in invalid state");
+      clearStmtBinding(stmtPtr, i);
+    }
+
+    /**
+     * Clears all bindings.
+     *
+     * <p>Must not be called after {@link #executeQuery} returns a {@link Result} and before the
+     * {@link Result} is closed.
+     */
+    public void clearBindings() throws IOException {
+      checkState(stmtPtr != 0 && currentResult == null, "clearBindings() called in invalid state");
+      clearStmtBindings(stmtPtr);
+    }
+
+    /**
+     * Executes a statement expected to return a result.
+     *
+     * <p>Execution doesn't actually start until the first call to {@link Result#next}.
+     *
+     * <p>Must not be called again until the returned {@link Result} has been closed.
+     */
+    public Result executeQuery() {
+      checkState(stmtPtr != 0 && currentResult == null, "executeQuery() called in invalid state");
+      currentResult = new Result(this);
+      return currentResult;
+    }
+
+    /**
+     * Executes a statement not expected to return a result.
+     *
+     * <p>Must not be called after {@link #executeQuery} until the returned {@link Result} has been
+     * closed.
+     *
+     * @throws IOException if the statement returned a non-empty result or an execution error
+     *     occurred
+     */
+    public void executeUpdate() throws IOException {
+      checkState(stmtPtr != 0 && currentResult == null, "executeUpdate() called in invalid state");
+      currentResult = new Result(this);
+      try {
+        if (currentResult.next()) {
+          throw new IOException("unexpected non-empty result");
+        }
+      } finally {
+        currentResult.close();
+      }
+    }
+  }
+
+  /**
+   * The result of executing a statement.
+   *
+   * <p>Acts as a cursor to iterate over result rows and obtain the corresponding column values. The
+   * cursor is initially positioned before the first row. A call to {@link #next} moves the cursor
+   * to the next row, returning {@code false} once no more results are available. If a call to
+   * {@link #next} returns {@code true}, the getter methods may be called to retrieve the column
+   * values for the current row.
+   */
+  public static final class Result implements AutoCloseable {
+
+    // The statement owning this result.
+    private Statement stmt;
+
+    enum State {
+      START, // next() not yet called
+      CONTINUE, // last call to next() returned true
+      DONE, // last call to next() returned false, but close() not yet called
+      ERROR, // last call to next() threw, but close() not yet called
+      CLOSED // close() was called
+    }
+
+    private State state = State.START;
+
+    private Result(Statement stmt) {
+      this.stmt = stmt;
+    }
+
+    /**
+     * Closes the result, rendering it unusable.
+     *
+     * <p>Multiple calls have no additional effect.
+     *
+     * @throws IOException if an error occurred while finishing execution
+     */
+    @Override
+    public void close() throws IOException {
+      if (state != State.CLOSED) {
+        try {
+          resetStmt(stmt.stmtPtr);
+        } catch (IOException e) {
+          // Some statements may throw an error only after the result has been fully consumed.
+          // However, if the error has already been thrown by next(), don't throw it again.
+          if (state != State.ERROR) {
+            throw e;
+          }
+        } finally {
+          stmt.currentResult = null;
+          stmt = null;
+          state = State.CLOSED;
+        }
+      }
+    }
+
+    /**
+     * Advances the cursor the next row.
+     *
+     * <p>Must not be called further after {@code false} is returned or an exception is thrown.
+     *
+     * @return whether another row was available
+     * @throws IOException if an error occurred while executing the statement
+     */
+    public boolean next() throws IOException {
+      checkState(state == State.START || state == State.CONTINUE, "next() called in invalid state");
+
+      try {
+        boolean available = stepStmt(stmt.stmtPtr);
+        state = available ? State.CONTINUE : State.DONE;
+        return available;
+      } catch (IOException e) {
+        state = State.ERROR;
+        throw e;
+      }
+    }
+
+    /**
+     * Returns whether the i-th column for the current result row is null.
+     *
+     * <p>WARNING: this must be called before any of the getter methods. Calling a getter method may
+     * cause a conversion to occur, after which the return value of this method is unspecified.
+     */
+    public boolean isNull(int i) throws IOException {
+      checkState(state == State.CONTINUE, "isNull() called in invalid state");
+      return columnIsNull(stmt.stmtPtr, i);
+    }
+
+    /**
+     * Reads the i-th column for the current result row, starting from 0, as a long.
+     *
+     * <p>If the column is not of long type, a conversion occurs. In particular, null is converted
+     * to 0.
+     *
+     * <p>Must not be called unless the last call to {@link #next} returned {@code true}.
+     */
+    public long getLong(int i) throws IOException {
+      checkState(state == State.CONTINUE, "getLong() called in invalid state");
+      return columnLong(stmt.stmtPtr, i);
+    }
+
+    /**
+     * Reads the i-th column for the current result row, starting from 0, as a double.
+     *
+     * <p>If the column is not of double type, a conversion occurs. In particular, null is converted
+     * to 0.0.
+     *
+     * <p>Must not be called unless the last call to {@link #next} returned {@code true}.
+     */
+    public double getDouble(int i) throws IOException {
+      checkState(state == State.CONTINUE, "getDouble() called in invalid state");
+      return columnDouble(stmt.stmtPtr, i);
+    }
+
+    /**
+     * Reads the i-th column for the current result row, starting from 0, as a string.
+     *
+     * <p>If the column is not of string type, a conversion occurs. In particular, null is converted
+     * to the empty string.
+     *
+     * <p>Must not be called unless the last call to {@link #next} returned {@code true}.
+     */
+    public String getString(int i) throws IOException {
+      checkState(state == State.CONTINUE, "getString() called in invalid state");
+      return columnString(stmt.stmtPtr, i);
+    }
+  }
+
+  private static native long openConn(String path) throws IOException;
+
+  private static native void closeConn(long conn) throws IOException;
+
+  private static native long prepareStmt(long conn, String sql) throws IOException;
+
+  private static native void bindStmtLong(long stmt, int i, long value) throws IOException;
+
+  private static native void bindStmtDouble(long stmt, int i, double value) throws IOException;
+
+  private static native void bindStmtString(long stmt, int i, String value) throws IOException;
+
+  private static native void clearStmtBinding(long stmt, int i) throws IOException;
+
+  private static native void clearStmtBindings(long stmt) throws IOException;
+
+  private static native boolean stepStmt(long stmt) throws IOException;
+
+  private static native boolean columnIsNull(long stmt, int i) throws IOException;
+
+  private static native long columnLong(long stmt, int i) throws IOException;
+
+  private static native double columnDouble(long stmt, int i) throws IOException;
+
+  private static native String columnString(long stmt, int i) throws IOException;
+
+  private static native void resetStmt(long stmt) throws IOException;
+
+  private static native void finalizeStmt(long stmt) throws IOException;
+}
diff --git a/src/main/native/BUILD b/src/main/native/BUILD
index c011aef..31f6b1c 100644
--- a/src/main/native/BUILD
+++ b/src/main/native/BUILD
@@ -69,6 +69,23 @@
     alwayslink = 1,
 )
 
+cc_library(
+    name = "sqlite_jni",
+    srcs = [
+        "sqlite_jni.cc",
+        ":jni.h",
+        ":jni_md.h",
+    ],
+    includes = ["."],  # For jni headers.
+    visibility = ["//src/main/native:__subpackages__"],
+    deps = [
+        ":latin1_jni_path",
+        "//src/main/cpp/util:logging",
+        "@sqlite3",
+    ],
+    alwayslink = 1,
+)
+
 cc_binary(
     name = "libunix_jni.so",
     srcs = [
@@ -97,6 +114,7 @@
     deps = [
         ":blake3_jni",
         ":latin1_jni_path",
+        ":sqlite_jni",
         "//src/main/cpp/util:logging",
         "//src/main/cpp/util:md5",
         "//src/main/cpp/util:port",
diff --git a/src/main/native/sqlite_jni.cc b/src/main/native/sqlite_jni.cc
new file mode 100644
index 0000000..f1f83ac
--- /dev/null
+++ b/src/main/native/sqlite_jni.cc
@@ -0,0 +1,230 @@
+// Copyright 2024 The Bazel Authors. All rights reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//    http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+#include <jni.h>
+
+#include <string>
+
+#include "src/main/cpp/util/logging.h"
+#include "src/main/native/latin1_jni_path.h"
+#include "sqlite3.h"
+
+namespace blaze_jni {
+
+namespace {
+
+// A RAII wrapper around a null-terminated UTF string obtained from a jstring.
+class ScopedUTFString {
+ public:
+  ScopedUTFString(JNIEnv *env, jstring jstr)
+      : env_(env),
+        jstr_(jstr),
+        str_(env->GetStringUTFChars(jstr, nullptr)),
+        len_(env->GetStringUTFLength(jstr)) {}
+
+  ~ScopedUTFString() { env_->ReleaseStringUTFChars(jstr_, str_); }
+
+  const char *c_str() const { return str_; }
+
+  int length() const { return len_; }
+
+ private:
+  JNIEnv *env_;
+  jstring jstr_;
+  const char *str_;
+  int len_;
+};
+
+// Throws an exception for the given JNI function name and SQLite error code.
+void PostException(JNIEnv *env, const std::string &fn, int err) {
+  std::string message =
+      fn + ": [" + std::to_string(err) + "] " + sqlite3_errstr(err);
+  bool success = false;
+  jclass exc_class = env->FindClass("java/io/IOException");
+  if (exc_class != nullptr) {
+    success = env->ThrowNew(exc_class, message.c_str()) == 0;
+  }
+  if (!success) {
+    BAZEL_LOG(FATAL) << "Failed to throw Java exception from JNI: "
+                     << message.c_str();
+  }
+}
+
+}  // namespace
+
+extern "C" JNIEXPORT jlong JNICALL
+Java_com_google_devtools_build_lib_remote_disk_Sqlite_openConn(
+    JNIEnv *env, jclass cls, jstring path_str) {
+  const char *path = GetStringLatin1Chars(env, path_str);
+  sqlite3 *conn = nullptr;
+  int err = sqlite3_open(path, &conn);
+  if (err != SQLITE_OK) {
+    PostException(env, "sqlite3_open", err);
+  }
+  ReleaseStringLatin1Chars(path);
+  return reinterpret_cast<jlong>(conn);
+}
+
+extern "C" JNIEXPORT void JNICALL
+Java_com_google_devtools_build_lib_remote_disk_Sqlite_closeConn(
+    JNIEnv *env, jclass cls, jlong conn_ptr) {
+  sqlite3 *conn = reinterpret_cast<sqlite3 *>(conn_ptr);
+  int err = sqlite3_close(conn);
+  if (err != SQLITE_OK) {
+    PostException(env, "sqlite3_close", err);
+  }
+}
+
+extern "C" JNIEXPORT jlong JNICALL
+Java_com_google_devtools_build_lib_remote_disk_Sqlite_prepareStmt(
+    JNIEnv *env, jclass cls, jlong conn_ptr, jstring sql_jstr) {
+  sqlite3 *conn = reinterpret_cast<sqlite3 *>(conn_ptr);
+  ScopedUTFString sql(env, sql_jstr);
+  sqlite3_stmt *stmt;
+  const char *sql_tail;
+  int err = sqlite3_prepare_v3(conn, sql.c_str(), sql.length(),
+                               SQLITE_PREPARE_PERSISTENT, &stmt, &sql_tail);
+  if (err == SQLITE_OK && *sql_tail != '\0') {
+    // Return special value to signal an unsupported multi-statement string.
+    sqlite3_finalize(stmt);
+    return -1L;
+  }
+  if (err != SQLITE_OK) {
+    PostException(env, "sqlite3_prepare_v3", err);
+  }
+  return reinterpret_cast<jlong>(stmt);
+}
+
+extern "C" JNIEXPORT void JNICALL
+Java_com_google_devtools_build_lib_remote_disk_Sqlite_bindStmtLong(
+    JNIEnv *env, jclass cls, jlong stmt_ptr, jint i, jlong val) {
+  sqlite3_stmt *stmt = reinterpret_cast<sqlite3_stmt *>(stmt_ptr);
+  int err = sqlite3_bind_int64(stmt, i, val);
+  if (err != SQLITE_OK) {
+    PostException(env, "sqlite3_bind_int64", err);
+  }
+}
+
+extern "C" JNIEXPORT void JNICALL
+Java_com_google_devtools_build_lib_remote_disk_Sqlite_bindStmtDouble(
+    JNIEnv *env, jclass cls, jlong stmt_ptr, jint i, jdouble val) {
+  sqlite3_stmt *stmt = reinterpret_cast<sqlite3_stmt *>(stmt_ptr);
+  int err = sqlite3_bind_double(stmt, i, val);
+  if (err != SQLITE_OK) {
+    PostException(env, "sqlite3_bind_double", err);
+  }
+}
+
+extern "C" JNIEXPORT void JNICALL
+Java_com_google_devtools_build_lib_remote_disk_Sqlite_bindStmtString(
+    JNIEnv *env, jclass cls, jlong stmt_ptr, jint i, jstring val_jstr) {
+  sqlite3_stmt *stmt = reinterpret_cast<sqlite3_stmt *>(stmt_ptr);
+  ScopedUTFString val(env, val_jstr);
+  int err =
+      sqlite3_bind_text(stmt, i, val.c_str(), val.length(), SQLITE_TRANSIENT);
+  if (err != SQLITE_OK) {
+    PostException(env, "sqlite3_bind_text", err);
+  }
+}
+
+extern "C" JNIEXPORT void JNICALL
+Java_com_google_devtools_build_lib_remote_disk_Sqlite_clearStmtBinding(
+    JNIEnv *env, jclass cls, jlong stmt_ptr, jint i) {
+  sqlite3_stmt *stmt = reinterpret_cast<sqlite3_stmt *>(stmt_ptr);
+  int err = sqlite3_bind_null(stmt, i);
+  if (err != SQLITE_OK) {
+    PostException(env, "sqlite3_bind_null", err);
+  }
+}
+
+extern "C" JNIEXPORT void JNICALL
+Java_com_google_devtools_build_lib_remote_disk_Sqlite_clearStmtBindings(
+    JNIEnv *env, jclass cls, jlong stmt_ptr) {
+  sqlite3_stmt *stmt = reinterpret_cast<sqlite3_stmt *>(stmt_ptr);
+  int err = sqlite3_clear_bindings(stmt);
+  if (err != SQLITE_OK) {
+    PostException(env, "sqlite3_clear_bindings", err);
+  }
+}
+
+extern "C" JNIEXPORT jboolean JNICALL
+Java_com_google_devtools_build_lib_remote_disk_Sqlite_stepStmt(JNIEnv *env,
+                                                               jclass cls,
+                                                               jlong stmt_ptr) {
+  sqlite3_stmt *stmt = reinterpret_cast<sqlite3_stmt *>(stmt_ptr);
+  int err = sqlite3_step(stmt);
+  // Deviation from the C API: SQLITE_ROW and SQLITE_DONE are returned as true
+  // or false, respectively. Everything else causes an exception to be thrown.
+  if (err == SQLITE_ROW) {
+    return true;
+  } else if (err == SQLITE_DONE) {
+    return false;
+  }
+  PostException(env, "sqlite3_step", err);
+  return false;
+}
+
+extern "C" JNIEXPORT jboolean JNICALL
+Java_com_google_devtools_build_lib_remote_disk_Sqlite_columnIsNull(
+    JNIEnv *env, jclass cls, jlong stmt_ptr, jint i) {
+  sqlite3_stmt *stmt = reinterpret_cast<sqlite3_stmt *>(stmt_ptr);
+  return sqlite3_column_type(stmt, i) == SQLITE_NULL;
+}
+
+extern "C" JNIEXPORT jlong JNICALL
+Java_com_google_devtools_build_lib_remote_disk_Sqlite_columnLong(JNIEnv *env,
+                                                                 jclass cls,
+                                                                 jlong stmt_ptr,
+                                                                 jint i) {
+  sqlite3_stmt *stmt = reinterpret_cast<sqlite3_stmt *>(stmt_ptr);
+  return sqlite3_column_int64(stmt, i);
+}
+
+extern "C" JNIEXPORT jdouble JNICALL
+Java_com_google_devtools_build_lib_remote_disk_Sqlite_columnDouble(
+    JNIEnv *env, jclass cls, jlong stmt_ptr, jint i) {
+  sqlite3_stmt *stmt = reinterpret_cast<sqlite3_stmt *>(stmt_ptr);
+  return sqlite3_column_double(stmt, i);
+}
+
+extern "C" JNIEXPORT jstring JNICALL
+Java_com_google_devtools_build_lib_remote_disk_Sqlite_columnString(
+    JNIEnv *env, jclass cls, jlong stmt_ptr, jint i) {
+  sqlite3_stmt *stmt = reinterpret_cast<sqlite3_stmt *>(stmt_ptr);
+  const char *val =
+      reinterpret_cast<const char *>(sqlite3_column_text(stmt, i));
+  return val != nullptr ? env->NewStringUTF(val) : env->NewStringUTF("");
+}
+
+extern "C" JNIEXPORT void JNICALL
+Java_com_google_devtools_build_lib_remote_disk_Sqlite_resetStmt(
+    JNIEnv *env, jclass cls, jlong stmt_ptr) {
+  sqlite3_stmt *stmt = reinterpret_cast<sqlite3_stmt *>(stmt_ptr);
+  int err = sqlite3_reset(stmt);
+  if (err != SQLITE_OK) {
+    PostException(env, "sqlite3_reset", err);
+  }
+}
+
+extern "C" JNIEXPORT void JNICALL
+Java_com_google_devtools_build_lib_remote_disk_Sqlite_finalizeStmt(
+    JNIEnv *env, jclass cls, jlong stmt_ptr) {
+  sqlite3_stmt *stmt = reinterpret_cast<sqlite3_stmt *>(stmt_ptr);
+  int err = sqlite3_finalize(stmt);
+  if (err != SQLITE_OK) {
+    PostException(env, "sqlite3_finalize", err);
+  }
+}
+
+}  // namespace blaze_jni
diff --git a/src/main/native/unix_jni.cc b/src/main/native/unix_jni.cc
index 893507d..c05d54d 100644
--- a/src/main/native/unix_jni.cc
+++ b/src/main/native/unix_jni.cc
@@ -65,7 +65,8 @@
     success = env->ThrowNew(exception_class, message.c_str()) == 0;
   }
   if (!success) {
-    BAZEL_LOG(FATAL) << "Failure to throw java error: " << message.c_str();
+    BAZEL_LOG(FATAL) << "Failed to throw Java exception from JNI: "
+                     << message.c_str();
   }
 }
 
diff --git a/src/main/native/windows/BUILD b/src/main/native/windows/BUILD
index b595e75..c7e3de4 100644
--- a/src/main/native/windows/BUILD
+++ b/src/main/native/windows/BUILD
@@ -77,6 +77,7 @@
         ":lib-file",
         ":lib-process",
         "//src/main/native:blake3_jni",
+        "//src/main/native:sqlite_jni",
     ],
 )
 
diff --git a/src/test/java/com/google/devtools/build/lib/remote/disk/BUILD b/src/test/java/com/google/devtools/build/lib/remote/disk/BUILD
index e088c67..e437a4e 100644
--- a/src/test/java/com/google/devtools/build/lib/remote/disk/BUILD
+++ b/src/test/java/com/google/devtools/build/lib/remote/disk/BUILD
@@ -29,6 +29,7 @@
         "//src/main/java/com/google/devtools/build/lib/vfs/bazel",
         "//src/main/java/com/google/devtools/build/lib/vfs/inmemoryfs",
         "//src/test/java/com/google/devtools/build/lib:test_runner",
+        "//src/test/java/com/google/devtools/build/lib/testutil:TestUtils",
         "//third_party:error_prone_annotations",
         "//third_party:guava",
         "//third_party:junit4",
diff --git a/src/test/java/com/google/devtools/build/lib/remote/disk/SqliteTest.java b/src/test/java/com/google/devtools/build/lib/remote/disk/SqliteTest.java
new file mode 100644
index 0000000..8b6358c
--- /dev/null
+++ b/src/test/java/com/google/devtools/build/lib/remote/disk/SqliteTest.java
@@ -0,0 +1,276 @@
+// Copyright 2024 The Bazel Authors. All rights reserved.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//    http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+package com.google.devtools.build.lib.remote.disk;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertThrows;
+
+import com.google.devtools.build.lib.remote.disk.Sqlite.Connection;
+import com.google.devtools.build.lib.remote.disk.Sqlite.Result;
+import com.google.devtools.build.lib.remote.disk.Sqlite.Statement;
+import com.google.devtools.build.lib.testutil.TestUtils;
+import com.google.devtools.build.lib.vfs.Path;
+import java.io.IOException;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+@RunWith(JUnit4.class)
+public final class SqliteTest {
+
+  private Path dbPath;
+
+  @Before
+  public void setUp() throws Exception {
+    dbPath = TestUtils.createUniqueTmpDir(null).getChild("tmp.db");
+  }
+
+  @After
+  public void tearDown() throws Exception {
+    try {
+      dbPath.delete();
+    } catch (IOException e) {
+      // Intentionally ignored.
+    }
+  }
+
+  @Test
+  public void executeQuery_withEmptyResult_nextReturnsFalse() throws Exception {
+    try (Connection conn = Sqlite.newConnection(dbPath);
+        Statement stmt = conn.newStatement("SELECT 1 LIMIT 0");
+        Result result = stmt.executeQuery()) {
+      assertThat(result.next()).isFalse();
+    }
+  }
+
+  @Test
+  public void executeQuery_withNonEmptyResult_nextReturnsTrue() throws Exception {
+    try (Connection conn = Sqlite.newConnection(dbPath);
+        Statement stmt = conn.newStatement("SELECT 1 UNION ALL SELECT 2 UNION ALL SELECT 3");
+        Result result = stmt.executeQuery()) {
+      assertThat(result.next()).isTrue();
+      assertThat(result.next()).isTrue();
+      assertThat(result.next()).isTrue();
+      assertThat(result.next()).isFalse();
+    }
+  }
+
+  @Test
+  public void executeQuery_getterCalledBeforeNextReturnsTrue_throws() throws Exception {
+    try (Connection conn = Sqlite.newConnection(dbPath);
+        Statement stmt = conn.newStatement("SELECT 1");
+        Result result = stmt.executeQuery()) {
+      assertThrows(IllegalStateException.class, () -> result.isNull(0));
+      assertThrows(IllegalStateException.class, () -> result.getLong(0));
+      assertThrows(IllegalStateException.class, () -> result.getDouble(0));
+      assertThrows(IllegalStateException.class, () -> result.getString(0));
+    }
+  }
+
+  @Test
+  public void executeQuery_getterCalledAfterNextReturnsFalse_throws() throws Exception {
+    try (Connection conn = Sqlite.newConnection(dbPath);
+        Statement stmt = conn.newStatement("SELECT 1 LIMIT 0");
+        Result result = stmt.executeQuery()) {
+      assertThat(result.next()).isFalse();
+      assertThrows(IllegalStateException.class, () -> result.isNull(0));
+      assertThrows(IllegalStateException.class, () -> result.getLong(0));
+      assertThrows(IllegalStateException.class, () -> result.getDouble(0));
+      assertThrows(IllegalStateException.class, () -> result.getString(0));
+    }
+  }
+
+  @Test
+  public void executeQuery_prematureClose_works() throws Exception {
+    try (Connection conn = Sqlite.newConnection(dbPath);
+        Statement stmt = conn.newStatement("SELECT 1");
+        Result result = stmt.executeQuery()) {}
+  }
+
+  @Test
+  public void executeQuery_gettersConvertNull() throws Exception {
+    try (Connection conn = Sqlite.newConnection(dbPath);
+        Statement stmt = conn.newStatement("SELECT NULL, NULL, NULL");
+        Result result = stmt.executeQuery()) {
+      assertThat(result.next()).isTrue();
+      assertThat(result.isNull(0)).isTrue();
+      assertThat(result.getLong(0)).isEqualTo(0);
+      assertThat(result.isNull(1)).isTrue();
+      assertThat(result.getDouble(1)).isEqualTo(0.0);
+      assertThat(result.isNull(2)).isTrue();
+      assertThat(result.getString(2)).isEmpty();
+    }
+  }
+
+  @Test
+  public void executeQuery_onError_throws() throws Exception {
+    try (Connection conn = Sqlite.newConnection(dbPath);
+        Statement stmt = conn.newStatement("CREATE TABLE tbl (id INTEGER)")) {
+      try (Result result = stmt.executeQuery()) {
+        assertThat(result.next()).isFalse();
+      }
+      try (Result result = stmt.executeQuery()) {
+        IOException e = assertThrows(IOException.class, result::next);
+        assertThat(e).hasMessageThat().contains("SQL logic error");
+      }
+    }
+  }
+
+  @Test
+  public void executeUpdate_works() throws Exception {
+    try (Connection conn = Sqlite.newConnection(dbPath)) {
+      try (Statement stmt = conn.newStatement("CREATE TABLE tbl (id INTEGER)")) {
+        stmt.executeUpdate();
+      }
+
+      try (Statement stmt = conn.newStatement("SELECT COUNT(*) FROM tbl");
+          Result result = stmt.executeQuery()) {
+        assertThat(result.next()).isTrue();
+      }
+    }
+  }
+
+  @Test
+  public void executeUpdate_withParameters_works() throws Exception {
+    try (Connection conn = Sqlite.newConnection(dbPath)) {
+      try (Statement stmt = conn.newStatement("CREATE TABLE tbl AS SELECT ?, ?, ?")) {
+        stmt.bindLong(1, 42);
+        stmt.bindDouble(2, 3.14);
+        stmt.bindString(3, "hello");
+        stmt.executeUpdate();
+      }
+
+      try (Statement stmt = conn.newStatement("SELECT * FROM tbl");
+          Result result = stmt.executeQuery()) {
+        assertThat(result.next()).isTrue();
+        assertThat(result.isNull(0)).isFalse();
+        assertThat(result.getLong(0)).isEqualTo(42);
+        assertThat(result.isNull(1)).isFalse();
+        assertThat(result.getDouble(1)).isEqualTo(3.14);
+        assertThat(result.isNull(2)).isFalse();
+        assertThat(result.getString(2)).isEqualTo("hello");
+        assertThat(result.next()).isFalse();
+      }
+    }
+  }
+
+  @Test
+  public void executeUpdate_withNonEmptyResult_throws() throws Exception {
+    try (Connection conn = Sqlite.newConnection(dbPath);
+        Statement stmt = conn.newStatement("SELECT NULL")) {
+      IOException e = assertThrows(IOException.class, stmt::executeUpdate);
+      assertThat(e).hasMessageThat().contains("unexpected non-empty result");
+    }
+  }
+
+  @Test
+  public void executeUpdate_onError_throws() throws Exception {
+    try (Connection conn = Sqlite.newConnection(dbPath);
+        Statement stmt = conn.newStatement("CREATE TABLE tbl (id INTEGER)")) {
+      stmt.executeUpdate();
+      IOException e = assertThrows(IOException.class, stmt::executeUpdate);
+      assertThat(e).hasMessageThat().contains("SQL logic error");
+    }
+  }
+
+  @Test
+  public void clearBinding_works() throws Exception {
+    try (Connection conn = Sqlite.newConnection(dbPath);
+        Statement stmt = conn.newStatement("SELECT ?, ?")) {
+      stmt.bindString(1, "abc");
+      stmt.bindString(2, "def");
+
+      stmt.clearBinding(1);
+      try (Result result = stmt.executeQuery()) {
+        assertThat(result.next()).isTrue();
+        assertThat(result.isNull(0)).isTrue();
+        assertThat(result.isNull(1)).isFalse();
+        assertThat(result.next()).isFalse();
+      }
+
+      stmt.clearBinding(2);
+      try (Result result = stmt.executeQuery()) {
+        assertThat(result.next()).isTrue();
+        assertThat(result.isNull(0)).isTrue();
+        assertThat(result.isNull(1)).isTrue();
+        assertThat(result.next()).isFalse();
+      }
+    }
+  }
+
+  @Test
+  public void clearBindings_works() throws Exception {
+    try (Connection conn = Sqlite.newConnection(dbPath);
+        Statement stmt = conn.newStatement("SELECT ?, ?")) {
+      stmt.bindString(1, "abc");
+      stmt.bindString(2, "def");
+      stmt.clearBindings();
+
+      try (Result result = stmt.executeQuery()) {
+        assertThat(result.next()).isTrue();
+        assertThat(result.isNull(0)).isTrue();
+        assertThat(result.isNull(1)).isTrue();
+        assertThat(result.next()).isFalse();
+      }
+    }
+  }
+
+  @Test
+  public void newStatement_withInvalidStatement_throws() throws Exception {
+    try (Connection conn = Sqlite.newConnection(dbPath)) {
+      IOException e = assertThrows(IOException.class, () -> conn.newStatement("i am not sql"));
+      assertThat(e).hasMessageThat().contains("[1] SQL logic error");
+    }
+  }
+
+  @Test
+  public void newStatement_withTrailingSemicolon_works() throws Exception {
+    try (Connection conn = Sqlite.newConnection(dbPath);
+        Statement stmt = conn.newStatement("SELECT 1;")) {}
+  }
+
+  @Test
+  public void newStatement_withMultipleStatements_throws() throws Exception {
+    try (Connection conn = Sqlite.newConnection(dbPath)) {
+      IOException e =
+          assertThrows(IOException.class, () -> conn.newStatement("SELECT 1; SELECT 2"));
+      assertThat(e).hasMessageThat().contains("unsupported multi-statement string");
+    }
+  }
+
+  @Test
+  public void closeStatement_withOpenResult_works() throws Exception {
+    try (Connection conn = Sqlite.newConnection(dbPath);
+        Statement stmt = conn.newStatement("SELECT 1")) {
+      Result unusedResult = stmt.executeQuery();
+    }
+  }
+
+  @Test
+  public void closeConnection_withOpenStatement_works() throws Exception {
+    try (Connection conn = Sqlite.newConnection(dbPath)) {
+      Statement unusedStmt = conn.newStatement("SELECT 1");
+    }
+  }
+
+  @Test
+  public void closeConnection_withOpenStatementAndResult_works() throws Exception {
+    try (Connection conn = Sqlite.newConnection(dbPath)) {
+      Statement stmt = conn.newStatement("SELECT 1");
+      Result unusedResult = stmt.executeQuery();
+    }
+  }
+}