v2 / cmd / tools / vsqlite / main.v
1231 lines · 1187 sloc · 32.66 KB · 8e35f4d9848f7ad35d857a187dddbfd2eca5e19d
Raw
1module main
2
3import db.sqlite
4import os
5import readline
6import time
7
8const version = '0.1.0'
9
10const help_text = 'vsqlite ${version} - SQLite CLI written in V
11
12Usage:
13 v sqlite <database> Open database in interactive mode
14 v sqlite <database> <stmt> Execute a single SQL statement
15 v sqlite <database> -f <file> Execute SQL from a file
16 v sqlite --help Show this help
17 v sqlite --version Show version
18
19Interactive mode commands:
20 .tables List all tables
21 .schema [table] Show schema for table(s)
22 .mode [table|csv|line|box| Change output mode (default: table)
23 markdown|json|html|
24 insert [tbl]|quote]
25 .headers [on|off] Toggle column headers
26 .nullvalue [str] Set NULL display string (default: NULL)
27 .separator [sep] Set column separator (default: ,)
28 .width [N1 N2 ...] Set per-column widths (0 = reset to auto)
29 .output [file|-] Redirect output to file (no arg = stdout)
30 .once <file> Redirect next query result to file
31 .timer [on|off] Toggle query execution timing (default: off)
32 .explain <stmt> Show EXPLAIN QUERY PLAN tree for a statement
33 .import <file> <table> Import CSV/TSV into table (auto-detects .tsv/.tab)
34 .export <file> Export last query to CSV
35 .dump [file] Dump full SQL schema+data to stdout or file
36 .load <file> [entry] Load a SQLite extension (.so/.dylib)
37 .bail [on|off] Stop on first error (default: off)
38 .echo [on|off] Echo each statement before executing (default: off)
39 .log [file|off] Log all statements to a file; off to disable
40 .changes [on|off] Show row-change count after each statement (default: off)
41 .open <file> Open (or switch to) a different database file
42 .databases List attached databases
43 .indexes [table] List indexes
44 .size Show database file size
45 .read <file> Execute SQL file from within the REPL
46 .show Print snapshot of all current settings
47 .print [string...] Print literal text (blank line if no args)
48 .prompt MAIN [CONTINUE] Set REPL prompt strings
49 .eqp [on|off] Auto-EXPLAIN QUERY PLAN before each SELECT
50 .trace [file|stderr|off] Trace all SQL statements as they execute
51 .timeout <ms> Set busy-wait timeout (milliseconds)
52 .shell <cmd> / .system <cmd> Run an OS command
53 .backup <file> Backup database using VACUUM INTO
54 .fullschema Full schema including sqlite_stat tables
55 .dbinfo Database file info (page count, encoding, etc.)
56 .stats Page and table statistics
57 .lint Report missing indexes on FK columns
58 .cd [directory] Change or show working directory
59 .quit / .exit / Ctrl+D Exit'
60
61const history_file = os.join_path(os.home_dir(), '.vsqlite_history')
62
63struct App {
64mut:
65 db sqlite.DB
66 rl readline.Readline
67 mode OutputMode = .table
68 headers bool = true
69 last_rows []sqlite.Row
70 nullvalue string = 'NULL'
71 separator string = ','
72 col_widths map[int]int
73 output_path string
74 output_once bool
75 insert_table string = 'tbl'
76 timer bool
77 bail bool
78 echo bool
79 log_path string
80 changes bool
81 db_path string
82 eqp bool
83 trace_path string
84 prompt string = 'vsqlite> '
85 prompt2 string = ' ...> '
86}
87
88fn main() {
89 // When launched via `v sqlite`, os.args[1] is the subcommand name 'sqlite'.
90 // Skip it so the remaining args start with the database path.
91 raw := os.args[1..]
92 args := if raw.len > 0 && raw[0] == 'sqlite' { raw[1..] } else { raw }
93
94 if args.len == 0 || args[0] in ['--help', '-h'] {
95 println(help_text)
96 return
97 }
98
99 if args[0] in ['--version', '-v'] {
100 println('vsqlite ${version}')
101 return
102 }
103
104 mut db := sqlite.connect(args[0]) or {
105 eprintln('Error: cannot open "${args[0]}": ${err}')
106 exit(1)
107 }
108 db.exec_none('PRAGMA journal_mode=WAL')
109 db.exec_none('PRAGMA foreign_keys=ON')
110
111 mut app := App{
112 db: db
113 db_path: args[0]
114 }
115
116 if args.len == 1 {
117 app.interactive_mode()
118 } else if args[1] == '-f' {
119 if args.len < 3 {
120 eprintln('Error: -f requires a file path')
121 exit(1)
122 }
123 app.exec_file(args[2])
124 } else {
125 cmd := args[1..].join(' ')
126 if cmd.starts_with('.') {
127 app.dot_cmd(cmd)
128 } else {
129 app.run(cmd)
130 }
131 }
132}
133
134fn (mut app App) interactive_mode() {
135 println('vsqlite ${version} - Type .help for commands, .quit to exit')
136 println('')
137 app.rl.skip_empty = true
138 app.load_history()
139 app.refresh_completions()
140 mut buf := []string{}
141 for {
142 prompt := if buf.len == 0 { app.prompt } else { app.prompt2 }
143 line := app.rl.read_line(prompt) or {
144 if buf.len > 0 {
145 eprintln('Incomplete statement discarded.')
146 }
147 break
148 }
149 trimmed := line.trim_space()
150 if trimmed == '' {
151 continue
152 }
153 if buf.len == 0 {
154 if trimmed in ['.quit', '.exit', 'quit', 'exit'] {
155 break
156 }
157 if trimmed.starts_with('.') {
158 app.dot_cmd(trimmed)
159 continue
160 }
161 }
162 buf << line
163 if stmt_complete(buf) {
164 full := buf.join('\n').trim_space()
165 for s in split_statements(full) {
166 if s != '' {
167 app.run(s)
168 }
169 }
170 buf = []string{}
171 }
172 }
173 app.save_history()
174 println('\nBye!')
175}
176
177fn stmt_complete(lines []string) bool {
178 return lines.join('\n').trim_space().ends_with(';')
179}
180
181fn (mut app App) load_history() {
182 history_load(mut app.rl, history_file)
183}
184
185fn (app App) save_history() {
186 history_save(app.rl, history_file)
187}
188
189fn history_load(mut rl readline.Readline, path string) {
190 content := os.read_file(path) or { return }
191 for line in content.split('\n') {
192 if line.len > 0 {
193 rl.previous_lines << line.runes()
194 }
195 }
196}
197
198fn history_save(rl readline.Readline, path string) {
199 lines := rl.previous_lines.map(it.string()).filter(it.len > 0)
200 os.write_file(path, lines.join('\n') + '\n') or {}
201}
202
203fn (app App) make_format_opts() FormatOptions {
204 return FormatOptions{
205 mode: app.mode
206 headers: app.headers
207 nullvalue: app.nullvalue
208 separator: app.separator
209 col_widths: app.col_widths
210 table_name: app.insert_table
211 }
212}
213
214fn (mut app App) write_out(s string) {
215 if app.output_path != '' {
216 os.write_file(app.output_path, os.read_file(app.output_path) or { '' } + s + '\n') or {
217 eprintln('Error: cannot write to "${app.output_path}": ${err}')
218 }
219 } else {
220 println(s)
221 }
222}
223
224fn (mut app App) finish_output() {
225 if app.output_once {
226 app.output_path = ''
227 app.output_once = false
228 }
229}
230
231fn (mut app App) log_stmt(stmt string) {
232 if app.log_path == '' {
233 return
234 }
235 existing := os.read_file(app.log_path) or { '' }
236 os.write_file(app.log_path, existing + stmt + ';\n') or {}
237}
238
239fn (mut app App) trace_stmt(stmt string) {
240 if app.trace_path == '' {
241 return
242 }
243 if app.trace_path == 'stderr' {
244 eprintln(stmt)
245 return
246 }
247 existing := os.read_file(app.trace_path) or { '' }
248 os.write_file(app.trace_path, existing + stmt + ';\n') or {}
249}
250
251fn (mut app App) run(stmt string) bool {
252 if app.echo {
253 println(stmt)
254 }
255 app.log_stmt(stmt)
256 app.trace_stmt(stmt)
257 upper := stmt.trim_space().to_upper()
258 is_query := upper.starts_with('SELECT') || upper.starts_with('PRAGMA')
259 || upper.starts_with('EXPLAIN') || upper.starts_with('WITH') || upper.starts_with('VALUES')
260
261 if app.eqp && is_query && !upper.starts_with('EXPLAIN') {
262 app.run_explain(stmt)
263 }
264
265 t0 := time.now()
266
267 if is_query {
268 rows := app.db.exec(stmt) or {
269 eprintln('Error: ${err}')
270 if app.bail {
271 exit(1)
272 }
273 return false
274 }
275 elapsed := time.since(t0)
276 if rows.len == 0 {
277 app.finish_output()
278 if app.timer {
279 println('Run time: ${format_duration(elapsed)}')
280 }
281 return true
282 }
283 app.last_rows = rows
284 app.write_out(format_opts(rows, app.make_format_opts()))
285 app.write_out('(${rows.len} row${if rows.len == 1 { '' } else { 's' }})')
286 app.finish_output()
287 if app.timer {
288 println('Run time: ${format_duration(elapsed)}')
289 }
290 } else {
291 code := app.db.exec_none(stmt)
292 if code != 101 && code != 100 {
293 // sqlite_done=101, sqlite_row=100; anything else is an error
294 eprintln('Error: exec failed (code ${code})')
295 if app.bail {
296 exit(1)
297 }
298 return false
299 }
300 elapsed := time.since(t0)
301 affected := app.db.get_affected_rows_count()
302 last_id := app.db.last_insert_rowid()
303 if upper.starts_with('INSERT') {
304 println('Inserted 1 row (rowid: ${last_id})')
305 } else if upper.starts_with('UPDATE') || upper.starts_with('DELETE') {
306 println('${affected} row${if affected == 1 { '' } else { 's' }} affected')
307 } else {
308 println('OK')
309 }
310 if app.changes {
311 println('Changes: ${affected}')
312 }
313 if app.timer {
314 println('Run time: ${format_duration(elapsed)}')
315 }
316 if upper.starts_with('CREATE') || upper.starts_with('DROP') || upper.starts_with('ALTER') {
317 app.refresh_completions()
318 }
319 }
320 return true
321}
322
323fn (mut app App) exec_file(path string) {
324 content := os.read_file(path) or {
325 eprintln('Error reading "${path}": ${err}')
326 exit(1)
327 }
328 mut count := 0
329 for stmt in split_statements(content) {
330 trimmed := stmt.trim_space()
331 // Strip comment-only lines to decide if the chunk has real SQL,
332 // but pass the original (with comments) to SQLite so it can parse them.
333 sql_content :=
334 trimmed.split('\n').filter(!it.trim_space().starts_with('--')).join('\n').trim_space()
335 if sql_content == '' {
336 continue
337 }
338 app.run(trimmed)
339 count++
340 }
341 println('Executed ${count} statement(s) from ${path}')
342}
343
344fn (mut app App) read_file_repl(path string) {
345 content := os.read_file(path) or {
346 eprintln('Error: cannot read "${path}": ${err}')
347 return
348 }
349 mut count := 0
350 for stmt in split_statements(content) {
351 trimmed := stmt.trim_space()
352 sql_content :=
353 trimmed.split('\n').filter(!it.trim_space().starts_with('--')).join('\n').trim_space()
354 if sql_content == '' {
355 continue
356 }
357 app.run(trimmed)
358 count++
359 }
360 println('Executed ${count} statement(s) from ${path}')
361}
362
363fn (mut app App) dot_cmd(cmd string) {
364 parts := cmd.split(' ').filter(it != '')
365 match parts[0] {
366 '.help' {
367 println(help_text)
368 }
369 '.tables' {
370 tables := app.db.tables() or {
371 eprintln('Error: ${err}')
372 return
373 }
374 if tables.len == 0 {
375 println('(no tables)')
376 } else {
377 println(tables.join('\n'))
378 }
379 }
380 '.schema' {
381 table := if parts.len > 1 { parts[1] } else { '' }
382 result := app.db.schema(table) or {
383 eprintln('Error: ${err}')
384 return
385 }
386 if result == '' {
387 println('(no schema found)')
388 } else {
389 println(result)
390 }
391 }
392 '.mode' {
393 if parts.len < 2 {
394 println('Current mode: ${app.mode}')
395 return
396 }
397 match parts[1] {
398 'table' {
399 app.mode = .table
400 }
401 'csv' {
402 app.mode = .csv
403 }
404 'line' {
405 app.mode = .line
406 }
407 'box' {
408 app.mode = .box
409 }
410 'markdown' {
411 app.mode = .markdown
412 }
413 'json' {
414 app.mode = .json
415 }
416 'html' {
417 app.mode = .html
418 }
419 'insert' {
420 app.mode = .insert
421 if parts.len > 2 {
422 app.insert_table = parts[2]
423 }
424 }
425 'quote' {
426 app.mode = .quote
427 }
428 else {
429 eprintln('Unknown mode: ${parts[1]}. Use: table, csv, line, box, markdown, json, html, insert [tbl], quote')
430 return
431 }
432 }
433
434 println('Mode set to: ${app.mode}')
435 }
436 '.headers' {
437 if parts.len < 2 {
438 println('Headers: ${if app.headers { 'on' } else { 'off' }}')
439 return
440 }
441 match parts[1] {
442 'on' { app.headers = true }
443 'off' { app.headers = false }
444 else { eprintln('Use: .headers on|off') }
445 }
446 }
447 '.nullvalue' {
448 if parts.len < 2 {
449 println('NULL display: "${app.nullvalue}"')
450 return
451 }
452 app.nullvalue = parts[1]
453 println('NULL value set to "${app.nullvalue}"')
454 }
455 '.separator' {
456 if parts.len < 2 {
457 println('Separator: "${app.separator}"')
458 return
459 }
460 mut sep := parts[1]
461 sep = sep.replace('\\t', '\t').replace('\\n', '\n')
462 app.separator = sep
463 println('Separator set to "${app.separator}"')
464 }
465 '.width' {
466 if parts.len < 2 {
467 if app.col_widths.len == 0 {
468 println('Column widths: auto')
469 } else {
470 mut pairs := []string{}
471 for k, v in app.col_widths {
472 pairs << 'col${k}=${v}'
473 }
474 println('Column widths: ${pairs.join(', ')}')
475 }
476 return
477 }
478 if parts.len == 2 && parts[1] == '0' {
479 app.col_widths = map[int]int{}
480 println('Column widths reset to auto')
481 return
482 }
483 app.col_widths = map[int]int{}
484 for i, part in parts[1..] {
485 w := part.int()
486 if w > 0 {
487 app.col_widths[i] = w
488 }
489 }
490 println('Column widths set')
491 }
492 '.output' {
493 if parts.len < 2 || parts[1] in ['stdout', '-'] {
494 app.output_path = ''
495 app.output_once = false
496 println('Output reset to stdout')
497 } else {
498 os.write_file(parts[1], '') or {
499 eprintln('Error: cannot open "${parts[1]}": ${err}')
500 return
501 }
502 app.output_path = parts[1]
503 app.output_once = false
504 println('Output redirected to ${parts[1]}')
505 }
506 }
507 '.once' {
508 if parts.len < 2 {
509 eprintln('Usage: .once <file>')
510 return
511 }
512 os.write_file(parts[1], '') or {
513 eprintln('Error: cannot open "${parts[1]}": ${err}')
514 return
515 }
516 app.output_path = parts[1]
517 app.output_once = true
518 }
519 '.timer' {
520 if parts.len < 2 {
521 println('Timer: ${if app.timer { 'on' } else { 'off' }}')
522 return
523 }
524 match parts[1] {
525 'on' {
526 app.timer = true
527 println('Timer: on')
528 }
529 'off' {
530 app.timer = false
531 println('Timer: off')
532 }
533 else {
534 eprintln('Use: .timer on|off')
535 }
536 }
537 }
538 '.explain' {
539 if parts.len < 2 {
540 eprintln('Usage: .explain <sql statement>')
541 return
542 }
543 stmt := parts[1..].join(' ')
544 app.run_explain(stmt)
545 }
546 '.import' {
547 if parts.len < 3 {
548 eprintln('Usage: .import <file> <table>')
549 return
550 }
551 app.import_csv(parts[1], parts[2])
552 }
553 '.export' {
554 if parts.len < 2 {
555 eprintln('Usage: .export <file>')
556 return
557 }
558 if app.last_rows.len == 0 {
559 eprintln('No previous query results to export')
560 return
561 }
562 write_csv(parts[1], app.last_rows, app.headers) or {
563 eprintln('Error: ${err}')
564 return
565 }
566 println('Exported ${app.last_rows.len} rows to ${parts[1]}')
567 }
568 '.dump' {
569 text := dump_database(mut app.db)
570 if parts.len > 1 {
571 os.write_file(parts[1], text) or {
572 eprintln('Error: cannot write "${parts[1]}": ${err}')
573 return
574 }
575 println('Dumped to ${parts[1]}')
576 } else {
577 println(text)
578 }
579 }
580 '.load' {
581 if parts.len < 2 {
582 eprintln('Usage: .load <file> [entry-point]')
583 return
584 }
585 entry := if parts.len > 2 { parts[2] } else { '' }
586 ep := if entry != '' { ", '${entry.replace("'", "''")}'" } else { '' }
587 escaped_path := parts[1].replace("'", "''")
588 _ = app.db.exec("SELECT load_extension('${escaped_path}' ${ep})") or {
589 eprintln('Error: ${err}')
590 return
591 }
592 println('Extension loaded: ${parts[1]}')
593 }
594 '.bail' {
595 if parts.len < 2 {
596 println('Bail: ${if app.bail { 'on' } else { 'off' }}')
597 return
598 }
599 match parts[1] {
600 'on' {
601 app.bail = true
602 println('Bail: on')
603 }
604 'off' {
605 app.bail = false
606 println('Bail: off')
607 }
608 else {
609 eprintln('Use: .bail on|off')
610 }
611 }
612 }
613 '.echo' {
614 if parts.len < 2 {
615 println('Echo: ${if app.echo { 'on' } else { 'off' }}')
616 return
617 }
618 match parts[1] {
619 'on' {
620 app.echo = true
621 println('Echo: on')
622 }
623 'off' {
624 app.echo = false
625 println('Echo: off')
626 }
627 else {
628 eprintln('Use: .echo on|off')
629 }
630 }
631 }
632 '.log' {
633 if parts.len < 2 {
634 if app.log_path == '' {
635 println('Log: off')
636 } else {
637 println('Log: ${app.log_path}')
638 }
639 return
640 }
641 if parts[1] == 'off' {
642 app.log_path = ''
643 println('Logging off')
644 } else {
645 app.log_path = parts[1]
646 println('Logging to ${app.log_path}')
647 }
648 }
649 '.changes' {
650 if parts.len < 2 {
651 println('Changes: ${if app.changes { 'on' } else { 'off' }}')
652 return
653 }
654 match parts[1] {
655 'on' {
656 app.changes = true
657 println('Changes: on')
658 }
659 'off' {
660 app.changes = false
661 println('Changes: off')
662 }
663 else {
664 eprintln('Use: .changes on|off')
665 }
666 }
667 }
668 '.open' {
669 if parts.len < 2 {
670 eprintln('Usage: .open <database-file>')
671 return
672 }
673 mut new_db := sqlite.connect(parts[1]) or {
674 eprintln('Error: cannot open "${parts[1]}": ${err}')
675 return
676 }
677 new_db.exec_none('PRAGMA journal_mode=WAL')
678 new_db.exec_none('PRAGMA foreign_keys=ON')
679 app.db = new_db
680 app.db_path = parts[1]
681 app.refresh_completions()
682 println('Opened ${parts[1]}')
683 }
684 '.databases' {
685 rows := app.db.exec('PRAGMA database_list') or { return }
686 for row in rows {
687 println('${row.vals[1]}: ${row.vals[2]}')
688 }
689 }
690 '.indexes' {
691 filter := if parts.len > 1 {
692 escaped := parts[1].replace("'", "''")
693 "AND tbl_name='${escaped}'"
694 } else {
695 ''
696 }
697 rows := app.db.exec("SELECT name, tbl_name FROM sqlite_master WHERE type='index' ${filter} ORDER BY tbl_name, name") or {
698 return
699 }
700 if rows.len == 0 {
701 println('(no indexes found)')
702 } else {
703 for row in rows {
704 println('${row.vals[1]}.${row.vals[0]}')
705 }
706 }
707 }
708 '.size' {
709 sz := app.db.db_size() or {
710 eprintln('Error: ${err}')
711 return
712 }
713 println('Database size: ${format_bytes(sz)}')
714 }
715 '.read' {
716 if parts.len < 2 {
717 eprintln('Usage: .read <file>')
718 return
719 }
720 app.read_file_repl(parts[1])
721 }
722 '.show' {
723 log_disp := if app.log_path == '' { 'off' } else { app.log_path }
724 trace_disp := if app.trace_path == '' { 'off' } else { app.trace_path }
725 output_disp := if app.output_path == '' { 'stdout' } else { app.output_path }
726 println(' bail: ${if app.bail { 'on' } else { 'off' }}')
727 println(' changes: ${if app.changes { 'on' } else { 'off' }}')
728 println(' echo: ${if app.echo { 'on' } else { 'off' }}')
729 println(' eqp: ${if app.eqp { 'on' } else { 'off' }}')
730 println(' headers: ${if app.headers { 'on' } else { 'off' }}')
731 println(' log_path: ${log_disp}')
732 println(' mode: ${app.mode}')
733 println(' nullvalue: "${app.nullvalue}"')
734 println(' output: ${output_disp}')
735 println(' prompt: "${app.prompt}"')
736 println(' separator: "${app.separator}"')
737 println(' timer: ${if app.timer { 'on' } else { 'off' }}')
738 println(' trace: ${trace_disp}')
739 }
740 '.print' {
741 if parts.len < 2 {
742 println('')
743 } else {
744 println(parts[1..].join(' '))
745 }
746 }
747 '.prompt' {
748 if parts.len < 2 {
749 eprintln('Usage: .prompt MAIN [CONTINUE]')
750 return
751 }
752 app.prompt = parts[1]
753 if parts.len > 2 {
754 app.prompt2 = parts[2]
755 }
756 }
757 '.eqp' {
758 if parts.len < 2 {
759 println('EQP: ${if app.eqp { 'on' } else { 'off' }}')
760 return
761 }
762 match parts[1] {
763 'on' {
764 app.eqp = true
765 println('EQP: on')
766 }
767 'off' {
768 app.eqp = false
769 println('EQP: off')
770 }
771 'full' {
772 app.eqp = true
773 println('EQP: on (full)')
774 }
775 else {
776 eprintln('Use: .eqp on|off')
777 }
778 }
779 }
780 '.trace' {
781 if parts.len < 2 || parts[1] == 'off' {
782 app.trace_path = ''
783 println('Tracing off')
784 } else if parts[1] == 'stderr' {
785 app.trace_path = 'stderr'
786 println('Tracing to stderr')
787 } else {
788 app.trace_path = parts[1]
789 println('Tracing to ${app.trace_path}')
790 }
791 }
792 '.timeout' {
793 if parts.len < 2 {
794 eprintln('Usage: .timeout <ms>')
795 return
796 }
797 ms := parts[1].int()
798 app.db.busy_timeout(ms)
799 println('Timeout: ${ms}ms')
800 }
801 '.shell', '.system' {
802 if parts.len < 2 {
803 eprintln('Usage: ${parts[0]} <command>')
804 return
805 }
806 shell_cmd := parts[1..].join(' ')
807 result := os.execute(shell_cmd)
808 if result.output.len > 0 {
809 print(result.output)
810 if !result.output.ends_with('\n') {
811 println('')
812 }
813 }
814 if result.exit_code != 0 {
815 eprintln('Exit code: ${result.exit_code}')
816 }
817 }
818 '.backup' {
819 if parts.len < 2 {
820 eprintln('Usage: .backup <file>')
821 return
822 }
823 escaped := parts[1].replace("'", "''")
824 app.db.exec("VACUUM INTO '${escaped}'") or {
825 eprintln('Error: ${err}')
826 return
827 }
828 println('Backed up to ${parts[1]}')
829 }
830 '.fullschema' {
831 full_schema := app.db.schema('') or {
832 eprintln('Error: ${err}')
833 return
834 }
835 if full_schema == '' {
836 println('(no schema found)')
837 } else {
838 println(full_schema)
839 }
840 for stat_tbl in ['sqlite_stat1', 'sqlite_stat2', 'sqlite_stat3', 'sqlite_stat4'] {
841 rows := app.db.exec('SELECT * FROM ${stat_tbl}') or { continue }
842 if rows.len == 0 {
843 continue
844 }
845 println('')
846 println('/* contents of ${stat_tbl} */')
847 for row in rows {
848 println(row.vals.join('|'))
849 }
850 }
851 }
852 '.dbinfo' {
853 db_list_rows := app.db.exec('PRAGMA database_list') or { []sqlite.Row{} }
854 mut filename := app.db_path
855 for row in db_list_rows {
856 if row.vals.len > 1 && row.vals[1] == 'main' {
857 filename = if row.vals.len > 2 { row.vals[2] } else { app.db_path }
858 break
859 }
860 }
861 ver_rows := app.db.exec('SELECT sqlite_version()') or { []sqlite.Row{} }
862 sqlite_ver := if ver_rows.len > 0 && ver_rows[0].vals.len > 0 {
863 ver_rows[0].vals[0]
864 } else {
865 'unknown'
866 }
867 pc_rows := app.db.exec('PRAGMA page_count') or { []sqlite.Row{} }
868 ps_rows := app.db.exec('PRAGMA page_size') or { []sqlite.Row{} }
869 enc_rows := app.db.exec('PRAGMA encoding') or { []sqlite.Row{} }
870 jm_rows := app.db.exec('PRAGMA journal_mode') or { []sqlite.Row{} }
871 fl_rows := app.db.exec('PRAGMA freelist_count') or { []sqlite.Row{} }
872 ai_rows := app.db.exec('PRAGMA application_id') or { []sqlite.Row{} }
873 uv_rows := app.db.exec('PRAGMA user_version') or { []sqlite.Row{} }
874 page_count := if pc_rows.len > 0 { pc_rows[0].vals[0] } else { '0' }
875 page_size := if ps_rows.len > 0 { ps_rows[0].vals[0] } else { '0' }
876 encoding := if enc_rows.len > 0 { enc_rows[0].vals[0] } else { 'unknown' }
877 journal_mode := if jm_rows.len > 0 { jm_rows[0].vals[0] } else { 'unknown' }
878 freelist_count := if fl_rows.len > 0 { fl_rows[0].vals[0] } else { '0' }
879 application_id := if ai_rows.len > 0 { ai_rows[0].vals[0] } else { '0' }
880 user_version := if uv_rows.len > 0 { uv_rows[0].vals[0] } else { '0' }
881 file_size := page_count.i64() * page_size.i64()
882 println(' filename: ${filename}')
883 println(' sqlite_version: ${sqlite_ver}')
884 println(' page_count: ${page_count}')
885 println(' page_size: ${page_size}')
886 println(' file_size: ${format_bytes(file_size)}')
887 println(' encoding: ${encoding}')
888 println(' journal_mode: ${journal_mode}')
889 println(' freelist_count: ${freelist_count}')
890 println(' application_id: ${application_id}')
891 println(' user_version: ${user_version}')
892 }
893 '.stats' {
894 pc_rows := app.db.exec('PRAGMA page_count') or { []sqlite.Row{} }
895 ps_rows := app.db.exec('PRAGMA page_size') or { []sqlite.Row{} }
896 fl_rows := app.db.exec('PRAGMA freelist_count') or { []sqlite.Row{} }
897 cs_rows := app.db.exec('PRAGMA cache_size') or { []sqlite.Row{} }
898 page_count := if pc_rows.len > 0 { pc_rows[0].vals[0] } else { '0' }
899 page_size := if ps_rows.len > 0 { ps_rows[0].vals[0] } else { '0' }
900 freelist_count := if fl_rows.len > 0 { fl_rows[0].vals[0] } else { '0' }
901 cache_size := if cs_rows.len > 0 { cs_rows[0].vals[0] } else { '0' }
902 pc := page_count.i64()
903 ps := page_size.i64()
904 fl := freelist_count.i64()
905 total_size := pc * ps
906 used_size := (pc - fl) * ps
907 free_size := fl * ps
908 println(' page_count: ${page_count}')
909 println(' page_size: ${page_size}')
910 println(' total_size: ${format_bytes(total_size)}')
911 println(' used_size: ${format_bytes(used_size)}')
912 println(' free_size: ${format_bytes(free_size)}')
913 println(' cache_size: ${cache_size}')
914 tables := app.db.tables() or { []string{} }
915 if tables.len > 0 {
916 println('')
917 println('Table row counts:')
918 for tbl in tables {
919 escaped_tbl := tbl.replace('"', '""')
920 cnt_rows := app.db.exec('SELECT COUNT(*) FROM "${escaped_tbl}"') or { continue }
921 cnt := if cnt_rows.len > 0 { cnt_rows[0].vals[0] } else { '0' }
922 println(' ${tbl}: ${cnt}')
923 }
924 }
925 }
926 '.lint' {
927 tables := app.db.tables() or { []string{} }
928 mut issue_count := 0
929 for tbl in tables {
930 escaped_tbl := tbl.replace('"', '""')
931 fk_rows := app.db.exec('PRAGMA foreign_key_list("${escaped_tbl}")') or { continue }
932 if fk_rows.len == 0 {
933 continue
934 }
935 mut indexed_cols := map[string]bool{}
936 ti_rows := app.db.exec('PRAGMA table_info("${escaped_tbl}")') or { []sqlite.Row{} }
937 for ti_row in ti_rows {
938 if ti_row.vals.len > 5 && ti_row.vals[5] != '0' {
939 indexed_cols[ti_row.vals[1]] = true
940 }
941 }
942 il_rows := app.db.exec('PRAGMA index_list("${escaped_tbl}")') or { []sqlite.Row{} }
943 for il_row in il_rows {
944 if il_row.vals.len < 2 {
945 continue
946 }
947 idx_name := il_row.vals[1].replace('"', '""')
948 ii_rows := app.db.exec('PRAGMA index_info("${idx_name}")') or { continue }
949 for ii_row in ii_rows {
950 if ii_row.vals.len > 2 {
951 indexed_cols[ii_row.vals[2]] = true
952 }
953 }
954 }
955 for fk_row in fk_rows {
956 if fk_row.vals.len < 4 {
957 continue
958 }
959 fk_col := fk_row.vals[3]
960 ref_table := fk_row.vals[2]
961 ref_col := fk_row.vals[4]
962 if fk_col !in indexed_cols {
963 println('${tbl}: FK column "${fk_col}" -> ${ref_table}(${ref_col}) has no covering index')
964 issue_count++
965 }
966 }
967 }
968 if issue_count == 0 {
969 println('No issues found.')
970 } else {
971 println('${issue_count} issue${if issue_count == 1 { '' } else { 's' }} found.')
972 }
973 }
974 '.cd' {
975 if parts.len < 2 {
976 cwd := os.getwd()
977 println(cwd)
978 return
979 }
980 os.chdir(parts[1]) or {
981 eprintln('Error: cannot change to "${parts[1]}": ${err}')
982 return
983 }
984 println('Changed to ${parts[1]}')
985 }
986 else {
987 eprintln('Unknown command: ${parts[0]}. Type .help for help.')
988 }
989 }
990}
991
992fn (mut app App) import_csv(file string, table string) {
993 sep := if file.ends_with('.tsv') || file.ends_with('.tab') { u8(`\t`) } else { u8(`,`) }
994 headers, rows := read_csv_sep(file, sep) or {
995 eprintln('Error: ${err}')
996 return
997 }
998 col_list := headers.join(',')
999 app.db.exec_none('BEGIN TRANSACTION')
1000 mut imported := 0
1001 for vals in rows {
1002 quoted := vals.map("'${it.replace("'", "''")}'")
1003 code := app.db.exec_none('INSERT INTO ${table} (${col_list}) VALUES (${quoted.join(',')})')
1004 if code != 101 && code != 100 {
1005 app.db.exec_none('ROLLBACK')
1006 eprintln('Error: import failed on row ${imported + 1} (code ${code})')
1007 return
1008 }
1009 imported++
1010 }
1011 app.db.exec_none('COMMIT')
1012 println('Imported ${imported} rows into ${table}')
1013}
1014
1015fn (mut app App) refresh_completions() {
1016 dot_cmds := ['.tables', '.schema', '.mode', '.headers', '.nullvalue', '.separator', '.width',
1017 '.output', '.once', '.timer', '.explain', '.import', '.export', '.dump', '.load', '.bail',
1018 '.echo', '.log', '.changes', '.open', '.databases', '.indexes', '.size', '.help', '.quit',
1019 '.exit', '.read', '.show', '.print', '.prompt', '.eqp', '.trace', '.timeout', '.shell',
1020 '.system', '.backup', '.fullschema', '.dbinfo', '.stats', '.lint', '.cd']
1021 kws := ['SELECT', 'FROM', 'WHERE', 'INSERT', 'INTO', 'UPDATE', 'SET', 'DELETE', 'CREATE', 'TABLE',
1022 'DROP', 'ALTER', 'JOIN', 'LEFT', 'RIGHT', 'INNER', 'OUTER', 'ON', 'ORDER', 'BY', 'GROUP',
1023 'HAVING', 'LIMIT', 'OFFSET', 'AND', 'OR', 'NOT', 'NULL', 'VALUES', 'PRIMARY', 'KEY',
1024 'INTEGER', 'TEXT', 'REAL', 'BLOB', 'UNIQUE', 'DEFAULT', 'AUTOINCREMENT', 'PRAGMA', 'INDEX',
1025 'DISTINCT', 'COUNT', 'SUM', 'MIN', 'MAX', 'AVG', 'AS', 'LIKE', 'IN', 'IS', 'BETWEEN', 'CASE',
1026 'WHEN', 'THEN', 'ELSE', 'END', 'BEGIN', 'TRANSACTION', 'COMMIT', 'ROLLBACK']
1027 mut words := []string{}
1028 words << dot_cmds
1029 words << kws
1030 tables := app.db.tables() or { []string{} }
1031 words << tables
1032 for t in tables {
1033 words << (app.db.columns(t) or { []string{} })
1034 }
1035 app.rl.completion_callback = make_completer(words)
1036}
1037
1038fn make_completer(words []string) fn (string) []string {
1039 return fn [words] (line string) []string {
1040 if line.len == 0 {
1041 return []string{}
1042 }
1043 if line.starts_with('.') && !line.contains(' ') {
1044 return words.filter(it.starts_with(line))
1045 }
1046 tok_start := last_token_start(line)
1047 pre := line[..tok_start]
1048 tok := line[tok_start..]
1049 if tok.len == 0 {
1050 return []string{}
1051 }
1052 tok_upper := tok.to_upper()
1053 mut results := []string{}
1054 for w in words {
1055 if w.to_upper().starts_with(tok_upper) {
1056 results << pre + w
1057 }
1058 }
1059 return results
1060 }
1061}
1062
1063fn last_token_start(line string) int {
1064 for i := line.len - 1; i >= 0; i-- {
1065 b := line[i]
1066 if b == 32 || b == 9 || b == 40 || b == 44 {
1067 return i + 1
1068 }
1069 }
1070 return 0
1071}
1072
1073// dump_database generates a SQL script that recreates the entire database.
1074fn dump_database(mut db sqlite.DB) string {
1075 mut lines := []string{}
1076 lines << 'BEGIN TRANSACTION;'
1077 lines << ''
1078 schema_rows := db.exec("SELECT type, name, sql FROM sqlite_master WHERE sql IS NOT NULL AND name NOT LIKE 'sqlite_%' ORDER BY CASE type WHEN 'table' THEN 0 ELSE 1 END, name") or {
1079 lines << 'COMMIT;'
1080 return lines.join('\n')
1081 }
1082 for row in schema_rows {
1083 if row.vals[0] != 'table' {
1084 continue
1085 }
1086 obj_name := row.vals[1]
1087 escaped := obj_name.replace('"', '""')
1088 lines << row.vals[2] + ';'
1089 col_rows := db.exec('PRAGMA table_info("${escaped}")') or { continue }
1090 if col_rows.len == 0 {
1091 lines << ''
1092 continue
1093 }
1094 col_names := col_rows.map(it.vals[1])
1095 col_list := col_names.map('"${it.replace('"', '""')}"').join(', ')
1096 // Use quote() to correctly distinguish NULL from empty string.
1097 quoted_cols := col_names.map('quote("${it.replace('"', '""')}")').join(', ')
1098 data_rows := db.exec('SELECT ${quoted_cols} FROM "${escaped}"') or { continue }
1099 for drow in data_rows {
1100 lines << 'INSERT INTO "${escaped}"(${col_list}) VALUES(${drow.vals.join(', ')});'
1101 }
1102 lines << ''
1103 }
1104 for row in schema_rows {
1105 if row.vals[0] == 'table' {
1106 continue
1107 }
1108 lines << row.vals[2] + ';'
1109 lines << ''
1110 }
1111 lines << 'COMMIT;'
1112 return lines.join('\n')
1113}
1114
1115fn format_bytes(n i64) string {
1116 if n < 1024 {
1117 return '${n} B'
1118 } else if n < 1024 * 1024 {
1119 return '${n / 1024} KB'
1120 } else if n < 1024 * 1024 * 1024 {
1121 return '${n / (1024 * 1024)} MB'
1122 } else {
1123 return '${n / (1024 * 1024 * 1024)} GB'
1124 }
1125}
1126
1127fn format_duration(d time.Duration) string {
1128 us := d.microseconds()
1129 if us < 1000 {
1130 return '${us} µs'
1131 }
1132 ms := us / 1000
1133 frac := us % 1000
1134 frac_str := if frac < 10 {
1135 '00${frac}'
1136 } else if frac < 100 {
1137 '0${frac}'
1138 } else {
1139 '${frac}'
1140 }
1141 return '${ms}.${frac_str} ms'
1142}
1143
1144// split_statements splits SQL on semicolons, respecting quoted strings and
1145// BEGIN...END blocks (e.g. CREATE TRIGGER bodies).
1146fn split_statements(src string) []string {
1147 mut stmts := []string{}
1148 mut start := 0
1149 mut in_single := false
1150 mut in_double := false
1151 mut begin_depth := 0
1152 for i := 0; i < src.len; i++ {
1153 c := src[i]
1154 if c == `'` && !in_double {
1155 in_single = !in_single
1156 } else if c == `"` && !in_single {
1157 in_double = !in_double
1158 } else if !in_single && !in_double {
1159 // Track BEGIN/END nesting for trigger bodies.
1160 if c == `;` && begin_depth == 0 {
1161 s := src[start..i].trim_space()
1162 if s != '' {
1163 stmts << s
1164 }
1165 start = i + 1
1166 } else if is_keyword_at(src, i, 'BEGIN') {
1167 // Only count as a trigger-body BEGIN if there is already content
1168 // in this statement. A standalone 'BEGIN'/'BEGIN TRANSACTION'
1169 // starts a new statement and must not suppress semicolon splitting.
1170 if src[start..i].trim_space() != '' {
1171 begin_depth++
1172 }
1173 } else if is_keyword_at(src, i, 'END') && begin_depth > 0 {
1174 begin_depth--
1175 }
1176 }
1177 }
1178 s := src[start..].trim_space()
1179 if s != '' {
1180 stmts << s
1181 }
1182 return stmts
1183}
1184
1185// is_keyword_at checks if keyword appears at position i as a whole word
1186// (not part of a larger identifier).
1187fn is_keyword_at(src string, i int, keyword string) bool {
1188 if i + keyword.len > src.len {
1189 return false
1190 }
1191 // Must not be preceded by a word character.
1192 if i > 0 && is_word_char(src[i - 1]) {
1193 return false
1194 }
1195 // Must not be followed by a word character.
1196 end := i + keyword.len
1197 if end < src.len && is_word_char(src[end]) {
1198 return false
1199 }
1200 return src[i..end].to_upper() == keyword
1201}
1202
1203fn is_word_char(c u8) bool {
1204 return (c >= `a` && c <= `z`) || (c >= `A` && c <= `Z`) || (c >= `0` && c <= `9`) || c == `_`
1205}
1206
1207fn (mut app App) run_explain(stmt string) {
1208 rows := app.db.exec('EXPLAIN QUERY PLAN ${stmt}') or {
1209 eprintln('Error: ${err}')
1210 return
1211 }
1212 if rows.len == 0 {
1213 println('(no query plan)')
1214 return
1215 }
1216 println('QUERY PLAN')
1217 app.print_eqp_tree(rows, 0, 0)
1218}
1219
1220fn (mut app App) print_eqp_tree(rows []sqlite.Row, parent_id int, depth int) {
1221 indent := ' '.repeat(depth)
1222 for row in rows {
1223 if row.vals.len < 4 {
1224 continue
1225 }
1226 if row.vals[1].int() == parent_id {
1227 println('${indent}|--${row.vals[3]}')
1228 app.print_eqp_tree(rows, row.vals[0].int(), depth + 1)
1229 }
1230 }
1231}
1232