Repository: xerial/sqlite-jdbc
Pull request #835 (2023-02-01 23:04:22)

native-image - use Feature implementation instead of config files
This PR replaces the `resource-config.json` and `jni-config.json` files by a `org.graalvm.nativeimage.hosted.Feature` implementation.
* The registered JNI configuration is unchanged, but is now configured using the native-image java API. This is easier to maintain than the json files.
* The resource config is different, as previously all library resources were included. Now only the library relevant for the target runtime of the native-image compilation is included as a resource. To do this, the existing logic in `SQLiteJDBCLoader` was reused. Additionally the metadata resources containing version info and properties are read at build-time in static code blocks which avoid the need to include those resources and avoid the need to do the IO operations at runtime.

Finally, I added a new `org.sqlite.lib.exportPath` which takes in a directory that is used by the `SqliteJdbcFeature` to export the JNI library to at build-time. If this property is set, the library will no longer be included as a resource and it becomes the responsibility of the end user to provide the exported library next to the native-image executable.
The benefit of doing this is that the library no longer needs to be unpacked at runtime and again some IO operations can be eliminated.

pyckle: Nice work (: This should reduce native image size by only including the relevant JNI lib.
kkriske: > This should reduce native image size by only including the relevant JNI lib.

That's the intention indeed. I would like to go one step further even, and allow to choose between having the resource unpacked at build-time or at runtime, probably in a property that can be set at build time. Because having it stored internally is bad for startup time, and it is entirely possible to unpack it at build-time and store it next to the executable. Then no library would need to be stored in the executable itself, and it could be loaded directly from next to the executable.
pyckle: > you could argue that startup time isn't really impacted that much

This sort of bloat adds up as software use more and more libraries. I think that adding this as an option is useful and wise.
gotson: @kkriske i see you moved the PR from draft to ready, but it doesn't have any description at all.

Would you be able to provide some general description of what this changes aim to address, and how?
gotson: @kkriske looks like there's a conflict to resolve
kkriske: > @kkriske is it possible to somehow statically link the exported library into resulting executable?

No. It's a dynamic library. Static linking would require building static libraries.
It would be possible to statically link this hypothetical static library but that would rely on native-image internal api's which I would not recommend using as a library. That would force the end user to add certain `--add-exports` arguments to the native-image command in order to allow the build to work and obviously there's no stability guarantee of that api.
kkriske: @gotson FYI this should be ready for consideration/review.
gotson: > @gotson FYI this should be ready for consideration/review.

i will have a look once i have some time. Can you fix the conflicts in the meantime?
kkriske: There didn't seem to be any conflicts, but I have updated the feature branch.
gotson: > There didn't seem to be any conflicts, but I have updated the feature branch.

Yes my bad, the github screen was showing conflicts for a rebase, but not for a squash merge. I don't know why. Thanks. 

Patch:
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 7a93859e..455126f0 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -65,11 +65,17 @@ jobs:
         run: mvn --batch-mode --no-transfer-progress test
 
   test_graalvm:
-    name: test ${{ matrix.os }} jdk${{ matrix.java }} GraalVM native-image
+    name: test ${{ matrix.os }} jdk${{ matrix.java }} GraalVM native-image - ${{ matrix.profiles }}
     strategy:
       matrix:
         os: [ ubuntu-latest, windows-latest, macos-latest ]
         java: [ 11, 17 ]
+        profiles: ['native', 'native,native-exported']
+        exclude: # Exclusions can be removed from GraalVM 23.0: https://github.com/oracle/graal/pull/5932
+          - os: ubuntu-latest
+            profiles: 'native,native-exported'
+          - os: macos-latest
+            profiles: 'native,native-exported'
     runs-on: ${{ matrix.os }}
     steps:
       - uses: actions/checkout@v3
@@ -80,7 +86,7 @@ jobs:
           components: 'native-image'
           github-token: ${{ secrets.GITHUB_TOKEN }}
       - name: Test
-        run: mvn --batch-mode --no-transfer-progress -P native test
+        run: mvn --batch-mode --no-transfer-progress -P ${{ matrix.profiles }} integration-test
 
   test_multiarch:
     name: test ${{ matrix.arch }} ${{ matrix.distro }} jdk${{ matrix.java }}
diff --git a/README.md b/README.md
index a9d0c53d..a6aabf31 100644
--- a/README.md
+++ b/README.md
@@ -91,7 +91,7 @@ id = 2
 
 # How does SQLiteJDBC work?
 Our SQLite JDBC driver package (i.e., `sqlite-jdbc-(VERSION).jar`) contains three
-types of native SQLite libraries (`sqlite-jdbc.dll`, `sqlite-jdbc.jnilib`, `sqlite-jdbc.so`),
+types of native SQLite libraries (`sqlitejdbc.dll`, `sqlitejdbc.jnilib`, `sqlitejdbc.so`),
 each of them is compiled for Windows, macOS and Linux. An appropriate native library
 file is automatically extracted into your OS's temporary folder, when your program
 loads `org.sqlite.JDBC` driver.
@@ -114,6 +114,41 @@ In the other OSs not listed above, the pure-java SQLite is used. (Applies to ver
 
 If you want to use the native library for your OS, [build the source from scratch](./CONTRIBUTING.md).
 
+## GraalVM native-image support
+
+Sqlite JDBC supports [GraalVM native-image](https://www.graalvm.org/native-image/) out of the box starting from version 3.40.1.0.
+There has been rudimentary support for some versions before that, but this was not actively tested by the CI.
+
+By default, the `sqlitejdbc` library for the compilation target will be included in the native image, accompanied by the required JNI configuration.
+At runtime, this library will be extracted to the temp folder and loaded from there.
+For faster startup however, it is recommended to set the `org.sqlite.lib.exportPath` property at build-time.
+This will export the `sqlitejdbc` library at build-time to the specified directory, and the library will not be included as a resource.
+As a result, the native image itself will be slightly smaller and the overhead of exporting the library at run-time is eliminated,
+but you need to make sure the library can be found at run-time.
+The best way to do this is to simply place the library next to the executable.
+
+### CLI example
+```shell
+native-image -Dorg.sqlite.lib.exportPath=~/outDir -H:Path=~/outDir -cp foo.jar org.example.Main
+```
+This will place both the `sqlitejdbc` shared library and the native-image output in the `~/outDir` folder.
+
+### Maven example
+This example uses the [native-build-tools](https://graalvm.github.io/native-build-tools/latest/index.html) maven plugin:
+```xml
+<plugin>
+    <groupId>org.graalvm.buildtools</groupId>
+    <artifactId>native-maven-plugin</artifactId>
+    <configuration>
+        <buildArgs>
+            <buildArg>-Dorg.sqlite.lib.exportPath=${project.build.directory}</buildArg>
+        </buildArgs>
+    </configuration>
+</plugin>
+```
+This will automatically place the `sqlitejdbc` library in the `/target` folder of your project, creating a functional execution environment.
+When packaging the resulting app, simply include the library in the distribution bundle.
+
 # Download
 
 Download from [Maven Central](https://search.maven.org/artifact/org.xerial/sqlite-jdbc) or from the [releases](https://github.com/xerial/sqlite-jdbc/releases) page.
diff --git a/SECURITY.md b/SECURITY.md
new file mode 100644
index 00000000..398a729f
--- /dev/null
+++ b/SECURITY.md
@@ -0,0 +1,12 @@
+# Security Policy
+
+## Supported Versions
+
+| Version | Supported          |
+| ------- | ------------------ |
+| 3.41.x  | :white_check_mark: |
+| < 3.40.0| :x:                |
+
+## Reporting a Vulnerability
+
+See https://docs.github.com/en/code-security/security-advisories/guidance-on-reporting-and-writing/privately-reporting-a-security-vulnerability#privately-reporting-a-security-vulnerability
diff --git a/pom.xml b/pom.xml
index 0096fd96..42ee80e2 100644
--- a/pom.xml
+++ b/pom.xml
@@ -11,8 +11,9 @@
 
     <properties>
         <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
-        <junit.version>5.9.2</junit.version>
+        <junit.version>5.9.3</junit.version>
         <surefire.version>3.0.0</surefire.version>
+        <graalvm.version>22.3.0</graalvm.version>
         <java9.sourceDirectory>${project.basedir}/src/main/java9</java9.sourceDirectory>
     </properties>
 
@@ -190,7 +191,7 @@
             <plugin>
                 <groupId>org.jreleaser</groupId>
                 <artifactId>jreleaser-maven-plugin</artifactId>
-                <version>1.5.1</version>
+                <version>1.6.0</version>
                 <configuration>
                     <configFile>jreleaser.yml</configFile>
                 </configuration>
@@ -340,11 +341,11 @@
                         <extensions>true</extensions>
                         <executions>
                             <execution>
-                                <id>test-native</id>
+                                <id>test-native-default</id>
                                 <goals>
                                     <goal>test</goal>
                                 </goals>
-                                <phase>test</phase>
+                                <phase>integration-test</phase>
                             </execution>
                         </executions>
                         <configuration>
@@ -378,9 +379,38 @@
                 </plugins>
             </build>
         </profile>
+        <profile>
+            <id>native-exported</id>
+            <build>
+                <plugins>
+                    <plugin>
+                        <groupId>org.graalvm.buildtools</groupId>
+                        <artifactId>native-maven-plugin</artifactId>
+                        <configuration>
+                            <buildArgs combine.children="append">
+                                <buildArg>
+                                    -Dorg.sqlite.lib.exportPath=${project.build.directory}
+                                </buildArg>
+                            </buildArgs>
+                        </configuration>
+                    </plugin>
+                </plugins>
+            </build>
+        </profile>
     </profiles>
 
     <dependencies>
+        <!--
+        This dependency makes compilation on non-GraalVM versions possible.
+        The dependency should however never actually be required by end-users because if they require
+        GraalVM features, they will be provided by the JDK they are using.
+        -->
+        <dependency>
+            <groupId>org.graalvm.sdk</groupId>
+            <artifactId>graal-sdk</artifactId>
+            <version>${graalvm.version}</version>
+            <scope>provided</scope>
+        </dependency>
         <dependency>
             <groupId>org.junit.jupiter</groupId>
             <artifactId>junit-jupiter</artifactId>
@@ -402,7 +432,7 @@
         <dependency>
             <groupId>org.mockito</groupId>
             <artifactId>mockito-core</artifactId>
-            <version>5.3.0</version>
+            <version>5.3.1</version>
             <scope>test</scope>
         </dependency>
         <dependency>
@@ -415,7 +445,7 @@
         <dependency>
             <groupId>ch.qos.logback</groupId>
             <artifactId>logback-classic</artifactId>
-            <version>1.4.6</version>
+            <version>1.4.7</version>
             <scope>test</scope>
         </dependency>
     </dependencies>
diff --git a/src/main/java/org/sqlite/SQLiteJDBCLoader.java b/src/main/java/org/sqlite/SQLiteJDBCLoader.java
index 99980d17..7055ed34 100644
--- a/src/main/java/org/sqlite/SQLiteJDBCLoader.java
+++ b/src/main/java/org/sqlite/SQLiteJDBCLoader.java
@@ -27,7 +27,10 @@ package org.sqlite;
 import java.io.*;
 import java.net.URL;
 import java.net.URLConnection;
-import java.nio.file.*;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.nio.file.StandardCopyOption;
 import java.security.DigestInputStream;
 import java.security.MessageDigest;
 import java.security.NoSuchAlgorithmException;
@@ -36,6 +39,7 @@ import java.util.List;
 import java.util.Properties;
 import java.util.UUID;
 import java.util.stream.Stream;
+import org.sqlite.util.LibraryLoaderUtil;
 import org.sqlite.util.OSInfo;
 import org.sqlite.util.StringUtils;
 
@@ -310,10 +314,7 @@ public class SQLiteJDBCLoader {
         String sqliteNativeLibraryPath = System.getProperty("org.sqlite.lib.path");
         String sqliteNativeLibraryName = System.getProperty("org.sqlite.lib.name");
         if (sqliteNativeLibraryName == null) {
-            sqliteNativeLibraryName = System.mapLibraryName("sqlitejdbc");
-            if (sqliteNativeLibraryName != null && sqliteNativeLibraryName.endsWith(".dylib")) {
-                sqliteNativeLibraryName = sqliteNativeLibraryName.replace(".dylib", ".jnilib");
-            }
+            sqliteNativeLibraryName = LibraryLoaderUtil.getNativeLibName();
         }
 
         if (sqliteNativeLibraryPath != null) {
@@ -326,22 +327,9 @@ public class SQLiteJDBCLoader {
         }
 
         // Load the os-dependent library from the jar file
-        String packagePath = SQLiteJDBCLoader.class.getPackage().getName().replaceAll("\\.", "/");
-        sqliteNativeLibraryPath =
-                String.format(
-                        "/%s/native/%s", packagePath, OSInfo.getNativeLibFolderPathForCurrentOS());
-        boolean hasNativeLib = hasResource(sqliteNativeLibraryPath + "/" + sqliteNativeLibraryName);
-
-        if (!hasNativeLib) {
-            if (OSInfo.getOSName().equals("Mac")) {
-                // Fix for openjdk7 for Mac
-                String altName = "libsqlitejdbc.jnilib";
-                if (hasResource(sqliteNativeLibraryPath + "/" + altName)) {
-                    sqliteNativeLibraryName = altName;
-                    hasNativeLib = true;
-                }
-            }
-        }
+        sqliteNativeLibraryPath = LibraryLoaderUtil.getNativeLibResourcePath();
+        boolean hasNativeLib =
+                LibraryLoaderUtil.hasNativeLib(sqliteNativeLibraryPath, sqliteNativeLibraryName);
 
         if (hasNativeLib) {
             // temporary library folder
@@ -379,10 +367,6 @@ public class SQLiteJDBCLoader {
                         StringUtils.join(triedPaths, File.pathSeparator)));
     }
 
-    private static boolean hasResource(String path) {
-        return SQLiteJDBCLoader.class.getResource(path) != null;
-    }
-
     @SuppressWarnings("unused")
     private static void getNativeLibraryFolderForTheCurrentOS() {
         String osName = OSInfo.getOSName();
@@ -403,27 +387,39 @@ public class SQLiteJDBCLoader {
 
     /** @return The version of the SQLite JDBC driver. */
     public static String getVersion() {
+        return VersionHolder.VERSION;
+    }
 
-        URL versionFile =
-                SQLiteJDBCLoader.class.getResource(
-                        "/META-INF/maven/org.xerial/sqlite-jdbc/pom.properties");
-        if (versionFile == null) {
-            versionFile =
-                    SQLiteJDBCLoader.class.getResource(
-                            "/META-INF/maven/org.xerial/sqlite-jdbc/VERSION");
-        }
+    /**
+     * This class will load the version from resources during <clinit>. By initializing this at
+     * build-time in native-image, the resources do not need to be included in the native
+     * executable, and we're eliminating the IO operations as well.
+     */
+    public static final class VersionHolder {
+        private static final String VERSION;
+
+        static {
+            URL versionFile =
+                    VersionHolder.class.getResource(
+                            "/META-INF/maven/org.xerial/sqlite-jdbc/pom.properties");
+            if (versionFile == null) {
+                versionFile =
+                        VersionHolder.class.getResource(
+                                "/META-INF/maven/org.xerial/sqlite-jdbc/VERSION");
+            }
 
-        String version = "unknown";
-        try {
-            if (versionFile != null) {
-                Properties versionData = new Properties();
-                versionData.load(versionFile.openStream());
-                version = versionData.getProperty("version", version);
-                version = version.trim().replaceAll("[^0-9\\.]", "");
+            String version = "unknown";
+            try {
+                if (versionFile != null) {
+                    Properties versionData = new Properties();
+                    versionData.load(versionFile.openStream());
+                    version = versionData.getProperty("version", version);
+                    version = version.trim().replaceAll("[^0-9\\.]", "");
+                }
+            } catch (IOException e) {
+                e.printStackTrace();
             }
-        } catch (IOException e) {
-            e.printStackTrace();
+            VERSION = version;
         }
-        return version;
     }
 }
diff --git a/src/main/java/org/sqlite/core/CoreStatement.java b/src/main/java/org/sqlite/core/CoreStatement.java
index 55b97c4e..c37f2c0f 100644
--- a/src/main/java/org/sqlite/core/CoreStatement.java
+++ b/src/main/java/org/sqlite/core/CoreStatement.java
@@ -140,4 +140,10 @@ public abstract class CoreStatement implements Codes {
     }
 
     public abstract ResultSet executeQuery(String sql, boolean closeStmt) throws SQLException;
+
+    protected void checkIndex(int index) throws SQLException {
+        if (index < 1 || index > batch.length) {
+            throw new SQLException("Parameter index is invalid");
+        }
+    }
 }
diff --git a/src/main/java/org/sqlite/jdbc3/JDBC3PreparedStatement.java b/src/main/java/org/sqlite/jdbc3/JDBC3PreparedStatement.java
index 6feb1e0f..62488b0b 100644
--- a/src/main/java/org/sqlite/jdbc3/JDBC3PreparedStatement.java
+++ b/src/main/java/org/sqlite/jdbc3/JDBC3PreparedStatement.java
@@ -10,6 +10,7 @@ import java.sql.Array;
 import java.sql.Blob;
 import java.sql.Clob;
 import java.sql.Date;
+import java.sql.JDBCType;
 import java.sql.ParameterMetaData;
 import java.sql.Ref;
 import java.sql.ResultSet;
@@ -154,13 +155,29 @@ public abstract class JDBC3PreparedStatement extends CorePreparedStatement {
     }
 
     /** @see java.sql.ParameterMetaData#getParameterTypeName(int) */
-    public String getParameterTypeName(int pos) {
-        return "VARCHAR";
+    public String getParameterTypeName(int pos) throws SQLException {
+        checkIndex(pos);
+        return JDBCType.valueOf(getParameterType(pos)).getName();
     }
 
     /** @see java.sql.ParameterMetaData#getParameterType(int) */
-    public int getParameterType(int pos) {
-        return Types.VARCHAR;
+    public int getParameterType(int pos) throws SQLException {
+        checkIndex(pos);
+        Object paramValue = batch[pos - 1];
+
+        if (paramValue == null) {
+            return Types.NULL;
+        } else if (paramValue instanceof Integer
+                || paramValue instanceof Short
+                || paramValue instanceof Boolean) {
+            return Types.INTEGER;
+        } else if (paramValue instanceof Long) {
+            return Types.BIGINT;
+        } else if (paramValue instanceof Double || paramValue instanceof Float) {
+            return Types.REAL;
+        } else {
+            return Types.VARCHAR;
+        }
     }
 
     /** @see java.sql.ParameterMetaData#getParameterMode(int) */
diff --git a/src/main/java/org/sqlite/jdbc4/JDBC4ResultSet.java b/src/main/java/org/sqlite/jdbc4/JDBC4ResultSet.java
index c28a94b8..28691731 100644
--- a/src/main/java/org/sqlite/jdbc4/JDBC4ResultSet.java
+++ b/src/main/java/org/sqlite/jdbc4/JDBC4ResultSet.java
@@ -23,6 +23,9 @@ import java.sql.SQLFeatureNotSupportedException;
 import java.sql.SQLXML;
 import java.sql.Time;
 import java.sql.Timestamp;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.LocalTime;
 import java.util.Map;
 import org.sqlite.core.CoreStatement;
 import org.sqlite.jdbc3.JDBC3ResultSet;
@@ -319,6 +322,27 @@ public class JDBC4ResultSet extends JDBC3ResultSet implements ResultSet, ResultS
         if (type == Date.class) return type.cast(getDate(columnIndex));
         if (type == Time.class) return type.cast(getTime(columnIndex));
         if (type == Timestamp.class) return type.cast(getTimestamp(columnIndex));
+        if (type == LocalDate.class) {
+            try {
+                return type.cast(getDate(columnIndex).toLocalDate());
+            } catch (SQLException sqlException) {
+                // If the FastDateParser failed, try parse it with LocalDate.
+                // It's a workaround for a value like '2022-12-1' (i.e no time presents).
+                return type.cast(LocalDate.parse(getString(columnIndex)));
+            }
+        }
+        if (type == LocalTime.class) {
+            try {
+                return type.cast(getTime(columnIndex).toLocalTime());
+            } catch (SQLException sqlException) {
+                // If the FastDateParser failed, try parse it with LocalTime.
+                // It's a workaround for a value like '11:22:22' (i.e no date presents).
+                return type.cast(LocalTime.parse(getString(columnIndex)));
+            }
+        }
+        if (type == LocalDateTime.class) {
+            return type.cast(getTimestamp(columnIndex).toLocalDateTime());
+        }
 
         int columnType = safeGetColumnType(markCol(columnIndex));
         if (type == Double.class) {
diff --git a/src/main/java/org/sqlite/util/LibraryLoaderUtil.java b/src/main/java/org/sqlite/util/LibraryLoaderUtil.java
new file mode 100644
index 00000000..aad84573
--- /dev/null
+++ b/src/main/java/org/sqlite/util/LibraryLoaderUtil.java
@@ -0,0 +1,28 @@
+package org.sqlite.util;
+
+import org.sqlite.SQLiteJDBCLoader;
+
+public class LibraryLoaderUtil {
+    /**
+     * Get the OS-specific resource directory within the jar, where the relevant sqlitejdbc native
+     * library is located.
+     */
+    public static String getNativeLibResourcePath() {
+        String packagePath = SQLiteJDBCLoader.class.getPackage().getName().replace(".", "/");
+        return String.format(
+                "/%s/native/%s", packagePath, OSInfo.getNativeLibFolderPathForCurrentOS());
+    }
+
+    /** Get the OS-specific name of the sqlitejdbc native library. */
+    public static String getNativeLibName() {
+        String nativeLibName = System.mapLibraryName("sqlitejdbc");
+        if (nativeLibName != null && nativeLibName.endsWith(".dylib")) {
+            nativeLibName = nativeLibName.replace(".dylib", ".jnilib");
+        }
+        return nativeLibName;
+    }
+
+    public static boolean hasNativeLib(String path, String libraryName) {
+        return SQLiteJDBCLoader.class.getResource(path + "/" + libraryName) != null;
+    }
+}
diff --git a/src/main/java9/module-info.java b/src/main/java9/module-info.java
index f54d5923..158af5dd 100644
--- a/src/main/java9/module-info.java
+++ b/src/main/java9/module-info.java
@@ -2,6 +2,7 @@ module org.xerial.sqlitejdbc {
 
     requires transitive java.sql;
     requires transitive java.sql.rowset;
+    requires static org.graalvm.sdk;
 
     exports org.sqlite;
     exports org.sqlite.core;
diff --git a/src/main/java9/org/sqlite/nativeimage/SqliteJdbcFeature.java b/src/main/java9/org/sqlite/nativeimage/SqliteJdbcFeature.java
new file mode 100644
index 00000000..d346c7ed
--- /dev/null
+++ b/src/main/java9/org/sqlite/nativeimage/SqliteJdbcFeature.java
@@ -0,0 +1,157 @@
+package org.sqlite.nativeimage;
+
+import org.graalvm.nativeimage.hosted.Feature;
+import org.graalvm.nativeimage.hosted.RuntimeClassInitialization;
+import org.graalvm.nativeimage.hosted.RuntimeJNIAccess;
+import org.graalvm.nativeimage.hosted.RuntimeResourceAccess;
+import org.sqlite.*;
+import org.sqlite.core.DB;
+import org.sqlite.core.NativeDB;
+import org.sqlite.jdbc3.JDBC3DatabaseMetaData;
+import org.sqlite.util.LibraryLoaderUtil;
+import org.sqlite.util.OSInfo;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.nio.file.StandardCopyOption;
+
+public class SqliteJdbcFeature implements Feature {
+
+    @Override
+    public void beforeAnalysis(BeforeAnalysisAccess a) {
+        RuntimeClassInitialization.initializeAtBuildTime(SQLiteJDBCLoader.VersionHolder.class);
+        RuntimeClassInitialization.initializeAtBuildTime(JDBC3DatabaseMetaData.class);
+        RuntimeClassInitialization.initializeAtBuildTime(OSInfo.class);
+        RuntimeClassInitialization.initializeAtBuildTime(LibraryLoaderUtil.class);
+        a.registerReachabilityHandler(
+                this::nativeDbReachable, method(SQLiteJDBCLoader.class, "initialize"));
+    }
+
+    private void nativeDbReachable(DuringAnalysisAccess a) {
+        handleLibraryResources();
+        registerJNICalls();
+    }
+
+    private void handleLibraryResources() {
+        String libraryPath = LibraryLoaderUtil.getNativeLibResourcePath();
+        String libraryName = LibraryLoaderUtil.getNativeLibName();
+        // Sanity check
+        if (!LibraryLoaderUtil.hasNativeLib(libraryPath, libraryName)) {
+            throw new SqliteJdbcFeatureException(
+                    "Unable to locate the required native resources for native-image. Please contact the maintainers of sqlite-jdbc.");
+        }
+
+        // libraryResource always has a leading '/'
+        String libraryResource = libraryPath + "/" + libraryName;
+        String exportLocation = System.getProperty("org.sqlite.lib.exportPath", "");
+        if (exportLocation.isEmpty()) {
+            // Do not export the library and include it in native-image instead
+            RuntimeResourceAccess.addResource(
+                    SQLiteJDBCLoader.class.getModule(), libraryResource.substring(1));
+        } else {
+            // export the required library to the specified path,
+            // the user is responsible to make sure the created native-image can actually find it.
+            Path targetPath = Paths.get(exportLocation, libraryName);
+            try (InputStream in = SQLiteJDBCLoader.class.getResourceAsStream(libraryResource)) {
+                Files.copy(in, targetPath, StandardCopyOption.REPLACE_EXISTING);
+            } catch (IOException e) {
+                throw new SqliteJdbcFeatureException(e);
+            }
+        }
+    }
+
+    private void registerJNICalls() {
+        // NativeDB and DB JNI calls
+        RuntimeJNIAccess.register(NativeDB.class);
+        RuntimeJNIAccess.register(
+                fields(
+                        NativeDB.class,
+                        "pointer",
+                        "busyHandler",
+                        "commitListener",
+                        "updateListener",
+                        "progressHandler"));
+        RuntimeJNIAccess.register(
+                method(DB.class, "onUpdate", int.class, String.class, String.class, long.class));
+        RuntimeJNIAccess.register(method(DB.class, "onCommit", boolean.class));
+        RuntimeJNIAccess.register(method(NativeDB.class, "stringToUtf8ByteArray", String.class));
+        RuntimeJNIAccess.register(method(DB.class, "throwex"));
+        RuntimeJNIAccess.register(method(DB.class, "throwex", int.class));
+        RuntimeJNIAccess.register(method(NativeDB.class, "throwex", String.class));
+
+        // Function JNI calls
+        RuntimeJNIAccess.register(Function.class);
+        RuntimeJNIAccess.register(fields(Function.class, "context", "value", "args"));
+        RuntimeJNIAccess.register(method(Function.class, "xFunc"));
+
+        // Collation JNI calls
+        RuntimeJNIAccess.register(Collation.class);
+        RuntimeJNIAccess.register(method(Collation.class, "xCompare", String.class, String.class));
+
+        // Function$Aggregate JNI calls
+        RuntimeJNIAccess.register(Function.Aggregate.class);
+        RuntimeJNIAccess.register(method(Function.Aggregate.class, "xStep"));
+        RuntimeJNIAccess.register(method(Function.Aggregate.class, "xFinal"));
+        RuntimeJNIAccess.register(method(Function.Aggregate.class, "clone"));
+
+        // Function&Window JNI calls
+        RuntimeJNIAccess.register(Function.Window.class);
+        RuntimeJNIAccess.register(method(Function.Window.class, "xInverse"));
+        RuntimeJNIAccess.register(method(Function.Window.class, "xValue"));
+
+        // DB&ProgressObserver JNI calls
+        RuntimeJNIAccess.register(DB.ProgressObserver.class);
+        RuntimeJNIAccess.register(
+                method(DB.ProgressObserver.class, "progress", int.class, int.class));
+
+        // ProgressHandler JNI calls
+        RuntimeJNIAccess.register(ProgressHandler.class);
+        RuntimeJNIAccess.register(method(ProgressHandler.class, "progress"));
+
+        // BusyHandler JNI calls
+        RuntimeJNIAccess.register(BusyHandler.class);
+        RuntimeJNIAccess.register(method(BusyHandler.class, "callback", int.class));
+
+        // Throwable JNI calls
+        RuntimeJNIAccess.register(Throwable.class);
+        RuntimeJNIAccess.register(method(Throwable.class, "toString"));
+
+        // Other JNI calls
+        RuntimeJNIAccess.register(boolean[].class);
+    }
+
+    private Method method(Class<?> clazz, String methodName, Class<?>... args) {
+        try {
+            return clazz.getDeclaredMethod(methodName, args);
+        } catch (NoSuchMethodException e) {
+            throw new SqliteJdbcFeatureException(e);
+        }
+    }
+
+    private Field[] fields(Class<?> clazz, String... fieldNames) {
+        try {
+            Field[] fields = new Field[fieldNames.length];
+            for (int i = 0; i < fieldNames.length; i++) {
+                fields[i] = clazz.getDeclaredField(fieldNames[i]);
+            }
+            return fields;
+        } catch (NoSuchFieldException e) {
+            throw new SqliteJdbcFeatureException(e);
+        }
+    }
+
+    private static class SqliteJdbcFeatureException extends RuntimeException {
+        private SqliteJdbcFeatureException(Throwable cause) {
+            super(cause);
+        }
+
+        private SqliteJdbcFeatureException(String message) {
+            super(message);
+        }
+    }
+}
diff --git a/src/main/resources/META-INF/native-image/org.xerial/sqlite-jdbc/jni-config.json b/src/main/resources/META-INF/native-image/org.xerial/sqlite-jdbc/jni-config.json
deleted file mode 100644
index 2110bdc0..00000000
--- a/src/main/resources/META-INF/native-image/org.xerial/sqlite-jdbc/jni-config.json
+++ /dev/null
@@ -1,105 +0,0 @@
-[
-    {
-        "condition": {
-            "typeReachable": "org.sqlite.core.NativeDB"
-        },
-        "name":"org.sqlite.core.NativeDB",
-        "fields":[
-            {"name":"busyHandler"},
-            {"name":"commitListener"},
-            {"name":"pointer"},
-            {"name":"progressHandler"},
-            {"name":"updateListener"}
-        ],
-        "methods":[
-            {"name":"stringToUtf8ByteArray","parameterTypes":["java.lang.String"] },
-            {"name":"throwex","parameterTypes":["java.lang.String"] }
-        ]
-    },
-    {
-        "condition": {
-            "typeReachable": "org.sqlite.core.NativeDB"
-        },
-        "name":"org.sqlite.core.DB",
-        "methods":[
-            {"name":"onCommit","parameterTypes":["boolean"] },
-            {"name":"onUpdate","parameterTypes":["int","java.lang.String","java.lang.String","long"] },
-            {"name":"throwex","parameterTypes":[] },
-            {"name":"throwex","parameterTypes":["int"] }
-        ]
-    },
-    {
-        "condition": {
-            "typeReachable": "org.sqlite.core.NativeDB"
-        },
-        "name":"org.sqlite.Function",
-        "fields":[
-            {"name":"args"},
-            {"name":"context"},
-            {"name":"value"}
-        ],
-        "methods":[{"name":"xFunc","parameterTypes":[] }]
-    },
-    {
-        "condition": {
-            "typeReachable": "org.sqlite.core.NativeDB"
-        },
-        "name":"org.sqlite.Collation",
-        "methods":[{"name":"xCompare","parameterTypes":["java.lang.String","java.lang.String"] }]
-    },
-    {
-        "condition": {
-            "typeReachable": "org.sqlite.core.NativeDB"
-        },
-        "name":"org.sqlite.Function$Aggregate",
-        "methods":[
-            {"name":"clone","parameterTypes":[] },
-            {"name":"xFinal","parameterTypes":[] },
-            {"name":"xStep","parameterTypes":[] }
-        ]
-    },
-    {
-        "condition": {
-            "typeReachable": "org.sqlite.core.NativeDB"
-        },
-        "name":"org.sqlite.Function$Window",
-        "methods":[
-            {"name":"xInverse","parameterTypes":[] },
-            {"name":"xValue","parameterTypes":[] }
-        ]
-    },
-    {
-        "condition": {
-            "typeReachable": "org.sqlite.core.NativeDB"
-        },
-        "name":"org.sqlite.core.DB$ProgressObserver",
-        "methods":[{"name":"progress","parameterTypes":["int","int"] }]
-    },
-    {
-        "condition": {
-            "typeReachable": "org.sqlite.core.NativeDB"
-        },
-        "name":"org.sqlite.ProgressHandler",
-        "methods":[{"name":"progress","parameterTypes":[] }]
-    },
-    {
-        "condition": {
-            "typeReachable": "org.sqlite.core.NativeDB"
-        },
-        "name":"org.sqlite.BusyHandler",
-        "methods":[{"name":"callback","parameterTypes":["int"] }]
-    },
-    {
-        "condition": {
-            "typeReachable": "org.sqlite.core.NativeDB"
-        },
-        "name":"java.lang.Throwable",
-        "methods":[{"name":"toString","parameterTypes":[] }]
-    },
-    {
-        "condition": {
-            "typeReachable": "org.sqlite.core.NativeDB"
-        },
-        "name":"boolean[]"
-    }
-]
diff --git a/src/main/resources/META-INF/native-image/org.xerial/sqlite-jdbc/native-image.properties b/src/main/resources/META-INF/native-image/org.xerial/sqlite-jdbc/native-image.properties
index ab95497d..f06f15f9 100644
--- a/src/main/resources/META-INF/native-image/org.xerial/sqlite-jdbc/native-image.properties
+++ b/src/main/resources/META-INF/native-image/org.xerial/sqlite-jdbc/native-image.properties
@@ -1,1 +1,1 @@
-Args=--enable-url-protocols=jar
+Args=--enable-url-protocols=jar --features=org.sqlite.nativeimage.SqliteJdbcFeature
diff --git a/src/main/resources/META-INF/native-image/org.xerial/sqlite-jdbc/resource-config.json b/src/main/resources/META-INF/native-image/org.xerial/sqlite-jdbc/resource-config.json
deleted file mode 100644
index 72ac4eb4..00000000
--- a/src/main/resources/META-INF/native-image/org.xerial/sqlite-jdbc/resource-config.json
+++ /dev/null
@@ -1,10 +0,0 @@
-{
-  "resources":{
-  "includes":[
-    {"pattern":"\\QMETA-INF/maven/org.xerial/sqlite-jdbc/VERSION\\E"}, 
-    {"pattern":"\\QMETA-INF/maven/org.xerial/sqlite-jdbc/pom.properties\\E"},
-    {"pattern":"\\Qsqlite-jdbc.properties\\E"},
-    {"pattern":"org/sqlite/native/.*"}
-  ]},
-  "bundles":[]
-}


Test patch:
diff --git a/src/test/java/org/sqlite/PrepStmtTest.java b/src/test/java/org/sqlite/PrepStmtTest.java
index fb700852..fad13e64 100644
--- a/src/test/java/org/sqlite/PrepStmtTest.java
+++ b/src/test/java/org/sqlite/PrepStmtTest.java
@@ -777,4 +777,26 @@ public class PrepStmtTest {
             assertThat(meta.getColumnClassName(1)).isEqualTo("java.lang.Object");
         }
     }
+
+    @Test
+    public void getParameterTypeTest() throws SQLException {
+        stat.executeUpdate("create table t_int(i INT)");
+
+        try (PreparedStatement ps = conn.prepareStatement("INSERT INTO t_int VALUES(?)")) {
+            ps.setLong(1, 100);
+            assertThat(ps.getParameterMetaData().getParameterType(1)).isEqualTo(Types.BIGINT);
+            assertThat(ps.getParameterMetaData().getParameterTypeName(1)).isEqualTo("BIGINT");
+        }
+
+        stat.executeUpdate("create table t_real(a REAL, b REAL)");
+
+        try (PreparedStatement ps = conn.prepareStatement("INSERT INTO t_real VALUES(?, ?)")) {
+            ps.setDouble(1, 100.0);
+            ps.setFloat(2, 100.0f);
+            assertThat(ps.getParameterMetaData().getParameterType(1)).isEqualTo(Types.REAL);
+            assertThat(ps.getParameterMetaData().getParameterTypeName(1)).isEqualTo("REAL");
+            assertThat(ps.getParameterMetaData().getParameterType(2)).isEqualTo(Types.REAL);
+            assertThat(ps.getParameterMetaData().getParameterTypeName(2)).isEqualTo("REAL");
+        }
+    }
 }
diff --git a/src/test/java/org/sqlite/ResultSetTest.java b/src/test/java/org/sqlite/ResultSetTest.java
index d0888e96..1a604c85 100644
--- a/src/test/java/org/sqlite/ResultSetTest.java
+++ b/src/test/java/org/sqlite/ResultSetTest.java
@@ -14,11 +14,14 @@ import java.sql.SQLException;
 import java.sql.Statement;
 import java.sql.Time;
 import java.sql.Timestamp;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.LocalTime;
 import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 
-public class ResultSetTest {
+class ResultSetTest {
 
     private Connection conn;
     private Statement stat;
@@ -39,70 +42,70 @@ public class ResultSetTest {
     }
 
     @Test
-    public void testTableColumnLowerNowFindLowerCaseColumn() throws SQLException {
+    void testTableColumnLowerNowFindLowerCaseColumn() throws SQLException {
         ResultSet resultSet = stat.executeQuery("select * from test");
         assertThat(resultSet.next()).isTrue();
         assertThat(resultSet.findColumn("id")).isEqualTo(1);
     }
 
     @Test
-    public void testTableColumnLowerNowFindUpperCaseColumn() throws SQLException {
+    void testTableColumnLowerNowFindUpperCaseColumn() throws SQLException {
         ResultSet resultSet = stat.executeQuery("select * from test");
         assertThat(resultSet.next()).isTrue();
         assertThat(resultSet.findColumn("ID")).isEqualTo(1);
     }
 
     @Test
-    public void testTableColumnLowerNowFindMixedCaseColumn() throws SQLException {
+    void testTableColumnLowerNowFindMixedCaseColumn() throws SQLException {
         ResultSet resultSet = stat.executeQuery("select * from test");
         assertThat(resultSet.next()).isTrue();
         assertThat(resultSet.findColumn("Id")).isEqualTo(1);
     }
 
     @Test
-    public void testTableColumnUpperNowFindLowerCaseColumn() throws SQLException {
+    void testTableColumnUpperNowFindLowerCaseColumn() throws SQLException {
         ResultSet resultSet = stat.executeQuery("select * from test");
         assertThat(resultSet.next()).isTrue();
         assertThat(resultSet.findColumn("description")).isEqualTo(2);
     }
 
     @Test
-    public void testTableColumnUpperNowFindUpperCaseColumn() throws SQLException {
+    void testTableColumnUpperNowFindUpperCaseColumn() throws SQLException {
         ResultSet resultSet = stat.executeQuery("select * from test");
         assertThat(resultSet.next()).isTrue();
         assertThat(resultSet.findColumn("DESCRIPTION")).isEqualTo(2);
     }
 
     @Test
-    public void testTableColumnUpperNowFindMixedCaseColumn() throws SQLException {
+    void testTableColumnUpperNowFindMixedCaseColumn() throws SQLException {
         ResultSet resultSet = stat.executeQuery("select * from test");
         assertThat(resultSet.next()).isTrue();
         assertThat(resultSet.findColumn("Description")).isEqualTo(2);
     }
 
     @Test
-    public void testTableColumnMixedNowFindLowerCaseColumn() throws SQLException {
+    void testTableColumnMixedNowFindLowerCaseColumn() throws SQLException {
         ResultSet resultSet = stat.executeQuery("select * from test");
         assertThat(resultSet.next()).isTrue();
         assertThat(resultSet.findColumn("foo")).isEqualTo(3);
     }
 
     @Test
-    public void testTableColumnMixedNowFindUpperCaseColumn() throws SQLException {
+    void testTableColumnMixedNowFindUpperCaseColumn() throws SQLException {
         ResultSet resultSet = stat.executeQuery("select * from test");
         assertThat(resultSet.next()).isTrue();
         assertThat(resultSet.findColumn("FOO")).isEqualTo(3);
     }
 
     @Test
-    public void testTableColumnMixedNowFindMixedCaseColumn() throws SQLException {
+    void testTableColumnMixedNowFindMixedCaseColumn() throws SQLException {
         ResultSet resultSet = stat.executeQuery("select * from test");
         assertThat(resultSet.next()).isTrue();
         assertThat(resultSet.findColumn("fOo")).isEqualTo(3);
     }
 
     @Test
-    public void testSelectWithTableNameAliasNowFindWithoutTableNameAlias() throws SQLException {
+    void testSelectWithTableNameAliasNowFindWithoutTableNameAlias() throws SQLException {
         ResultSet resultSet = stat.executeQuery("select t.id from test as t");
         assertThat(resultSet.next()).isTrue();
         assertThat(resultSet.findColumn("id")).isEqualTo(1);
@@ -114,7 +117,7 @@ public class ResultSetTest {
      * the column is unspecified"
      */
     @Test
-    public void testSelectWithTableNameAliasNowNotFindWithTableNameAlias() throws SQLException {
+    void testSelectWithTableNameAliasNowNotFindWithTableNameAlias() throws SQLException {
         ResultSet resultSet = stat.executeQuery("select t.id from test as t");
         assertThat(resultSet.next()).isTrue();
         assertThatExceptionOfType(SQLException.class)
@@ -122,14 +125,14 @@ public class ResultSetTest {
     }
 
     @Test
-    public void testSelectWithTableNameNowFindWithoutTableName() throws SQLException {
+    void testSelectWithTableNameNowFindWithoutTableName() throws SQLException {
         ResultSet resultSet = stat.executeQuery("select test.id from test");
         assertThat(resultSet.next()).isTrue();
         assertThat(resultSet.findColumn("id")).isEqualTo(1);
     }
 
     @Test
-    public void testSelectWithTableNameNowNotFindWithTableName() throws SQLException {
+    void testSelectWithTableNameNowNotFindWithTableName() throws SQLException {
         ResultSet resultSet = stat.executeQuery("select test.id from test");
         assertThat(resultSet.next()).isTrue();
         assertThatExceptionOfType(SQLException.class)
@@ -137,7 +140,7 @@ public class ResultSetTest {
     }
 
     @Test
-    public void testCloseStatement() throws SQLException {
+    void testCloseStatement() throws SQLException {
         ResultSet resultSet = stat.executeQuery("select test.id from test");
 
         stat.close();
@@ -151,7 +154,7 @@ public class ResultSetTest {
     }
 
     @Test
-    public void testReturnsNonAsciiCodepoints() throws SQLException {
+    void testReturnsNonAsciiCodepoints() throws SQLException {
         String nonAsciiString = "국정의 중요한 사항에 관한";
         PreparedStatement pstat = conn.prepareStatement("select ?");
         pstat.setString(1, nonAsciiString);
@@ -164,14 +167,14 @@ public class ResultSetTest {
     }
 
     @Test
-    public void testFindColumnOnEmptyResultSet() throws SQLException {
+    void testFindColumnOnEmptyResultSet() throws SQLException {
         ResultSet resultSet = stat.executeQuery("select * from test where id = 0");
         assertThat(resultSet.next()).isFalse();
         assertThat(resultSet.findColumn("id")).isEqualTo(1);
     }
 
     @Test
-    public void testNumericTypes() throws SQLException {
+    void testNumericTypes() throws SQLException {
         stat.executeUpdate("create table numeric(c1, c2, c3)");
         stat.executeUpdate("insert into numeric values (1, 1.1, null)");
 
@@ -196,7 +199,7 @@ public class ResultSetTest {
     }
 
     @Test
-    public void testGetBigDecimal() throws SQLException {
+    void testGetBigDecimal() throws SQLException {
         stat.executeUpdate(
                 "create table bigdecimal(c1, c2 integer, c3 real, c4 double, c5 decimal, c6 numeric, c7 float)");
         stat.executeUpdate("insert into bigdecimal values (1, 2, 3, 4, 5, 6, 7)");
@@ -326,6 +329,28 @@ public class ResultSetTest {
         assertThat(rs.next()).isFalse();
     }
 
+    @Test
+    void testJdk8AddedDateTimeObjects() throws SQLException {
+        stat.executeUpdate("create table datetime_test(c1)");
+        stat.executeUpdate("insert into datetime_test values ('2021-11-09 11:20:58')");
+        stat.executeUpdate("insert into datetime_test values ('2021-11-09')");
+        stat.executeUpdate("insert into datetime_test values ('11:20:58')");
+
+        ResultSet rs = stat.executeQuery("select * from datetime_test");
+
+        rs.next();
+        assertThat(rs.getObject(1, LocalDate.class)).isEqualTo(LocalDate.of(2021, 11, 9));
+        assertThat(rs.getObject(1, LocalTime.class)).isEqualTo(LocalTime.of(11, 20, 58));
+        assertThat(rs.getObject(1, LocalDateTime.class))
+                .isEqualTo(LocalDateTime.of(2021, 11, 9, 11, 20, 58));
+
+        rs.next();
+        assertThat(rs.getObject(1, LocalDate.class)).isEqualTo(LocalDate.of(2021, 11, 9));
+
+        rs.next();
+        assertThat(rs.getObject(1, LocalTime.class)).isEqualTo(LocalTime.of(11, 20, 58));
+    }
+
     @Test
     void gh808_getResultSetMetadataAfterReadingLastRow() throws SQLException {
         for (int i = 0; i < 2; i++) {
diff --git a/src/test/java/org/sqlite/SQLiteJDBCLoaderTest.java b/src/test/java/org/sqlite/SQLiteJDBCLoaderTest.java
index 64ae3d13..3ff78729 100644
--- a/src/test/java/org/sqlite/SQLiteJDBCLoaderTest.java
+++ b/src/test/java/org/sqlite/SQLiteJDBCLoaderTest.java
@@ -104,7 +104,7 @@ public class SQLiteJDBCLoaderTest {
 
     @Test
     public void version() {
-        // System.out.println(SQLiteJDBCLoader.getVersion());
+        assertThat(SQLiteJDBCLoader.getVersion()).isNotEqualTo("unknown");
     }
 
     @Test