diff --git a/.clj-kondo/inline-configs/sqlite4clj.core.clj/config.edn b/.clj-kondo/inline-configs/sqlite4clj.core.clj/config.edn new file mode 100644 index 0000000..6f1e42f --- /dev/null +++ b/.clj-kondo/inline-configs/sqlite4clj.core.clj/config.edn @@ -0,0 +1 @@ +{:lint-as {sqlite4clj.core/with-stmt-reset clojure.core/with-open, sqlite4clj.core/with-conn clojure.core/with-open, sqlite4clj.core/with-read-tx clojure.core/with-open, sqlite4clj.core/with-write-tx clojure.core/with-open}} \ No newline at end of file diff --git a/.clj-kondo/inline-configs/sqlite4clj.test_common.clj/config.edn b/.clj-kondo/inline-configs/sqlite4clj.test_common.clj/config.edn new file mode 100644 index 0000000..4f25d34 --- /dev/null +++ b/.clj-kondo/inline-configs/sqlite4clj.test_common.clj/config.edn @@ -0,0 +1 @@ +{:lint-as {sqlite4clj.test-common/with-db clojure.core/with-open}} \ No newline at end of file diff --git a/src/sqlite4clj/core.clj b/src/sqlite4clj/core.clj index 5b092d5..dea3269 100644 --- a/src/sqlite4clj/core.clj +++ b/src/sqlite4clj/core.clj @@ -174,9 +174,22 @@ (api/sqlite3-limit pdb limit-id (int v)))) limits)) -(defn pragma->set-pragma-query [pragma] - (conj (->> (merge default-pragma pragma) - (mapv (fn [[k v]] [(str "pragma " (name k) "=" v)]))))) +(def ^:private read-only-skipped-pragmas + "Pragmas that can require a database header write when set. Converting the + journal mode to WAL, or setting a page size on an empty database, writes the + header, which fails on a connection opened with SQLITE_OPEN_READONLY. + Read-only connections skip them; the remaining pragmas are + connection-local and safe to set." + #{:journal_mode :page_size}) + +(defn pragma->set-pragma-query + ([pragma] + (pragma->set-pragma-query pragma false)) + ([pragma read-only] + (->> (merge default-pragma pragma) + (remove (fn [[k _]] + (and read-only (contains? read-only-skipped-pragmas k)))) + (mapv (fn [[k v]] [(str "pragma " (name k) "=" v)]))))) (defn no-unwrap-result-set-fn [_col-metadata result-set] @@ -206,7 +219,7 @@ :stmt-cache statement-cache :default-result-set-fn default-result-set-fn}] (set-limits conn limits) - (->> (pragma->set-pragma-query pragma) + (->> (pragma->set-pragma-query pragma read-only) (run! #(q* conn % default-result-set-fn))) conn)) @@ -233,8 +246,13 @@ (defn init-db! "A db consists of a read pool of size :pool-size and a write pool of size 1. - The same pragma are set for both pools." - [url & [{:keys [pool-size pragma writer-pragma vfs + The same pragma are set for both pools. + + When :read-only is true, every connection — the writer pool included — + opens with SQLITE_OPEN_READONLY, so writes through any connection fail at + the SQLite level. Read-only connections skip the :journal_mode and + :page_size pragmas, which can require a database header write." + [url & [{:keys [pool-size pragma writer-pragma read-only vfs default-result-set-fn limits writer-limits] :or {default-result-set-fn unwrap-result-set-fn pool-size (Runtime/.availableProcessors @@ -244,6 +262,7 @@ writer (init-pool! url {:pool-size 1 + :read-only read-only :pragma (merge pragma writer-pragma) :limits (merge limits writer-limits) :vfs vfs diff --git a/test/sqlite4clj/core_test.clj b/test/sqlite4clj/core_test.clj index 1b2087f..22d19e1 100644 --- a/test/sqlite4clj/core_test.clj +++ b/test/sqlite4clj/core_test.clj @@ -2,7 +2,7 @@ (:require [clojure.test :refer [deftest is testing use-fixtures]] [sqlite4clj.core :as d] - [sqlite4clj.test-common :refer [test-db test-fixture with-db]])) + [sqlite4clj.test-common :refer [test-db test-db-path test-fixture with-db]])) (use-fixtures :once test-fixture) @@ -140,3 +140,31 @@ #"too many attached databases - max 0" (d/q (:reader db) ["ATTACH DATABASE 'test-data/test.db' AS other"])))))) + +(deftest read-only-db-init + (testing "A db opened with :read-only true serves reads and rejects writes + through the writer pool." + (with-db [db (test-db)] + (d/q (:writer db) ["create table ro (id integer primary key, data text)"]) + (d/q (:writer db) ["insert into ro (id, data) values (1, 'one')"])) + (with-db [db (d/init-db! test-db-path {:pool-size 2 :read-only true})] + (is (= [1] (d/q (:reader db) ["select id from ro"]))) + (is (= [1] (d/q (:writer db) ["select id from ro"]))) + (is (thrown-with-msg? clojure.lang.ExceptionInfo + #"readonly database" + (d/q (:writer db) + ["insert into ro (id, data) values (2, 'two')"])))))) + +(deftest read-only-opens-rollback-journal-dbs + (testing "Read-only connections skip journal_mode and page_size pragmas, so + a rollback-journal database opens read-only without error." + ;; A file of its own: journal_mode=delete cannot be set on a database + ;; with a leftover write-ahead log, and earlier tests leave the shared + ;; test.db in WAL mode. + (let [path "test-data/rollback-test.db"] + (with-db [db (d/init-db! path {:pool-size 2 :pragma {:journal_mode "delete"}})] + (d/q (:writer db) ["create table ro_journal (id integer primary key)"]) + (d/q (:writer db) ["insert into ro_journal (id) values (1)"])) + (with-db [db (d/init-db! path {:pool-size 2 :read-only true})] + (is (= [1] (d/q (:reader db) ["select id from ro_journal"]))) + (is (= "delete" (first (d/q (:writer db) ["pragma journal_mode"]))))))))