Repository: xerial/sqlite-jdbc
Pull request #1263: remove an undocumented side-effect from SQLiteConnection::open
Author: login: hofmandl1
Created: 2025-04-04T11:47:07Z | Merged: 2025-04-16T09:35:36Z

The side-effect consisted in temporarily creating a non-existing db-file to test for write-permission, see #1260
Also add some test to better document the expected behavior.


Diff:
diff --git a/src/main/java/org/sqlite/SQLiteConnection.java b/src/main/java/org/sqlite/SQLiteConnection.java
index b02cf9cb6..9837bedf1 100644
--- a/src/main/java/org/sqlite/SQLiteConnection.java
+++ b/src/main/java/org/sqlite/SQLiteConnection.java
@@ -251,26 +251,7 @@ private static DB open(String url, String origFileName, Properties props) throws
                     throw new SQLException(String.format("failed to load %s: %s", resourceName, e));
                 }
             } else {
-                File file = new File(fileName).getAbsoluteFile();
-                File parent = file.getParentFile();
-                if (parent != null && !parent.exists()) {
-                    for (File up = parent; up != null && !up.exists(); ) {
-                        parent = up;
-                        up = up.getParentFile();
-                    }
-                    throw new SQLException(
-                            "path to '" + fileName + "': '" + parent + "' does not exist");
-                }
-
-                // check write access if file does not exist
-                try {
-                    // The extra check to exists() is necessary as createNewFile()
-                    // does not follow the JavaDoc when used on read-only shares.
-                    if (!file.exists() && file.createNewFile()) file.delete();
-                } catch (Exception e) {
-                    throw new SQLException("opening db: '" + fileName + "': " + e.getMessage());
-                }
-                fileName = file.getAbsolutePath();
+                fileName = new File(fileName).getAbsolutePath();
             }
         }
 
diff --git a/src/test/java/org/sqlite/ConnectionTest.java b/src/test/java/org/sqlite/ConnectionTest.java
index 1f775a77d..e5f3a1c39 100644
--- a/src/test/java/org/sqlite/ConnectionTest.java
+++ b/src/test/java/org/sqlite/ConnectionTest.java
@@ -6,6 +6,10 @@
 import java.io.FileOutputStream;
 import java.io.IOException;
 import java.io.InputStream;
+import java.nio.file.AccessDeniedException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
 import java.sql.*;
 import java.util.ArrayList;
 import java.util.Properties;
@@ -14,6 +18,8 @@
 import java.util.concurrent.Executors;
 import java.util.concurrent.Future;
 import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.condition.DisabledOnOs;
+import org.junit.jupiter.api.condition.OS;
 import org.junit.jupiter.api.io.TempDir;
 import org.sqlite.SQLiteConfig.JournalMode;
 import org.sqlite.SQLiteConfig.Pragma;
@@ -409,4 +415,49 @@ public void overrideURIPragmaValuesWithProperties() throws Exception {
         stat.close();
         conn.close();
     }
