Revision control

Copy as Markdown

Other Tools

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