| 1 | module main |
| 2 | |
| 3 | import db.sqlite |
| 4 | import os |
| 5 | import readline |
| 6 | import time |
| 7 | |
| 8 | const version = '0.1.0' |
| 9 | |
| 10 | const help_text = 'vsqlite ${version} - SQLite CLI written in V |
| 11 | |
| 12 | Usage: |
| 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 | |
| 19 | Interactive 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 | |
| 61 | const history_file = os.join_path(os.home_dir(), '.vsqlite_history') |
| 62 | |
| 63 | struct App { |
| 64 | mut: |
| 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 | |
| 88 | fn 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 | |
| 134 | fn (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 | |
| 177 | fn stmt_complete(lines []string) bool { |
| 178 | return lines.join('\n').trim_space().ends_with(';') |
| 179 | } |
| 180 | |
| 181 | fn (mut app App) load_history() { |
| 182 | history_load(mut app.rl, history_file) |
| 183 | } |
| 184 | |
| 185 | fn (app App) save_history() { |
| 186 | history_save(app.rl, history_file) |
| 187 | } |
| 188 | |
| 189 | fn 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 | |
| 198 | fn 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 | |
| 203 | fn (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 | |
| 214 | fn (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 | |
| 224 | fn (mut app App) finish_output() { |
| 225 | if app.output_once { |
| 226 | app.output_path = '' |
| 227 | app.output_once = false |
| 228 | } |
| 229 | } |
| 230 | |
| 231 | fn (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 | |
| 239 | fn (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 | |
| 251 | fn (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 | |
| 323 | fn (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 | |
| 344 | fn (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 | |
| 363 | fn (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 | |
| 992 | fn (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 | |
| 1015 | fn (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 | |
| 1038 | fn 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 | |
| 1063 | fn 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. |
| 1074 | fn 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 | |
| 1115 | fn 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 | |
| 1127 | fn 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). |
| 1146 | fn 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). |
| 1187 | fn 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 | |
| 1203 | fn is_word_char(c u8) bool { |
| 1204 | return (c >= `a` && c <= `z`) || (c >= `A` && c <= `Z`) || (c >= `0` && c <= `9`) || c == `_` |
| 1205 | } |
| 1206 | |
| 1207 | fn (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 | |
| 1220 | fn (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 | |