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