Minimal SQLite key-value store for OCaml

Merge commit '96b1b53dd02a833e479f932053600f6161a35282' as 'ocaml-sqlite'

+927
+17
.gitignore
··· 1 + # OCaml build artifacts 2 + _build/ 3 + *.install 4 + *.merlin 5 + 6 + # Dune package management 7 + dune.lock/ 8 + 9 + # Editor and OS files 10 + .DS_Store 11 + *.swp 12 + *~ 13 + .vscode/ 14 + .idea/ 15 + 16 + # Opam local switch 17 + _opam/
+1
.ocamlformat
··· 1 + version=0.27.0
+21
LICENSE.md
··· 1 + MIT License 2 + 3 + Copyright (c) 2025 Thomas Gazagnaire 4 + 5 + Permission is hereby granted, free of charge, to any person obtaining a copy 6 + of this software and associated documentation files (the "Software"), to deal 7 + in the Software without restriction, including without limitation the rights 8 + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 + copies of the Software, and to permit persons to whom the Software is 10 + furnished to do so, subject to the following conditions: 11 + 12 + The above copyright notice and this permission notice shall be included in all 13 + copies or substantial portions of the Software. 14 + 15 + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 + SOFTWARE.
+67
README.md
··· 1 + # sqlite 2 + 3 + Minimal SQLite key-value store for OCaml. 4 + 5 + ## Overview 6 + 7 + A simple key-value store backed by SQLite with support for: 8 + - Namespaced tables for organizing data 9 + - WAL mode for concurrent access 10 + - Efficient batch operations 11 + - Eio-compatible synchronous API 12 + 13 + ## Installation 14 + 15 + ``` 16 + opam install sqlite 17 + ``` 18 + 19 + ## Usage 20 + 21 + ```ocaml 22 + (* Open or create a database *) 23 + let db = Sqlite.create (Eio.Path.(fs / "data.db")) in 24 + 25 + (* Basic key-value operations *) 26 + Sqlite.put db "key1" "value1"; 27 + let value = Sqlite.get db "key1" in (* Some "value1" *) 28 + 29 + (* Namespaced tables *) 30 + let blocks = Sqlite.Table.create db ~name:"blocks" in 31 + Sqlite.Table.put blocks "cid1" "data1"; 32 + 33 + (* Sync to disk *) 34 + Sqlite.sync db; 35 + 36 + (* Close when done *) 37 + Sqlite.close db 38 + ``` 39 + 40 + ## API 41 + 42 + ### Database 43 + 44 + - `Sqlite.create path` - Open or create a SQLite database at path 45 + - `Sqlite.get db key` - Get value for key, or None 46 + - `Sqlite.put db key value` - Store value at key 47 + - `Sqlite.delete db key` - Remove key 48 + - `Sqlite.mem db key` - Check if key exists 49 + - `Sqlite.iter db ~f` - Iterate over all entries 50 + - `Sqlite.fold db ~init ~f` - Fold over all entries 51 + - `Sqlite.sync db` - Flush to disk (WAL checkpoint) 52 + - `Sqlite.close db` - Close the database 53 + 54 + ### Namespaced Tables 55 + 56 + - `Sqlite.Table.create db ~name` - Create or open a named table 57 + - `Sqlite.Table.get`, `put`, `delete`, `mem`, `iter` - Same as database operations 58 + 59 + ## Related Work 60 + 61 + - [sqlite3-ocaml](https://github.com/mmottl/sqlite3-ocaml) - Low-level SQLite3 bindings (used internally) 62 + - [ezsqlite](https://opam.ocaml.org/packages/ezsqlite/) - Alternative SQLite bindings with extensions 63 + - [irmin](https://github.com/mirage/irmin) - Git-like distributed database (different use case) 64 + 65 + ## License 66 + 67 + MIT License. See [LICENSE.md](LICENSE.md) for details.
+23
dune-project
··· 1 + (lang dune 3.0) 2 + 3 + (name sqlite) 4 + 5 + (generate_opam_files true) 6 + 7 + (license MIT) 8 + (authors "Thomas Gazagnaire") 9 + (maintainers "Thomas Gazagnaire") 10 + (source (uri https://tangled.org/gazagnaire.org/ocaml-sqlite)) 11 + 12 + (package 13 + (name sqlite) 14 + (synopsis "Minimal SQLite key-value store for OCaml") 15 + (description 16 + "A simple key-value store backed by SQLite with support for namespaced tables, WAL mode, and efficient batch operations.") 17 + (depends 18 + (ocaml (>= 5.1)) 19 + (eio (>= 1.0)) 20 + (sqlite3 (>= 5.0)) 21 + (alcotest :with-test) 22 + (eio_main :with-test) 23 + (crowbar :with-test)))
+15
fuzz/dune
··· 1 + ; Crowbar fuzz testing for sqlite 2 + ; 3 + ; To run: dune exec fuzz/fuzz_sqlite.exe 4 + ; With AFL: afl-fuzz -i fuzz/corpus -o fuzz/findings -- ./_build/default/fuzz/fuzz_sqlite.exe @@ 5 + 6 + (executable 7 + (name fuzz_sqlite) 8 + (modules fuzz_sqlite) 9 + (libraries sqlite crowbar eio_main)) 10 + 11 + (rule 12 + (alias fuzz) 13 + (deps fuzz_sqlite.exe) 14 + (action 15 + (run %{exe:fuzz_sqlite.exe})))
+121
fuzz/fuzz_sqlite.ml
··· 1 + (*--------------------------------------------------------------------------- 2 + Copyright (c) 2025 Thomas Gazagnaire. All rights reserved. 3 + SPDX-License-Identifier: MIT 4 + ---------------------------------------------------------------------------*) 5 + 6 + open Crowbar 7 + 8 + (* Test that any key/value pair can be stored and retrieved *) 9 + let test_roundtrip key value = 10 + Eio_main.run @@ fun env -> 11 + let cwd = Eio.Stdenv.cwd env in 12 + let tmp_dir = Eio.Path.(cwd / "_build" / "fuzz_sqlite") in 13 + (try Eio.Path.mkdirs ~exists_ok:true ~perm:0o755 tmp_dir with _ -> ()); 14 + let path = 15 + Eio.Path.(tmp_dir / Printf.sprintf "fuzz_%d.db" (Random.int 1_000_000)) 16 + in 17 + let db = Sqlite.create path in 18 + Fun.protect 19 + ~finally:(fun () -> 20 + Sqlite.close db; 21 + try Eio.Path.unlink path with _ -> ()) 22 + (fun () -> 23 + Sqlite.put db key value; 24 + let result = Sqlite.get db key in 25 + check_eq ~pp:Format.pp_print_string 26 + ~eq:( = ) 27 + (Option.get result) value) 28 + 29 + (* Test that delete actually removes the key *) 30 + let test_delete_removes key value = 31 + Eio_main.run @@ fun env -> 32 + let cwd = Eio.Stdenv.cwd env in 33 + let tmp_dir = Eio.Path.(cwd / "_build" / "fuzz_sqlite") in 34 + (try Eio.Path.mkdirs ~exists_ok:true ~perm:0o755 tmp_dir with _ -> ()); 35 + let path = 36 + Eio.Path.(tmp_dir / Printf.sprintf "fuzz_%d.db" (Random.int 1_000_000)) 37 + in 38 + let db = Sqlite.create path in 39 + Fun.protect 40 + ~finally:(fun () -> 41 + Sqlite.close db; 42 + try Eio.Path.unlink path with _ -> ()) 43 + (fun () -> 44 + Sqlite.put db key value; 45 + Sqlite.delete db key; 46 + let result = Sqlite.get db key in 47 + check (Option.is_none result)) 48 + 49 + (* Test mem consistency with get *) 50 + let test_mem_consistent key value = 51 + Eio_main.run @@ fun env -> 52 + let cwd = Eio.Stdenv.cwd env in 53 + let tmp_dir = Eio.Path.(cwd / "_build" / "fuzz_sqlite") in 54 + (try Eio.Path.mkdirs ~exists_ok:true ~perm:0o755 tmp_dir with _ -> ()); 55 + let path = 56 + Eio.Path.(tmp_dir / Printf.sprintf "fuzz_%d.db" (Random.int 1_000_000)) 57 + in 58 + let db = Sqlite.create path in 59 + Fun.protect 60 + ~finally:(fun () -> 61 + Sqlite.close db; 62 + try Eio.Path.unlink path with _ -> ()) 63 + (fun () -> 64 + Sqlite.put db key value; 65 + let mem_result = Sqlite.mem db key in 66 + let get_result = Sqlite.get db key in 67 + check_eq ~pp:Format.pp_print_bool mem_result (Option.is_some get_result)) 68 + 69 + (* Test overwrite replaces value *) 70 + let test_overwrite key value1 value2 = 71 + Eio_main.run @@ fun env -> 72 + let cwd = Eio.Stdenv.cwd env in 73 + let tmp_dir = Eio.Path.(cwd / "_build" / "fuzz_sqlite") in 74 + (try Eio.Path.mkdirs ~exists_ok:true ~perm:0o755 tmp_dir with _ -> ()); 75 + let path = 76 + Eio.Path.(tmp_dir / Printf.sprintf "fuzz_%d.db" (Random.int 1_000_000)) 77 + in 78 + let db = Sqlite.create path in 79 + Fun.protect 80 + ~finally:(fun () -> 81 + Sqlite.close db; 82 + try Eio.Path.unlink path with _ -> ()) 83 + (fun () -> 84 + Sqlite.put db key value1; 85 + Sqlite.put db key value2; 86 + let result = Sqlite.get db key in 87 + check_eq ~pp:Format.pp_print_string 88 + ~eq:( = ) 89 + (Option.get result) value2) 90 + 91 + (* Test table isolation: same key in different tables *) 92 + let test_table_isolation key value1 value2 = 93 + Eio_main.run @@ fun env -> 94 + let cwd = Eio.Stdenv.cwd env in 95 + let tmp_dir = Eio.Path.(cwd / "_build" / "fuzz_sqlite") in 96 + (try Eio.Path.mkdirs ~exists_ok:true ~perm:0o755 tmp_dir with _ -> ()); 97 + let path = 98 + Eio.Path.(tmp_dir / Printf.sprintf "fuzz_%d.db" (Random.int 1_000_000)) 99 + in 100 + let db = Sqlite.create path in 101 + Fun.protect 102 + ~finally:(fun () -> 103 + Sqlite.close db; 104 + try Eio.Path.unlink path with _ -> ()) 105 + (fun () -> 106 + let t1 = Sqlite.Table.create db ~name:"table1" in 107 + let t2 = Sqlite.Table.create db ~name:"table2" in 108 + Sqlite.Table.put t1 key value1; 109 + Sqlite.Table.put t2 key value2; 110 + let r1 = Sqlite.Table.get t1 key in 111 + let r2 = Sqlite.Table.get t2 key in 112 + check_eq ~pp:Format.pp_print_string ~eq:( = ) (Option.get r1) value1; 113 + check_eq ~pp:Format.pp_print_string ~eq:( = ) (Option.get r2) value2) 114 + 115 + let () = 116 + (* Use bytes to allow arbitrary binary data including null bytes *) 117 + add_test ~name:"sqlite: roundtrip" [ bytes; bytes ] test_roundtrip; 118 + add_test ~name:"sqlite: delete removes" [ bytes; bytes ] test_delete_removes; 119 + add_test ~name:"sqlite: mem consistent" [ bytes; bytes ] test_mem_consistent; 120 + add_test ~name:"sqlite: overwrite" [ bytes; bytes; bytes ] test_overwrite; 121 + add_test ~name:"sqlite: table isolation" [ bytes; bytes; bytes ] test_table_isolation
+4
lib/dune
··· 1 + (library 2 + (name sqlite) 3 + (public_name sqlite) 4 + (libraries eio sqlite3))
+208
lib/sqlite.ml
··· 1 + (*--------------------------------------------------------------------------- 2 + Copyright (c) 2025 Thomas Gazagnaire. All rights reserved. 3 + SPDX-License-Identifier: MIT 4 + ---------------------------------------------------------------------------*) 5 + 6 + type t = { 7 + db : Sqlite3.db; 8 + get_stmt : Sqlite3.stmt; 9 + put_stmt : Sqlite3.stmt; 10 + delete_stmt : Sqlite3.stmt; 11 + mem_stmt : Sqlite3.stmt; 12 + iter_stmt : Sqlite3.stmt; 13 + } 14 + 15 + let check_rc db rc = 16 + if rc <> Sqlite3.Rc.OK && rc <> Sqlite3.Rc.DONE then 17 + failwith (Printf.sprintf "SQLite error: %s" (Sqlite3.errmsg db)) 18 + 19 + let create path = 20 + let path_str = snd (Eio.Path.split path) in 21 + let db = Sqlite3.db_open path_str in 22 + (* Enable WAL mode for concurrent access *) 23 + check_rc db (Sqlite3.exec db "PRAGMA journal_mode = WAL"); 24 + check_rc db (Sqlite3.exec db "PRAGMA synchronous = NORMAL"); 25 + (* Create default KV table *) 26 + check_rc db 27 + (Sqlite3.exec db 28 + "CREATE TABLE IF NOT EXISTS kv (key TEXT PRIMARY KEY, value BLOB NOT \ 29 + NULL)"); 30 + (* Prepare statements *) 31 + let get_stmt = Sqlite3.prepare db "SELECT value FROM kv WHERE key = ?" in 32 + let put_stmt = 33 + Sqlite3.prepare db 34 + "INSERT OR REPLACE INTO kv (key, value) VALUES (?, ?)" 35 + in 36 + let delete_stmt = Sqlite3.prepare db "DELETE FROM kv WHERE key = ?" in 37 + let mem_stmt = Sqlite3.prepare db "SELECT 1 FROM kv WHERE key = ?" in 38 + let iter_stmt = Sqlite3.prepare db "SELECT key, value FROM kv" in 39 + { db; get_stmt; put_stmt; delete_stmt; mem_stmt; iter_stmt } 40 + 41 + let get t key = 42 + let stmt = t.get_stmt in 43 + check_rc t.db (Sqlite3.reset stmt); 44 + check_rc t.db (Sqlite3.bind_text stmt 1 key); 45 + match Sqlite3.step stmt with 46 + | Sqlite3.Rc.ROW -> Some (Sqlite3.column_blob stmt 0) 47 + | Sqlite3.Rc.DONE -> None 48 + | rc -> failwith (Printf.sprintf "SQLite step error: %s" (Sqlite3.Rc.to_string rc)) 49 + 50 + let put t key value = 51 + let stmt = t.put_stmt in 52 + check_rc t.db (Sqlite3.reset stmt); 53 + check_rc t.db (Sqlite3.bind_text stmt 1 key); 54 + check_rc t.db (Sqlite3.bind_blob stmt 2 value); 55 + check_rc t.db (Sqlite3.step stmt) 56 + 57 + let delete t key = 58 + let stmt = t.delete_stmt in 59 + check_rc t.db (Sqlite3.reset stmt); 60 + check_rc t.db (Sqlite3.bind_text stmt 1 key); 61 + check_rc t.db (Sqlite3.step stmt) 62 + 63 + let mem t key = 64 + let stmt = t.mem_stmt in 65 + check_rc t.db (Sqlite3.reset stmt); 66 + check_rc t.db (Sqlite3.bind_text stmt 1 key); 67 + match Sqlite3.step stmt with 68 + | Sqlite3.Rc.ROW -> true 69 + | Sqlite3.Rc.DONE -> false 70 + | rc -> failwith (Printf.sprintf "SQLite step error: %s" (Sqlite3.Rc.to_string rc)) 71 + 72 + let iter t ~f = 73 + let stmt = t.iter_stmt in 74 + check_rc t.db (Sqlite3.reset stmt); 75 + let rec loop () = 76 + match Sqlite3.step stmt with 77 + | Sqlite3.Rc.ROW -> 78 + let key = Sqlite3.column_text stmt 0 in 79 + let value = Sqlite3.column_blob stmt 1 in 80 + f key value; 81 + loop () 82 + | Sqlite3.Rc.DONE -> () 83 + | rc -> failwith (Printf.sprintf "SQLite step error: %s" (Sqlite3.Rc.to_string rc)) 84 + in 85 + loop () 86 + 87 + let fold t ~init ~f = 88 + let acc = ref init in 89 + iter t ~f:(fun k v -> acc := f k v !acc); 90 + !acc 91 + 92 + let sync t = check_rc t.db (Sqlite3.exec t.db "PRAGMA wal_checkpoint(TRUNCATE)") 93 + 94 + let close t = 95 + ignore (Sqlite3.finalize t.get_stmt); 96 + ignore (Sqlite3.finalize t.put_stmt); 97 + ignore (Sqlite3.finalize t.delete_stmt); 98 + ignore (Sqlite3.finalize t.mem_stmt); 99 + ignore (Sqlite3.finalize t.iter_stmt); 100 + ignore (Sqlite3.db_close t.db) 101 + 102 + (* Namespaced Tables *) 103 + 104 + module Table = struct 105 + type db = t 106 + 107 + type t = { 108 + parent : db; 109 + name : string; 110 + get_stmt : Sqlite3.stmt; 111 + put_stmt : Sqlite3.stmt; 112 + delete_stmt : Sqlite3.stmt; 113 + mem_stmt : Sqlite3.stmt; 114 + iter_stmt : Sqlite3.stmt; 115 + } 116 + 117 + let valid_name name = 118 + String.length name > 0 119 + && String.for_all 120 + (fun c -> 121 + (c >= 'a' && c <= 'z') 122 + || (c >= 'A' && c <= 'Z') 123 + || (c >= '0' && c <= '9') 124 + || c = '_') 125 + name 126 + 127 + let create parent ~name = 128 + if not (valid_name name) then 129 + invalid_arg (Printf.sprintf "Invalid table name: %S" name); 130 + let table_name = name ^ "_kv" in 131 + let db = parent.db in 132 + (* Create table *) 133 + check_rc db 134 + (Sqlite3.exec db 135 + (Printf.sprintf 136 + "CREATE TABLE IF NOT EXISTS %s (key TEXT PRIMARY KEY, value BLOB \ 137 + NOT NULL)" 138 + table_name)); 139 + (* Prepare statements *) 140 + let get_stmt = 141 + Sqlite3.prepare db 142 + (Printf.sprintf "SELECT value FROM %s WHERE key = ?" table_name) 143 + in 144 + let put_stmt = 145 + Sqlite3.prepare db 146 + (Printf.sprintf "INSERT OR REPLACE INTO %s (key, value) VALUES (?, ?)" 147 + table_name) 148 + in 149 + let delete_stmt = 150 + Sqlite3.prepare db 151 + (Printf.sprintf "DELETE FROM %s WHERE key = ?" table_name) 152 + in 153 + let mem_stmt = 154 + Sqlite3.prepare db 155 + (Printf.sprintf "SELECT 1 FROM %s WHERE key = ?" table_name) 156 + in 157 + let iter_stmt = 158 + Sqlite3.prepare db 159 + (Printf.sprintf "SELECT key, value FROM %s" table_name) 160 + in 161 + { parent; name; get_stmt; put_stmt; delete_stmt; mem_stmt; iter_stmt } 162 + 163 + let get t key = 164 + let stmt = t.get_stmt in 165 + check_rc t.parent.db (Sqlite3.reset stmt); 166 + check_rc t.parent.db (Sqlite3.bind_text stmt 1 key); 167 + match Sqlite3.step stmt with 168 + | Sqlite3.Rc.ROW -> Some (Sqlite3.column_blob stmt 0) 169 + | Sqlite3.Rc.DONE -> None 170 + | rc -> failwith (Printf.sprintf "SQLite step error: %s" (Sqlite3.Rc.to_string rc)) 171 + 172 + let put t key value = 173 + let stmt = t.put_stmt in 174 + check_rc t.parent.db (Sqlite3.reset stmt); 175 + check_rc t.parent.db (Sqlite3.bind_text stmt 1 key); 176 + check_rc t.parent.db (Sqlite3.bind_blob stmt 2 value); 177 + check_rc t.parent.db (Sqlite3.step stmt) 178 + 179 + let delete t key = 180 + let stmt = t.delete_stmt in 181 + check_rc t.parent.db (Sqlite3.reset stmt); 182 + check_rc t.parent.db (Sqlite3.bind_text stmt 1 key); 183 + check_rc t.parent.db (Sqlite3.step stmt) 184 + 185 + let mem t key = 186 + let stmt = t.mem_stmt in 187 + check_rc t.parent.db (Sqlite3.reset stmt); 188 + check_rc t.parent.db (Sqlite3.bind_text stmt 1 key); 189 + match Sqlite3.step stmt with 190 + | Sqlite3.Rc.ROW -> true 191 + | Sqlite3.Rc.DONE -> false 192 + | rc -> failwith (Printf.sprintf "SQLite step error: %s" (Sqlite3.Rc.to_string rc)) 193 + 194 + let iter t ~f = 195 + let stmt = t.iter_stmt in 196 + check_rc t.parent.db (Sqlite3.reset stmt); 197 + let rec loop () = 198 + match Sqlite3.step stmt with 199 + | Sqlite3.Rc.ROW -> 200 + let key = Sqlite3.column_text stmt 0 in 201 + let value = Sqlite3.column_blob stmt 1 in 202 + f key value; 203 + loop () 204 + | Sqlite3.Rc.DONE -> () 205 + | rc -> failwith (Printf.sprintf "SQLite step error: %s" (Sqlite3.Rc.to_string rc)) 206 + in 207 + loop () 208 + end
+71
lib/sqlite.mli
··· 1 + (*--------------------------------------------------------------------------- 2 + Copyright (c) 2025 Thomas Gazagnaire. All rights reserved. 3 + SPDX-License-Identifier: MIT 4 + ---------------------------------------------------------------------------*) 5 + 6 + (** Minimal SQLite key-value store. 7 + 8 + A simple key-value store backed by SQLite with support for namespaced 9 + tables, WAL mode, and efficient batch operations. *) 10 + 11 + type t 12 + (** A SQLite-backed key-value store. *) 13 + 14 + val create : Eio.Fs.dir_ty Eio.Path.t -> t 15 + (** [create path] opens or creates a SQLite database at [path]. 16 + Enables WAL mode for concurrent access. *) 17 + 18 + val get : t -> string -> string option 19 + (** [get t key] returns the value for [key], or [None] if not found. *) 20 + 21 + val put : t -> string -> string -> unit 22 + (** [put t key value] stores [value] at [key], replacing any existing value. *) 23 + 24 + val delete : t -> string -> unit 25 + (** [delete t key] removes [key] from the store. No-op if key doesn't exist. *) 26 + 27 + val mem : t -> string -> bool 28 + (** [mem t key] is [true] if [key] exists in the store. *) 29 + 30 + val iter : t -> f:(string -> string -> unit) -> unit 31 + (** [iter t ~f] calls [f key value] for each entry in the store. *) 32 + 33 + val fold : t -> init:'a -> f:(string -> string -> 'a -> 'a) -> 'a 34 + (** [fold t ~init ~f] folds over all entries in the store. *) 35 + 36 + val sync : t -> unit 37 + (** [sync t] flushes to disk by performing a WAL checkpoint. *) 38 + 39 + val close : t -> unit 40 + (** [close t] closes the database connection. *) 41 + 42 + (** {1 Namespaced Tables} 43 + 44 + Tables provide isolated key-value namespaces within a single database. *) 45 + 46 + module Table : sig 47 + type db = t 48 + (** The parent database type. *) 49 + 50 + type t 51 + (** A namespaced table within a database. *) 52 + 53 + val create : db -> name:string -> t 54 + (** [create db ~name] creates or opens a table named [name] within [db]. 55 + The table name must be a valid SQL identifier. *) 56 + 57 + val get : t -> string -> string option 58 + (** [get t key] returns the value for [key], or [None]. *) 59 + 60 + val put : t -> string -> string -> unit 61 + (** [put t key value] stores [value] at [key]. *) 62 + 63 + val delete : t -> string -> unit 64 + (** [delete t key] removes [key] from the table. *) 65 + 66 + val mem : t -> string -> bool 67 + (** [mem t key] is [true] if [key] exists in the table. *) 68 + 69 + val iter : t -> f:(string -> string -> unit) -> unit 70 + (** [iter t ~f] calls [f key value] for each entry in the table. *) 71 + end
+82
test/cram/interop.t
··· 1 + Test interoperability with SQLite CLI 2 + 3 + Create a test database using our OCaml library: 4 + 5 + $ cat > test_create.ml << 'EOF' 6 + > let () = 7 + > Eio_main.run @@ fun env -> 8 + > let cwd = Eio.Stdenv.cwd env in 9 + > let db = Sqlite.create Eio.Path.(cwd / "test.db") in 10 + > Sqlite.put db "key1" "value1"; 11 + > Sqlite.put db "key2" "value2"; 12 + > Sqlite.put db "binary" "\x00\x01\x02\xff"; 13 + > let table = Sqlite.Table.create db ~name:"blocks" in 14 + > Sqlite.Table.put table "cid1" "block_data_1"; 15 + > Sqlite.Table.put table "cid2" "block_data_2"; 16 + > Sqlite.close db; 17 + > print_endline "Database created" 18 + > EOF 19 + 20 + $ ocamlfind ocamlopt -package sqlite,eio_main -linkpkg test_create.ml -o test_create 2>/dev/null || echo "Build requires dune" 21 + Build requires dune 22 + 23 + Skip CLI tests if sqlite3 CLI is not available: 24 + 25 + $ which sqlite3 >/dev/null 2>&1 || exit 0 26 + 27 + Create test database using sqlite3 CLI directly: 28 + 29 + $ sqlite3 cli_test.db "CREATE TABLE kv (key TEXT PRIMARY KEY, value BLOB NOT NULL)" 30 + $ sqlite3 cli_test.db "INSERT INTO kv VALUES ('hello', 'world')" 31 + $ sqlite3 cli_test.db "INSERT INTO kv VALUES ('test', 'data')" 32 + 33 + Verify data with CLI: 34 + 35 + $ sqlite3 cli_test.db "SELECT key, value FROM kv ORDER BY key" 36 + hello|world 37 + test|data 38 + 39 + Create OCaml reader to verify CLI-created database: 40 + 41 + $ cat > test_read.ml << 'EOF' 42 + > let () = 43 + > Eio_main.run @@ fun env -> 44 + > let cwd = Eio.Stdenv.cwd env in 45 + > let db = Sqlite.create Eio.Path.(cwd / "cli_test.db") in 46 + > (match Sqlite.get db "hello" with 47 + > | Some v -> Printf.printf "hello = %s\n" v 48 + > | None -> print_endline "hello not found"); 49 + > (match Sqlite.get db "test" with 50 + > | Some v -> Printf.printf "test = %s\n" v 51 + > | None -> print_endline "test not found"); 52 + > Sqlite.close db 53 + > EOF 54 + 55 + Test WAL mode pragma is set: 56 + 57 + $ sqlite3 cli_test.db "PRAGMA journal_mode" 58 + wal 59 + 60 + Verify table structure matches expected schema: 61 + 62 + $ sqlite3 cli_test.db ".schema kv" 63 + CREATE TABLE kv (key TEXT PRIMARY KEY, value BLOB NOT NULL); 64 + 65 + Test with namespaced tables: 66 + 67 + $ sqlite3 cli_test.db "CREATE TABLE blocks_kv (key TEXT PRIMARY KEY, value BLOB NOT NULL)" 68 + $ sqlite3 cli_test.db "INSERT INTO blocks_kv VALUES ('cid1', X'deadbeef')" 69 + $ sqlite3 cli_test.db "SELECT hex(value) FROM blocks_kv WHERE key = 'cid1'" 70 + DEADBEEF 71 + 72 + Verify tables exist: 73 + 74 + $ sqlite3 cli_test.db ".tables" | tr ' ' '\n' | sort | grep -v '^$' 75 + blocks_kv 76 + kv 77 + 78 + Clean up: 79 + 80 + $ rm -f cli_test.db cli_test.db-wal cli_test.db-shm 81 + $ rm -f test.db test.db-wal test.db-shm 82 + $ rm -f test_create.ml test_read.ml test_create
+6
test/dune
··· 1 + (test 2 + (name test) 3 + (libraries sqlite alcotest eio_main)) 4 + 5 + (cram 6 + (deps (package sqlite)))
+1
test/test.ml
··· 1 + let () = Alcotest.run "sqlite" Test_sqlite.suite
+290
test/test_sqlite.ml
··· 1 + (*--------------------------------------------------------------------------- 2 + Copyright (c) 2025 Thomas Gazagnaire. All rights reserved. 3 + SPDX-License-Identifier: MIT 4 + ---------------------------------------------------------------------------*) 5 + 6 + let with_temp_db f = 7 + Eio_main.run @@ fun env -> 8 + let fs = Eio.Stdenv.fs env in 9 + let cwd = Eio.Stdenv.cwd env in 10 + let tmp_dir = Eio.Path.(cwd / "_build" / "test_sqlite") in 11 + (try Eio.Path.mkdirs ~exists_ok:true ~perm:0o755 tmp_dir with _ -> ()); 12 + let path = 13 + Eio.Path.( 14 + tmp_dir / Printf.sprintf "test_%d.db" (Random.int 1_000_000)) 15 + in 16 + let db = Sqlite.create path in 17 + Fun.protect ~finally:(fun () -> Sqlite.close db) (fun () -> f fs db) 18 + 19 + (* Basic operations *) 20 + 21 + let test_put_get () = 22 + with_temp_db @@ fun _fs db -> 23 + Sqlite.put db "key1" "value1"; 24 + let result = Sqlite.get db "key1" in 25 + Alcotest.(check (option string)) "get returns put value" (Some "value1") result 26 + 27 + let test_get_missing () = 28 + with_temp_db @@ fun _fs db -> 29 + let result = Sqlite.get db "nonexistent" in 30 + Alcotest.(check (option string)) "missing key returns None" None result 31 + 32 + let test_put_overwrite () = 33 + with_temp_db @@ fun _fs db -> 34 + Sqlite.put db "key1" "value1"; 35 + Sqlite.put db "key1" "value2"; 36 + let result = Sqlite.get db "key1" in 37 + Alcotest.(check (option string)) "overwrite works" (Some "value2") result 38 + 39 + let test_delete () = 40 + with_temp_db @@ fun _fs db -> 41 + Sqlite.put db "key1" "value1"; 42 + Sqlite.delete db "key1"; 43 + let result = Sqlite.get db "key1" in 44 + Alcotest.(check (option string)) "delete removes key" None result 45 + 46 + let test_delete_missing () = 47 + with_temp_db @@ fun _fs db -> 48 + (* Should not raise *) 49 + Sqlite.delete db "nonexistent"; 50 + Alcotest.(check bool) "delete missing key is no-op" true true 51 + 52 + let test_mem () = 53 + with_temp_db @@ fun _fs db -> 54 + Sqlite.put db "key1" "value1"; 55 + Alcotest.(check bool) "mem finds existing key" true (Sqlite.mem db "key1"); 56 + Alcotest.(check bool) "mem returns false for missing" false (Sqlite.mem db "missing") 57 + 58 + let test_iter () = 59 + with_temp_db @@ fun _fs db -> 60 + Sqlite.put db "a" "1"; 61 + Sqlite.put db "b" "2"; 62 + Sqlite.put db "c" "3"; 63 + let items = ref [] in 64 + Sqlite.iter db ~f:(fun k v -> items := (k, v) :: !items); 65 + let sorted = List.sort compare !items in 66 + Alcotest.(check (list (pair string string))) 67 + "iter visits all entries" 68 + [ ("a", "1"); ("b", "2"); ("c", "3") ] 69 + sorted 70 + 71 + let test_fold () = 72 + with_temp_db @@ fun _fs db -> 73 + Sqlite.put db "a" "1"; 74 + Sqlite.put db "b" "2"; 75 + let count = Sqlite.fold db ~init:0 ~f:(fun _ _ acc -> acc + 1) in 76 + Alcotest.(check int) "fold counts entries" 2 count 77 + 78 + (* Binary data *) 79 + 80 + let test_binary_values () = 81 + with_temp_db @@ fun _fs db -> 82 + let binary = "\x00\x01\x02\xff\xfe\xfd" in 83 + Sqlite.put db "binary" binary; 84 + let result = Sqlite.get db "binary" in 85 + Alcotest.(check (option string)) "binary data preserved" (Some binary) result 86 + 87 + let test_empty_value () = 88 + with_temp_db @@ fun _fs db -> 89 + Sqlite.put db "empty" ""; 90 + let result = Sqlite.get db "empty" in 91 + Alcotest.(check (option string)) "empty value works" (Some "") result 92 + 93 + let test_large_value () = 94 + with_temp_db @@ fun _fs db -> 95 + let large = String.make 1_000_000 'x' in 96 + Sqlite.put db "large" large; 97 + let result = Sqlite.get db "large" in 98 + Alcotest.(check (option string)) "large value works" (Some large) result 99 + 100 + (* Namespaced tables *) 101 + 102 + let test_table_basic () = 103 + with_temp_db @@ fun _fs db -> 104 + let table = Sqlite.Table.create db ~name:"blocks" in 105 + Sqlite.Table.put table "cid1" "data1"; 106 + let result = Sqlite.Table.get table "cid1" in 107 + Alcotest.(check (option string)) "table get/put works" (Some "data1") result 108 + 109 + let test_table_isolation () = 110 + with_temp_db @@ fun _fs db -> 111 + let t1 = Sqlite.Table.create db ~name:"table1" in 112 + let t2 = Sqlite.Table.create db ~name:"table2" in 113 + Sqlite.Table.put t1 "key" "value1"; 114 + Sqlite.Table.put t2 "key" "value2"; 115 + (* Also put in default table *) 116 + Sqlite.put db "key" "default"; 117 + Alcotest.(check (option string)) "t1 isolated" (Some "value1") (Sqlite.Table.get t1 "key"); 118 + Alcotest.(check (option string)) "t2 isolated" (Some "value2") (Sqlite.Table.get t2 "key"); 119 + Alcotest.(check (option string)) "default isolated" (Some "default") (Sqlite.get db "key") 120 + 121 + let test_table_mem_delete () = 122 + with_temp_db @@ fun _fs db -> 123 + let table = Sqlite.Table.create db ~name:"test" in 124 + Sqlite.Table.put table "key1" "value1"; 125 + Alcotest.(check bool) "mem works" true (Sqlite.Table.mem table "key1"); 126 + Sqlite.Table.delete table "key1"; 127 + Alcotest.(check bool) "delete works" false (Sqlite.Table.mem table "key1") 128 + 129 + let test_table_iter () = 130 + with_temp_db @@ fun _fs db -> 131 + let table = Sqlite.Table.create db ~name:"iter_test" in 132 + Sqlite.Table.put table "a" "1"; 133 + Sqlite.Table.put table "b" "2"; 134 + let items = ref [] in 135 + Sqlite.Table.iter table ~f:(fun k v -> items := (k, v) :: !items); 136 + let sorted = List.sort compare !items in 137 + Alcotest.(check (list (pair string string))) 138 + "table iter works" 139 + [ ("a", "1"); ("b", "2") ] 140 + sorted 141 + 142 + (* Security tests - SQL injection resistance *) 143 + 144 + let test_sql_injection_key () = 145 + with_temp_db @@ fun _fs db -> 146 + (* These malicious keys should be treated as literal strings *) 147 + let malicious_keys = 148 + [ 149 + "'; DROP TABLE kv; --"; 150 + "key' OR '1'='1"; 151 + "key\"; DELETE FROM kv; --"; 152 + "key\x00injection"; 153 + "Robert'); DROP TABLE Students;--"; 154 + ] 155 + in 156 + List.iter 157 + (fun key -> 158 + Sqlite.put db key "value"; 159 + let result = Sqlite.get db key in 160 + Alcotest.(check (option string)) 161 + (Printf.sprintf "injection key %S safe" key) 162 + (Some "value") result) 163 + malicious_keys 164 + 165 + let test_sql_injection_value () = 166 + with_temp_db @@ fun _fs db -> 167 + let malicious_values = 168 + [ 169 + "'; DROP TABLE kv; --"; 170 + "value' OR '1'='1"; 171 + "\x00\x00\x00"; 172 + ] 173 + in 174 + List.iter 175 + (fun value -> 176 + Sqlite.put db "key" value; 177 + let result = Sqlite.get db "key" in 178 + Alcotest.(check (option string)) 179 + (Printf.sprintf "injection value safe") 180 + (Some value) result) 181 + malicious_values 182 + 183 + let test_table_name_validation () = 184 + with_temp_db @@ fun _fs db -> 185 + let invalid_names = 186 + [ 187 + ""; 188 + "table; DROP TABLE kv;"; 189 + "table'"; 190 + "table\""; 191 + "table\x00"; 192 + "table name"; 193 + "123start"; 194 + ] 195 + in 196 + List.iter 197 + (fun name -> 198 + try 199 + let _ = Sqlite.Table.create db ~name in 200 + Alcotest.fail (Printf.sprintf "should reject invalid name: %S" name) 201 + with Invalid_argument _ -> ()) 202 + invalid_names 203 + 204 + let test_valid_table_names () = 205 + with_temp_db @@ fun _fs db -> 206 + let valid_names = [ "blocks"; "refs"; "meta"; "Table1"; "my_table"; "a"; "A123_test" ] in 207 + List.iter 208 + (fun name -> 209 + let table = Sqlite.Table.create db ~name in 210 + Sqlite.Table.put table "key" "value"; 211 + let result = Sqlite.Table.get table "key" in 212 + Alcotest.(check (option string)) 213 + (Printf.sprintf "valid table %S works" name) 214 + (Some "value") result) 215 + valid_names 216 + 217 + (* Unicode and special characters *) 218 + 219 + let test_unicode_keys () = 220 + with_temp_db @@ fun _fs db -> 221 + let unicode_keys = [ "café"; "日本語"; "emoji🎉"; "Ω≈ç√∫" ] in 222 + List.iter 223 + (fun key -> 224 + Sqlite.put db key "value"; 225 + let result = Sqlite.get db key in 226 + Alcotest.(check (option string)) 227 + (Printf.sprintf "unicode key %S" key) 228 + (Some "value") result) 229 + unicode_keys 230 + 231 + let test_unicode_values () = 232 + with_temp_db @@ fun _fs db -> 233 + let unicode = "日本語テスト🎉" in 234 + Sqlite.put db "key" unicode; 235 + let result = Sqlite.get db "key" in 236 + Alcotest.(check (option string)) "unicode value" (Some unicode) result 237 + 238 + (* Sync *) 239 + 240 + let test_sync () = 241 + with_temp_db @@ fun _fs db -> 242 + Sqlite.put db "key" "value"; 243 + (* sync should not raise *) 244 + Sqlite.sync db; 245 + let result = Sqlite.get db "key" in 246 + Alcotest.(check (option string)) "data persists after sync" (Some "value") result 247 + 248 + let suite = 249 + [ 250 + ( "basic", 251 + [ 252 + Alcotest.test_case "put/get" `Quick test_put_get; 253 + Alcotest.test_case "get missing" `Quick test_get_missing; 254 + Alcotest.test_case "put overwrite" `Quick test_put_overwrite; 255 + Alcotest.test_case "delete" `Quick test_delete; 256 + Alcotest.test_case "delete missing" `Quick test_delete_missing; 257 + Alcotest.test_case "mem" `Quick test_mem; 258 + Alcotest.test_case "iter" `Quick test_iter; 259 + Alcotest.test_case "fold" `Quick test_fold; 260 + ] ); 261 + ( "binary", 262 + [ 263 + Alcotest.test_case "binary values" `Quick test_binary_values; 264 + Alcotest.test_case "empty value" `Quick test_empty_value; 265 + Alcotest.test_case "large value" `Quick test_large_value; 266 + ] ); 267 + ( "tables", 268 + [ 269 + Alcotest.test_case "basic" `Quick test_table_basic; 270 + Alcotest.test_case "isolation" `Quick test_table_isolation; 271 + Alcotest.test_case "mem/delete" `Quick test_table_mem_delete; 272 + Alcotest.test_case "iter" `Quick test_table_iter; 273 + ] ); 274 + ( "security", 275 + [ 276 + Alcotest.test_case "sql injection key" `Quick test_sql_injection_key; 277 + Alcotest.test_case "sql injection value" `Quick test_sql_injection_value; 278 + Alcotest.test_case "table name validation" `Quick test_table_name_validation; 279 + Alcotest.test_case "valid table names" `Quick test_valid_table_names; 280 + ] ); 281 + ( "unicode", 282 + [ 283 + Alcotest.test_case "unicode keys" `Quick test_unicode_keys; 284 + Alcotest.test_case "unicode values" `Quick test_unicode_values; 285 + ] ); 286 + ( "persistence", 287 + [ 288 + Alcotest.test_case "sync" `Quick test_sync; 289 + ] ); 290 + ]