1/* 2 2022-08-29 3 4 The author disclaims copyright to this source code. In place of a 5 legal notice, here is a blessing: 6 7 * May you do good and not evil. 8 * May you find forgiveness for yourself and forgive others. 9 * May you share freely, never taking more than you give. 10 11 *********************************************************************** 12 13 A basic batch SQL runner for sqlite3-api.js. This file must be run in 14 main JS thread and sqlite3.js must have been loaded before it. 15*/ 16'use strict'; 17(function(){ 18 const toss = function(...args){throw new Error(args.join(' '))}; 19 const warn = console.warn.bind(console); 20 let sqlite3; 21 22 /** Throws if the given sqlite3 result code is not 0. */ 23 const checkSqliteRc = (dbh,rc)=>{ 24 if(rc) toss("Prepare failed:",sqlite3.capi.sqlite3_errmsg(dbh)); 25 }; 26 27 const sqlToDrop = [ 28 "SELECT type,name FROM sqlite_schema ", 29 "WHERE name NOT LIKE 'sqlite\\_%' escape '\\' ", 30 "AND name NOT LIKE '\\_%' escape '\\'" 31 ].join(''); 32 33 const clearDbWebSQL = function(db){ 34 db.handle.transaction(function(tx){ 35 const onErr = (e)=>console.error(e); 36 const callback = function(tx, result){ 37 const rows = result.rows; 38 let i, n; 39 i = n = rows.length; 40 while(i--){ 41 const row = rows.item(i); 42 const name = JSON.stringify(row.name); 43 const type = row.type; 44 switch(type){ 45 case 'index': case 'table': 46 case 'trigger': case 'view': { 47 const sql2 = 'DROP '+type+' '+name; 48 tx.executeSql(sql2, [], ()=>{}, onErr); 49 break; 50 } 51 default: 52 warn("Unhandled db entry type:",type,'name =',name); 53 break; 54 } 55 } 56 }; 57 tx.executeSql(sqlToDrop, [], callback, onErr); 58 db.handle.changeVersion(db.handle.version, "", ()=>{}, onErr, ()=>{}); 59 }); 60 }; 61 62 const clearDbSqlite = function(db){ 63 // This would be SO much easier with the oo1 API, but we specifically want to 64 // inject metrics we can't get via that API, and we cannot reliably (OPFS) 65 // open the same DB twice to clear it using that API, so... 66 let pStmt = 0, pSqlBegin; 67 const capi = sqlite3.capi, wasm = capi.wasm; 68 const scope = wasm.scopedAllocPush(); 69 try { 70 const toDrop = [/* type, name pairs */]; 71 const ppStmt = wasm.scopedAllocPtr(); 72 // Collect list of tables/etc we can drop... 73 let rc = capi.sqlite3_prepare_v2(db.handle, sqlToDrop, -1, ppStmt, null); 74 checkSqliteRc(db.handle,rc); 75 pStmt = wasm.getPtrValue(ppStmt); 76 while(capi.SQLITE_ROW===capi.sqlite3_step(pStmt)){ 77 toDrop.push(capi.sqlite3_column_text(pStmt,0), 78 capi.sqlite3_column_text(pStmt,1)); 79 } 80 capi.sqlite3_finalize(pStmt); 81 pStmt = 0; 82 // Build SQL to delete them... 83 const sqlDrop = []; 84 const doDrop = 0!==toDrop.length; 85 while(doDrop){ 86 const name = toDrop.pop(); 87 if(name){ 88 const type = toDrop.pop(); 89 switch(type){ 90 case 'table': case 'view': case 'trigger': case 'index': 91 sqlDrop.push('DROP '+type+' '+name); 92 break; 93 default: 94 warn("Unhandled db entry type:",type,name); 95 continue; 96 } 97 }else{ 98 sqlDrop.push("VACUUM"); 99 break; 100 } 101 } 102 if(sqlDrop.length){ 103 const sqlClean = sqlDrop.join(';\n'); 104 console.log("Cleaning up",db.id,":",sqlClean); 105 capi.sqlite3_exec(db.handle, sqlClean, 0, 0, 0); 106 } 107 }finally{ 108 if(pStmt) capi.sqlite3_finalize(pStmt); 109 wasm.scopedAllocPop(scope); 110 } 111 }; 112 113 114 const E = (s)=>document.querySelector(s); 115 const App = { 116 e: { 117 output: E('#test-output'), 118 selSql: E('#sql-select'), 119 btnRun: E('#sql-run'), 120 btnRunNext: E('#sql-run-next'), 121 btnRunRemaining: E('#sql-run-remaining'), 122 btnExportMetrics: E('#export-metrics'), 123 btnClear: E('#output-clear'), 124 btnReset: E('#db-reset'), 125 cbReverseLog: E('#cb-reverse-log-order'), 126 selImpl: E('#select-impl'), 127 fsToolbar: E('#toolbar') 128 }, 129 db: Object.create(null), 130 dbs: Object.create(null), 131 cache:{}, 132 log: console.log.bind(console), 133 warn: console.warn.bind(console), 134 cls: function(){this.e.output.innerHTML = ''}, 135 logHtml2: function(cssClass,...args){ 136 const ln = document.createElement('div'); 137 if(cssClass) ln.classList.add(cssClass); 138 ln.append(document.createTextNode(args.join(' '))); 139 this.e.output.append(ln); 140 //this.e.output.lastElementChild.scrollIntoViewIfNeeded(); 141 }, 142 logHtml: function(...args){ 143 console.log(...args); 144 if(1) this.logHtml2('', ...args); 145 }, 146 logErr: function(...args){ 147 console.error(...args); 148 if(1) this.logHtml2('error', ...args); 149 }, 150 151 execSql: async function(name,sql){ 152 const db = this.getSelectedDb(); 153 const banner = "========================================"; 154 this.logHtml(banner, 155 "Running",name,'('+sql.length,'bytes) using',db.id); 156 const capi = this.sqlite3.capi, wasm = capi.wasm; 157 let pStmt = 0, pSqlBegin; 158 const stack = wasm.scopedAllocPush(); 159 const metrics = db.metrics = Object.create(null); 160 metrics.prepTotal = metrics.stepTotal = 0; 161 metrics.stmtCount = 0; 162 metrics.malloc = 0; 163 metrics.strcpy = 0; 164 this.blockControls(true); 165 if(this.gotErr){ 166 this.logErr("Cannot run SQL: error cleanup is pending."); 167 return; 168 } 169 // Run this async so that the UI can be updated for the above header... 170 const endRun = ()=>{ 171 metrics.evalSqlEnd = performance.now(); 172 metrics.evalTimeTotal = (metrics.evalSqlEnd - metrics.evalSqlStart); 173 this.logHtml(db.id,"metrics:",JSON.stringify(metrics, undefined, ' ')); 174 this.logHtml("prepare() count:",metrics.stmtCount); 175 this.logHtml("Time in prepare_v2():",metrics.prepTotal,"ms", 176 "("+(metrics.prepTotal / metrics.stmtCount),"ms per prepare())"); 177 this.logHtml("Time in step():",metrics.stepTotal,"ms", 178 "("+(metrics.stepTotal / metrics.stmtCount),"ms per step())"); 179 this.logHtml("Total runtime:",metrics.evalTimeTotal,"ms"); 180 this.logHtml("Overhead (time - prep - step):", 181 (metrics.evalTimeTotal - metrics.prepTotal - metrics.stepTotal)+"ms"); 182 this.logHtml(banner,"End of",name); 183 }; 184 185 let runner; 186 if('websql'===db.id){ 187 const who = this; 188 runner = function(resolve, reject){ 189 /* WebSQL cannot execute multiple statements, nor can it execute SQL without 190 an explicit transaction. Thus we have to do some fragile surgery on the 191 input SQL. Since we're only expecting carefully curated inputs, the hope is 192 that this will suffice. PS: it also can't run most SQL functions, e.g. even 193 instr() results in "not authorized". */ 194 if('string'!==typeof sql){ // assume TypedArray 195 sql = new TextDecoder().decode(sql); 196 } 197 sql = sql.replace(/-- [^\n]+\n/g,''); // comment lines interfere with our split() 198 const sqls = sql.split(/;+\n/); 199 const rxBegin = /^BEGIN/i, rxCommit = /^COMMIT/i; 200 try { 201 const nextSql = ()=>{ 202 let x = sqls.shift(); 203 while(sqls.length && !x) x = sqls.shift(); 204 return x && x.trim(); 205 }; 206 const who = this; 207 const transaction = function(tx){ 208 try { 209 let s; 210 /* Try to approximate the spirit of the input scripts 211 by running batches bound by BEGIN/COMMIT statements. */ 212 for(s = nextSql(); !!s; s = nextSql()){ 213 if(rxBegin.test(s)) continue; 214 else if(rxCommit.test(s)) break; 215 //console.log("websql sql again",sqls.length, s); 216 ++metrics.stmtCount; 217 const t = performance.now(); 218 tx.executeSql(s,[], ()=>{}, (t,e)=>{ 219 console.error("WebSQL error",e,"SQL =",s); 220 who.logErr(e.message); 221 //throw e; 222 return false; 223 }); 224 metrics.stepTotal += performance.now() - t; 225 } 226 }catch(e){ 227 who.logErr("transaction():",e.message); 228 throw e; 229 } 230 }; 231 const n = sqls.length; 232 const nextBatch = function(){ 233 if(sqls.length){ 234 console.log("websql sqls.length",sqls.length,'of',n); 235 db.handle.transaction(transaction, (e)=>{ 236 who.logErr("Ignoring and contiuing:",e.message) 237 //reject(e); 238 return false; 239 }, nextBatch); 240 }else{ 241 resolve(who); 242 } 243 }; 244 metrics.evalSqlStart = performance.now(); 245 nextBatch(); 246 }catch(e){ 247 //this.gotErr = e; 248 console.error("websql error:",e); 249 who.logErr(e.message); 250 //reject(e); 251 } 252 }.bind(this); 253 }else{/*sqlite3 db...*/ 254 runner = function(resolve, reject){ 255 metrics.evalSqlStart = performance.now(); 256 try { 257 let t; 258 let sqlByteLen = sql.byteLength; 259 const [ppStmt, pzTail] = wasm.scopedAllocPtr(2); 260 t = performance.now(); 261 pSqlBegin = wasm.scopedAlloc( sqlByteLen + 1/*SQL + NUL*/) || toss("alloc(",sqlByteLen,") failed"); 262 metrics.malloc = performance.now() - t; 263 metrics.byteLength = sqlByteLen; 264 let pSql = pSqlBegin; 265 const pSqlEnd = pSqlBegin + sqlByteLen; 266 t = performance.now(); 267 wasm.heap8().set(sql, pSql); 268 wasm.setMemValue(pSql + sqlByteLen, 0); 269 metrics.strcpy = performance.now() - t; 270 let breaker = 0; 271 while(pSql && wasm.getMemValue(pSql,'i8')){ 272 wasm.setPtrValue(ppStmt, 0); 273 wasm.setPtrValue(pzTail, 0); 274 t = performance.now(); 275 let rc = capi.sqlite3_prepare_v3( 276 db.handle, pSql, sqlByteLen, 0, ppStmt, pzTail 277 ); 278 metrics.prepTotal += performance.now() - t; 279 checkSqliteRc(db.handle, rc); 280 pStmt = wasm.getPtrValue(ppStmt); 281 pSql = wasm.getPtrValue(pzTail); 282 sqlByteLen = pSqlEnd - pSql; 283 if(!pStmt) continue/*empty statement*/; 284 ++metrics.stmtCount; 285 t = performance.now(); 286 rc = capi.sqlite3_step(pStmt); 287 capi.sqlite3_finalize(pStmt); 288 pStmt = 0; 289 metrics.stepTotal += performance.now() - t; 290 switch(rc){ 291 case capi.SQLITE_ROW: 292 case capi.SQLITE_DONE: break; 293 default: checkSqliteRc(db.handle, rc); toss("Not reached."); 294 } 295 } 296 resolve(this); 297 }catch(e){ 298 if(pStmt) capi.sqlite3_finalize(pStmt); 299 //this.gotErr = e; 300 reject(e); 301 }finally{ 302 capi.sqlite3_exec(db.handle,"rollback;",0,0,0); 303 wasm.scopedAllocPop(stack); 304 } 305 }.bind(this); 306 } 307 let p; 308 if(1){ 309 p = new Promise(function(res,rej){ 310 setTimeout(()=>runner(res, rej), 50)/*give UI a chance to output the "running" banner*/; 311 }); 312 }else{ 313 p = new Promise(runner); 314 } 315 return p.catch( 316 (e)=>this.logErr("Error via execSql("+name+",...):",e.message) 317 ).finally(()=>{ 318 endRun(); 319 this.blockControls(false); 320 }); 321 }, 322 323 clearDb: function(){ 324 const db = this.getSelectedDb(); 325 if('websql'===db.id){ 326 this.logErr("TODO: clear websql db."); 327 return; 328 } 329 if(!db.handle) return; 330 const capi = this.sqlite3, wasm = capi.wasm; 331 //const scope = wasm.scopedAllocPush( 332 this.logErr("TODO: clear db"); 333 }, 334 335 /** 336 Loads batch-runner.list and populates the selection list from 337 it. Returns a promise which resolves to nothing in particular 338 when it completes. Only intended to be run once at the start 339 of the app. 340 */ 341 loadSqlList: async function(){ 342 const sel = this.e.selSql; 343 sel.innerHTML = ''; 344 this.blockControls(true); 345 const infile = 'batch-runner.list'; 346 this.logHtml("Loading list of SQL files:", infile); 347 let txt; 348 try{ 349 const r = await fetch(infile); 350 if(404 === r.status){ 351 toss("Missing file '"+infile+"'."); 352 } 353 if(!r.ok) toss("Loading",infile,"failed:",r.statusText); 354 txt = await r.text(); 355 const warning = E('#warn-list'); 356 if(warning) warning.remove(); 357 }catch(e){ 358 this.logErr(e.message); 359 throw e; 360 }finally{ 361 this.blockControls(false); 362 } 363 const list = txt.split(/\n+/); 364 let opt; 365 if(0){ 366 opt = document.createElement('option'); 367 opt.innerText = "Select file to evaluate..."; 368 opt.value = ''; 369 opt.disabled = true; 370 opt.selected = true; 371 sel.appendChild(opt); 372 } 373 list.forEach(function(fn){ 374 if(!fn) return; 375 opt = document.createElement('option'); 376 opt.value = fn; 377 opt.innerText = fn.split('/').pop(); 378 sel.appendChild(opt); 379 }); 380 this.logHtml("Loaded",infile); 381 }, 382 383 /** Fetch ./fn and return its contents as a Uint8Array. */ 384 fetchFile: async function(fn, cacheIt=false){ 385 if(cacheIt && this.cache[fn]) return this.cache[fn]; 386 this.logHtml("Fetching",fn,"..."); 387 let sql; 388 try { 389 const r = await fetch(fn); 390 if(!r.ok) toss("Fetch failed:",r.statusText); 391 sql = new Uint8Array(await r.arrayBuffer()); 392 }catch(e){ 393 this.logErr(e.message); 394 throw e; 395 } 396 this.logHtml("Fetched",sql.length,"bytes from",fn); 397 if(cacheIt) this.cache[fn] = sql; 398 return sql; 399 }/*fetchFile()*/, 400 401 /** Disable or enable certain UI controls. */ 402 blockControls: function(disable){ 403 //document.querySelectorAll('.disable-during-eval').forEach((e)=>e.disabled = disable); 404 this.e.fsToolbar.disabled = disable; 405 }, 406 407 /** 408 Converts this.metrics() to a form which is suitable for easy conversion to 409 CSV. It returns an array of arrays. The first sub-array is the column names. 410 The 2nd and subsequent are the values, one per test file (only the most recent 411 metrics are kept for any given file). 412 */ 413 metricsToArrays: function(){ 414 const rc = []; 415 Object.keys(this.dbs).sort().forEach((k)=>{ 416 const d = this.dbs[k]; 417 const m = d.metrics; 418 delete m.evalSqlStart; 419 delete m.evalSqlEnd; 420 const mk = Object.keys(m).sort(); 421 if(!rc.length){ 422 rc.push(['db', ...mk]); 423 } 424 const row = [k.split('/').pop()/*remove dir prefix from filename*/]; 425 rc.push(row); 426 row.push(...mk.map((kk)=>m[kk])); 427 }); 428 return rc; 429 }, 430 431 metricsToBlob: function(colSeparator='\t'){ 432 const ar = [], ma = this.metricsToArrays(); 433 if(!ma.length){ 434 this.logErr("Metrics are empty. Run something."); 435 return; 436 } 437 ma.forEach(function(row){ 438 ar.push(row.join(colSeparator),'\n'); 439 }); 440 return new Blob(ar); 441 }, 442 443 downloadMetrics: function(){ 444 const b = this.metricsToBlob(); 445 if(!b) return; 446 const url = URL.createObjectURL(b); 447 const a = document.createElement('a'); 448 a.href = url; 449 a.download = 'batch-runner-js-'+((new Date().getTime()/1000) | 0)+'.csv'; 450 this.logHtml("Triggering download of",a.download); 451 document.body.appendChild(a); 452 a.click(); 453 setTimeout(()=>{ 454 document.body.removeChild(a); 455 URL.revokeObjectURL(url); 456 }, 500); 457 }, 458 459 /** 460 Fetch file fn and eval it as an SQL blob. This is an async 461 operation and returns a Promise which resolves to this 462 object on success. 463 */ 464 evalFile: async function(fn){ 465 const sql = await this.fetchFile(fn); 466 return this.execSql(fn,sql); 467 }/*evalFile()*/, 468 469 /** 470 Clears all DB tables in all _opened_ databases. Because of 471 disparities between backends, we cannot simply "unlink" the 472 databases to clean them up. 473 */ 474 clearStorage: function(onlySelectedDb=false){ 475 const list = onlySelectedDb 476 ? [('boolean'===typeof onlySelectedDb) 477 ? this.dbs[this.e.selImpl.value] 478 : onlySelectedDb] 479 : Object.values(this.dbs); 480 for(let db of list){ 481 if(db && db.handle){ 482 this.logHtml("Clearing db",db.id); 483 db.clear(); 484 } 485 } 486 }, 487 488 /** 489 Fetches the handle of the db associated with 490 this.e.selImpl.value, opening it if needed. 491 */ 492 getSelectedDb: function(){ 493 if(!this.dbs.memdb){ 494 for(let opt of this.e.selImpl.options){ 495 const d = this.dbs[opt.value] = Object.create(null); 496 d.id = opt.value; 497 switch(d.id){ 498 case 'virtualfs': 499 d.filename = 'file:/virtualfs.sqlite3?vfs=unix-none'; 500 break; 501 case 'memdb': 502 d.filename = ':memory:'; 503 break; 504 case 'wasmfs-opfs': 505 d.filename = 'file:'+( 506 this.sqlite3.capi.sqlite3_wasmfs_opfs_dir() 507 )+'/wasmfs-opfs.sqlite3b'; 508 break; 509 case 'websql': 510 d.filename = 'websql.db'; 511 break; 512 default: 513 this.logErr("Unhandled db selection option (see details in the console).",opt); 514 toss("Unhandled db init option"); 515 } 516 } 517 }/*first-time init*/ 518 const dbId = this.e.selImpl.value; 519 const d = this.dbs[dbId]; 520 if(d.handle) return d; 521 if('websql' === dbId){ 522 d.handle = self.openDatabase('batch-runner', '0.1', 'foo', 1024 * 1024 * 50); 523 d.clear = ()=>clearDbWebSQL(d); 524 }else{ 525 const capi = this.sqlite3.capi, wasm = capi.wasm; 526 const stack = wasm.scopedAllocPush(); 527 let pDb = 0; 528 try{ 529 const oFlags = capi.SQLITE_OPEN_CREATE | capi.SQLITE_OPEN_READWRITE; 530 const ppDb = wasm.scopedAllocPtr(); 531 const rc = capi.sqlite3_open_v2(d.filename, ppDb, oFlags, null); 532 pDb = wasm.getPtrValue(ppDb) 533 if(rc) toss("sqlite3_open_v2() failed with code",rc); 534 }catch(e){ 535 if(pDb) capi.sqlite3_close_v2(pDb); 536 }finally{ 537 wasm.scopedAllocPop(stack); 538 } 539 d.handle = pDb; 540 d.clear = ()=>clearDbSqlite(d); 541 } 542 d.clear(); 543 this.logHtml("Opened db:",dbId); 544 console.log("db =",d); 545 return d; 546 }, 547 548 run: function(sqlite3){ 549 delete this.run; 550 this.sqlite3 = sqlite3; 551 const capi = sqlite3.capi, wasm = capi.wasm; 552 this.logHtml("Loaded module:",capi.sqlite3_libversion(), capi.sqlite3_sourceid()); 553 this.logHtml("WASM heap size =",wasm.heap8().length); 554 this.loadSqlList(); 555 if(capi.sqlite3_wasmfs_opfs_dir()){ 556 E('#warn-opfs').classList.remove('hidden'); 557 }else{ 558 E('#warn-opfs').remove(); 559 E('option[value=wasmfs-opfs]').disabled = true; 560 } 561 if('function' === typeof self.openDatabase){ 562 E('#warn-websql').classList.remove('hidden'); 563 }else{ 564 E('option[value=websql]').disabled = true; 565 E('#warn-websql').remove(); 566 } 567 const who = this; 568 if(this.e.cbReverseLog.checked){ 569 this.e.output.classList.add('reverse'); 570 } 571 this.e.cbReverseLog.addEventListener('change', function(){ 572 who.e.output.classList[this.checked ? 'add' : 'remove']('reverse'); 573 }, false); 574 this.e.btnClear.addEventListener('click', ()=>this.cls(), false); 575 this.e.btnRun.addEventListener('click', function(){ 576 if(!who.e.selSql.value) return; 577 who.evalFile(who.e.selSql.value); 578 }, false); 579 this.e.btnRunNext.addEventListener('click', function(){ 580 ++who.e.selSql.selectedIndex; 581 if(!who.e.selSql.value) return; 582 who.evalFile(who.e.selSql.value); 583 }, false); 584 this.e.btnReset.addEventListener('click', function(){ 585 who.clearStorage(true); 586 }, false); 587 this.e.btnExportMetrics.addEventListener('click', function(){ 588 who.logHtml2('warning',"Triggering download of metrics CSV. Check your downloads folder."); 589 who.downloadMetrics(); 590 //const m = who.metricsToArrays(); 591 //console.log("Metrics:",who.metrics, m); 592 }); 593 this.e.selImpl.addEventListener('change', function(){ 594 who.getSelectedDb(); 595 }); 596 this.e.btnRunRemaining.addEventListener('click', async function(){ 597 let v = who.e.selSql.value; 598 const timeStart = performance.now(); 599 while(v){ 600 await who.evalFile(v); 601 if(who.gotError){ 602 who.logErr("Error handling script",v,":",who.gotError.message); 603 break; 604 } 605 ++who.e.selSql.selectedIndex; 606 v = who.e.selSql.value; 607 } 608 const timeTotal = performance.now() - timeStart; 609 who.logHtml("Run-remaining time:",timeTotal,"ms ("+(timeTotal/1000/60)+" minute(s))"); 610 who.clearStorage(); 611 }, false); 612 }/*run()*/ 613 }/*App*/; 614 615 self.sqlite3TestModule.initSqlite3().then(function(sqlite3_){ 616 sqlite3 = sqlite3_; 617 self.App = App /* only to facilitate dev console access */; 618 App.run(sqlite3); 619 }); 620})(); 621