+
+    @Test
+    public void openNonExistingFileNoCreate() {
+        Path nonExisting = Paths.get("non_existing.db").toAbsolutePath();
+        assertThat(Files.exists(nonExisting)).isFalse();
+        SQLiteConfig cfg = new SQLiteConfig();
+        cfg.resetOpenMode(SQLiteOpenMode.CREATE);
+        assertThatExceptionOfType(SQLiteException.class)
+                .isThrownBy(
+                        () -> {
+                            @SuppressWarnings({"resource", "unused"})
+                            Connection _c = cfg.createConnection("jdbc:sqlite:" + nonExisting);
+                        })
+                .satisfies(
+                        e ->
+                                assertThat(e.getResultCode())
+                                        .isEqualTo(SQLiteErrorCode.SQLITE_CANTOPEN));
+        assertThat(Files.exists(nonExisting)).isFalse();
+    }
+
+    @DisabledOnOs(OS.WINDOWS) // File.setReadOnly doesn't seem to work here
+    @Test
+    public void openNonExistingFileInReadOnlyDirectory(@TempDir Path tmpDir) {
+        assertThat(tmpDir.toFile().setReadOnly()).isTrue();
+        assertThat(Files.exists(tmpDir)).isTrue();
+        Path nonExisting = tmpDir.resolve("non_existing.db").toAbsolutePath();
+        assertThatThrownBy(() -> Files.createFile(nonExisting))
+                .isInstanceOf(AccessDeniedException.class);
+        assertThat(Files.exists(nonExisting)).isFalse();
+        SQLiteConfig cfg = new SQLiteConfig();
+        assertThatExceptionOfType(SQLiteException.class)
+                .isThrownBy(
+                        () -> {
+                            @SuppressWarnings({"resource", "unused"})
+                            Connection _c = cfg.createConnection("jdbc:sqlite:" + nonExisting);
+                        })
+                .satisfies(
+                        e ->
+                                // It would be nice, if the native error code were more specific on
+                                // why the file can't be
+                                // opened, but this is what we get:
+                                assertThat(e.getResultCode())
+                                        .isEqualTo(SQLiteErrorCode.SQLITE_CANTOPEN));
+        assertThat(Files.exists(nonExisting)).isFalse();
+    }
 }


Reviews:
user: login: gotson
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-04-11T02:42:02Z
user: login: hofmandl1
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-11T09:05:01Z
user: login: gotson
body: lgtm
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-04-16T09:32:21Z

Review comments:
diff_hunk: @@ -409,4 +415,46 @@ public void overrideURIPragmaValuesWithProperties() throws Exception {
         stat.close();
         conn.close();
     }
