Minimal SQLite key-value store for OCaml
at main 943 lines 33 kB view raw
1(*--------------------------------------------------------------------------- 2 Copyright (c) 2025 Thomas Gazagnaire. All rights reserved. 3 SPDX-License-Identifier: MIT 4 ---------------------------------------------------------------------------*) 5 6let 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 Eio.Io _ -> ()); 12 let path = Eio.Path.(tmp_dir / Fmt.str "test_%d.db" (Random.int 1_000_000)) in 13 Eio.Switch.run @@ fun sw -> 14 let db = Sqlite.v ~sw path in 15 Fun.protect ~finally:(fun () -> Sqlite.close db) (fun () -> f fs db) 16 17(* Basic operations *) 18 19let test_put_get () = 20 with_temp_db @@ fun _fs db -> 21 Sqlite.put db "key1" "value1"; 22 let result = Sqlite.find db "key1" in 23 Alcotest.(check (option string)) 24 "get returns put value" (Some "value1") result 25 26let test_get_missing () = 27 with_temp_db @@ fun _fs db -> 28 let result = Sqlite.find db "nonexistent" in 29 Alcotest.(check (option string)) "missing key returns None" None result 30 31let test_put_overwrite () = 32 with_temp_db @@ fun _fs db -> 33 Sqlite.put db "key1" "value1"; 34 Sqlite.put db "key1" "value2"; 35 let result = Sqlite.find db "key1" in 36 Alcotest.(check (option string)) "overwrite works" (Some "value2") result 37 38let test_delete () = 39 with_temp_db @@ fun _fs db -> 40 Sqlite.put db "key1" "value1"; 41 Sqlite.delete db "key1"; 42 let result = Sqlite.find db "key1" in 43 Alcotest.(check (option string)) "delete removes key" None result 44 45let test_delete_missing () = 46 with_temp_db @@ fun _fs db -> 47 (* Should not raise *) 48 Sqlite.delete db "nonexistent"; 49 Alcotest.(check bool) "delete missing key is no-op" true true 50 51let test_mem () = 52 with_temp_db @@ fun _fs db -> 53 Sqlite.put db "key1" "value1"; 54 Alcotest.(check bool) "mem finds existing key" true (Sqlite.mem db "key1"); 55 Alcotest.(check bool) 56 "mem returns false for missing" false (Sqlite.mem db "missing") 57 58let 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 71let 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 80let 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.find db "binary" in 85 Alcotest.(check (option string)) "binary data preserved" (Some binary) result 86 87let test_empty_value () = 88 with_temp_db @@ fun _fs db -> 89 Sqlite.put db "empty" ""; 90 let result = Sqlite.find db "empty" in 91 Alcotest.(check (option string)) "empty value works" (Some "") result 92 93let test_large_value () = 94 with_temp_db @@ fun _fs db -> 95 (* Note: B-tree has page splitting constraints limiting max entry size *) 96 let large = String.make 1000 'x' in 97 Sqlite.put db "large" large; 98 let result = Sqlite.find db "large" in 99 Alcotest.(check (option string)) "large value works" (Some large) result 100 101(* Namespaced tables *) 102 103let test_table_basic () = 104 with_temp_db @@ fun _fs db -> 105 let table = Sqlite.Table.create db ~name:"blocks" in 106 Sqlite.Table.put table "cid1" "data1"; 107 let result = Sqlite.Table.find table "cid1" in 108 Alcotest.(check (option string)) "table get/put works" (Some "data1") result 109 110let test_table_isolation () = 111 with_temp_db @@ fun _fs db -> 112 let t1 = Sqlite.Table.create db ~name:"table1" in 113 let t2 = Sqlite.Table.create db ~name:"table2" in 114 Sqlite.Table.put t1 "key" "value1"; 115 Sqlite.Table.put t2 "key" "value2"; 116 (* Also put in default table *) 117 Sqlite.put db "key" "default"; 118 Alcotest.(check (option string)) 119 "t1 isolated" (Some "value1") 120 (Sqlite.Table.find t1 "key"); 121 Alcotest.(check (option string)) 122 "t2 isolated" (Some "value2") 123 (Sqlite.Table.find t2 "key"); 124 Alcotest.(check (option string)) 125 "default isolated" (Some "default") (Sqlite.find db "key") 126 127let test_table_mem_delete () = 128 with_temp_db @@ fun _fs db -> 129 let table = Sqlite.Table.create db ~name:"test" in 130 Sqlite.Table.put table "key1" "value1"; 131 Alcotest.(check bool) "mem works" true (Sqlite.Table.mem table "key1"); 132 Sqlite.Table.delete table "key1"; 133 Alcotest.(check bool) "delete works" false (Sqlite.Table.mem table "key1") 134 135let test_table_iter () = 136 with_temp_db @@ fun _fs db -> 137 let table = Sqlite.Table.create db ~name:"iter_test" in 138 Sqlite.Table.put table "a" "1"; 139 Sqlite.Table.put table "b" "2"; 140 let items = ref [] in 141 Sqlite.Table.iter table ~f:(fun k v -> items := (k, v) :: !items); 142 let sorted = List.sort compare !items in 143 Alcotest.(check (list (pair string string))) 144 "table iter works" 145 [ ("a", "1"); ("b", "2") ] 146 sorted 147 148(* Security tests - SQL injection resistance *) 149 150let test_sql_injection_key () = 151 with_temp_db @@ fun _fs db -> 152 (* These malicious keys should be treated as literal strings *) 153 let malicious_keys = 154 [ 155 "'; DROP TABLE kv; --"; 156 "key' OR '1'='1"; 157 "key\"; DELETE FROM kv; --"; 158 "key\x00injection"; 159 "Robert'); DROP TABLE Students;--"; 160 ] 161 in 162 List.iter 163 (fun key -> 164 Sqlite.put db key "value"; 165 let result = Sqlite.find db key in 166 Alcotest.(check (option string)) 167 (Fmt.str "injection key %S safe" key) 168 (Some "value") result) 169 malicious_keys 170 171let test_sql_injection_value () = 172 with_temp_db @@ fun _fs db -> 173 let malicious_values = 174 [ "'; DROP TABLE kv; --"; "value' OR '1'='1"; "\x00\x00\x00" ] 175 in 176 List.iter 177 (fun value -> 178 Sqlite.put db "key" value; 179 let result = Sqlite.find db "key" in 180 Alcotest.(check (option string)) 181 (Fmt.str "injection value safe") 182 (Some value) result) 183 malicious_values 184 185let test_table_name_validation () = 186 with_temp_db @@ fun _fs db -> 187 let invalid_names = 188 [ 189 ""; 190 "table; DROP TABLE kv;"; 191 "table'"; 192 "table\""; 193 "table\x00"; 194 "table name"; 195 "123start"; 196 ] 197 in 198 List.iter 199 (fun name -> 200 try 201 let _ = Sqlite.Table.create db ~name in 202 Alcotest.failf "should reject invalid name: %S" name 203 with Invalid_argument _ -> ()) 204 invalid_names 205 206let test_valid_table_names () = 207 with_temp_db @@ fun _fs db -> 208 let valid_names = 209 [ "blocks"; "refs"; "meta"; "Table1"; "my_table"; "a"; "A123_test" ] 210 in 211 List.iter 212 (fun name -> 213 let table = Sqlite.Table.create db ~name in 214 Sqlite.Table.put table "key" "value"; 215 let result = Sqlite.Table.find table "key" in 216 Alcotest.(check (option string)) 217 (Fmt.str "valid table %S works" name) 218 (Some "value") result) 219 valid_names 220 221(* Unicode and special characters *) 222 223let test_unicode_keys () = 224 with_temp_db @@ fun _fs db -> 225 let unicode_keys = [ "café"; "日本語"; "emoji🎉"; "Ω≈ç√∫" ] in 226 List.iter 227 (fun key -> 228 Sqlite.put db key "value"; 229 let result = Sqlite.find db key in 230 Alcotest.(check (option string)) 231 (Fmt.str "unicode key %S" key) 232 (Some "value") result) 233 unicode_keys 234 235let test_unicode_values () = 236 with_temp_db @@ fun _fs db -> 237 let unicode = "日本語テスト🎉" in 238 Sqlite.put db "key" unicode; 239 let result = Sqlite.find db "key" in 240 Alcotest.(check (option string)) "unicode value" (Some unicode) result 241 242(* Sync *) 243 244let test_sync () = 245 with_temp_db @@ fun _fs db -> 246 Sqlite.put db "key" "value"; 247 (* sync should not raise *) 248 Sqlite.sync db; 249 let result = Sqlite.find db "key" in 250 Alcotest.(check (option string)) 251 "data persists after sync" (Some "value") result 252 253(* Persistence - critical for correctness *) 254 255let test_persistence_basic () = 256 Eio_main.run @@ fun env -> 257 let cwd = Eio.Stdenv.cwd env in 258 let tmp_dir = Eio.Path.(cwd / "_build" / "test_sqlite") in 259 (try Eio.Path.mkdirs ~exists_ok:true ~perm:0o755 tmp_dir with Eio.Io _ -> ()); 260 let path = 261 Eio.Path.(tmp_dir / Fmt.str "persist_%d.db" (Random.int 1_000_000)) 262 in 263 (* Create and write *) 264 Eio.Switch.run (fun sw -> 265 let db = Sqlite.v ~sw path in 266 Sqlite.put db "key1" "value1"; 267 Sqlite.put db "key2" "value2"; 268 Sqlite.close db); 269 (* Reopen and read *) 270 Eio.Switch.run (fun sw -> 271 let db = Sqlite.open_ ~sw path in 272 let r1 = Sqlite.find db "key1" in 273 let r2 = Sqlite.find db "key2" in 274 Alcotest.(check (option string)) "key1 persisted" (Some "value1") r1; 275 Alcotest.(check (option string)) "key2 persisted" (Some "value2") r2; 276 Sqlite.close db) 277 278let test_persistence_with_delete () = 279 Eio_main.run @@ fun env -> 280 let cwd = Eio.Stdenv.cwd env in 281 let tmp_dir = Eio.Path.(cwd / "_build" / "test_sqlite") in 282 (try Eio.Path.mkdirs ~exists_ok:true ~perm:0o755 tmp_dir with Eio.Io _ -> ()); 283 let path = 284 Eio.Path.(tmp_dir / Fmt.str "persist_del_%d.db" (Random.int 1_000_000)) 285 in 286 (* Create, write, delete *) 287 Eio.Switch.run (fun sw -> 288 let db = Sqlite.v ~sw path in 289 Sqlite.put db "keep" "value1"; 290 Sqlite.put db "delete" "value2"; 291 Sqlite.delete db "delete"; 292 Sqlite.close db); 293 (* Reopen and verify *) 294 Eio.Switch.run (fun sw -> 295 let db = Sqlite.open_ ~sw path in 296 let r1 = Sqlite.find db "keep" in 297 let r2 = Sqlite.find db "delete" in 298 Alcotest.(check (option string)) "kept key persisted" (Some "value1") r1; 299 Alcotest.(check (option string)) "deleted key gone" None r2; 300 Sqlite.close db) 301 302let test_persistence_tables () = 303 Eio_main.run @@ fun env -> 304 let cwd = Eio.Stdenv.cwd env in 305 let tmp_dir = Eio.Path.(cwd / "_build" / "test_sqlite") in 306 (try Eio.Path.mkdirs ~exists_ok:true ~perm:0o755 tmp_dir with Eio.Io _ -> ()); 307 let path = 308 Eio.Path.(tmp_dir / Fmt.str "persist_tbl_%d.db" (Random.int 1_000_000)) 309 in 310 (* Create with tables *) 311 Eio.Switch.run (fun sw -> 312 let db = Sqlite.v ~sw path in 313 let t1 = Sqlite.Table.create db ~name:"blocks" in 314 let t2 = Sqlite.Table.create db ~name:"refs" in 315 Sqlite.Table.put t1 "cid1" "data1"; 316 Sqlite.Table.put t2 "head" "cid123"; 317 Sqlite.close db); 318 (* Reopen and verify tables *) 319 Eio.Switch.run (fun sw -> 320 let db = Sqlite.open_ ~sw path in 321 let t1 = Sqlite.Table.create db ~name:"blocks" in 322 let t2 = Sqlite.Table.create db ~name:"refs" in 323 let r1 = Sqlite.Table.find t1 "cid1" in 324 let r2 = Sqlite.Table.find t2 "head" in 325 Alcotest.(check (option string)) "table1 data persisted" (Some "data1") r1; 326 Alcotest.(check (option string)) 327 "table2 data persisted" (Some "cid123") r2; 328 Sqlite.close db) 329 330(* Edge cases *) 331 332let test_empty_key () = 333 with_temp_db @@ fun _fs db -> 334 Sqlite.put db "" "value_for_empty_key"; 335 let result = Sqlite.find db "" in 336 Alcotest.(check (option string)) 337 "empty key works" (Some "value_for_empty_key") result 338 339let test_key_with_nulls () = 340 with_temp_db @@ fun _fs db -> 341 let key = "key\x00with\x00nulls" in 342 let value = "value\x00also\x00has\x00nulls" in 343 Sqlite.put db key value; 344 let result = Sqlite.find db key in 345 Alcotest.(check (option string)) "null bytes preserved" (Some value) result 346 347let test_long_key () = 348 with_temp_db @@ fun _fs db -> 349 (* Note: B-tree has page splitting constraints limiting max entry size *) 350 let key = String.make 500 'k' in 351 let value = "value" in 352 Sqlite.put db key value; 353 let result = Sqlite.find db key in 354 Alcotest.(check (option string)) "long key works" (Some value) result 355 356let test_all_byte_values () = 357 with_temp_db @@ fun _fs db -> 358 (* Test all possible byte values in keys and values *) 359 let all_bytes = String.init 256 Char.chr in 360 Sqlite.put db all_bytes all_bytes; 361 let result = Sqlite.find db all_bytes in 362 Alcotest.(check (option string)) 363 "all byte values preserved" (Some all_bytes) result 364 365let test_max_int_key_length () = 366 with_temp_db @@ fun _fs db -> 367 (* Test key length near encoding boundaries *) 368 let lengths = [ 127; 128; 255; 256; 400 ] in 369 List.iter 370 (fun len -> 371 let key = String.make len 'x' in 372 let value = Fmt.str "value_%d" len in 373 Sqlite.put db key value; 374 let result = Sqlite.find db key in 375 Alcotest.(check (option string)) 376 (Fmt.str "key length %d" len) 377 (Some value) result) 378 lengths 379 380(* Stress tests *) 381 382let test_many_keys () = 383 with_temp_db @@ fun _fs db -> 384 let n = 1000 in 385 (* Insert many keys *) 386 for i = 0 to n - 1 do 387 Sqlite.put db (Fmt.str "key_%05d" i) (Fmt.str "value_%d" i) 388 done; 389 (* Verify all present *) 390 for i = 0 to n - 1 do 391 let result = Sqlite.find db (Fmt.str "key_%05d" i) in 392 Alcotest.(check (option string)) 393 (Fmt.str "key %d present" i) 394 (Some (Fmt.str "value_%d" i)) 395 result 396 done 397 398let test_many_updates () = 399 with_temp_db @@ fun _fs db -> 400 let n = 100 in 401 (* Update same key many times *) 402 for i = 0 to n - 1 do 403 Sqlite.put db "key" (Fmt.str "value_%d" i) 404 done; 405 let result = Sqlite.find db "key" in 406 Alcotest.(check (option string)) 407 "final value" 408 (Some (Fmt.str "value_%d" (n - 1))) 409 result 410 411let test_interleaved_operations () = 412 with_temp_db @@ fun _fs db -> 413 (* Mix of puts, gets, deletes *) 414 for i = 0 to 99 do 415 Sqlite.put db (Fmt.str "a_%d" i) "value"; 416 Sqlite.put db (Fmt.str "b_%d" i) "value"; 417 if i mod 2 = 0 then Sqlite.delete db (Fmt.str "a_%d" i) 418 done; 419 (* Verify state *) 420 let a_count = ref 0 in 421 let b_count = ref 0 in 422 Sqlite.iter db ~f:(fun k _ -> 423 if String.length k > 2 && k.[0] = 'a' then incr a_count 424 else if String.length k > 2 && k.[0] = 'b' then incr b_count); 425 Alcotest.(check int) "a keys (half deleted)" 50 !a_count; 426 Alcotest.(check int) "b keys (all present)" 100 !b_count 427 428(* Multiple tables stress *) 429 430let test_many_tables () = 431 with_temp_db @@ fun _fs db -> 432 let n = 20 in 433 (* Create many tables *) 434 let tables = 435 Array.init n (fun i -> Sqlite.Table.create db ~name:(Fmt.str "table%d" i)) 436 in 437 (* Write to all tables *) 438 Array.iteri 439 (fun i t -> Sqlite.Table.put t "key" (Fmt.str "value_%d" i)) 440 tables; 441 (* Verify isolation *) 442 Array.iteri 443 (fun i t -> 444 let result = Sqlite.Table.find t "key" in 445 Alcotest.(check (option string)) 446 (Fmt.str "table %d" i) 447 (Some (Fmt.str "value_%d" i)) 448 result) 449 tables 450 451(* Regression tests based on SQLite CVE patterns *) 452 453let test_cve_key_overflow () = 454 with_temp_db @@ fun _fs db -> 455 (* Ensure large key doesn't cause integer overflow in length encoding *) 456 let key = String.make 500 'x' in 457 Sqlite.put db key "value"; 458 let result = Sqlite.find db key in 459 Alcotest.(check (option string)) "large key no overflow" (Some "value") result 460 461let test_cve_like_boundary_conditions () = 462 with_temp_db @@ fun _fs db -> 463 (* Test boundary conditions within B-tree page constraints *) 464 let sizes = [ 100; 200; 300; 400; 500 ] in 465 List.iter 466 (fun size -> 467 let key = Fmt.str "key_%d" size in 468 let value = String.make size 'v' in 469 Sqlite.put db key value; 470 let result = Sqlite.find db key in 471 Alcotest.(check (option string)) 472 (Fmt.str "boundary size %d" size) 473 (Some value) result) 474 sizes 475 476(* CREATE TABLE parser tests *) 477 478let check_columns msg expected actual = 479 let pp_col ppf (c : Sqlite.column) = 480 Fmt.pf ppf "{name=%S; affinity=%S; rowid=%b}" c.col_name c.col_affinity 481 c.col_is_rowid_alias 482 in 483 let col_eq (a : Sqlite.column) (b : Sqlite.column) = 484 a.col_name = b.col_name 485 && a.col_affinity = b.col_affinity 486 && a.col_is_rowid_alias = b.col_is_rowid_alias 487 in 488 let col_testable = Alcotest.testable pp_col col_eq in 489 Alcotest.(check (list col_testable)) msg expected actual 490 491let test_parse_simple () = 492 let cols = 493 Sqlite.parse_create_table "CREATE TABLE kv (key TEXT, value BLOB)" 494 in 495 check_columns "simple kv schema" 496 [ 497 { col_name = "key"; col_affinity = "TEXT"; col_is_rowid_alias = false }; 498 { col_name = "value"; col_affinity = "BLOB"; col_is_rowid_alias = false }; 499 ] 500 cols 501 502let test_parse_integer_primary_key () = 503 let cols = 504 Sqlite.parse_create_table 505 "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, age INTEGER)" 506 in 507 check_columns "integer primary key" 508 [ 509 { col_name = "id"; col_affinity = "INTEGER"; col_is_rowid_alias = true }; 510 { col_name = "name"; col_affinity = "TEXT"; col_is_rowid_alias = false }; 511 { col_name = "age"; col_affinity = "INTEGER"; col_is_rowid_alias = false }; 512 ] 513 cols 514 515let test_parse_if_not_exists () = 516 let cols = 517 Sqlite.parse_create_table 518 "CREATE TABLE IF NOT EXISTS foo (bar TEXT, baz REAL)" 519 in 520 check_columns "if not exists" 521 [ 522 { col_name = "bar"; col_affinity = "TEXT"; col_is_rowid_alias = false }; 523 { col_name = "baz"; col_affinity = "REAL"; col_is_rowid_alias = false }; 524 ] 525 cols 526 527let test_parse_nested_parens () = 528 let cols = 529 Sqlite.parse_create_table 530 "CREATE TABLE t (a DECIMAL(10,2), b VARCHAR(255) NOT NULL)" 531 in 532 check_columns "nested parens in types" 533 [ 534 { 535 col_name = "a"; 536 col_affinity = "DECIMAL(10,2)"; 537 col_is_rowid_alias = false; 538 }; 539 { 540 col_name = "b"; 541 col_affinity = "VARCHAR(255)"; 542 col_is_rowid_alias = false; 543 }; 544 ] 545 cols 546 547let test_parse_table_constraints () = 548 let cols = 549 Sqlite.parse_create_table 550 "CREATE TABLE t (a INTEGER, b TEXT, PRIMARY KEY(a), UNIQUE(b))" 551 in 552 check_columns "table-level constraints skipped" 553 [ 554 { col_name = "a"; col_affinity = "INTEGER"; col_is_rowid_alias = false }; 555 { col_name = "b"; col_affinity = "TEXT"; col_is_rowid_alias = false }; 556 ] 557 cols 558 559let test_parse_no_type () = 560 let cols = Sqlite.parse_create_table "CREATE TABLE t (a, b, c)" in 561 check_columns "columns without types" 562 [ 563 { col_name = "a"; col_affinity = ""; col_is_rowid_alias = false }; 564 { col_name = "b"; col_affinity = ""; col_is_rowid_alias = false }; 565 { col_name = "c"; col_affinity = ""; col_is_rowid_alias = false }; 566 ] 567 cols 568 569let test_parse_autoincrement () = 570 let cols = 571 Sqlite.parse_create_table 572 "CREATE TABLE t (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT)" 573 in 574 check_columns "autoincrement" 575 [ 576 { col_name = "id"; col_affinity = "INTEGER"; col_is_rowid_alias = true }; 577 { col_name = "name"; col_affinity = "TEXT"; col_is_rowid_alias = false }; 578 ] 579 cols 580 581let test_parse_invalid () = 582 let cols = Sqlite.parse_create_table "not valid sql at all" in 583 Alcotest.(check int) "invalid sql returns empty" 0 (List.length cols) 584 585(* Generic table read tests *) 586 587let with_temp_path f = 588 Eio_main.run @@ fun env -> 589 let fs = Eio.Stdenv.fs env in 590 let tmp_dir = "/tmp/test_sqlite" in 591 (try Eio.Path.mkdirs ~exists_ok:true ~perm:0o755 Eio.Path.(fs / tmp_dir) 592 with Eio.Io _ -> ()); 593 let fpath = Fmt.str "%s/test_%d.db" tmp_dir (Random.int 1_000_000) in 594 let path = Eio.Path.(fs / fpath) in 595 Fun.protect 596 ~finally:(fun () -> try Sys.remove fpath with Sys_error _ -> ()) 597 (fun () -> f env fpath path) 598 599let test_open_no_kv () = 600 with_temp_path @@ fun _env fpath path -> 601 let rc = 602 Sys.command 603 (Fmt.str 604 "sqlite3 '%s' \"CREATE TABLE users (id INTEGER PRIMARY KEY, name \ 605 TEXT, age INTEGER)\"" 606 fpath) 607 in 608 if rc <> 0 then Alcotest.skip (); 609 Eio.Switch.run @@ fun sw -> 610 let t = Sqlite.open_ ~sw path in 611 let schemas = Sqlite.tables t in 612 Alcotest.(check int) "one table" 1 (List.length schemas); 613 let s = List.hd schemas in 614 Alcotest.(check string) "table name" "users" s.Sqlite.tbl_name; 615 Alcotest.(check int) "3 columns" 3 (List.length s.Sqlite.columns); 616 (* KV API should fail *) 617 (try 618 Sqlite.iter t ~f:(fun _ _ -> ()); 619 Alcotest.fail "should have raised" 620 with Failure _ -> ()); 621 Sqlite.close t 622 623let test_read_generic_table () = 624 with_temp_path @@ fun _env fpath path -> 625 let rc = 626 Sys.command 627 (Fmt.str 628 "sqlite3 '%s' \"CREATE TABLE users (id INTEGER PRIMARY KEY, name \ 629 TEXT, age INTEGER); INSERT INTO users VALUES (1, 'Alice', 30); \ 630 INSERT INTO users VALUES (2, 'Bob', 25);\"" 631 fpath) 632 in 633 if rc <> 0 then Alcotest.skip (); 634 Eio.Switch.run @@ fun sw -> 635 let t = Sqlite.open_ ~sw path in 636 let rows = Sqlite.read_table t "users" in 637 Alcotest.(check int) "2 rows" 2 (List.length rows); 638 let _rowid1, values1 = List.nth rows 0 in 639 (match values1 with 640 | [ Sqlite.Vint 1L; Sqlite.Vtext "Alice"; Sqlite.Vint 30L ] -> () 641 | _ -> 642 Alcotest.failf "unexpected row 1: %a" Fmt.(list Sqlite.pp_value) values1); 643 let _rowid2, values2 = List.nth rows 1 in 644 (match values2 with 645 | [ Sqlite.Vint 2L; Sqlite.Vtext "Bob"; Sqlite.Vint 25L ] -> () 646 | _ -> 647 Alcotest.failf "unexpected row 2: %a" Fmt.(list Sqlite.pp_value) values2); 648 Sqlite.close t 649 650let test_integer_primary_key () = 651 with_temp_path @@ fun _env fpath path -> 652 let rc = 653 Sys.command 654 (Fmt.str 655 "sqlite3 '%s' \"CREATE TABLE t (id INTEGER PRIMARY KEY, val TEXT); \ 656 INSERT INTO t VALUES (42, 'hello');\"" 657 fpath) 658 in 659 if rc <> 0 then Alcotest.skip (); 660 Eio.Switch.run @@ fun sw -> 661 let t = Sqlite.open_ ~sw path in 662 let rows = Sqlite.read_table t "t" in 663 Alcotest.(check int) "1 row" 1 (List.length rows); 664 let rowid, values = List.hd rows in 665 Alcotest.(check int64) "rowid is 42" 42L rowid; 666 (match values with 667 | [ Sqlite.Vint 42L; Sqlite.Vtext "hello" ] -> () 668 | _ -> 669 Alcotest.failf "expected [Vint 42; Vtext hello], got: %a" 670 Fmt.(list Sqlite.pp_value) 671 values); 672 Sqlite.close t 673 674let test_tables_lists_all () = 675 with_temp_path @@ fun _env fpath path -> 676 let rc = 677 Sys.command 678 (Fmt.str 679 "sqlite3 '%s' \"CREATE TABLE t1 (a TEXT); CREATE TABLE t2 (b INTEGER, \ 680 c REAL);\"" 681 fpath) 682 in 683 if rc <> 0 then Alcotest.skip (); 684 Eio.Switch.run @@ fun sw -> 685 let t = Sqlite.open_ ~sw path in 686 let schemas = Sqlite.tables t in 687 let names = 688 List.map (fun (s : Sqlite.schema) -> s.tbl_name) schemas 689 |> List.sort String.compare 690 in 691 Alcotest.(check (list string)) "table names" [ "t1"; "t2" ] names; 692 Sqlite.close t 693 694let sum_int_values _rowid values acc = 695 match values with [ Sqlite.Vint n ] -> Int64.add acc n | _ -> acc 696 697let test_fold_table () = 698 with_temp_path @@ fun _env fpath path -> 699 let rc = 700 Sys.command 701 (Fmt.str 702 "sqlite3 '%s' \"CREATE TABLE nums (n INTEGER); INSERT INTO nums \ 703 VALUES (10); INSERT INTO nums VALUES (20); INSERT INTO nums VALUES \ 704 (30);\"" 705 fpath) 706 in 707 if rc <> 0 then Alcotest.skip (); 708 Eio.Switch.run @@ fun sw -> 709 let t = Sqlite.open_ ~sw path in 710 let sum = Sqlite.fold_table t "nums" ~init:0L ~f:sum_int_values in 711 Alcotest.(check int64) "sum of values" 60L sum; 712 Sqlite.close t 713 714(* ---- SQLite file format spec test vectors ---- *) 715 716let with_temp_db_path f = 717 Eio_main.run @@ fun env -> 718 let cwd = Eio.Stdenv.cwd env in 719 let tmp_dir = Eio.Path.(cwd / "_build" / "test_sqlite") in 720 (try Eio.Path.mkdirs ~exists_ok:true ~perm:0o755 tmp_dir with Eio.Io _ -> ()); 721 let path = Eio.Path.(tmp_dir / Fmt.str "spec_%d.db" (Random.int 1_000_000)) in 722 Eio.Switch.run @@ fun sw -> 723 let db = Sqlite.v ~sw path in 724 Fun.protect ~finally:(fun () -> Sqlite.close db) (fun () -> f path db) 725 726(* Section 1.2: Database header byte-level verification *) 727let test_db_header_magic () = 728 with_temp_db_path @@ fun path db -> 729 Sqlite.sync db; 730 let data = Eio.Path.load path in 731 let magic = String.sub data 0 16 in 732 Alcotest.(check string) "magic" "SQLite format 3\000" magic 733 734let test_db_header_fixed_values () = 735 with_temp_db_path @@ fun path db -> 736 Sqlite.sync db; 737 let data = Eio.Path.load path in 738 (* Offset 16-17: page size (4096 = 0x10 0x00) *) 739 Alcotest.(check int) "page size hi" 0x10 (Char.code data.[16]); 740 Alcotest.(check int) "page size lo" 0x00 (Char.code data.[17]); 741 (* Offset 18: write version = 1 (legacy) *) 742 Alcotest.(check int) "write version" 1 (Char.code data.[18]); 743 (* Offset 19: read version = 1 (legacy) *) 744 Alcotest.(check int) "read version" 1 (Char.code data.[19]); 745 (* Offset 20: reserved bytes = 0 *) 746 Alcotest.(check int) "reserved" 0 (Char.code data.[20]); 747 (* Offset 21: max_embedded_payload_fraction = 64 (MUST be 64) *) 748 Alcotest.(check int) "max payload fraction" 64 (Char.code data.[21]); 749 (* Offset 22: min_embedded_payload_fraction = 32 (MUST be 32) *) 750 Alcotest.(check int) "min payload fraction" 32 (Char.code data.[22]); 751 (* Offset 23: leaf_payload_fraction = 32 (MUST be 32) *) 752 Alcotest.(check int) "leaf payload fraction" 32 (Char.code data.[23]); 753 (* Offset 44: schema format = 4 *) 754 let schema_format = 755 (Char.code data.[44] lsl 24) 756 lor (Char.code data.[45] lsl 16) 757 lor (Char.code data.[46] lsl 8) 758 lor Char.code data.[47] 759 in 760 Alcotest.(check int) "schema format" 4 schema_format; 761 (* Offset 56: text encoding = 1 (UTF-8) *) 762 let encoding = 763 (Char.code data.[56] lsl 24) 764 lor (Char.code data.[57] lsl 16) 765 lor (Char.code data.[58] lsl 8) 766 lor Char.code data.[59] 767 in 768 Alcotest.(check int) "text encoding UTF-8" 1 encoding; 769 (* Offset 72-91: reserved for expansion = all zeros *) 770 for i = 72 to 91 do 771 Alcotest.(check int) (Fmt.str "reserved byte %d" i) 0 (Char.code data.[i]) 772 done 773 774let test_db_header_change_counter () = 775 with_temp_db_path @@ fun path db -> 776 Sqlite.put db "key" "value"; 777 Sqlite.sync db; 778 let data = Eio.Path.load path in 779 let read_u32 off = 780 (Char.code data.[off] lsl 24) 781 lor (Char.code data.[off + 1] lsl 16) 782 lor (Char.code data.[off + 2] lsl 8) 783 lor Char.code data.[off + 3] 784 in 785 let change_counter = read_u32 24 in 786 let version_valid_for = read_u32 92 in 787 Alcotest.(check int) 788 "change_counter == version_valid_for" change_counter version_valid_for 789 790(* Section 1.5: Page 1 B-tree header at offset 100 *) 791let test_page1_btree_header () = 792 with_temp_db_path @@ fun path db -> 793 Sqlite.sync db; 794 let data = Eio.Path.load path in 795 (* Offset 100: page type = 0x0d (leaf table) *) 796 Alcotest.(check int) "page1 type" 0x0d (Char.code data.[100]); 797 (* Offset 107: fragmented bytes <= 60 *) 798 Alcotest.(check bool) "fragmented <= 60" true (Char.code data.[107] <= 60) 799 800(* Section 2.1: sqlite_schema table format — 801 columns: type, name, tbl_name, rootpage, sql *) 802let test_sqlite_schema_format () = 803 with_temp_db @@ fun _fs db -> 804 let table = Sqlite.Table.create db ~name:"test_table" in 805 Sqlite.Table.put table "key" "value"; 806 let schemas = Sqlite.tables db in 807 let names = 808 List.map (fun (s : Sqlite.schema) -> s.tbl_name) schemas 809 |> List.sort String.compare 810 in 811 (* Should have both the default kv table and test_table *) 812 Alcotest.(check bool) "has test_table" true (List.mem "test_table" names) 813 814(* Overflow values in SQLite-compatible files *) 815let test_sqlite_overflow_values () = 816 with_temp_db @@ fun _fs db -> 817 (* Values larger than max_local (4061 for 4096-byte pages) *) 818 let large = String.make 5000 'X' in 819 Sqlite.put db "overflow_key" large; 820 let result = Sqlite.find db "overflow_key" in 821 Alcotest.(check (option string)) 822 "overflow value roundtrip" (Some large) result 823 824let test_sqlite_overflow_persistence () = 825 Eio_main.run @@ fun env -> 826 let cwd = Eio.Stdenv.cwd env in 827 let tmp_dir = Eio.Path.(cwd / "_build" / "test_sqlite") in 828 (try Eio.Path.mkdirs ~exists_ok:true ~perm:0o755 tmp_dir with Eio.Io _ -> ()); 829 let path = 830 Eio.Path.(tmp_dir / Fmt.str "overflow_%d.db" (Random.int 1_000_000)) 831 in 832 let large = String.make 10000 'Y' in 833 (* Write *) 834 Eio.Switch.run (fun sw -> 835 let db = Sqlite.v ~sw path in 836 Sqlite.put db "big" large; 837 Sqlite.close db); 838 (* Read back *) 839 Eio.Switch.run (fun sw -> 840 let db = Sqlite.open_ ~sw path in 841 let result = Sqlite.find db "big" in 842 Alcotest.(check (option string)) "overflow persists" (Some large) result; 843 Sqlite.close db) 844 845let suite = 846 ( "sqlite", 847 List.concat 848 [ 849 [ 850 Alcotest.test_case "put/get" `Quick test_put_get; 851 Alcotest.test_case "get missing" `Quick test_get_missing; 852 Alcotest.test_case "put overwrite" `Quick test_put_overwrite; 853 Alcotest.test_case "delete" `Quick test_delete; 854 Alcotest.test_case "delete missing" `Quick test_delete_missing; 855 Alcotest.test_case "mem" `Quick test_mem; 856 Alcotest.test_case "iter" `Quick test_iter; 857 Alcotest.test_case "fold" `Quick test_fold; 858 ]; 859 [ 860 Alcotest.test_case "binary values" `Quick test_binary_values; 861 Alcotest.test_case "empty value" `Quick test_empty_value; 862 Alcotest.test_case "large value" `Quick test_large_value; 863 ]; 864 [ 865 Alcotest.test_case "table basic" `Quick test_table_basic; 866 Alcotest.test_case "table isolation" `Quick test_table_isolation; 867 Alcotest.test_case "table mem/delete" `Quick test_table_mem_delete; 868 Alcotest.test_case "table iter" `Quick test_table_iter; 869 ]; 870 [ 871 Alcotest.test_case "sql injection key" `Quick test_sql_injection_key; 872 Alcotest.test_case "sql injection value" `Quick 873 test_sql_injection_value; 874 Alcotest.test_case "table name validation" `Quick 875 test_table_name_validation; 876 Alcotest.test_case "valid table names" `Quick test_valid_table_names; 877 ]; 878 [ 879 Alcotest.test_case "unicode keys" `Quick test_unicode_keys; 880 Alcotest.test_case "unicode values" `Quick test_unicode_values; 881 ]; 882 [ 883 Alcotest.test_case "sync" `Quick test_sync; 884 Alcotest.test_case "persistence basic" `Quick test_persistence_basic; 885 Alcotest.test_case "persistence with delete" `Quick 886 test_persistence_with_delete; 887 Alcotest.test_case "persistence tables" `Quick test_persistence_tables; 888 ]; 889 [ 890 Alcotest.test_case "empty key" `Quick test_empty_key; 891 Alcotest.test_case "key with nulls" `Quick test_key_with_nulls; 892 Alcotest.test_case "long key" `Quick test_long_key; 893 Alcotest.test_case "all byte values" `Quick test_all_byte_values; 894 Alcotest.test_case "max int key length" `Quick test_max_int_key_length; 895 ]; 896 [ 897 Alcotest.test_case "many keys" `Slow test_many_keys; 898 Alcotest.test_case "many updates" `Quick test_many_updates; 899 Alcotest.test_case "interleaved ops" `Quick 900 test_interleaved_operations; 901 Alcotest.test_case "many tables" `Quick test_many_tables; 902 ]; 903 [ 904 Alcotest.test_case "overflow key length" `Quick test_cve_key_overflow; 905 Alcotest.test_case "boundary conditions" `Quick 906 test_cve_like_boundary_conditions; 907 ]; 908 [ 909 Alcotest.test_case "parse simple" `Quick test_parse_simple; 910 Alcotest.test_case "parse integer pk" `Quick 911 test_parse_integer_primary_key; 912 Alcotest.test_case "parse if not exists" `Quick 913 test_parse_if_not_exists; 914 Alcotest.test_case "parse nested parens" `Quick 915 test_parse_nested_parens; 916 Alcotest.test_case "parse table constraints" `Quick 917 test_parse_table_constraints; 918 Alcotest.test_case "parse no type" `Quick test_parse_no_type; 919 Alcotest.test_case "parse autoincrement" `Quick 920 test_parse_autoincrement; 921 Alcotest.test_case "parse invalid" `Quick test_parse_invalid; 922 Alcotest.test_case "open no kv" `Quick test_open_no_kv; 923 Alcotest.test_case "read generic table" `Quick test_read_generic_table; 924 Alcotest.test_case "integer primary key" `Quick 925 test_integer_primary_key; 926 Alcotest.test_case "tables lists all" `Quick test_tables_lists_all; 927 Alcotest.test_case "fold table" `Quick test_fold_table; 928 ]; 929 [ 930 Alcotest.test_case "spec header magic" `Quick test_db_header_magic; 931 Alcotest.test_case "spec header values" `Quick 932 test_db_header_fixed_values; 933 Alcotest.test_case "spec change counter" `Quick 934 test_db_header_change_counter; 935 Alcotest.test_case "spec page1 btree" `Quick test_page1_btree_header; 936 Alcotest.test_case "spec schema format" `Quick 937 test_sqlite_schema_format; 938 Alcotest.test_case "spec overflow values" `Quick 939 test_sqlite_overflow_values; 940 Alcotest.test_case "spec overflow persist" `Quick 941 test_sqlite_overflow_persistence; 942 ]; 943 ] )