| /* |
| ** 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}; |