+
+    @SuppressWarnings("resource")
+    @Test
+    public void openNonExistingFileNoCreate() {
+        Path nonExisting = Paths.get("non_existing.db").toAbsolutePath();
+        assertThat(Files.exists(nonExisting)).isFalse();
+        SQLiteConfig cfg = new SQLiteConfig();
+        cfg.resetOpenMode(SQLiteOpenMode.CREATE);
+        assertThatExceptionOfType(SQLiteException.class)
+                .isThrownBy(() -> cfg.createConnection("jdbc:sqlite:" + nonExisting))
+                .satisfies(
+                        e ->
+                                assertThat(e.getResultCode())
+                                        .isEqualTo(SQLiteErrorCode.SQLITE_CANTOPEN));
+        assertThat(Files.exists(nonExisting)).isFalse();
+    }
+
+    @SuppressWarnings("resource")
+    @Test
+    public void openNonExistingFileInReadOnlyDirectory(@TempDir Path tmpDir) {
+        // skip test on windows as there marking a folder as readonly doesn't seem to work with pure
+        // java means
+        Assumptions.assumeFalse(OSInfo.getOSName().equals("Windows"));
path: src/test/java/org/sqlite/ConnectionTest.java
user: login: gotson
body: this could be replaced by `@DisabledOnOs`
created_at: 2025-04-11T02:41:37Z
author_association: COLLABORATOR
diff_hunk: @@ -409,4 +415,46 @@ public void overrideURIPragmaValuesWithProperties() throws Exception {
         stat.close();
         conn.close();
     }
+
+    @SuppressWarnings("resource")
+    @Test
+    public void openNonExistingFileNoCreate() {
+        Path nonExisting = Paths.get("non_existing.db").toAbsolutePath();
+        assertThat(Files.exists(nonExisting)).isFalse();
+        SQLiteConfig cfg = new SQLiteConfig();
+        cfg.resetOpenMode(SQLiteOpenMode.CREATE);
+        assertThatExceptionOfType(SQLiteException.class)
+                .isThrownBy(() -> cfg.createConnection("jdbc:sqlite:" + nonExisting))
+                .satisfies(
+                        e ->
+                                assertThat(e.getResultCode())
+                                        .isEqualTo(SQLiteErrorCode.SQLITE_CANTOPEN));
+        assertThat(Files.exists(nonExisting)).isFalse();
+    }
+
+    @SuppressWarnings("resource")
path: src/test/java/org/sqlite/ConnectionTest.java
user: login: gotson
body: why do you need this ?
created_at: 2025-04-11T02:41:46Z
author_association: COLLABORATOR
diff_hunk: @@ -409,4 +415,46 @@ public void overrideURIPragmaValuesWithProperties() throws Exception {
         stat.close();
         conn.close();
     }
+
+    @SuppressWarnings("resource")
+    @Test
+    public void openNonExistingFileNoCreate() {
+        Path nonExisting = Paths.get("non_existing.db").toAbsolutePath();
+        assertThat(Files.exists(nonExisting)).isFalse();
+        SQLiteConfig cfg = new SQLiteConfig();
+        cfg.resetOpenMode(SQLiteOpenMode.CREATE);
+        assertThatExceptionOfType(SQLiteException.class)
+                .isThrownBy(() -> cfg.createConnection("jdbc:sqlite:" + nonExisting))
+                .satisfies(
+                        e ->
+                                assertThat(e.getResultCode())
+                                        .isEqualTo(SQLiteErrorCode.SQLITE_CANTOPEN));
+        assertThat(Files.exists(nonExisting)).isFalse();
+    }
+
+    @SuppressWarnings("resource")
path: src/test/java/org/sqlite/ConnectionTest.java
user: login: hofmandl1
body: I'm developing with Intellij and get this warning 
![image](https://github.com/user-attachments/assets/661a6b62-0b43-4ced-9aac-46e9f707d354)
Intellij also offers me to add createConnection to some list of ignored methods (not sure where that ends up). The annotation I used should also be respected by eclipse, so is a little bit more cross-IDE.
I've found a way to put it closer to the culprit. The annotation only works on fields, methods or statements which is why it needs introduction of an unused variable though ..
created_at: 2025-04-11T09:05:01Z
author_association: CONTRIBUTOR

PR comments:
user: login: hofmandl1
created_at: 2025-04-04T11:54:30Z
body: Note, that the PR currently does NOT preserve the status quo error messages. Instead it preserves the orignal SQLiteException containing the SQLiteErrorCode CANT_OPEN.

I *could* add a try-catch-block for `SQLiteException` around the native code that if the error-code is CANT_OPEN tries determining write-permission via e.g. https://docs.oracle.com/javase/8/docs/api/java/io/File.html#canWrite-- which of course is not as exact as actually trying to write, however it won't have that potentially loop-creating side-effect.

I would however, prefer to merge as-is. Providing a more detailed / exact error is IMHO the responsibility of native SQLite via their open error codes and sqlite-jdbc shouldn't try to work around / enhance on this.
author_association: CONTRIBUTOR
user: login: gotson
created_at: 2025-04-07T05:53:36Z
body: can you check the code formatting and failing tests ?

Can you also change the assertions in the test to assertJ like in the rest of the project ?
author_association: COLLABORATOR
user: login: hofmandl1
created_at: 2025-04-10T07:41:30Z
body: > can you check the code formatting and failing tests ?

Done. Though the only solution for the failing test coming to my mind was skipping the test on windows, where apparently setting readonly from java code doesn't work that easily. But  I guess it's fine if the test doesn't run on all platforms as relying on native sqlite error codes has the benefit that we can also rely on their platform abstraction efforts.

> Can you also change the assertions in the test to assertJ like in the rest of the project ?

Done


author_association: CONTRIBUTOR
user: login: hofmandl1
created_at: 2025-04-10T07:42:32Z
body: can I somehow retrigger the checks? I'm not that familiar with github ..
author_association: CONTRIBUTOR
user: login: gotson
created_at: 2025-04-11T02:14:28Z
body: > can I somehow retrigger the checks? I'm not that familiar with github ..

because you force pushed, i have to approve again. Don't force push, it's not easy for reviewers to understand what has changed.
author_association: COLLABORATOR
user: login: hofmandl1
created_at: 2025-04-11T09:10:33Z
body: sorry for force-pushing again, but it seems I unintentionally merged master into my branch when trying to retrigger the checks before .. anyway this time I refrained from squashing
author_association: CONTRIBUTOR