From 1ad6961cbd24366a4efe56e6bb54f21665c65f7a Mon Sep 17 00:00:00 2001 From: Richard Wheeler Date: Tue, 31 Mar 2026 09:04:20 -0400 Subject: [PATCH] cmd/tools: add vsqlite interactive SQLite CLI (#26793) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * cmd/tools: add vsqlite — interactive SQLite CLI tool Add `v sqlite` as a V-native replacement for sqlite3's shell.c (which the amalgamation install script removes). Features: - Full readline REPL with history and tab completion - 9 output modes: table, box, markdown, csv, line, json, html, insert, quote - .dump, .import/.export, .backup (VACUUM INTO), .load extensions - Session control: .bail, .echo, .log, .trace, .eqp, .timeout - Schema tools: .fullschema, .dbinfo, .stats, .lint - Uses db.sqlite directly with no wrapper layer Co-Authored-By: Claude Opus 4.6 * cmd/tools/vsqlite: address review feedback - Check exec_none return code and report errors - Use quote() in dump to distinguish NULL from empty string - Handle BEGIN/END blocks in split_statements for triggers - Respect .headers off in markdown output mode Co-Authored-By: Claude Opus 4.6 * cmd/tools/vbuild-tools: add vsqlite to tools_in_subfolders vsqlite is a multi-file tool (main.v, format.v, csv.v) so it must be compiled as a whole directory, not as individual files. Co-Authored-By: Claude Sonnet 4.6 * cmd/tools/vsqlite: fix review issues; tools_ci: install sqlite on Windows - split_statements: only track BEGIN depth for trigger bodies, not standalone BEGIN/BEGIN TRANSACTION statements - read_file/read_file_repl: pass comment-prefixed statements to SQLite instead of silently dropping them - import_csv: check exec_none return code per row, rollback on failure - tools_ci.yml: add sqlite installation step for Windows builds Co-Authored-By: Claude Sonnet 4.6 --------- Co-authored-by: Richard Wheeler Co-authored-by: Claude Opus 4.6 --- .github/workflows/tools_ci.yml | 2 + cmd/tools/vbuild-tools.v | 3 +- cmd/tools/vsqlite/csv.v | 153 ++++ cmd/tools/vsqlite/format.v | 425 +++++++++++ cmd/tools/vsqlite/main.v | 1223 ++++++++++++++++++++++++++++++++ cmd/v/v.v | 1 + 6 files changed, 1806 insertions(+), 1 deletion(-) create mode 100644 cmd/tools/vsqlite/csv.v create mode 100644 cmd/tools/vsqlite/format.v create mode 100644 cmd/tools/vsqlite/main.v diff --git a/.github/workflows/tools_ci.yml b/.github/workflows/tools_ci.yml index 5f296cb1b..de0b56cc5 100644 --- a/.github/workflows/tools_ci.yml +++ b/.github/workflows/tools_ci.yml @@ -96,6 +96,8 @@ jobs: - uses: actions/checkout@v6 - name: Build V run: ./make.bat -${{ matrix.cc }} && ./v -o v2.exe cmd/v && ./v2 -showcc -o v.exe cmd/v && ./v symlink && ./v doctor + - name: Install SQLite (needed by vsqlite tool) + run: .\.github\workflows\windows-install-sqlite.bat - name: Check build tools run: ./v build-tools - name: Test tools diff --git a/cmd/tools/vbuild-tools.v b/cmd/tools/vbuild-tools.v index 975f905aa..0d6b21fec 100644 --- a/cmd/tools/vbuild-tools.v +++ b/cmd/tools/vbuild-tools.v @@ -11,7 +11,8 @@ import v.util // should be compiled (v folder). // To implement that, these folders are initially skipped, then added // as a whole *after the testing.prepare_test_session call*. -const tools_in_subfolders = ['vast', 'vcreate', 'vdoc', 'vpm', 'vsymlink', 'vvet', 'vwhere', 'vcover'] +const tools_in_subfolders = ['vast', 'vcreate', 'vdoc', 'vpm', 'vsqlite', 'vsymlink', 'vvet', + 'vwhere', 'vcover'] // non_packaged_tools are tools that should not be packaged with // prebuild versions of V, to keep the size smaller. diff --git a/cmd/tools/vsqlite/csv.v b/cmd/tools/vsqlite/csv.v new file mode 100644 index 000000000..16e938934 --- /dev/null +++ b/cmd/tools/vsqlite/csv.v @@ -0,0 +1,153 @@ +module main + +import db.sqlite +import os + +// read_csv reads a CSV file (comma-separated) and returns (headers, data_rows). +pub fn read_csv(path string) !([]string, [][]string) { + return read_csv_sep(path, `,`) +} + +// read_tsv reads a TSV (tab-separated) file and returns (headers, data_rows). +pub fn read_tsv(path string) !([]string, [][]string) { + return read_csv_sep(path, `\t`) +} + +// read_csv_sep reads a delimited file with sep as the field separator. +pub fn read_csv_sep(path string, sep u8) !([]string, [][]string) { + content := os.read_file(path)! + if content.len == 0 { + return error('file is empty') + } + records := parse_csv_records(content, sep) + if records.len == 0 { + return error('CSV file is empty') + } + return records[0], records[1..] +} + +// write_csv writes rows to a CSV file. +pub fn write_csv(path string, rows []sqlite.Row, headers bool) ! { + mut lines := []string{} + if headers && rows.len > 0 { + lines << rows[0].names.map(csv_escape(it)).join(',') + } + for row in rows { + lines << row.vals.map(csv_escape(it)).join(',') + } + os.write_file(path, lines.join('\n') + '\n')! +} + +// csv_escape quotes a field for RFC 4180 CSV (comma separator). +pub fn csv_escape(s string) string { + return csv_escape_sep(s, ',') +} + +// csv_escape_sep quotes a field if it contains the separator, a double-quote, or a newline. +fn csv_escape_sep(s string, sep string) string { + if s.contains(sep) || s.contains('"') || s.contains('\n') { + return '"' + s.replace('"', '""') + '"' + } + return s +} + +// parse_csv_line parses a single CSV line (comma separator). +pub fn parse_csv_line(line string) []string { + return parse_csv_line_sep(line, `,`) +} + +// parse_csv_line_sep parses a single delimited line with a custom separator byte. +pub fn parse_csv_line_sep(line string, sep u8) []string { + mut fields := []string{} + mut field := '' + mut in_quotes := false + mut i := 0 + bytes := line.bytes() + for i < bytes.len { + c := bytes[i] + if in_quotes { + if c == `"` { + if i + 1 < bytes.len && bytes[i + 1] == `"` { + field += '"' + i += 2 + continue + } else { + in_quotes = false + } + } else { + field += c.ascii_str() + } + } else { + if c == `"` { + in_quotes = true + } else if c == sep { + fields << field + field = '' + } else { + field += c.ascii_str() + } + } + i++ + } + fields << field + return fields +} + +// parse_csv_records parses a full CSV/TSV content string into a slice of records. +pub fn parse_csv_records(content string, sep u8) [][]string { + mut records := [][]string{} + mut record := []string{} + mut field := '' + mut in_quotes := false + mut i := 0 + bytes := content.bytes() + for i < bytes.len { + c := bytes[i] + if in_quotes { + if c == `"` { + if i + 1 < bytes.len && bytes[i + 1] == `"` { + field += '"' + i += 2 + continue + } + in_quotes = false + } else { + field += c.ascii_str() + } + } else { + if c == `"` { + in_quotes = true + } else if c == sep { + record << field + field = '' + } else if c == `\r` { + if i + 1 < bytes.len && bytes[i + 1] == `\n` { + i++ + } + record << field + field = '' + if record.len > 1 || (record.len == 1 && record[0] != '') { + records << record + } + record = []string{} + } else if c == `\n` { + record << field + field = '' + if record.len > 1 || (record.len == 1 && record[0] != '') { + records << record + } + record = []string{} + } else { + field += c.ascii_str() + } + } + i++ + } + if field.len > 0 || record.len > 0 { + record << field + if record.len > 1 || (record.len == 1 && record[0] != '') { + records << record + } + } + return records +} diff --git a/cmd/tools/vsqlite/format.v b/cmd/tools/vsqlite/format.v new file mode 100644 index 000000000..714b642f7 --- /dev/null +++ b/cmd/tools/vsqlite/format.v @@ -0,0 +1,425 @@ +module main + +import db.sqlite +import term + +pub enum OutputMode { + table + csv + line + box + markdown + json + html + insert + quote +} + +// FormatOptions controls how query results are rendered. +pub struct FormatOptions { +pub: + mode OutputMode = .table + headers bool = true + nullvalue string = 'NULL' + separator string = ',' + col_widths map[int]int // key = column index (0-based); value = fixed width; 0 = auto + table_name string = 'tbl' // used by insert mode +} + +// format renders rows using mode and headers with default options. +pub fn format(rows []sqlite.Row, mode OutputMode, headers bool) string { + return format_opts(rows, FormatOptions{ + mode: mode + headers: headers + }) +} + +// format_opts renders rows using the full FormatOptions. +pub fn format_opts(rows []sqlite.Row, opts FormatOptions) string { + return match opts.mode { + .table { format_table_ex(rows, opts) } + .csv { format_csv_ex(rows, opts) } + .line { format_line_ex(rows, opts) } + .box { format_box_ex(rows, opts) } + .markdown { format_markdown_ex(rows, opts) } + .json { format_json_ex(rows, opts) } + .html { format_html_ex(rows, opts) } + .insert { format_insert_ex(rows, opts) } + .quote { format_quote_ex(rows, opts) } + } +} + +// ---------- internal helpers ---------- + +fn eff_width(opts FormatOptions, i int, auto_w int) int { + w := opts.col_widths[i] + if w > 0 { + return w + } + return auto_w +} + +fn null_disp(s string, nullvalue string) string { + return if s == '' { nullvalue } else { s } +} + +fn resolve_names(names []string, ncols int) []string { + if names.len == ncols { + return names + } + mut generated := []string{cap: ncols} + for i in 0 .. ncols { + generated << 'col${i}' + } + return generated +} + +fn str_pad(s string, w int) string { + if s.len >= w { + return s + } + return s + ' '.repeat(w - s.len) +} + +fn str_pad_left(s string, w int) string { + if s.len >= w { + return s + } + return ' '.repeat(w - s.len) + s +} + +fn compute_widths(rows []sqlite.Row, names []string, opts FormatOptions) []int { + ncols := names.len + mut auto_w := []int{len: ncols, init: names[index].len} + for row in rows { + for i, val in row.vals { + if i < ncols { + v := null_disp(val, opts.nullvalue) + if v.len > auto_w[i] { + auto_w[i] = v.len + } + } + } + } + mut widths := []int{len: ncols} + for i in 0 .. ncols { + widths[i] = eff_width(opts, i, auto_w[i]) + } + return widths +} + +// ---------- table ---------- + +fn format_table_ex(rows []sqlite.Row, opts FormatOptions) string { + if rows.len == 0 { + return '' + } + ncols := rows[0].vals.len + names := resolve_names(rows[0].names, ncols) + widths := compute_widths(rows, names, opts) + + mut sep := '+' + for w in widths { + sep += '-'.repeat(w + 2) + '+' + } + + mut out := '' + if opts.headers { + out += sep + '\n' + mut header := '|' + for i, col in names { + header += ' ' + term.bold(str_pad(col.limit(widths[i]), widths[i])) + ' |' + } + out += header + '\n' + } + out += sep + '\n' + for row in rows { + mut line := '|' + for i, val in row.vals { + if i >= ncols { + break + } + v := null_disp(val, opts.nullvalue) + line += ' ' + str_pad(v.limit(widths[i]), widths[i]) + ' |' + } + out += line + '\n' + } + out += sep + return out +} + +// ---------- csv ---------- + +fn format_csv_ex(rows []sqlite.Row, opts FormatOptions) string { + if rows.len == 0 { + return '' + } + ncols := rows[0].vals.len + names := resolve_names(rows[0].names, ncols) + sep := opts.separator + + mut lines := []string{} + if opts.headers { + lines << names.map(csv_escape_sep(it, sep)).join(sep) + } + for row in rows { + lines << row.vals.map(csv_escape_sep(null_disp(it, opts.nullvalue), sep)).join(sep) + } + return lines.join('\n') +} + +// ---------- line ---------- + +fn format_line_ex(rows []sqlite.Row, opts FormatOptions) string { + if rows.len == 0 { + return '' + } + ncols := rows[0].vals.len + names := resolve_names(rows[0].names, ncols) + + mut max_len := 0 + for col in names { + if col.len > max_len { + max_len = col.len + } + } + + mut blocks := []string{} + for row in rows { + mut lines := []string{} + for j, val in row.vals { + if j >= ncols { + break + } + lines << '${str_pad_left(names[j], max_len)}: ${null_disp(val, opts.nullvalue)}' + } + blocks << lines.join('\n') + } + return blocks.join('\n\n') +} + +// ---------- box (Unicode box-drawing) ---------- + +fn format_box_ex(rows []sqlite.Row, opts FormatOptions) string { + if rows.len == 0 { + return '' + } + ncols := rows[0].vals.len + names := resolve_names(rows[0].names, ncols) + widths := compute_widths(rows, names, opts) + + mut top := '┌' + mut mid := '├' + mut bot := '└' + for j, w in widths { + top += '─'.repeat(w + 2) + mid += '─'.repeat(w + 2) + bot += '─'.repeat(w + 2) + if j < ncols - 1 { + top += '┬' + mid += '┼' + bot += '┴' + } + } + top += '┐' + mid += '┤' + bot += '┘' + + mut out := top + '\n' + if opts.headers { + mut hdr := '│' + for i, col in names { + hdr += ' ' + term.bold(str_pad(col.limit(widths[i]), widths[i])) + ' │' + } + out += hdr + '\n' + mid + '\n' + } + for row in rows { + mut line := '│' + for i, val in row.vals { + if i >= ncols { + break + } + v := null_disp(val, opts.nullvalue) + line += ' ' + str_pad(v.limit(widths[i]), widths[i]) + ' │' + } + out += line + '\n' + } + out += bot + return out +} + +// ---------- markdown ---------- + +fn format_markdown_ex(rows []sqlite.Row, opts FormatOptions) string { + if rows.len == 0 { + return '' + } + ncols := rows[0].vals.len + names := resolve_names(rows[0].names, ncols) + raw_w := compute_widths(rows, names, opts) + mut widths := []int{len: ncols} + for i, w in raw_w { + widths[i] = if w < 3 { 3 } else { w } + } + + mut lines := []string{} + if opts.headers { + mut hdr := '|' + mut sep_row := '|' + for i, col in names { + hdr += ' ' + str_pad(col.limit(widths[i]), widths[i]) + ' |' + sep_row += ' ' + '-'.repeat(widths[i]) + ' |' + } + lines << hdr + lines << sep_row + } + for row in rows { + mut line := '|' + for i, val in row.vals { + if i >= ncols { + break + } + v := null_disp(val, opts.nullvalue) + line += ' ' + str_pad(v.limit(widths[i]), widths[i]) + ' |' + } + lines << line + } + return lines.join('\n') +} + +// ---------- json ---------- + +fn format_json_ex(rows []sqlite.Row, opts FormatOptions) string { + if rows.len == 0 { + return '[]' + } + ncols := rows[0].vals.len + names := resolve_names(rows[0].names, ncols) + + mut items := []string{} + for row in rows { + mut fields := []string{} + for i, val in row.vals { + if i >= ncols { + break + } + key := '"${json_escape(names[i])}"' + if val == '' { + fields << '${key}:null' + } else { + fields << '${key}:"${json_escape(val)}"' + } + } + items << '{${fields.join(',')}}' + } + return '[${items.join(',\n ')}]' +} + +fn json_escape(s string) string { + return s + .replace('\\', '\\\\') + .replace('"', '\\"') + .replace('\n', '\\n') + .replace('\r', '\\r') + .replace('\t', '\\t') +} + +// ---------- html ---------- + +fn format_html_ex(rows []sqlite.Row, opts FormatOptions) string { + if rows.len == 0 { + return '' + } + ncols := rows[0].vals.len + names := resolve_names(rows[0].names, ncols) + + mut out := '\n' + if opts.headers { + out += '' + for col in names { + out += '' + } + out += '\n' + } + for row in rows { + out += '' + for i, val in row.vals { + if i >= ncols { + break + } + v := null_disp(val, opts.nullvalue) + out += '' + } + out += '\n' + } + out += '
${html_escape(col)}
${html_escape(v)}
' + return out +} + +fn html_escape(s string) string { + return s + .replace('&', '&') + .replace('<', '<') + .replace('>', '>') + .replace('"', '"') +} + +// ---------- insert ---------- + +fn format_insert_ex(rows []sqlite.Row, opts FormatOptions) string { + if rows.len == 0 { + return '' + } + ncols := rows[0].vals.len + names := resolve_names(rows[0].names, ncols) + tbl := if opts.table_name != '' { opts.table_name } else { 'tbl' } + col_list := names.join(',') + + mut lines := []string{} + for row in rows { + mut vals := []string{} + for i, val in row.vals { + if i >= ncols { + break + } + if val == '' { + vals << 'NULL' + } else { + vals << "'" + val.replace("'", "''") + "'" + } + } + lines << 'INSERT INTO ${tbl}(${col_list}) VALUES(${vals.join(',')});' + } + return lines.join('\n') +} + +// ---------- quote ---------- + +fn format_quote_ex(rows []sqlite.Row, opts FormatOptions) string { + if rows.len == 0 { + return '' + } + ncols := rows[0].vals.len + names := resolve_names(rows[0].names, ncols) + sep := opts.separator + + mut lines := []string{} + if opts.headers { + lines << names.join(sep) + } + for row in rows { + mut vals := []string{} + for i, val in row.vals { + if i >= ncols { + break + } + if val == '' { + vals << 'NULL' + } else { + vals << "'" + val.replace("'", "''") + "'" + } + } + lines << vals.join(sep) + } + return lines.join('\n') +} diff --git a/cmd/tools/vsqlite/main.v b/cmd/tools/vsqlite/main.v new file mode 100644 index 000000000..74c7367ef --- /dev/null +++ b/cmd/tools/vsqlite/main.v @@ -0,0 +1,1223 @@ +module main + +import db.sqlite +import os +import readline +import time + +const version = '0.1.0' + +const help_text = 'vsqlite ${version} - SQLite CLI written in V + +Usage: + v sqlite Open database in interactive mode + v sqlite Execute a single SQL statement + v sqlite -f Execute SQL from a file + v sqlite --help Show this help + v sqlite --version Show version + +Interactive mode commands: + .tables List all tables + .schema [table] Show schema for table(s) + .mode [table|csv|line|box| Change output mode (default: table) + markdown|json|html| + insert [tbl]|quote] + .headers [on|off] Toggle column headers + .nullvalue [str] Set NULL display string (default: NULL) + .separator [sep] Set column separator (default: ,) + .width [N1 N2 ...] Set per-column widths (0 = reset to auto) + .output [file|-] Redirect output to file (no arg = stdout) + .once Redirect next query result to file + .timer [on|off] Toggle query execution timing (default: off) + .explain Show EXPLAIN QUERY PLAN tree for a statement + .import Import CSV/TSV into table (auto-detects .tsv/.tab) + .export Export last query to CSV + .dump [file] Dump full SQL schema+data to stdout or file + .load [entry] Load a SQLite extension (.so/.dylib) + .bail [on|off] Stop on first error (default: off) + .echo [on|off] Echo each statement before executing (default: off) + .log [file|off] Log all statements to a file; off to disable + .changes [on|off] Show row-change count after each statement (default: off) + .open Open (or switch to) a different database file + .databases List attached databases + .indexes [table] List indexes + .size Show database file size + .read Execute SQL file from within the REPL + .show Print snapshot of all current settings + .print [string...] Print literal text (blank line if no args) + .prompt MAIN [CONTINUE] Set REPL prompt strings + .eqp [on|off] Auto-EXPLAIN QUERY PLAN before each SELECT + .trace [file|stderr|off] Trace all SQL statements as they execute + .timeout Set busy-wait timeout (milliseconds) + .shell / .system Run an OS command + .backup Backup database using VACUUM INTO + .fullschema Full schema including sqlite_stat tables + .dbinfo Database file info (page count, encoding, etc.) + .stats Page and table statistics + .lint Report missing indexes on FK columns + .cd [directory] Change or show working directory + .quit / .exit / Ctrl+D Exit' + +const history_file = os.join_path(os.home_dir(), '.vsqlite_history') + +struct App { +mut: + db sqlite.DB + rl readline.Readline + mode OutputMode = .table + headers bool = true + last_rows []sqlite.Row + nullvalue string = 'NULL' + separator string = ',' + col_widths map[int]int + output_path string + output_once bool + insert_table string = 'tbl' + timer bool + bail bool + echo bool + log_path string + changes bool + db_path string + eqp bool + trace_path string + prompt string = 'vsqlite> ' + prompt2 string = ' ...> ' +} + +fn main() { + // When launched via `v sqlite`, os.args[1] is the subcommand name 'sqlite'. + // Skip it so the remaining args start with the database path. + raw := os.args[1..] + args := if raw.len > 0 && raw[0] == 'sqlite' { raw[1..] } else { raw } + + if args.len == 0 || args[0] in ['--help', '-h'] { + println(help_text) + return + } + + if args[0] in ['--version', '-v'] { + println('vsqlite ${version}') + return + } + + mut db := sqlite.connect(args[0]) or { + eprintln('Error: cannot open "${args[0]}": ${err}') + exit(1) + } + db.exec_none('PRAGMA journal_mode=WAL') + db.exec_none('PRAGMA foreign_keys=ON') + + mut app := App{ + db: db + db_path: args[0] + } + + if args.len == 1 { + app.interactive_mode() + } else if args[1] == '-f' { + if args.len < 3 { + eprintln('Error: -f requires a file path') + exit(1) + } + app.exec_file(args[2]) + } else { + app.run(args[1..].join(' ')) + } +} + +fn (mut app App) interactive_mode() { + println('vsqlite ${version} - Type .help for commands, .quit to exit') + println('') + app.rl.skip_empty = true + app.load_history() + app.refresh_completions() + mut buf := []string{} + for { + prompt := if buf.len == 0 { app.prompt } else { app.prompt2 } + line := app.rl.read_line(prompt) or { + if buf.len > 0 { + eprintln('Incomplete statement discarded.') + } + break + } + trimmed := line.trim_space() + if trimmed == '' { + continue + } + if buf.len == 0 { + if trimmed in ['.quit', '.exit', 'quit', 'exit'] { + break + } + if trimmed.starts_with('.') { + app.dot_cmd(trimmed) + continue + } + } + buf << line + if stmt_complete(buf) { + full := buf.join('\n').trim_space() + for s in split_statements(full) { + if s != '' { + app.run(s) + } + } + buf = []string{} + } + } + app.save_history() + println('\nBye!') +} + +fn stmt_complete(lines []string) bool { + return lines.join('\n').trim_space().ends_with(';') +} + +fn (mut app App) load_history() { + history_load(mut app.rl, history_file) +} + +fn (app App) save_history() { + history_save(app.rl, history_file) +} + +fn history_load(mut rl readline.Readline, path string) { + content := os.read_file(path) or { return } + for line in content.split('\n') { + if line.len > 0 { + rl.previous_lines << line.runes() + } + } +} + +fn history_save(rl readline.Readline, path string) { + lines := rl.previous_lines.map(it.string()).filter(it.len > 0) + os.write_file(path, lines.join('\n') + '\n') or {} +} + +fn (app App) make_format_opts() FormatOptions { + return FormatOptions{ + mode: app.mode + headers: app.headers + nullvalue: app.nullvalue + separator: app.separator + col_widths: app.col_widths + table_name: app.insert_table + } +} + +fn (mut app App) write_out(s string) { + if app.output_path != '' { + os.write_file(app.output_path, os.read_file(app.output_path) or { '' } + s + '\n') or { + eprintln('Error: cannot write to "${app.output_path}": ${err}') + } + } else { + println(s) + } +} + +fn (mut app App) finish_output() { + if app.output_once { + app.output_path = '' + app.output_once = false + } +} + +fn (mut app App) log_stmt(stmt string) { + if app.log_path == '' { + return + } + existing := os.read_file(app.log_path) or { '' } + os.write_file(app.log_path, existing + stmt + ';\n') or {} +} + +fn (mut app App) trace_stmt(stmt string) { + if app.trace_path == '' { + return + } + if app.trace_path == 'stderr' { + eprintln(stmt) + return + } + existing := os.read_file(app.trace_path) or { '' } + os.write_file(app.trace_path, existing + stmt + ';\n') or {} +} + +fn (mut app App) run(stmt string) bool { + if app.echo { + println(stmt) + } + app.log_stmt(stmt) + app.trace_stmt(stmt) + upper := stmt.trim_space().to_upper() + is_query := upper.starts_with('SELECT') || upper.starts_with('PRAGMA') + || upper.starts_with('EXPLAIN') || upper.starts_with('WITH') || upper.starts_with('VALUES') + + if app.eqp && is_query && !upper.starts_with('EXPLAIN') { + app.run_explain(stmt) + } + + t0 := time.now() + + if is_query { + rows := app.db.exec(stmt) or { + eprintln('Error: ${err}') + if app.bail { + exit(1) + } + return false + } + elapsed := time.since(t0) + if rows.len == 0 { + app.finish_output() + if app.timer { + println('Run time: ${format_duration(elapsed)}') + } + return true + } + app.last_rows = rows + app.write_out(format_opts(rows, app.make_format_opts())) + app.write_out('(${rows.len} row${if rows.len == 1 { '' } else { 's' }})') + app.finish_output() + if app.timer { + println('Run time: ${format_duration(elapsed)}') + } + } else { + code := app.db.exec_none(stmt) + if code != 101 && code != 100 { + // sqlite_done=101, sqlite_row=100; anything else is an error + eprintln('Error: exec failed (code ${code})') + if app.bail { + exit(1) + } + return false + } + elapsed := time.since(t0) + affected := app.db.get_affected_rows_count() + last_id := app.db.last_insert_rowid() + if upper.starts_with('INSERT') { + println('Inserted 1 row (rowid: ${last_id})') + } else if upper.starts_with('UPDATE') || upper.starts_with('DELETE') { + println('${affected} row${if affected == 1 { '' } else { 's' }} affected') + } else { + println('OK') + } + if app.changes { + println('Changes: ${affected}') + } + if app.timer { + println('Run time: ${format_duration(elapsed)}') + } + if upper.starts_with('CREATE') || upper.starts_with('DROP') || upper.starts_with('ALTER') { + app.refresh_completions() + } + } + return true +} + +fn (mut app App) exec_file(path string) { + content := os.read_file(path) or { + eprintln('Error reading "${path}": ${err}') + exit(1) + } + mut count := 0 + for stmt in split_statements(content) { + trimmed := stmt.trim_space() + // Strip comment-only lines to decide if the chunk has real SQL, + // but pass the original (with comments) to SQLite so it can parse them. + sql_content := trimmed.split('\n').filter(!it.trim_space().starts_with('--')).join('\n').trim_space() + if sql_content == '' { + continue + } + app.run(trimmed) + count++ + } + println('Executed ${count} statement(s) from ${path}') +} + +fn (mut app App) read_file_repl(path string) { + content := os.read_file(path) or { + eprintln('Error: cannot read "${path}": ${err}') + return + } + mut count := 0 + for stmt in split_statements(content) { + trimmed := stmt.trim_space() + sql_content := trimmed.split('\n').filter(!it.trim_space().starts_with('--')).join('\n').trim_space() + if sql_content == '' { + continue + } + app.run(trimmed) + count++ + } + println('Executed ${count} statement(s) from ${path}') +} + +fn (mut app App) dot_cmd(cmd string) { + parts := cmd.split(' ').filter(it != '') + match parts[0] { + '.help' { + println(help_text) + } + '.tables' { + tables := app.db.tables() or { + eprintln('Error: ${err}') + return + } + if tables.len == 0 { + println('(no tables)') + } else { + println(tables.join('\n')) + } + } + '.schema' { + table := if parts.len > 1 { parts[1] } else { '' } + result := app.db.schema(table) or { + eprintln('Error: ${err}') + return + } + if result == '' { + println('(no schema found)') + } else { + println(result) + } + } + '.mode' { + if parts.len < 2 { + println('Current mode: ${app.mode}') + return + } + match parts[1] { + 'table' { + app.mode = .table + } + 'csv' { + app.mode = .csv + } + 'line' { + app.mode = .line + } + 'box' { + app.mode = .box + } + 'markdown' { + app.mode = .markdown + } + 'json' { + app.mode = .json + } + 'html' { + app.mode = .html + } + 'insert' { + app.mode = .insert + if parts.len > 2 { + app.insert_table = parts[2] + } + } + 'quote' { + app.mode = .quote + } + else { + eprintln('Unknown mode: ${parts[1]}. Use: table, csv, line, box, markdown, json, html, insert [tbl], quote') + return + } + } + println('Mode set to: ${app.mode}') + } + '.headers' { + if parts.len < 2 { + println('Headers: ${if app.headers { 'on' } else { 'off' }}') + return + } + match parts[1] { + 'on' { app.headers = true } + 'off' { app.headers = false } + else { eprintln('Use: .headers on|off') } + } + } + '.nullvalue' { + if parts.len < 2 { + println('NULL display: "${app.nullvalue}"') + return + } + app.nullvalue = parts[1] + println('NULL value set to "${app.nullvalue}"') + } + '.separator' { + if parts.len < 2 { + println('Separator: "${app.separator}"') + return + } + mut sep := parts[1] + sep = sep.replace('\\t', '\t').replace('\\n', '\n') + app.separator = sep + println('Separator set to "${app.separator}"') + } + '.width' { + if parts.len < 2 { + if app.col_widths.len == 0 { + println('Column widths: auto') + } else { + mut pairs := []string{} + for k, v in app.col_widths { + pairs << 'col${k}=${v}' + } + println('Column widths: ${pairs.join(', ')}') + } + return + } + if parts.len == 2 && parts[1] == '0' { + app.col_widths = map[int]int{} + println('Column widths reset to auto') + return + } + app.col_widths = map[int]int{} + for i, part in parts[1..] { + w := part.int() + if w > 0 { + app.col_widths[i] = w + } + } + println('Column widths set') + } + '.output' { + if parts.len < 2 || parts[1] in ['stdout', '-'] { + app.output_path = '' + app.output_once = false + println('Output reset to stdout') + } else { + os.write_file(parts[1], '') or { + eprintln('Error: cannot open "${parts[1]}": ${err}') + return + } + app.output_path = parts[1] + app.output_once = false + println('Output redirected to ${parts[1]}') + } + } + '.once' { + if parts.len < 2 { + eprintln('Usage: .once ') + return + } + os.write_file(parts[1], '') or { + eprintln('Error: cannot open "${parts[1]}": ${err}') + return + } + app.output_path = parts[1] + app.output_once = true + } + '.timer' { + if parts.len < 2 { + println('Timer: ${if app.timer { 'on' } else { 'off' }}') + return + } + match parts[1] { + 'on' { + app.timer = true + println('Timer: on') + } + 'off' { + app.timer = false + println('Timer: off') + } + else { + eprintln('Use: .timer on|off') + } + } + } + '.explain' { + if parts.len < 2 { + eprintln('Usage: .explain ') + return + } + stmt := parts[1..].join(' ') + app.run_explain(stmt) + } + '.import' { + if parts.len < 3 { + eprintln('Usage: .import
') + return + } + app.import_csv(parts[1], parts[2]) + } + '.export' { + if parts.len < 2 { + eprintln('Usage: .export ') + return + } + if app.last_rows.len == 0 { + eprintln('No previous query results to export') + return + } + write_csv(parts[1], app.last_rows, app.headers) or { + eprintln('Error: ${err}') + return + } + println('Exported ${app.last_rows.len} rows to ${parts[1]}') + } + '.dump' { + text := dump_database(mut app.db) + if parts.len > 1 { + os.write_file(parts[1], text) or { + eprintln('Error: cannot write "${parts[1]}": ${err}') + return + } + println('Dumped to ${parts[1]}') + } else { + println(text) + } + } + '.load' { + if parts.len < 2 { + eprintln('Usage: .load [entry-point]') + return + } + entry := if parts.len > 2 { parts[2] } else { '' } + ep := if entry != '' { ", '${entry.replace("'", "''")}'" } else { '' } + escaped_path := parts[1].replace("'", "''") + _ = app.db.exec("SELECT load_extension('${escaped_path}' ${ep})") or { + eprintln('Error: ${err}') + return + } + println('Extension loaded: ${parts[1]}') + } + '.bail' { + if parts.len < 2 { + println('Bail: ${if app.bail { 'on' } else { 'off' }}') + return + } + match parts[1] { + 'on' { + app.bail = true + println('Bail: on') + } + 'off' { + app.bail = false + println('Bail: off') + } + else { + eprintln('Use: .bail on|off') + } + } + } + '.echo' { + if parts.len < 2 { + println('Echo: ${if app.echo { 'on' } else { 'off' }}') + return + } + match parts[1] { + 'on' { + app.echo = true + println('Echo: on') + } + 'off' { + app.echo = false + println('Echo: off') + } + else { + eprintln('Use: .echo on|off') + } + } + } + '.log' { + if parts.len < 2 { + if app.log_path == '' { + println('Log: off') + } else { + println('Log: ${app.log_path}') + } + return + } + if parts[1] == 'off' { + app.log_path = '' + println('Logging off') + } else { + app.log_path = parts[1] + println('Logging to ${app.log_path}') + } + } + '.changes' { + if parts.len < 2 { + println('Changes: ${if app.changes { 'on' } else { 'off' }}') + return + } + match parts[1] { + 'on' { + app.changes = true + println('Changes: on') + } + 'off' { + app.changes = false + println('Changes: off') + } + else { + eprintln('Use: .changes on|off') + } + } + } + '.open' { + if parts.len < 2 { + eprintln('Usage: .open ') + return + } + mut new_db := sqlite.connect(parts[1]) or { + eprintln('Error: cannot open "${parts[1]}": ${err}') + return + } + new_db.exec_none('PRAGMA journal_mode=WAL') + new_db.exec_none('PRAGMA foreign_keys=ON') + app.db = new_db + app.db_path = parts[1] + app.refresh_completions() + println('Opened ${parts[1]}') + } + '.databases' { + rows := app.db.exec('PRAGMA database_list') or { return } + for row in rows { + println('${row.vals[1]}: ${row.vals[2]}') + } + } + '.indexes' { + filter := if parts.len > 1 { + escaped := parts[1].replace("'", "''") + "AND tbl_name='${escaped}'" + } else { + '' + } + rows := app.db.exec("SELECT name, tbl_name FROM sqlite_master WHERE type='index' ${filter} ORDER BY tbl_name, name") or { + return + } + if rows.len == 0 { + println('(no indexes found)') + } else { + for row in rows { + println('${row.vals[1]}.${row.vals[0]}') + } + } + } + '.size' { + sz := app.db.db_size() or { + eprintln('Error: ${err}') + return + } + println('Database size: ${format_bytes(sz)}') + } + '.read' { + if parts.len < 2 { + eprintln('Usage: .read ') + return + } + app.read_file_repl(parts[1]) + } + '.show' { + log_disp := if app.log_path == '' { 'off' } else { app.log_path } + trace_disp := if app.trace_path == '' { 'off' } else { app.trace_path } + output_disp := if app.output_path == '' { 'stdout' } else { app.output_path } + println(' bail: ${if app.bail { 'on' } else { 'off' }}') + println(' changes: ${if app.changes { 'on' } else { 'off' }}') + println(' echo: ${if app.echo { 'on' } else { 'off' }}') + println(' eqp: ${if app.eqp { 'on' } else { 'off' }}') + println(' headers: ${if app.headers { 'on' } else { 'off' }}') + println(' log_path: ${log_disp}') + println(' mode: ${app.mode}') + println(' nullvalue: "${app.nullvalue}"') + println(' output: ${output_disp}') + println(' prompt: "${app.prompt}"') + println(' separator: "${app.separator}"') + println(' timer: ${if app.timer { 'on' } else { 'off' }}') + println(' trace: ${trace_disp}') + } + '.print' { + if parts.len < 2 { + println('') + } else { + println(parts[1..].join(' ')) + } + } + '.prompt' { + if parts.len < 2 { + eprintln('Usage: .prompt MAIN [CONTINUE]') + return + } + app.prompt = parts[1] + if parts.len > 2 { + app.prompt2 = parts[2] + } + } + '.eqp' { + if parts.len < 2 { + println('EQP: ${if app.eqp { 'on' } else { 'off' }}') + return + } + match parts[1] { + 'on' { + app.eqp = true + println('EQP: on') + } + 'off' { + app.eqp = false + println('EQP: off') + } + 'full' { + app.eqp = true + println('EQP: on (full)') + } + else { + eprintln('Use: .eqp on|off') + } + } + } + '.trace' { + if parts.len < 2 || parts[1] == 'off' { + app.trace_path = '' + println('Tracing off') + } else if parts[1] == 'stderr' { + app.trace_path = 'stderr' + println('Tracing to stderr') + } else { + app.trace_path = parts[1] + println('Tracing to ${app.trace_path}') + } + } + '.timeout' { + if parts.len < 2 { + eprintln('Usage: .timeout ') + return + } + ms := parts[1].int() + app.db.busy_timeout(ms) + println('Timeout: ${ms}ms') + } + '.shell', '.system' { + if parts.len < 2 { + eprintln('Usage: ${parts[0]} ') + return + } + shell_cmd := parts[1..].join(' ') + result := os.execute(shell_cmd) + if result.output.len > 0 { + print(result.output) + if !result.output.ends_with('\n') { + println('') + } + } + if result.exit_code != 0 { + eprintln('Exit code: ${result.exit_code}') + } + } + '.backup' { + if parts.len < 2 { + eprintln('Usage: .backup ') + return + } + escaped := parts[1].replace("'", "''") + app.db.exec("VACUUM INTO '${escaped}'") or { + eprintln('Error: ${err}') + return + } + println('Backed up to ${parts[1]}') + } + '.fullschema' { + full_schema := app.db.schema('') or { + eprintln('Error: ${err}') + return + } + if full_schema == '' { + println('(no schema found)') + } else { + println(full_schema) + } + for stat_tbl in ['sqlite_stat1', 'sqlite_stat2', 'sqlite_stat3', 'sqlite_stat4'] { + rows := app.db.exec('SELECT * FROM ${stat_tbl}') or { continue } + if rows.len == 0 { + continue + } + println('') + println('/* contents of ${stat_tbl} */') + for row in rows { + println(row.vals.join('|')) + } + } + } + '.dbinfo' { + db_list_rows := app.db.exec('PRAGMA database_list') or { []sqlite.Row{} } + mut filename := app.db_path + for row in db_list_rows { + if row.vals.len > 1 && row.vals[1] == 'main' { + filename = if row.vals.len > 2 { row.vals[2] } else { app.db_path } + break + } + } + ver_rows := app.db.exec('SELECT sqlite_version()') or { []sqlite.Row{} } + sqlite_ver := if ver_rows.len > 0 && ver_rows[0].vals.len > 0 { + ver_rows[0].vals[0] + } else { + 'unknown' + } + pc_rows := app.db.exec('PRAGMA page_count') or { []sqlite.Row{} } + ps_rows := app.db.exec('PRAGMA page_size') or { []sqlite.Row{} } + enc_rows := app.db.exec('PRAGMA encoding') or { []sqlite.Row{} } + jm_rows := app.db.exec('PRAGMA journal_mode') or { []sqlite.Row{} } + fl_rows := app.db.exec('PRAGMA freelist_count') or { []sqlite.Row{} } + ai_rows := app.db.exec('PRAGMA application_id') or { []sqlite.Row{} } + uv_rows := app.db.exec('PRAGMA user_version') or { []sqlite.Row{} } + page_count := if pc_rows.len > 0 { pc_rows[0].vals[0] } else { '0' } + page_size := if ps_rows.len > 0 { ps_rows[0].vals[0] } else { '0' } + encoding := if enc_rows.len > 0 { enc_rows[0].vals[0] } else { 'unknown' } + journal_mode := if jm_rows.len > 0 { jm_rows[0].vals[0] } else { 'unknown' } + freelist_count := if fl_rows.len > 0 { fl_rows[0].vals[0] } else { '0' } + application_id := if ai_rows.len > 0 { ai_rows[0].vals[0] } else { '0' } + user_version := if uv_rows.len > 0 { uv_rows[0].vals[0] } else { '0' } + file_size := page_count.i64() * page_size.i64() + println(' filename: ${filename}') + println(' sqlite_version: ${sqlite_ver}') + println(' page_count: ${page_count}') + println(' page_size: ${page_size}') + println(' file_size: ${format_bytes(file_size)}') + println(' encoding: ${encoding}') + println(' journal_mode: ${journal_mode}') + println(' freelist_count: ${freelist_count}') + println(' application_id: ${application_id}') + println(' user_version: ${user_version}') + } + '.stats' { + pc_rows := app.db.exec('PRAGMA page_count') or { []sqlite.Row{} } + ps_rows := app.db.exec('PRAGMA page_size') or { []sqlite.Row{} } + fl_rows := app.db.exec('PRAGMA freelist_count') or { []sqlite.Row{} } + cs_rows := app.db.exec('PRAGMA cache_size') or { []sqlite.Row{} } + page_count := if pc_rows.len > 0 { pc_rows[0].vals[0] } else { '0' } + page_size := if ps_rows.len > 0 { ps_rows[0].vals[0] } else { '0' } + freelist_count := if fl_rows.len > 0 { fl_rows[0].vals[0] } else { '0' } + cache_size := if cs_rows.len > 0 { cs_rows[0].vals[0] } else { '0' } + pc := page_count.i64() + ps := page_size.i64() + fl := freelist_count.i64() + total_size := pc * ps + used_size := (pc - fl) * ps + free_size := fl * ps + println(' page_count: ${page_count}') + println(' page_size: ${page_size}') + println(' total_size: ${format_bytes(total_size)}') + println(' used_size: ${format_bytes(used_size)}') + println(' free_size: ${format_bytes(free_size)}') + println(' cache_size: ${cache_size}') + tables := app.db.tables() or { []string{} } + if tables.len > 0 { + println('') + println('Table row counts:') + for tbl in tables { + escaped_tbl := tbl.replace('"', '""') + cnt_rows := app.db.exec('SELECT COUNT(*) FROM "${escaped_tbl}"') or { continue } + cnt := if cnt_rows.len > 0 { cnt_rows[0].vals[0] } else { '0' } + println(' ${tbl}: ${cnt}') + } + } + } + '.lint' { + tables := app.db.tables() or { []string{} } + mut issue_count := 0 + for tbl in tables { + escaped_tbl := tbl.replace('"', '""') + fk_rows := app.db.exec('PRAGMA foreign_key_list("${escaped_tbl}")') or { continue } + if fk_rows.len == 0 { + continue + } + mut indexed_cols := map[string]bool{} + ti_rows := app.db.exec('PRAGMA table_info("${escaped_tbl}")') or { []sqlite.Row{} } + for ti_row in ti_rows { + if ti_row.vals.len > 5 && ti_row.vals[5] != '0' { + indexed_cols[ti_row.vals[1]] = true + } + } + il_rows := app.db.exec('PRAGMA index_list("${escaped_tbl}")') or { []sqlite.Row{} } + for il_row in il_rows { + if il_row.vals.len < 2 { + continue + } + idx_name := il_row.vals[1].replace('"', '""') + ii_rows := app.db.exec('PRAGMA index_info("${idx_name}")') or { continue } + for ii_row in ii_rows { + if ii_row.vals.len > 2 { + indexed_cols[ii_row.vals[2]] = true + } + } + } + for fk_row in fk_rows { + if fk_row.vals.len < 4 { + continue + } + fk_col := fk_row.vals[3] + ref_table := fk_row.vals[2] + ref_col := fk_row.vals[4] + if fk_col !in indexed_cols { + println('${tbl}: FK column "${fk_col}" -> ${ref_table}(${ref_col}) has no covering index') + issue_count++ + } + } + } + if issue_count == 0 { + println('No issues found.') + } else { + println('${issue_count} issue${if issue_count == 1 { '' } else { 's' }} found.') + } + } + '.cd' { + if parts.len < 2 { + cwd := os.getwd() + println(cwd) + return + } + os.chdir(parts[1]) or { + eprintln('Error: cannot change to "${parts[1]}": ${err}') + return + } + println('Changed to ${parts[1]}') + } + else { + eprintln('Unknown command: ${parts[0]}. Type .help for help.') + } + } +} + +fn (mut app App) import_csv(file string, table string) { + sep := if file.ends_with('.tsv') || file.ends_with('.tab') { u8(`\t`) } else { u8(`,`) } + headers, rows := read_csv_sep(file, sep) or { + eprintln('Error: ${err}') + return + } + col_list := headers.join(',') + app.db.exec_none('BEGIN TRANSACTION') + mut imported := 0 + for vals in rows { + quoted := vals.map("'${it.replace("'", "''")}'") + code := app.db.exec_none('INSERT INTO ${table} (${col_list}) VALUES (${quoted.join(',')})') + if code != 101 && code != 100 { + app.db.exec_none('ROLLBACK') + eprintln('Error: import failed on row ${imported + 1} (code ${code})') + return + } + imported++ + } + app.db.exec_none('COMMIT') + println('Imported ${imported} rows into ${table}') +} + +fn (mut app App) refresh_completions() { + dot_cmds := ['.tables', '.schema', '.mode', '.headers', '.nullvalue', '.separator', '.width', + '.output', '.once', '.timer', '.explain', '.import', '.export', '.dump', '.load', '.bail', + '.echo', '.log', '.changes', '.open', '.databases', '.indexes', '.size', '.help', '.quit', + '.exit', '.read', '.show', '.print', '.prompt', '.eqp', '.trace', '.timeout', '.shell', + '.system', '.backup', '.fullschema', '.dbinfo', '.stats', '.lint', '.cd'] + kws := ['SELECT', 'FROM', 'WHERE', 'INSERT', 'INTO', 'UPDATE', 'SET', 'DELETE', 'CREATE', 'TABLE', + 'DROP', 'ALTER', 'JOIN', 'LEFT', 'RIGHT', 'INNER', 'OUTER', 'ON', 'ORDER', 'BY', 'GROUP', + 'HAVING', 'LIMIT', 'OFFSET', 'AND', 'OR', 'NOT', 'NULL', 'VALUES', 'PRIMARY', 'KEY', + 'INTEGER', 'TEXT', 'REAL', 'BLOB', 'UNIQUE', 'DEFAULT', 'AUTOINCREMENT', 'PRAGMA', 'INDEX', + 'DISTINCT', 'COUNT', 'SUM', 'MIN', 'MAX', 'AVG', 'AS', 'LIKE', 'IN', 'IS', 'BETWEEN', 'CASE', + 'WHEN', 'THEN', 'ELSE', 'END', 'BEGIN', 'TRANSACTION', 'COMMIT', 'ROLLBACK'] + mut words := []string{} + words << dot_cmds + words << kws + tables := app.db.tables() or { []string{} } + words << tables + for t in tables { + words << (app.db.columns(t) or { []string{} }) + } + app.rl.completion_callback = make_completer(words) +} + +fn make_completer(words []string) fn (string) []string { + return fn [words] (line string) []string { + if line.len == 0 { + return []string{} + } + if line.starts_with('.') && !line.contains(' ') { + return words.filter(it.starts_with(line)) + } + tok_start := last_token_start(line) + pre := line[..tok_start] + tok := line[tok_start..] + if tok.len == 0 { + return []string{} + } + tok_upper := tok.to_upper() + mut results := []string{} + for w in words { + if w.to_upper().starts_with(tok_upper) { + results << pre + w + } + } + return results + } +} + +fn last_token_start(line string) int { + for i := line.len - 1; i >= 0; i-- { + b := line[i] + if b == 32 || b == 9 || b == 40 || b == 44 { + return i + 1 + } + } + return 0 +} + +// dump_database generates a SQL script that recreates the entire database. +fn dump_database(mut db sqlite.DB) string { + mut lines := []string{} + lines << 'BEGIN TRANSACTION;' + lines << '' + 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 { + lines << 'COMMIT;' + return lines.join('\n') + } + for row in schema_rows { + if row.vals[0] != 'table' { + continue + } + obj_name := row.vals[1] + escaped := obj_name.replace('"', '""') + lines << row.vals[2] + ';' + col_rows := db.exec('PRAGMA table_info("${escaped}")') or { continue } + if col_rows.len == 0 { + lines << '' + continue + } + col_names := col_rows.map(it.vals[1]) + col_list := col_names.map('"${it.replace('"', '""')}"').join(', ') + // Use quote() to correctly distinguish NULL from empty string. + quoted_cols := col_names.map('quote("${it.replace('"', '""')}")').join(', ') + data_rows := db.exec('SELECT ${quoted_cols} FROM "${escaped}"') or { continue } + for drow in data_rows { + lines << 'INSERT INTO "${escaped}"(${col_list}) VALUES(${drow.vals.join(', ')});' + } + lines << '' + } + for row in schema_rows { + if row.vals[0] == 'table' { + continue + } + lines << row.vals[2] + ';' + lines << '' + } + lines << 'COMMIT;' + return lines.join('\n') +} + +fn format_bytes(n i64) string { + if n < 1024 { + return '${n} B' + } else if n < 1024 * 1024 { + return '${n / 1024} KB' + } else if n < 1024 * 1024 * 1024 { + return '${n / (1024 * 1024)} MB' + } else { + return '${n / (1024 * 1024 * 1024)} GB' + } +} + +fn format_duration(d time.Duration) string { + us := d.microseconds() + if us < 1000 { + return '${us} µs' + } + ms := us / 1000 + frac := us % 1000 + frac_str := if frac < 10 { + '00${frac}' + } else if frac < 100 { + '0${frac}' + } else { + '${frac}' + } + return '${ms}.${frac_str} ms' +} + +// split_statements splits SQL on semicolons, respecting quoted strings and +// BEGIN...END blocks (e.g. CREATE TRIGGER bodies). +fn split_statements(src string) []string { + mut stmts := []string{} + mut start := 0 + mut in_single := false + mut in_double := false + mut begin_depth := 0 + for i := 0; i < src.len; i++ { + c := src[i] + if c == `'` && !in_double { + in_single = !in_single + } else if c == `"` && !in_single { + in_double = !in_double + } else if !in_single && !in_double { + // Track BEGIN/END nesting for trigger bodies. + if c == `;` && begin_depth == 0 { + s := src[start..i].trim_space() + if s != '' { + stmts << s + } + start = i + 1 + } else if is_keyword_at(src, i, 'BEGIN') { + // Only count as a trigger-body BEGIN if there is already content + // in this statement. A standalone 'BEGIN'/'BEGIN TRANSACTION' + // starts a new statement and must not suppress semicolon splitting. + if src[start..i].trim_space() != '' { + begin_depth++ + } + } else if is_keyword_at(src, i, 'END') && begin_depth > 0 { + begin_depth-- + } + } + } + s := src[start..].trim_space() + if s != '' { + stmts << s + } + return stmts +} + +// is_keyword_at checks if keyword appears at position i as a whole word +// (not part of a larger identifier). +fn is_keyword_at(src string, i int, keyword string) bool { + if i + keyword.len > src.len { + return false + } + // Must not be preceded by a word character. + if i > 0 && is_word_char(src[i - 1]) { + return false + } + // Must not be followed by a word character. + end := i + keyword.len + if end < src.len && is_word_char(src[end]) { + return false + } + return src[i..end].to_upper() == keyword +} + +fn is_word_char(c u8) bool { + return (c >= `a` && c <= `z`) || (c >= `A` && c <= `Z`) || (c >= `0` && c <= `9`) || c == `_` +} + +fn (mut app App) run_explain(stmt string) { + rows := app.db.exec('EXPLAIN QUERY PLAN ${stmt}') or { + eprintln('Error: ${err}') + return + } + if rows.len == 0 { + println('(no query plan)') + return + } + println('QUERY PLAN') + app.print_eqp_tree(rows, 0, 0) +} + +fn (mut app App) print_eqp_tree(rows []sqlite.Row, parent_id int, depth int) { + indent := ' '.repeat(depth) + for row in rows { + if row.vals.len < 4 { + continue + } + if row.vals[1].int() == parent_id { + println('${indent}|--${row.vals[3]}') + app.print_eqp_tree(rows, row.vals[0].int(), depth + 1) + } + } +} diff --git a/cmd/v/v.v b/cmd/v/v.v index d84d1f2b1..d0b14d650 100644 --- a/cmd/v/v.v +++ b/cmd/v/v.v @@ -44,6 +44,7 @@ const external_tools = [ 'shader', 'share', 'should-compile-all', + 'sqlite', 'symlink', 'scan', 'test', -- 2.39.5