xref: /sqlite-3.40.0/ext/wasm/batch-runner.js (revision 63e9ec2f)
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