diff options
Diffstat (limited to 'ext/wasm/SQLTester/SQLTester.mjs')
-rw-r--r-- | ext/wasm/SQLTester/SQLTester.mjs | 1339 |
1 files changed, 1339 insertions, 0 deletions
diff --git a/ext/wasm/SQLTester/SQLTester.mjs b/ext/wasm/SQLTester/SQLTester.mjs new file mode 100644 index 0000000..a72399a --- /dev/null +++ b/ext/wasm/SQLTester/SQLTester.mjs @@ -0,0 +1,1339 @@ +/* +** 2023-08-29 +** +** The author disclaims copyright to this source code. In place of +** a legal notice, here is a blessing: +** +** May you do good and not evil. +** May you find forgiveness for yourself and forgive others. +** May you share freely, never taking more than you give. +** +************************************************************************* +** This file contains the main application entry pointer for the JS +** implementation of the SQLTester framework. +** +** This version is not well-documented because it's a direct port of +** the Java immplementation, which is documented: in the main SQLite3 +** source tree, see ext/jni/src/org/sqlite/jni/tester/SQLite3Tester.java. +*/ + +import sqlite3ApiInit from '/jswasm/sqlite3.mjs'; + +const sqlite3 = await sqlite3ApiInit(); + +const log = (...args)=>{ + console.log('SQLTester:',...args); +}; + +/** + Try to install vfsName as the new default VFS. Once this succeeds + (returns true) then it becomes a no-op on future calls. Throws if + vfs registration as the default VFS fails but has no side effects + if vfsName is not currently registered. +*/ +const tryInstallVfs = function f(vfsName){ + if(f.vfsName) return false; + const pVfs = sqlite3.capi.sqlite3_vfs_find(vfsName); + if(pVfs){ + log("Installing",'"'+vfsName+'"',"as default VFS."); + const rc = sqlite3.capi.sqlite3_vfs_register(pVfs, 1); + if(rc){ + sqlite3.SQLite3Error.toss(rc,"While trying to register",vfsName,"vfs."); + } + f.vfsName = vfsName; + } + return !!pVfs; +}; +tryInstallVfs.vfsName = undefined; + +if( 0 && globalThis.WorkerGlobalScope ){ + // Try OPFS storage, if available... + if( 0 && sqlite3.oo1.OpfsDb ){ + /* Really slow with these tests */ + tryInstallVfs("opfs"); + } + if( sqlite3.installOpfsSAHPoolVfs ){ + await sqlite3.installOpfsSAHPoolVfs({ + clearOnInit: true, + initialCapacity: 15, + name: 'opfs-SQLTester' + }).then(pool=>{ + tryInstallVfs(pool.vfsName); + }).catch(e=>{ + log("OpfsSAHPool could not load:",e); + }); + } +} + +const wPost = (function(){ + return (('undefined'===typeof WorkerGlobalScope) + ? ()=>{} + : (type, payload)=>{ + postMessage({type, payload}); + }); +})(); +//log("WorkerGlobalScope",globalThis.WorkerGlobalScope); + +// Return a new enum entry value +const newE = ()=>Object.create(null); + +const newObj = (props)=>Object.assign(newE(), props); + +/** + Modes for how to escape (or not) column values and names from + SQLTester.execSql() to the result buffer output. +*/ +const ResultBufferMode = Object.assign(Object.create(null),{ + //! Do not append to result buffer + NONE: newE(), + //! Append output escaped. + ESCAPED: newE(), + //! Append output as-is + ASIS: newE() +}); + +/** + Modes to specify how to emit multi-row output from + SQLTester.execSql() to the result buffer. +*/ +const ResultRowMode = newObj({ + //! Keep all result rows on one line, space-separated. + ONLINE: newE(), + //! Add a newline between each result row. + NEWLINE: newE() +}); + +class SQLTesterException extends globalThis.Error { + constructor(testScript, ...args){ + if(testScript){ + super( [testScript.getOutputPrefix()+": ", ...args].join('') ); + }else{ + super( args.join('') ); + } + this.name = 'SQLTesterException'; + } + isFatal() { return false; } +} + +SQLTesterException.toss = (...args)=>{ + throw new SQLTesterException(...args); +} + +class DbException extends SQLTesterException { + constructor(testScript, pDb, rc, closeDb=false){ + super(testScript, "DB error #"+rc+": "+sqlite3.capi.sqlite3_errmsg(pDb)); + this.name = 'DbException'; + if( closeDb ) sqlite3.capi.sqlite3_close_v2(pDb); + } + isFatal() { return true; } +} + +class TestScriptFailed extends SQLTesterException { + constructor(testScript, ...args){ + super(testScript,...args); + this.name = 'TestScriptFailed'; + } + isFatal() { return true; } +} + +class UnknownCommand extends SQLTesterException { + constructor(testScript, cmdName){ + super(testScript, cmdName); + this.name = 'UnknownCommand'; + } + isFatal() { return true; } +} + +class IncompatibleDirective extends SQLTesterException { + constructor(testScript, ...args){ + super(testScript,...args); + this.name = 'IncompatibleDirective'; + } +} + +//! For throwing where an expression is required. +const toss = (errType, ...args)=>{ + throw new errType(...args); +}; + +const __utf8Decoder = new TextDecoder(); +const __utf8Encoder = new TextEncoder('utf-8'); +//! Workaround for Util.utf8Decode() +const __SAB = ('undefined'===typeof globalThis.SharedArrayBuffer) + ? function(){} : globalThis.SharedArrayBuffer; + + +/* Frequently-reused regexes. */ +const Rx = newObj({ + requiredProperties: / REQUIRED_PROPERTIES:[ \t]*(\S.*)\s*$/, + scriptModuleName: / SCRIPT_MODULE_NAME:[ \t]*(\S+)\s*$/, + mixedModuleName: / ((MIXED_)?MODULE_NAME):[ \t]*(\S+)\s*$/, + command: /^--(([a-z-]+)( .*)?)$/, + //! "Special" characters - we have to escape output if it contains any. + special: /[\x00-\x20\x22\x5c\x7b\x7d]/, + squiggly: /[{}]/ +}); + +const Util = newObj({ + toss, + + unlink: function(fn){ + return 0==sqlite3.wasm.sqlite3_wasm_vfs_unlink(0,fn); + }, + + argvToString: (list)=>{ + const m = [...list]; + m.shift() /* strip command name */; + return m.join(" ") + }, + + utf8Decode: function(arrayBuffer, begin, end){ + return __utf8Decoder.decode( + (arrayBuffer.buffer instanceof __SAB) + ? arrayBuffer.slice(begin, end) + : arrayBuffer.subarray(begin, end) + ); + }, + + utf8Encode: (str)=>__utf8Encoder.encode(str), + + strglob: sqlite3.wasm.xWrap('sqlite3_wasm_SQLTester_strglob','int', + ['string','string']) +})/*Util*/; + +class Outer { + #lnBuf = []; + #verbosity = 0; + #logger = console.log.bind(console); + + constructor(func){ + if(func) this.setFunc(func); + } + + logger(...args){ + if(args.length){ + this.#logger = args[0]; + return this; + } + return this.#logger; + } + + out(...args){ + if( this.getOutputPrefix && !this.#lnBuf.length ){ + this.#lnBuf.push(this.getOutputPrefix()); + } + this.#lnBuf.push(...args); + return this; + } + + #outlnImpl(vLevel, ...args){ + if( this.getOutputPrefix && !this.#lnBuf.length ){ + this.#lnBuf.push(this.getOutputPrefix()); + } + this.#lnBuf.push(...args,'\n'); + const msg = this.#lnBuf.join(''); + this.#lnBuf.length = 0; + this.#logger(msg); + return this; + } + + outln(...args){ + return this.#outlnImpl(0,...args); + } + + outputPrefix(){ + if( 0==arguments.length ){ + return (this.getOutputPrefix + ? (this.getOutputPrefix() ?? '') : ''); + }else{ + this.getOutputPrefix = arguments[0]; + return this; + } + } + + static #verboseLabel = ["π",/*"π",*/"π","π’"]; + verboseN(lvl, args){ + if( this.#verbosity>=lvl ){ + this.#outlnImpl(lvl, Outer.#verboseLabel[lvl-1],': ',...args); + } + } + verbose1(...args){ return this.verboseN(1,args); } + verbose2(...args){ return this.verboseN(2,args); } + verbose3(...args){ return this.verboseN(3,args); } + + verbosity(){ + const rc = this.#verbosity; + if(arguments.length) this.#verbosity = +arguments[0]; + return rc; + } + +}/*Outer*/ + +class SQLTester { + + //! Console output utility. + #outer = new Outer().outputPrefix( ()=>'SQLTester: ' ); + //! List of input scripts. + #aScripts = []; + //! Test input buffer. + #inputBuffer = []; + //! Test result buffer. + #resultBuffer = []; + //! Output representation of SQL NULL. + #nullView; + metrics = newObj({ + //! Total tests run + nTotalTest: 0, + //! Total test script files run + nTestFile: 0, + //! Test-case count for to the current TestScript + nTest: 0, + //! Names of scripts which were aborted. + failedScripts: [] + }); + #emitColNames = false; + //! True to keep going regardless of how a test fails. + #keepGoing = false; + #db = newObj({ + //! The list of available db handles. + list: new Array(7), + //! Index into this.list of the current db. + iCurrentDb: 0, + //! Name of the default db, re-created for each script. + initialDbName: "test.db", + //! Buffer for REQUIRED_PROPERTIES pragmas. + initSql: ['select 1;'], + //! (sqlite3*) to the current db. + currentDb: function(){ + return this.list[this.iCurrentDb]; + } + }); + + constructor(){ + this.reset(); + } + + outln(...args){ return this.#outer.outln(...args); } + out(...args){ return this.#outer.out(...args); } + outer(...args){ + if(args.length){ + this.#outer = args[0]; + return this; + } + return this.#outer; + } + verbose1(...args){ return this.#outer.verboseN(1,args); } + verbose2(...args){ return this.#outer.verboseN(2,args); } + verbose3(...args){ return this.#outer.verboseN(3,args); } + verbosity(...args){ + const rc = this.#outer.verbosity(...args); + return args.length ? this : rc; + } + setLogger(func){ + this.#outer.logger(func); + return this; + } + + incrementTestCounter(){ + ++this.metrics.nTotalTest; + ++this.metrics.nTest; + } + + reset(){ + this.clearInputBuffer(); + this.clearResultBuffer(); + this.#clearBuffer(this.#db.initSql); + this.closeAllDbs(); + this.metrics.nTest = 0; + this.#nullView = "nil"; + this.emitColNames = false; + this.#db.iCurrentDb = 0; + //this.#db.initSql.push("SELECT 1;"); + } + + appendInput(line, addNL){ + this.#inputBuffer.push(line); + if( addNL ) this.#inputBuffer.push('\n'); + } + appendResult(line, addNL){ + this.#resultBuffer.push(line); + if( addNL ) this.#resultBuffer.push('\n'); + } + appendDbInitSql(sql){ + this.#db.initSql.push(sql); + if( this.currentDb() ){ + this.execSql(null, true, ResultBufferMode.NONE, null, sql); + } + } + + #runInitSql(pDb){ + let rc = 0; + for(const sql of this.#db.initSql){ + this.#outer.verbose2("RUNNING DB INIT CODE: ",sql); + rc = this.execSql(pDb, false, ResultBufferMode.NONE, null, sql); + if( rc ) break; + } + return rc; + } + +#clearBuffer(buffer){ + buffer.length = 0; + return buffer; + } + + clearInputBuffer(){ return this.#clearBuffer(this.#inputBuffer); } + clearResultBuffer(){return this.#clearBuffer(this.#resultBuffer); } + + getInputText(){ return this.#inputBuffer.join(''); } + getResultText(){ return this.#resultBuffer.join(''); } + + #takeBuffer(buffer){ + const s = buffer.join(''); + buffer.length = 0; + return s; + } + + takeInputBuffer(){ + return this.#takeBuffer(this.#inputBuffer); + } + takeResultBuffer(){ + return this.#takeBuffer(this.#resultBuffer); + } + + nullValue(){ + return (0==arguments.length) + ? this.#nullView + : (this.#nullView = ''+arguments[0]); + } + + outputColumnNames(){ + return (0==arguments.length) + ? this.#emitColNames + : (this.#emitColNames = !!arguments[0]); + } + + currentDbId(){ + return (0==arguments.length) + ? this.#db.iCurrentDb + : (this.#affirmDbId(arguments[0]).#db.iCurrentDb = arguments[0]); + } + + #affirmDbId(id){ + if(id<0 || id>=this.#db.list.length){ + toss(SQLTesterException, "Database index ",id," is out of range."); + } + return this; + } + + currentDb(...args){ + if( 0!=args.length ){ + this.#affirmDbId(id).#db.iCurrentDb = id; + } + return this.#db.currentDb(); + } + + getDbById(id){ + return this.#affirmDbId(id).#db.list[id]; + } + + getCurrentDb(){ return this.#db.list[this.#db.iCurrentDb]; } + + + closeDb(id) { + if( 0==arguments.length ){ + id = this.#db.iCurrentDb; + } + const pDb = this.#affirmDbId(id).#db.list[id]; + if( pDb ){ + sqlite3.capi.sqlite3_close_v2(pDb); + this.#db.list[id] = null; + } + } + + closeAllDbs(){ + for(let i = 0; i<this.#db.list.length; ++i){ + if(this.#db.list[i]){ + sqlite3.capi.sqlite3_close_v2(this.#db.list[i]); + this.#db.list[i] = null; + } + } + this.#db.iCurrentDb = 0; + } + + openDb(name, createIfNeeded){ + if( 3===arguments.length ){ + const slot = arguments[0]; + this.#affirmDbId(slot).#db.iCurrentDb = slot; + name = arguments[1]; + createIfNeeded = arguments[2]; + } + this.closeDb(); + const capi = sqlite3.capi, wasm = sqlite3.wasm; + let pDb = 0; + let flags = capi.SQLITE_OPEN_READWRITE; + if( createIfNeeded ) flags |= capi.SQLITE_OPEN_CREATE; + try{ + let rc; + wasm.pstack.call(function(){ + let ppOut = wasm.pstack.allocPtr(); + rc = sqlite3.capi.sqlite3_open_v2(name, ppOut, flags, null); + pDb = wasm.peekPtr(ppOut); + }); + let sql; + if( 0==rc && this.#db.initSql.length > 0){ + rc = this.#runInitSql(pDb); + } + if( 0!=rc ){ + sqlite3.SQLite3Error.toss( + rc, + "sqlite3 result code",rc+":", + (pDb ? sqlite3.capi.sqlite3_errmsg(pDb) + : sqlite3.capi.sqlite3_errstr(rc)) + ); + } + return this.#db.list[this.#db.iCurrentDb] = pDb; + }catch(e){ + sqlite3.capi.sqlite3_close_v2(pDb); + throw e; + } + } + + addTestScript(ts){ + if( 2===arguments.length ){ + ts = new TestScript(arguments[0], arguments[1]); + }else if(ts instanceof Uint8Array){ + ts = new TestScript('<unnamed>', ts); + }else if('string' === typeof arguments[1]){ + ts = new TestScript('<unnamed>', Util.utf8Encode(arguments[1])); + } + if( !(ts instanceof TestScript) ){ + Util.toss(SQLTesterException, "Invalid argument type for addTestScript()"); + } + this.#aScripts.push(ts); + return this; + } + + runTests(){ + const tStart = (new Date()).getTime(); + let isVerbose = this.verbosity(); + this.metrics.failedScripts.length = 0; + this.metrics.nTotalTest = 0; + this.metrics.nTestFile = 0; + for(const ts of this.#aScripts){ + this.reset(); + ++this.metrics.nTestFile; + let threw = false; + const timeStart = (new Date()).getTime(); + let msgTail = ''; + try{ + ts.run(this); + }catch(e){ + if(e instanceof SQLTesterException){ + threw = true; + this.outln("π₯EXCEPTION: ",e); + this.metrics.failedScripts.push({script: ts.filename(), message:e.toString()}); + if( this.#keepGoing ){ + this.outln("Continuing anyway because of the keep-going option."); + }else if( e.isFatal() ){ + throw e; + } + }else{ + throw e; + } + }finally{ + const timeEnd = (new Date()).getTime(); + this.out("π", (threw ? "β" : "β
"), " ", + this.metrics.nTest, " test(s) in ", + (timeEnd-timeStart),"ms. "); + const mod = ts.moduleName(); + if( mod ){ + this.out( "[",mod,"] " ); + } + this.outln(ts.filename()); + } + } + const tEnd = (new Date()).getTime(); + Util.unlink(this.#db.initialDbName); + this.outln("Took ",(tEnd-tStart),"ms. Test count = ", + this.metrics.nTotalTest,", script count = ", + this.#aScripts.length,( + this.metrics.failedScripts.length + ? ", failed scripts = "+this.metrics.failedScripts.length + : "" + ) + ); + return this; + } + + #setupInitialDb(){ + if( !this.#db.list[0] ){ + Util.unlink(this.#db.initialDbName); + this.openDb(0, this.#db.initialDbName, true); + }else{ + this.#outer.outln("WARNING: setupInitialDb() was unexpectedly ", + "triggered while it is opened."); + } + } + + #escapeSqlValue(v){ + if( !v ) return "{}"; + if( !Rx.special.test(v) ){ + return v /* no escaping needed */; + } + if( !Rx.squiggly.test(v) ){ + return "{"+v+"}"; + } + const sb = ["\""]; + const n = v.length; + for(let i = 0; i < n; ++i){ + const ch = v.charAt(i); + switch(ch){ + case '\\': sb.push("\\\\"); break; + case '"': sb.push("\\\""); break; + default:{ + //verbose("CHAR ",(int)ch," ",ch," octal=",String.format("\\%03o", (int)ch)); + const ccode = ch.charCodeAt(i); + if( ccode < 32 ) sb.push('\\',ccode.toString(8),'o'); + else sb.push(ch); + break; + } + } + } + sb.append("\""); + return sb.join(''); + } + + #appendDbErr(pDb, sb, rc){ + sb.push(sqlite3.capi.sqlite3_js_rc_str(rc), ' '); + const msg = this.#escapeSqlValue(sqlite3.capi.sqlite3_errmsg(pDb)); + if( '{' === msg.charAt(0) ){ + sb.push(msg); + }else{ + sb.push('{', msg, '}'); + } + } + + #checkDbRc(pDb,rc){ + sqlite3.oo1.DB.checkRc(pDb, rc); + } + + execSql(pDb, throwOnError, appendMode, rowMode, sql){ + if( !pDb && !this.#db.list[0] ){ + this.#setupInitialDb(); + } + if( !pDb ) pDb = this.#db.currentDb(); + const wasm = sqlite3.wasm, capi = sqlite3.capi; + sql = (sql instanceof Uint8Array) + ? sql + : Util.utf8Encode(capi.sqlite3_js_sql_to_string(sql)); + const self = this; + const sb = (ResultBufferMode.NONE===appendMode) ? null : this.#resultBuffer; + let rc = 0; + wasm.scopedAllocCall(function(){ + let sqlByteLen = sql.byteLength; + const ppStmt = wasm.scopedAlloc( + /* output (sqlite3_stmt**) arg and pzTail */ + (2 * wasm.ptrSizeof) + (sqlByteLen + 1/* SQL + NUL */) + ); + const pzTail = ppStmt + wasm.ptrSizeof /* final arg to sqlite3_prepare_v2() */; + let pSql = pzTail + wasm.ptrSizeof; + const pSqlEnd = pSql + sqlByteLen; + wasm.heap8().set(sql, pSql); + wasm.poke8(pSql + sqlByteLen, 0/*NUL terminator*/); + let pos = 0, n = 1, spacing = 0; + while( pSql && wasm.peek8(pSql) ){ + wasm.pokePtr([ppStmt, pzTail], 0); + rc = capi.sqlite3_prepare_v3( + pDb, pSql, sqlByteLen, 0, ppStmt, pzTail + ); + if( 0!==rc ){ + if(throwOnError){ + throw new DbException(self, pDb, rc); + }else if( sb ){ + self.#appendDbErr(pDb, sb, rc); + } + break; + } + const pStmt = wasm.peekPtr(ppStmt); + pSql = wasm.peekPtr(pzTail); + sqlByteLen = pSqlEnd - pSql; + if(!pStmt) continue /* only whitespace or comments */; + if( sb ){ + const nCol = capi.sqlite3_column_count(pStmt); + let colName, val; + while( capi.SQLITE_ROW === (rc = capi.sqlite3_step(pStmt)) ) { + for( let i=0; i < nCol; ++i ){ + if( spacing++ > 0 ) sb.push(' '); + if( self.#emitColNames ){ + colName = capi.sqlite3_column_name(pStmt, i); + switch(appendMode){ + case ResultBufferMode.ASIS: sb.push( colName ); break; + case ResultBufferMode.ESCAPED: + sb.push( self.#escapeSqlValue(colName) ); + break; + default: + self.toss("Unhandled ResultBufferMode."); + } + sb.push(' '); + } + val = capi.sqlite3_column_text(pStmt, i); + if( null===val ){ + sb.push( self.#nullView ); + continue; + } + switch(appendMode){ + case ResultBufferMode.ASIS: sb.push( val ); break; + case ResultBufferMode.ESCAPED: + sb.push( self.#escapeSqlValue(val) ); + break; + } + }/* column loop */ + }/* row loop */ + if( ResultRowMode.NEWLINE === rowMode ){ + spacing = 0; + sb.push('\n'); + } + }else{ // no output but possibly other side effects + while( capi.SQLITE_ROW === (rc = capi.sqlite3_step(pStmt)) ) {} + } + capi.sqlite3_finalize(pStmt); + if( capi.SQLITE_ROW===rc || capi.SQLITE_DONE===rc) rc = 0; + else if( rc!=0 ){ + if( sb ){ + self.#appendDbErr(db, sb, rc); + } + break; + } + }/* SQL script loop */; + })/*scopedAllocCall()*/; + return rc; + } + +}/*SQLTester*/ + +class Command { + constructor(){ + } + + process(sqlTester,testScript,argv){ + SQLTesterException.toss("process() must be overridden"); + } + + argcCheck(testScript,argv,min,max){ + const argc = argv.length-1; + if(argc<min || (max>=0 && argc>max)){ + if( min==max ){ + testScript.toss(argv[0]," requires exactly ",min," argument(s)"); + }else if(max>0){ + testScript.toss(argv[0]," requires ",min,"-",max," arguments."); + }else{ + testScript.toss(argv[0]," requires at least ",min," arguments."); + } + } + } +} + +class Cursor { + src; + sb = []; + pos = 0; + //! Current line number. Starts at 0 for internal reasons and will + // line up with 1-based reality once parsing starts. + lineNo = 0 /* yes, zero */; + //! Putback value for this.pos. + putbackPos = 0; + //! Putback line number + putbackLineNo = 0; + //! Peeked-to pos, used by peekLine() and consumePeeked(). + peekedPos = 0; + //! Peeked-to line number. + peekedLineNo = 0; + + constructor(){ + } + + //! Restore parsing state to the start of the stream. + rewind(){ + this.sb.length = this.pos = this.lineNo + = this.putbackPos = this.putbackLineNo + = this.peekedPos = this.peekedLineNo = 0; + } +} + +class TestScript { + #cursor = new Cursor(); + #moduleName = null; + #filename = null; + #testCaseName = null; + #outer = new Outer().outputPrefix( ()=>this.getOutputPrefix()+': ' ); + + constructor(...args){ + let content, filename; + if( 2 == args.length ){ + filename = args[0]; + content = args[1]; + }else if( 1 == args.length ){ + if(args[0] instanceof Object){ + const o = args[0]; + filename = o.name; + content = o.content; + }else{ + content = args[0]; + } + } + if(!(content instanceof Uint8Array)){ + if('string' === typeof content){ + content = Util.utf8Encode(content); + }else if((content instanceof ArrayBuffer) + ||(content instanceof Array)){ + content = new Uint8Array(content); + }else{ + toss(Error, "Invalid content type for TestScript constructor."); + } + } + this.#filename = filename; + this.#cursor.src = content; + } + + moduleName(){ + return (0==arguments.length) + ? this.#moduleName : (this.#moduleName = arguments[0]); + } + + testCaseName(){ + return (0==arguments.length) + ? this.#testCaseName : (this.#testCaseName = arguments[0]); + } + filename(){ + return (0==arguments.length) + ? this.#filename : (this.#filename = arguments[0]); + } + + getOutputPrefix() { + let rc = "["+(this.#moduleName || '<unnamed>')+"]"; + if( this.#testCaseName ) rc += "["+this.#testCaseName+"]"; + if( this.#filename ) rc += '['+this.#filename+']'; + return rc + " line "+ this.#cursor.lineNo; + } + + reset(){ + this.#testCaseName = null; + this.#cursor.rewind(); + return this; + } + + toss(...args){ + throw new TestScriptFailed(this,...args); + } + + verbose1(...args){ return this.#outer.verboseN(1,args); } + verbose2(...args){ return this.#outer.verboseN(2,args); } + verbose3(...args){ return this.#outer.verboseN(3,args); } + verbosity(...args){ + const rc = this.#outer.verbosity(...args); + return args.length ? this : rc; + } + + #checkRequiredProperties(tester, props){ + if(true) return false; + let nOk = 0; + for(const rp of props){ + this.verbose2("REQUIRED_PROPERTIES: ",rp); + switch(rp){ + case "RECURSIVE_TRIGGERS": + tester.appendDbInitSql("pragma recursive_triggers=on;"); + ++nOk; + break; + case "TEMPSTORE_FILE": + /* This _assumes_ that the lib is built with SQLITE_TEMP_STORE=1 or 2, + which we just happen to know is the case */ + tester.appendDbInitSql("pragma temp_store=1;"); + ++nOk; + break; + case "TEMPSTORE_MEM": + /* This _assumes_ that the lib is built with SQLITE_TEMP_STORE=1 or 2, + which we just happen to know is the case */ + tester.appendDbInitSql("pragma temp_store=0;"); + ++nOk; + break; + case "AUTOVACUUM": + tester.appendDbInitSql("pragma auto_vacuum=full;"); + ++nOk; + break; + case "INCRVACUUM": + tester.appendDbInitSql("pragma auto_vacuum=incremental;"); + ++nOk; + default: + break; + } + } + return props.length == nOk; + } + + #checkForDirective(tester,line){ + if(line.startsWith("#")){ + throw new IncompatibleDirective(this, "C-preprocessor input: "+line); + }else if(line.startsWith("---")){ + throw new IncompatibleDirective(this, "triple-dash: ",line); + } + let m = Rx.scriptModuleName.exec(line); + if( m ){ + this.#moduleName = m[1]; + return; + } + m = Rx.requiredProperties.exec(line); + if( m ){ + const rp = m[1]; + if( !this.#checkRequiredProperties( tester, rp.split(/\s+/).filter(v=>!!v) ) ){ + throw new IncompatibleDirective(this, "REQUIRED_PROPERTIES: "+rp); + } + } + + m = Rx.mixedModuleName.exec(line); + if( m ){ + throw new IncompatibleDirective(this, m[1]+": "+m[3]); + } + if( line.indexOf("\n|")>=0 ){ + throw new IncompatibleDirective(this, "newline-pipe combination."); + } + + } + + #getCommandArgv(line){ + const m = Rx.command.exec(line); + return m ? m[1].trim().split(/\s+/) : null; + } + + + #isCommandLine(line, checkForImpl){ + let m = Rx.command.exec(line); + if( m && checkForImpl ){ + m = !!CommandDispatcher.getCommandByName(m[2]); + } + return !!m; + } + + fetchCommandBody(tester){ + const sb = []; + let line; + while( (null !== (line = this.peekLine())) ){ + this.#checkForDirective(tester, line); + if( this.#isCommandLine(line, true) ) break; + sb.push(line,"\n"); + this.consumePeeked(); + } + line = sb.join(''); + return !!line.trim() ? line : null; + } + + run(tester){ + this.reset(); + this.#outer.verbosity( tester.verbosity() ); + this.#outer.logger( tester.outer().logger() ); + let line, directive, argv = []; + while( null != (line = this.getLine()) ){ + this.verbose3("run() input line: ",line); + this.#checkForDirective(tester, line); + argv = this.#getCommandArgv(line); + if( argv ){ + this.#processCommand(tester, argv); + continue; + } + tester.appendInput(line,true); + } + return true; + } + + #processCommand(tester, argv){ + this.verbose2("processCommand(): ",argv[0], " ", Util.argvToString(argv)); + if(this.#outer.verbosity()>1){ + const input = tester.getInputText(); + this.verbose3("processCommand() input buffer = ",input); + } + CommandDispatcher.dispatch(tester, this, argv); + } + + getLine(){ + const cur = this.#cursor; + if( cur.pos==cur.src.byteLength ){ + return null/*EOF*/; + } + cur.putbackPos = cur.pos; + cur.putbackLineNo = cur.lineNo; + cur.sb.length = 0; + let b = 0, prevB = 0, i = cur.pos; + let doBreak = false; + let nChar = 0 /* number of bytes in the aChar char */; + const end = cur.src.byteLength; + for(; i < end && !doBreak; ++i){ + b = cur.src[i]; + switch( b ){ + case 13/*CR*/: continue; + case 10/*NL*/: + ++cur.lineNo; + if(cur.sb.length>0) doBreak = true; + // Else it's an empty string + break; + default:{ + /* Multi-byte chars need to be gathered up and appended at + one time so that we can get them as string objects. */ + nChar = 1; + switch( b & 0xF0 ){ + case 0xC0: nChar = 2; break; + case 0xE0: nChar = 3; break; + case 0xF0: nChar = 4; break; + default: + if( b > 127 ) this.toss("Invalid character (#"+b+")."); + break; + } + if( 1==nChar ){ + cur.sb.push(String.fromCharCode(b)); + }else{ + const aChar = [] /* multi-byte char buffer */; + for(let x = 0; (x < nChar) && (i+x < end); ++x) aChar[x] = cur.src[i+x]; + cur.sb.push( + Util.utf8Decode( new Uint8Array(aChar) ) + ); + i += nChar-1; + } + break; + } + } + } + cur.pos = i; + const rv = cur.sb.join(''); + if( i==cur.src.byteLength && 0==rv.length ){ + return null /* EOF */; + } + return rv; + }/*getLine()*/ + + /** + Fetches the next line then resets the cursor to its pre-call + state. consumePeeked() can be used to consume this peeked line + without having to re-parse it. + */ + peekLine(){ + const cur = this.#cursor; + const oldPos = cur.pos; + const oldPB = cur.putbackPos; + const oldPBL = cur.putbackLineNo; + const oldLine = cur.lineNo; + try { + return this.getLine(); + }finally{ + cur.peekedPos = cur.pos; + cur.peekedLineNo = cur.lineNo; + cur.pos = oldPos; + cur.lineNo = oldLine; + cur.putbackPos = oldPB; + cur.putbackLineNo = oldPBL; + } + } + + + /** + Only valid after calling peekLine() and before calling getLine(). + This places the cursor to the position it would have been at had + the peekLine() had been fetched with getLine(). + */ + consumePeeked(){ + const cur = this.#cursor; + cur.pos = cur.peekedPos; + cur.lineNo = cur.peekedLineNo; + } + + /** + Restores the cursor to the position it had before the previous + call to getLine(). + */ + putbackLine(){ + const cur = this.#cursor; + cur.pos = cur.putbackPos; + cur.lineNo = cur.putbackLineNo; + } + +}/*TestScript*/; + +//! --close command +class CloseDbCommand extends Command { + process(t, ts, argv){ + this.argcCheck(ts,argv,0,1); + let id; + if(argv.length>1){ + const arg = argv[1]; + if( "all" === arg ){ + t.closeAllDbs(); + return; + } + else{ + id = parseInt(arg); + } + }else{ + id = t.currentDbId(); + } + t.closeDb(id); + } +} + +//! --column-names command +class ColumnNamesCommand extends Command { + process( st, ts, argv ){ + this.argcCheck(ts,argv,1); + st.outputColumnNames( !!parseInt(argv[1]) ); + } +} + +//! --db command +class DbCommand extends Command { + process(t, ts, argv){ + this.argcCheck(ts,argv,1); + t.currentDbId( parseInt(argv[1]) ); + } +} + +//! --glob command +class GlobCommand extends Command { + #negate = false; + constructor(negate=false){ + super(); + this.#negate = negate; + } + + process(t, ts, argv){ + this.argcCheck(ts,argv,1,-1); + t.incrementTestCounter(); + const sql = t.takeInputBuffer(); + let rc = t.execSql(null, true, ResultBufferMode.ESCAPED, + ResultRowMode.ONELINE, sql); + const result = t.getResultText(); + const sArgs = Util.argvToString(argv); + //t2.verbose2(argv[0]," rc = ",rc," result buffer:\n", result,"\nargs:\n",sArgs); + const glob = Util.argvToString(argv); + rc = Util.strglob(glob, result); + if( (this.#negate && 0===rc) || (!this.#negate && 0!==rc) ){ + ts.toss(argv[0], " mismatch: ", glob," vs input: ",result); + } + } +} + +//! --notglob command +class NotGlobCommand extends GlobCommand { + constructor(){super(true);} +} + +//! --open command +class OpenDbCommand extends Command { + #createIfNeeded = false; + constructor(createIfNeeded=false){ + super(); + this.#createIfNeeded = createIfNeeded; + } + process(t, ts, argv){ + this.argcCheck(ts,argv,1); + t.openDb(argv[1], this.#createIfNeeded); + } +} + +//! --new command +class NewDbCommand extends OpenDbCommand { + constructor(){ super(true); } +} + +//! Placeholder dummy/no-op commands +class NoopCommand extends Command { + process(t, ts, argv){} +} + +//! --null command +class NullCommand extends Command { + process(st, ts, argv){ + this.argcCheck(ts,argv,1); + st.nullValue( argv[1] ); + } +} + +//! --print command +class PrintCommand extends Command { + process(st, ts, argv){ + st.out(ts.getOutputPrefix(),': '); + if( 1==argv.length ){ + st.out( st.getInputText() ); + }else{ + st.outln( Util.argvToString(argv) ); + } + } +} + +//! --result command +class ResultCommand extends Command { + #bufferMode; + constructor(resultBufferMode = ResultBufferMode.ESCAPED){ + super(); + this.#bufferMode = resultBufferMode; + } + process(t, ts, argv){ + this.argcCheck(ts,argv,0,-1); + t.incrementTestCounter(); + const sql = t.takeInputBuffer(); + //ts.verbose2(argv[0]," SQL =\n",sql); + t.execSql(null, false, this.#bufferMode, ResultRowMode.ONELINE, sql); + const result = t.getResultText().trim(); + const sArgs = argv.length>1 ? Util.argvToString(argv) : ""; + if( result !== sArgs ){ + t.outln(argv[0]," FAILED comparison. Result buffer:\n", + result,"\nExpected result:\n",sArgs); + ts.toss(argv[0]+" comparison failed."); + } + } +} + +//! --json command +class JsonCommand extends ResultCommand { + constructor(){ super(ResultBufferMode.ASIS); } +} + +//! --run command +class RunCommand extends Command { + process(t, ts, argv){ + this.argcCheck(ts,argv,0,1); + const pDb = (1==argv.length) + ? t.currentDb() : t.getDbById( parseInt(argv[1]) ); + const sql = t.takeInputBuffer(); + const rc = t.execSql(pDb, false, ResultBufferMode.NONE, + ResultRowMode.ONELINE, sql); + if( 0!==rc && t.verbosity()>0 ){ + const msg = sqlite3.capi.sqlite3_errmsg(pDb); + ts.verbose2(argv[0]," non-fatal command error #",rc,": ", + msg,"\nfor SQL:\n",sql); + } + } +} + +//! --tableresult command +class TableResultCommand extends Command { + #jsonMode; + constructor(jsonMode=false){ + super(); + this.#jsonMode = jsonMode; + } + process(t, ts, argv){ + this.argcCheck(ts,argv,0); + t.incrementTestCounter(); + let body = ts.fetchCommandBody(t); + if( null===body ) ts.toss("Missing ",argv[0]," body."); + body = body.trim(); + if( !body.endsWith("\n--end") ){ + ts.toss(argv[0], " must be terminated with --end\\n"); + }else{ + body = body.substring(0, body.length-6); + } + const globs = body.split(/\s*\n\s*/); + if( globs.length < 1 ){ + ts.toss(argv[0], " requires 1 or more ", + (this.#jsonMode ? "json snippets" : "globs"),"."); + } + const sql = t.takeInputBuffer(); + t.execSql(null, true, + this.#jsonMode ? ResultBufferMode.ASIS : ResultBufferMode.ESCAPED, + ResultRowMode.NEWLINE, sql); + const rbuf = t.getResultText().trim(); + const res = rbuf.split(/\r?\n/); + if( res.length !== globs.length ){ + ts.toss(argv[0], " failure: input has ", res.length, + " row(s) but expecting ",globs.length); + } + for(let i = 0; i < res.length; ++i){ + const glob = globs[i].replaceAll(/\s+/g," ").trim(); + //ts.verbose2(argv[0]," <<",glob,">> vs <<",res[i],">>"); + if( this.#jsonMode ){ + if( glob!==res[i] ){ + ts.toss(argv[0], " json <<",glob, ">> does not match: <<", + res[i],">>"); + } + }else if( 0!=Util.strglob(glob, res[i]) ){ + ts.toss(argv[0], " glob <<",glob,">> does not match: <<",res[i],">>"); + } + } + } +} + +//! --json-block command +class JsonBlockCommand extends TableResultCommand { + constructor(){ super(true); } +} + +//! --testcase command +class TestCaseCommand extends Command { + process(tester, script, argv){ + this.argcCheck(script, argv,1); + script.testCaseName(argv[1]); + tester.clearResultBuffer(); + tester.clearInputBuffer(); + } +} + + +//! --verbosity command +class VerbosityCommand extends Command { + process(t, ts, argv){ + this.argcCheck(ts,argv,1); + ts.verbosity( parseInt(argv[1]) ); + } +} + +class CommandDispatcher { + static map = newObj(); + + static getCommandByName(name){ + let rv = CommandDispatcher.map[name]; + if( rv ) return rv; + switch(name){ + case "close": rv = new CloseDbCommand(); break; + case "column-names": rv = new ColumnNamesCommand(); break; + case "db": rv = new DbCommand(); break; + case "glob": rv = new GlobCommand(); break; + case "json": rv = new JsonCommand(); break; + case "json-block": rv = new JsonBlockCommand(); break; + case "new": rv = new NewDbCommand(); break; + case "notglob": rv = new NotGlobCommand(); break; + case "null": rv = new NullCommand(); break; + case "oom": rv = new NoopCommand(); break; + case "open": rv = new OpenDbCommand(); break; + case "print": rv = new PrintCommand(); break; + case "result": rv = new ResultCommand(); break; + case "run": rv = new RunCommand(); break; + case "tableresult": rv = new TableResultCommand(); break; + case "testcase": rv = new TestCaseCommand(); break; + case "verbosity": rv = new VerbosityCommand(); break; + } + if( rv ){ + CommandDispatcher.map[name] = rv; + } + return rv; + } + + static dispatch(tester, testScript, argv){ + const cmd = CommandDispatcher.getCommandByName(argv[0]); + if( !cmd ){ + toss(UnknownCommand,testScript,argv[0]); + } + cmd.process(tester, testScript, argv); + } +}/*CommandDispatcher*/ + +const namespace = newObj({ + Command, + DbException, + IncompatibleDirective, + Outer, + SQLTester, + SQLTesterException, + TestScript, + TestScriptFailed, + UnknownCommand, + Util, + sqlite3 +}); + +export {namespace as default}; |