Minimal SQLite key-value store for OCaml
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)