Minimal SQLite key-value store for OCaml
at main 253 lines 7.3 kB view raw
1open Cmdliner 2 3let log_src = Logs.Src.create "sql" 4 5module Log = (val Logs.src_log log_src : Logs.LOG) 6 7(* -- write subcommand -- *) 8 9let open_or_create ~sw path = 10 try Sqlite.open_ ~sw path with Failure _ | Eio.Io _ -> Sqlite.v ~sw path 11 12let write_entry t (k, v) = 13 Log.info (fun m -> m "put %s=%s" k v); 14 Sqlite.put t k v 15 16let write () db pairs = 17 Eio_main.run @@ fun env -> 18 let cwd = Eio.Stdenv.cwd env in 19 Eio.Switch.run @@ fun sw -> 20 let path = Eio.Path.(cwd / db) in 21 let t = open_or_create ~sw path in 22 List.iter (write_entry t) pairs; 23 Sqlite.close t; 24 Log.info (fun m -> m "wrote %d entries to %s" (List.length pairs) db) 25 26let err_kv_pair s = Error (`Msg (Fmt.str "expected KEY=VALUE, got %S" s)) 27 28let kv_pair = 29 let parse s = 30 match String.split_on_char '=' s with 31 | [ k; v ] -> Ok (k, v) 32 | _ -> err_kv_pair s 33 in 34 let pp ppf (k, v) = Fmt.pf ppf "%s=%s" k v in 35 Arg.conv (parse, pp) 36 37let write_cmd = 38 let db = 39 Arg.( 40 required 41 & pos 0 (some string) None 42 & info [] ~docv:"DB" ~doc:"Database file path.") 43 in 44 let pairs = 45 Arg.( 46 value & pos_right 0 kv_pair [] 47 & info [] ~docv:"KEY=VALUE" ~doc:"Key-value pairs to write.") 48 in 49 let info = 50 Cmd.info "write" ~doc:"Write key-value pairs to a database." 51 ~man: 52 [ 53 `S Manpage.s_description; 54 `P "Write key-value pairs to a database, creating it if needed."; 55 `S Manpage.s_examples; 56 `P "$(iname) mydb.db hello=world foo=bar"; 57 ] 58 in 59 Cmd.v info Term.(const write $ Vlog.setup "sql" $ db $ pairs) 60 61(* -- read subcommand -- *) 62 63let pp_value_pipe ppf = function 64 | Sqlite.Vnull -> Fmt.string ppf "NULL" 65 | Sqlite.Vint n -> Fmt.pf ppf "%Ld" n 66 | Sqlite.Vfloat f -> Fmt.pf ppf "%f" f 67 | Sqlite.Vblob s -> Fmt.pf ppf "BLOB(%d)" (String.length s) 68 | Sqlite.Vtext s -> Fmt.pf ppf "%S" s 69 70let read () db table_name = 71 Eio_main.run @@ fun env -> 72 let cwd = Eio.Stdenv.cwd env in 73 Eio.Switch.run @@ fun sw -> 74 let path = Eio.Path.(cwd / db) in 75 let t = Sqlite.open_ ~sw path in 76 (match table_name with 77 | None -> 78 (* Original KV behavior *) 79 if Tty.is_tty () then begin 80 let rows = ref [] in 81 Sqlite.iter t ~f:(fun k v -> 82 rows := [ Tty.Span.text k; Tty.Span.text v ] :: !rows); 83 let rows = List.rev !rows in 84 let table = 85 Tty.Table.( 86 of_rows ~border:Tty.Border.rounded 87 [ column "key"; column "value" ] 88 rows) 89 in 90 Tty.Table.pp Format.std_formatter table 91 end 92 else Sqlite.iter t ~f:(fun k v -> Fmt.pr "%s|%s@." k v) 93 | Some name -> 94 (* Generic table read *) 95 if Tty.is_tty () then begin 96 let schemas = Sqlite.tables t in 97 let schema = List.find (fun s -> s.Sqlite.tbl_name = name) schemas in 98 let cols = 99 List.map 100 (fun c -> Tty.Table.column c.Sqlite.col_name) 101 schema.Sqlite.columns 102 in 103 let rows = ref [] in 104 Sqlite.iter_table t name ~f:(fun _rowid values -> 105 let row = 106 List.map 107 (fun v -> Tty.Span.text (Fmt.str "%a" pp_value_pipe v)) 108 values 109 in 110 rows := row :: !rows); 111 let rows = List.rev !rows in 112 let table = Tty.Table.(of_rows ~border:Tty.Border.rounded cols rows) in 113 Tty.Table.pp Format.std_formatter table 114 end 115 else 116 Sqlite.iter_table t name ~f:(fun _rowid values -> 117 let strs = 118 List.map (fun v -> Fmt.str "%a" pp_value_pipe v) values 119 in 120 Fmt.pr "%s@." (String.concat "|" strs))); 121 Sqlite.close t 122 123let read_cmd = 124 let db = 125 Arg.( 126 required 127 & pos 0 (some string) None 128 & info [] ~docv:"DB" ~doc:"Database file path.") 129 in 130 let table_name = 131 Arg.( 132 value 133 & opt (some string) None 134 & info [ "t"; "table" ] ~docv:"TABLE" 135 ~doc:"Table to read. Defaults to the kv table.") 136 in 137 let info = 138 Cmd.info "read" ~doc:"Read all entries from a database." 139 ~man: 140 [ 141 `S Manpage.s_description; 142 `P "Dump all key-value pairs from a SQLite database."; 143 `P 144 "When stdout is a terminal, output is rendered as a table. \ 145 Otherwise, entries are printed as pipe-separated values."; 146 `P 147 "Use $(b,--table) to read a specific table. Without it, reads the \ 148 default $(b,kv) table."; 149 `S Manpage.s_examples; 150 `P "$(iname) mydb.db"; 151 `P "$(iname) mydb.db -t users"; 152 ] 153 in 154 Cmd.v info Term.(const read $ Vlog.setup "sql" $ db $ table_name) 155 156(* -- delete subcommand -- *) 157 158let delete_entry t k = 159 Log.info (fun m -> m "delete %s" k); 160 Sqlite.delete t k 161 162let delete () db keys = 163 Eio_main.run @@ fun env -> 164 let cwd = Eio.Stdenv.cwd env in 165 Eio.Switch.run @@ fun sw -> 166 let path = Eio.Path.(cwd / db) in 167 let t = Sqlite.open_ ~sw path in 168 List.iter (delete_entry t) keys; 169 Sqlite.close t; 170 Log.info (fun m -> m "deleted %d entries from %s" (List.length keys) db) 171 172let delete_cmd = 173 let db = 174 Arg.( 175 required 176 & pos 0 (some string) None 177 & info [] ~docv:"DB" ~doc:"Database file path.") 178 in 179 let keys = 180 Arg.( 181 value & pos_right 0 string [] & info [] ~docv:"KEY" ~doc:"Keys to delete.") 182 in 183 let info = 184 Cmd.info "delete" ~doc:"Delete entries from a database." 185 ~man: 186 [ 187 `S Manpage.s_description; 188 `P "Remove the given keys from a SQLite database."; 189 `S Manpage.s_examples; 190 `P "$(iname) mydb.db key1 key2"; 191 ] 192 in 193 Cmd.v info Term.(const delete $ Vlog.setup "sql" $ db $ keys) 194 195(* -- tables subcommand -- *) 196 197let pp_column (c : Sqlite.column) = 198 let base = 199 if c.col_affinity = "" then c.col_name 200 else c.col_name ^ " " ^ c.col_affinity 201 in 202 if c.col_is_rowid_alias then base ^ " PRIMARY KEY" else base 203 204let pp_schema (s : Sqlite.schema) = 205 let cols = List.map pp_column s.columns in 206 Fmt.pr "%s (%s)@." s.tbl_name (String.concat ", " cols) 207 208let tables () db = 209 Eio_main.run @@ fun env -> 210 let cwd = Eio.Stdenv.cwd env in 211 Eio.Switch.run @@ fun sw -> 212 let path = Eio.Path.(cwd / db) in 213 let t = Sqlite.open_ ~sw path in 214 List.iter pp_schema (Sqlite.tables t); 215 Sqlite.close t 216 217let tables_cmd = 218 let db = 219 Arg.( 220 required 221 & pos 0 (some string) None 222 & info [] ~docv:"DB" ~doc:"Database file path.") 223 in 224 let info = 225 Cmd.info "tables" ~doc:"List tables in a database." 226 ~man: 227 [ 228 `S Manpage.s_description; 229 `P "List all tables and their schemas in a SQLite database."; 230 `S Manpage.s_examples; 231 `P "$(iname) mydb.db"; 232 ] 233 in 234 Cmd.v info Term.(const tables $ Vlog.setup "sql" $ db) 235 236(* -- main -- *) 237 238let main_cmd = 239 let info = 240 Cmd.info "sql" ~version:"%%VERSION%%" 241 ~doc:"SQLite-compatible key-value store." 242 ~man: 243 [ 244 `S Manpage.s_description; 245 `P 246 "$(tname) creates and reads SQLite-compatible databases using a \ 247 pure OCaml B-tree implementation. Files produced by $(tname) can \ 248 be read by the $(b,sqlite3) CLI and vice versa."; 249 ] 250 in 251 Cmd.group info [ write_cmd; read_cmd; delete_cmd; tables_cmd ] 252 253let () = exit (Cmd.eval main_cmd)