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