/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ /* * This file provides gloda query helpers for the test infrastructure. */ import { GlodaConstants } from "resource:///modules/gloda/GlodaConstants.sys.mjs"; import { GlodaDatastore } from "resource:///modules/gloda/GlodaDatastore.sys.mjs"; var log = console.createInstance({ prefix: "gloda.queryHelper", maxLogLevel: "Warn", maxLogLevelPref: "gloda.loglevel", }); var _defaultExpectationExtractors = {}; /* eslint-disable func-names */ _defaultExpectationExtractors[GlodaConstants.NOUN_MESSAGE] = [ function expectExtract_message_gloda(aGlodaMessage) { return aGlodaMessage.headerMessageID; }, function expectExtract_message_synth(aSynthMessage) { return aSynthMessage.messageId; }, ]; _defaultExpectationExtractors[GlodaConstants.NOUN_CONTACT] = [ function expectExtract_contact_gloda(aGlodaContact) { return aGlodaContact.name; }, function expectExtract_contact_name(aName) { return aName; }, ]; _defaultExpectationExtractors[GlodaConstants.NOUN_IDENTITY] = [ function expectExtract_identity_gloda(aGlodaIdentity) { return aGlodaIdentity.value; }, function expectExtract_identity_address(aAddress) { return aAddress; }, ]; /* eslint-enable func-names */ function expectExtract_default_toString(aThing) { return aThing.toString(); } /** * @see queryExpect for info on what we do. */ class QueryExpectationListener { constructor( aExpectedSet, aGlodaExtractor, aOrderVerifier, aCallerStackFrame ) { this.expectedSet = aExpectedSet; this.glodaExtractor = aGlodaExtractor; this.orderVerifier = aOrderVerifier; this.completed = false; this.callerStackFrame = aCallerStackFrame; // Track our current 'index' in the results for the (optional) order verifier, // but also so we can provide slightly more useful debug output. this.nextIndex = 0; this._promise = new Promise((resolve, reject) => { this._resolve = resolve; this._reject = reject; }); } onItemsAdded(aItems, aCollection) { log.debug("QueryExpectationListener onItemsAdded received."); for (const item of aItems) { let glodaStringRep; try { glodaStringRep = this.glodaExtractor(item); } catch (ex) { this._reject( new Error( "Gloda extractor threw during query expectation.\n" + "Item:\n" + item + "\nException:\n" + ex ) ); return; // We don't have to continue for more checks. } // Make sure we were expecting this guy. if (glodaStringRep in this.expectedSet) { delete this.expectedSet[glodaStringRep]; } else { this._reject( new Error( "Query returned unexpected result!\n" + "Item:\n" + item + "\nExpected set:\n" + this.expectedSet + "\nCaller:\n" + this.callerStackFrame ) ); return; // We don't have to continue for more checks. } if (this.orderVerifier) { try { this.orderVerifier(this.nextIndex, item, aCollection); } catch (ex) { // If the order was wrong, we could probably go for an output of what // we actually got... dump("Order Problem detected. Dump of data:\n"); for (const [iThing, thing] of aItems.entries()) { dump( iThing + ": " + thing + (aCollection.stashedColumns ? ". " + aCollection.stashedColumns[thing.id].join(", ") : "") + "\n" ); } this._reject(ex); return; // We don't have to continue for more checks. } } this.nextIndex++; // Make sure the query's test method agrees with the database about this. if (!aCollection.query.test(item)) { this._reject( new Error( "Query test returned false when it should have been true on.\n" + "Extracted:\n" + glodaStringRep + "\nItem:\n" + item ) ); } } } onItemsModified() { log.debug( "QueryExpectationListener onItemsModified received. Nothing done." ); } onItemsRemoved() { log.debug( "QueryExpectationListener onItemsRemoved received. Nothing done." ); } onQueryCompleted(aCollection) { log.debug("QueryExpectationListener onQueryCompleted received."); // We may continue to match newly added items if we leave our query as it // is, so let's become explicit to avoid related troubles. aCollection.becomeExplicit(); // `expectedSet` should now be empty. for (const key in this.expectedSet) { const value = this.expectedSet[key]; this._reject( new Error( "Query should have returned:\n" + key + " (" + value + ").\n" + "But " + this.nextIndex + " was seen." ) ); return; // We don't have to continue for more checks. } // If no error is thrown then we're fine here. this._resolve(); } get promise() { return this._promise; } } /** * Execute the given query, verifying that the result set contains exactly the * contents of the expected set; no more, no less. Since we expect that the * query will result in gloda objects, but your expectations will not be posed * in terms of gloda objects (though they could be), we rely on extractor * functions to take the gloda result objects and the expected result objects * into the same string. * If you don't provide extractor functions, we will use our defaults (based on * the query noun type) if available, or assume that calling toString is * sufficient. * * @param {GlodaQueryClass|object} aQuery - Either a query to execute, or a * dict with the following keys: * - queryFunc: The function to call that returns a function. * - queryThis: The 'this' to use for the invocation of queryFunc. * - args: A list (possibly empty) or arguments to precede the traditional * arguments to query.getCollection. * - nounId: The (numeric) noun id of the noun type expected to be returned. * @param {SyntheticMessageSet} aExpectedSet - The list of expected results * from the query where each item is suitable for extraction using * aExpectedExtractor. We have a soft spot for SyntheticMessageSets and * automatically unbox them. * @param {Function} aGlodaExtractor - The extractor function to take an * instance of the gloda representation and return a string for * comparison/equivalence against that returned by the expected extractor * (against the input instance in aExpectedSet.) * The value returned must be unique for all * of the expected gloda representations of the expected set. If omitted, * the default extractor for the gloda noun type is used. If no default * extractor exists, toString is called on the item. * @param {Function} [aExpectedExtractor] - The extractor function to take an * instance from the values in the aExpectedSet and return a string for * comparison/equivalence against that returned by the gloda extractor. * The value returned must be unique for all of the values in the expected set. * If omitted, the default extractor for the presumed input type based on * the gloda noun type used for the query is used, failing over to toString. * @param {Function} [aOrderVerifier] - Optional function to verify the order * the results are received in. * Function signature should be of the form (aZeroBasedIndex, aItem, aCollectionResultIsFor). */ export async function queryExpect( aQuery, aExpectedSet, aGlodaExtractor, aExpectedExtractor, aOrderVerifier ) { if (aQuery.test) { aQuery = { queryFunc: aQuery.getCollection, queryThis: aQuery, args: [], nounId: aQuery._nounDef.id, }; } if ("synMessages" in aExpectedSet) { aExpectedSet = aExpectedSet.synMessages; } // - set extractor functions to defaults if omitted if (aGlodaExtractor == null) { if (_defaultExpectationExtractors[aQuery.nounId] !== undefined) { aGlodaExtractor = _defaultExpectationExtractors[aQuery.nounId][0]; } else { aGlodaExtractor = expectExtract_default_toString; } } if (aExpectedExtractor == null) { if (_defaultExpectationExtractors[aQuery.nounId] !== undefined) { aExpectedExtractor = _defaultExpectationExtractors[aQuery.nounId][1]; } else { aExpectedExtractor = expectExtract_default_toString; } } // - build the expected set const expectedSet = {}; for (const item of aExpectedSet) { try { expectedSet[aExpectedExtractor(item)] = item; } catch (ex) { throw new Error( "Expected extractor threw during query expectation for item:\n" + item + "\nException:\n" + ex ); } } // - create the listener... const listener = new QueryExpectationListener( expectedSet, aGlodaExtractor, aOrderVerifier, Components.stack.caller ); aQuery.args.push(listener); const queryValue = aQuery.queryFunc.apply(aQuery.queryThis, aQuery.args); // Wait for the QueryListener to finish. await listener.promise; return queryValue; } /** * Asynchronously run a SQL statement against the gloda database. This can grow * binding logic and data returning as needed. * * We run the statement asynchronously to get a consistent view of the database. */ export async function sqlRun(sql) { const conn = GlodaDatastore.asyncConnection; const stmt = conn.createAsyncStatement(sql); let rows = null; let promiseResolve; let promiseReject; const promise = new Promise((resolve, reject) => { promiseResolve = resolve; promiseReject = reject; }); // Running SQL. stmt.executeAsync({ handleResult(aResultSet) { if (!rows) { rows = []; } let row; while ((row = aResultSet.getNextRow())) { rows.push(row); } }, handleError(aError) { promiseReject( new Error("SQL error!\nResult:\n" + aError + "\nSQL:\n" + sql) ); }, handleCompletion() { promiseResolve(rows); }, }); stmt.finalize(); return promise; } /** * Run an (async) SQL statement against the gloda database. The statement * should be a SELECT COUNT; we check the count against aExpectedCount. * Any additional arguments are positionally bound to the statement. * * We run the statement asynchronously to get a consistent view of the database. */ export async function sqlExpectCount(aExpectedCount, aSQLString, ...params) { const conn = GlodaDatastore.asyncConnection; const stmt = conn.createStatement(aSQLString); for (let iArg = 0; iArg < params.length; iArg++) { GlodaDatastore._bindVariant(stmt, iArg, params[iArg]); } const desc = [aSQLString, ...params]; // Running SQL count. const listener = new SqlExpectationListener( aExpectedCount, desc, Components.stack.caller ); stmt.executeAsync(listener); // We don't need the statement anymore. stmt.finalize(); await listener.promise; } class SqlExpectationListener { constructor(aExpectedCount, aDesc, aCallerStackFrame) { this.actualCount = null; this.expectedCount = aExpectedCount; this.sqlDesc = aDesc; this.callerStackFrame = aCallerStackFrame; this._promise = new Promise((resolve, reject) => { this._resolve = resolve; this._reject = reject; }); } handleResult(aResultSet) { const row = aResultSet.getNextRow(); if (!row) { this._reject( new Error( "No result row returned from caller:\n" + this.callerStackFrame + "\nSQL:\n" + this.sqlDesc ) ); return; // We don't have to continue for more checks. } this.actualCount = row.getInt64(0); } handleError(aError) { this._reject( new Error( "SQL error from caller:\n" + this.callerStackFrame + "\nResult:\n" + aError + "\nSQL:\n" + this.sqlDesc ) ); } handleCompletion() { if (this.actualCount != this.expectedCount) { this._reject( new Error( "Actual count of " + this.actualCount + "does not match expected count of:\n" + this.expectedCount + "\nFrom caller:" + this.callerStackFrame + "\nSQL:\n" + this.sqlDesc ) ); return; // We don't have to continue for more checks. } this._resolve(); } get promise() { return this._promise; } }