summaryrefslogtreecommitdiffstats
path: root/comm/mailnews/db/gloda/test/unit/resources/GlodaQueryHelper.jsm
blob: e8234f1a9799e9fa1e2cf32a9003d74d6394d8cb (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
/* 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/. */

const EXPORTED_SYMBOLS = ["queryExpect", "sqlExpectCount", "sqlRun"];

/*
 * This file provides gloda query helpers for the test infrastructure.
 */

var { GlodaConstants } = ChromeUtils.import(
  "resource:///modules/gloda/GlodaConstants.jsm"
);
var { GlodaDatastore } = ChromeUtils.import(
  "resource:///modules/gloda/GlodaDatastore.jsm"
);

var log = console.createInstance({
  prefix: "gloda.queryHelper",
  maxLogLevel: "Warn",
  maxLogLevelPref: "gloda.loglevel",
});

var _defaultExpectationExtractors = {};
_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;
  },
];

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 (let 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 (let [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(aItems, aCollection) {
    log.debug(
      "QueryExpectationListener onItemsModified received. Nothing done."
    );
  }
  onItemsRemoved(aItems, aCollection) {
    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 (let key in this.expectedSet) {
      let 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).
 */
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
  let expectedSet = {};
  for (let 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...
  let listener = new QueryExpectationListener(
    expectedSet,
    aGlodaExtractor,
    aOrderVerifier,
    Components.stack.caller
  );
  aQuery.args.push(listener);
  let 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.
 */
async function sqlRun(sql) {
  let conn = GlodaDatastore.asyncConnection;
  let stmt = conn.createAsyncStatement(sql);
  let rows = null;

  let promiseResolve;
  let promiseReject;
  let 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.
 */
async function sqlExpectCount(aExpectedCount, aSQLString, ...params) {
  let conn = GlodaDatastore.asyncConnection;
  let stmt = conn.createStatement(aSQLString);

  for (let iArg = 0; iArg < params.length; iArg++) {
    GlodaDatastore._bindVariant(stmt, iArg, params[iArg]);
  }

  let desc = [aSQLString, ...params];
  // Running SQL count.
  let 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) {
    let 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(aReason) {
    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;
  }
}