summaryrefslogtreecommitdiffstats
path: root/comm/mailnews/db/gloda/modules/GlodaQueryClassFactory.jsm
blob: 2e53cf592540d512ba835fdb67bcc0444f6dc260 (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
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
/* 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 = ["GlodaQueryClassFactory"];

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

/**
 * @class Query class core; each noun gets its own sub-class where attributes
 *  have helper methods bound.
 *
 * @param aOptions A dictionary of options.  Current legal options are:
 *     - noMagic: Indicates that the noun's dbQueryJoinMagic should be ignored.
 *                Currently, this means that messages will not have their
 *                full-text indexed values re-attached.  This is planned to be
 *                offset by having queries/cache lookups that do not request
 *                noMagic to ensure that their data does get loaded.
 *     - explicitSQL: A hand-rolled alternate representation for the core
 *           SELECT portion of the SQL query.  The queryFromQuery logic still
 *           generates its normal query, we just ignore its result in favor of
 *           your provided value.  This means that the positional parameter
 *           list is still built and you should/must rely on those bound
 *           parameters (using '?').  The replacement occurs prior to the
 *           outerWrapColumns, ORDER BY, and LIMIT contributions to the query.
 *     - outerWrapColumns: If provided, wraps the query in a "SELECT *,blah
 *           FROM (actual query)" where blah is your list of outerWrapColumns
 *           made comma-delimited.  The idea is that this allows you to
 *           reference the result of expressions inside the query using their
 *           names rather than having to duplicate the logic.  In practice,
 *           this makes things more readable but is unlikely to improve
 *           performance.  (Namely, my use of 'offsets' for full-text stuff
 *           ends up in the EXPLAIN plan twice despite this.)
 *     - noDbQueryValidityConstraints: Indicates that any validity constraints
 *           should be ignored. This should be used when you need to get every
 *           match regardless of whether it's valid.
 *
 * @property _owner The query instance that holds the list of unions...
 * @property _constraints A list of (lists of OR constraints) that are ANDed
 *     together.  For example [[FROM bob, FROM jim], [DATE last week]] would
 *     be requesting us to find all the messages from either bob or jim, and
 *     sent in the last week.
 * @property _unions A list of other queries whose results are unioned with our
 *     own.  There is no concept of nesting or sub-queries apart from this
 *     mechanism.
 */
function GlodaQueryClass(aOptions) {
  this.options = aOptions != null ? aOptions : {};

  // if we are an 'or' clause, who is our parent whom other 'or' clauses should
  //  spawn from...
  this._owner = null;
  // our personal chain of and-ing.
  this._constraints = [];
  // the other instances we union with
  this._unions = [];

  this._order = [];
  this._limit = 0;
}

GlodaQueryClass.prototype = {
  WILDCARD: {},

  get constraintCount() {
    return this._constraints.length;
  },

  or() {
    let owner = this._owner || this;
    let orQuery = new this._queryClass();
    orQuery._owner = owner;
    owner._unions.push(orQuery);
    return orQuery;
  },

  orderBy(...aArgs) {
    this._order.push(...aArgs);
    return this;
  },

  limit(aLimit) {
    this._limit = aLimit;
    return this;
  },

  /**
   * Return a collection asynchronously populated by this collection.  You must
   *  provide a listener to receive notifications from the collection as it
   *  receives updates.  The listener object should implement onItemsAdded,
   *  onItemsModified, and onItemsRemoved methods, all of which take a single
   *  argument which is the list of items which have been added, modified, or
   *  removed respectively.
   *
   * @param aListener The collection listener.
   * @param [aData] The data attribute to set on the collection.
   * @param [aArgs.becomeExplicit] Make the collection explicit so that the
   *     collection will only ever contain results found from the database
   *     query and the query will not be updated as new items are indexed that
   *     also match the query.
   * @param [aArgs.becomeNull] Change the collection's query to a null query so
   *     that it will never receive any additional added/modified/removed events
   *     apart from the underlying database query.  This is really only intended
   *     for gloda internal use but may be acceptable for non-gloda use.  Please
   *     ask on mozilla.dev.apps.thunderbird first to make sure there isn't a
   *     better solution for your use-case.  (Note: removals will still happen
   *     when things get fully deleted.)
   */
  getCollection(aListener, aData, aArgs) {
    this.completed = false;
    return this._nounDef.datastore.queryFromQuery(
      this,
      aListener,
      aData,
      /* aExistingCollection */ null,
      /* aMasterCollection */ null,
      aArgs
    );
  },

  /* eslint-disable complexity */
  /**
   * Test whether the given first-class noun instance satisfies this query.
   *
   * @testpoint gloda.query.test
   */
  test(aObj) {
    // when changing this method, be sure that GlodaDatastore's queryFromQuery
    //  method likewise has any required changes made.
    let unionQueries = [this].concat(this._unions);

    for (let iUnion = 0; iUnion < unionQueries.length; iUnion++) {
      let curQuery = unionQueries[iUnion];

      // assume success until a specific (or) constraint proves us wrong
      let querySatisfied = true;
      for (
        let iConstraint = 0;
        iConstraint < curQuery._constraints.length;
        iConstraint++
      ) {
        let constraint = curQuery._constraints[iConstraint];
        let [constraintType, attrDef] = constraint;
        let boundName = attrDef ? attrDef.boundName : "id";
        if (
          boundName in aObj &&
          aObj[boundName] === GlodaConstants.IGNORE_FACET
        ) {
          querySatisfied = false;
          break;
        }

        let constraintValues = constraint.slice(2);

        if (constraintType === GlodaConstants.kConstraintIdIn) {
          if (!constraintValues.includes(aObj.id)) {
            querySatisfied = false;
            break;
          }
        } else if (
          constraintType === GlodaConstants.kConstraintIn ||
          constraintType === GlodaConstants.kConstraintEquals
        ) {
          // @testpoint gloda.query.test.kConstraintIn
          let objectNounDef = attrDef.objectNounDef;

          // if they provide an equals comparator, use that.
          // (note: the next case has better optimization possibilities than
          //  this mechanism, but of course has higher initialization costs or
          //  code complexity costs...)
          if (objectNounDef.equals) {
            let testValues;
            if (!(boundName in aObj)) {
              testValues = [];
            } else if (attrDef.singular) {
              testValues = [aObj[boundName]];
            } else {
              testValues = aObj[boundName];
            }

            // If there are no constraints, then we are just testing for there
            //  being a value.  Succeed (continue) in that case.
            if (
              constraintValues.length == 0 &&
              testValues.length &&
              testValues[0] != null
            ) {
              continue;
            }

            // If there are no test values and the empty set is significant,
            //  then check if any of the constraint values are null (our
            //  empty indicator.)
            if (testValues.length == 0 && attrDef.emptySetIsSignificant) {
              let foundEmptySetSignifier = false;
              for (let constraintValue of constraintValues) {
                if (constraintValue == null) {
                  foundEmptySetSignifier = true;
                  break;
                }
              }
              if (foundEmptySetSignifier) {
                continue;
              }
            }

            let foundMatch = false;
            for (let testValue of testValues) {
              for (let value of constraintValues) {
                if (objectNounDef.equals(testValue, value)) {
                  foundMatch = true;
                  break;
                }
              }
              if (foundMatch) {
                break;
              }
            }
            if (!foundMatch) {
              querySatisfied = false;
              break;
            }
          } else {
            // otherwise, we need to convert everyone to their param/value form
            //  in order to test for equality
            // let's just do the simple, obvious thing for now.  which is
            //  what we did in the prior case but exploding values using
            //  toParamAndValue, and then comparing.
            let testValues;
            if (!(boundName in aObj)) {
              testValues = [];
            } else if (attrDef.singular) {
              testValues = [aObj[boundName]];
            } else {
              testValues = aObj[boundName];
            }

            // If there are no constraints, then we are just testing for there
            //  being a value.  Succeed (continue) in that case.
            if (
              constraintValues.length == 0 &&
              testValues.length &&
              testValues[0] != null
            ) {
              continue;
            }
            // If there are no test values and the empty set is significant,
            //  then check if any of the constraint values are null (our
            //  empty indicator.)
            if (testValues.length == 0 && attrDef.emptySetIsSignificant) {
              let foundEmptySetSignifier = false;
              for (let constraintValue of constraintValues) {
                if (constraintValue == null) {
                  foundEmptySetSignifier = true;
                  break;
                }
              }
              if (foundEmptySetSignifier) {
                continue;
              }
            }

            let foundMatch = false;
            for (let testValue of testValues) {
              let [aParam, aValue] = objectNounDef.toParamAndValue(testValue);
              for (let value of constraintValues) {
                // skip empty set check sentinel values
                if (value == null && attrDef.emptySetIsSignificant) {
                  continue;
                }
                let [bParam, bValue] = objectNounDef.toParamAndValue(value);
                if (aParam == bParam && aValue == bValue) {
                  foundMatch = true;
                  break;
                }
              }
              if (foundMatch) {
                break;
              }
            }
            if (!foundMatch) {
              querySatisfied = false;
              break;
            }
          }
        } else if (constraintType === GlodaConstants.kConstraintRanges) {
          // @testpoint gloda.query.test.kConstraintRanges
          let objectNounDef = attrDef.objectNounDef;

          let testValues;
          if (!(boundName in aObj)) {
            testValues = [];
          } else if (attrDef.singular) {
            testValues = [aObj[boundName]];
          } else {
            testValues = aObj[boundName];
          }

          let foundMatch = false;
          for (let testValue of testValues) {
            let [tParam, tValue] = objectNounDef.toParamAndValue(testValue);
            for (let rangeTuple of constraintValues) {
              let [lowerRValue, upperRValue] = rangeTuple;
              if (lowerRValue == null) {
                let [upperParam, upperValue] =
                  objectNounDef.toParamAndValue(upperRValue);
                if (tParam == upperParam && tValue <= upperValue) {
                  foundMatch = true;
                  break;
                }
              } else if (upperRValue == null) {
                let [lowerParam, lowerValue] =
                  objectNounDef.toParamAndValue(lowerRValue);
                if (tParam == lowerParam && tValue >= lowerValue) {
                  foundMatch = true;
                  break;
                }
              } else {
                // no one is null
                let [upperParam, upperValue] =
                  objectNounDef.toParamAndValue(upperRValue);
                let [lowerParam, lowerValue] =
                  objectNounDef.toParamAndValue(lowerRValue);
                if (
                  tParam == lowerParam &&
                  tValue >= lowerValue &&
                  tParam == upperParam &&
                  tValue <= upperValue
                ) {
                  foundMatch = true;
                  break;
                }
              }
            }
            if (foundMatch) {
              break;
            }
          }
          if (!foundMatch) {
            querySatisfied = false;
            break;
          }
        } else if (constraintType === GlodaConstants.kConstraintStringLike) {
          // @testpoint gloda.query.test.kConstraintStringLike
          let curIndex = 0;
          let value = boundName in aObj ? aObj[boundName] : "";
          // the attribute must be singular, we don't support arrays of strings.
          for (let valuePart of constraintValues) {
            if (typeof valuePart == "string") {
              let index = value.indexOf(valuePart);
              // if curIndex is null, we just need any match
              // if it's not null, it must match the offset of our found match
              if (curIndex === null) {
                if (index == -1) {
                  querySatisfied = false;
                } else {
                  curIndex = index + valuePart.length;
                }
              } else if (index != curIndex) {
                querySatisfied = false;
              } else {
                curIndex = index + valuePart.length;
              }
              if (!querySatisfied) {
                break;
              }
            } else {
              // wild!
              curIndex = null;
            }
          }
          // curIndex must be null or equal to the length of the string
          if (querySatisfied && curIndex !== null && curIndex != value.length) {
            querySatisfied = false;
          }
        } else if (constraintType === GlodaConstants.kConstraintFulltext) {
          // @testpoint gloda.query.test.kConstraintFulltext
          // this is beyond our powers. Even if we have the fulltext content in
          //  memory, which we may not, the tokenization and such to perform
          //  the testing gets very complicated in the face of i18n, etc.
          // so, let's fail if the item is not already in the collection, and
          //  let the testing continue if it is.  (some other constraint may no
          //  longer apply...)
          if (!(aObj.id in this.collection._idMap)) {
            querySatisfied = false;
          }
        }

        if (!querySatisfied) {
          break;
        }
      }

      if (querySatisfied) {
        return true;
      }
    }
    return false;
  },
  /* eslint-enable complexity */

  /**
   * Helper code for noun definitions of queryHelpers that want to build a
   *  traditional in/equals constraint.  The goal is to let them build a range
   *  without having to know how we structure |_constraints|.
   *
   * @protected
   */
  _inConstraintHelper(aAttrDef, aValues) {
    let constraint = [GlodaConstants.kConstraintIn, aAttrDef].concat(aValues);
    this._constraints.push(constraint);
    return this;
  },

  /**
   * Helper code for noun definitions of queryHelpers that want to build a
   *  range.  The goal is to let them build a range without having to know how
   *  we structure |_constraints| or requiring them to mark themselves as
   *  continuous to get a "Range".
   *
   * @protected
   */
  _rangedConstraintHelper(aAttrDef, aRanges) {
    let constraint = [GlodaConstants.kConstraintRanges, aAttrDef].concat(
      aRanges
    );
    this._constraints.push(constraint);
    return this;
  },
};

/**
 * @class A query that never matches anything.
 *
 * Collections corresponding to this query are intentionally frozen in time and
 *  do not want to be notified of any updates.  We need the collection to be
 *  registered with the collection manager so that the noun instances in the
 *  collection are always 'reachable' via the collection for as long as we might
 *  be handing out references to the instances.  (The other way to avoid updates
 *  would be to not register the collection, but then items might not be
 *  reachable.)
 * This is intended to be used in implementation details behind the gloda
 *  abstraction barrier.  For example, the message indexer likes to be able
 *  to represent 'ghost' and deleted messages, but these should never be exposed
 *  to the user.  For code simplicity, it wants to be able to use the query
 *  mechanism.  But it doesn't want updates that are effectively
 *  nonsensical.  For example, a ghost message that is reused by message
 *  indexing may already be present in a collection; when the collection manager
 *  receives an itemsAdded event, a GlodaExplicitQueryClass would result in
 *  an item added notification in that case, which would wildly not be desired.
 */
function GlodaNullQueryClass() {}

GlodaNullQueryClass.prototype = {
  /**
   * No options; they are currently only needed for SQL query generation, which
   *  does not happen for null queries.
   */
  options: {},

  /**
   * Provide a duck-typing way of indicating to GlodaCollectionManager that our
   *  associated collection just doesn't want anything to change.  Our test
   *  function is able to convey most of it, but special-casing has to happen
   *  somewhere, so it happens here.
   */
  frozen: true,

  /**
   * Since our query never matches anything, it doesn't make sense to let
   *  someone attempt to construct a boolean OR involving us.
   *
   * @returns null
   */
  or() {
    return null;
  },

  /**
   * Return nothing (null) because it does not make sense to create a collection
   *  based on a null query.  This method is normally used (on a normal query)
   *  to return a collection populated by the constraints of the query.  We
   *  match nothing, so we should return nothing.  More importantly, you are
   *  currently doing something wrong if you try and do this, so null is
   *  appropriate.  It may turn out that it makes sense for us to return an
   *  empty collection in the future for sentinel value purposes, but we'll
   *  cross that bridge when we come to it.
   *
   * @returns null
   */
  getCollection() {
    return null;
  },

  /**
   * Never matches anything.
   *
   * @param aObj The object someone wants us to test for relevance to our
   *     associated collection.  But we don't care!  Not a fig!
   * @returns false
   */
  test(aObj) {
    return false;
  },
};

/**
 * @class A query that only 'tests' for already belonging to the collection.
 *
 * This type of collection is useful for when you (or rather your listener)
 *  are interested in hearing about modifications to your collection or removals
 *  from your collection because of deletion, but do not want to be notified
 *  about newly indexed items matching your normal query constraints.
 *
 * @param aCollection The collection this query belongs to.  This needs to be
 *     passed-in here or the collection should set the attribute directly when
 *     the query is passed in to a collection's constructor.
 */
function GlodaExplicitQueryClass(aCollection) {
  this.collection = aCollection;
}

GlodaExplicitQueryClass.prototype = {
  /**
   * No options; they are currently only needed for SQL query generation, which
   *  does not happen for explicit queries.
   */
  options: {},

  /**
   * Since our query is intended to only match the contents of our collection,
   *  it doesn't make sense to let someone attempt to construct a boolean OR
   *  involving us.
   *
   * @returns null
   */
  or() {
    return null;
  },

  /**
   * Return nothing (null) because it does not make sense to create a collection
   *  based on an explicit query.  This method is normally used (on a normal
   *  query) to return a collection populated by the constraints of the query.
   *  In the case of an explicit query, we expect it will be associated with
   *  either a hand-created collection or the results of a normal query that is
   *  immediately converted into an explicit query.  In all likelihood, calling
   *  this method on an instance of this type is an error, so it is helpful to
   *  return null because people will error hard.
   *
   * @returns null
   */
  getCollection() {
    return null;
  },

  /**
   * Matches only items that are already in the collection associated with this
   *  query (by id).
   *
   * @param aObj The object/item to test for already being in the associated
   *     collection.
   * @returns true when the object is in the associated collection, otherwise
   *     false.
   */
  test(aObj) {
    return aObj.id in this.collection._idMap;
  },
};

/**
 * @class A query that 'tests' true for everything.  Intended for debugging purposes
 *  only.
 */
function GlodaWildcardQueryClass() {}

GlodaWildcardQueryClass.prototype = {
  /**
   * No options; they are currently only needed for SQL query generation.
   */
  options: {},

  // don't let people try and mess with us
  or() {
    return null;
  },
  // don't let people try and query on us (until we have a real use case for
  //  that...)
  getCollection() {
    return null;
  },
  /**
   * Everybody wins!
   */
  test(aObj) {
    return true;
  },
};

/**
 * Factory method to effectively create per-noun subclasses of GlodaQueryClass,
 *  GlodaNullQueryClass, GlodaExplicitQueryClass, and GlodaWildcardQueryClass.
 *  For GlodaQueryClass this allows us to add per-noun helpers.  For the others,
 *  this is merely a means of allowing us to attach the (per-noun) nounDef to
 *  the 'class'.
 */
function GlodaQueryClassFactory(aNounDef) {
  let newQueryClass = function (aOptions) {
    GlodaQueryClass.call(this, aOptions);
  };
  newQueryClass.prototype = new GlodaQueryClass();
  newQueryClass.prototype._queryClass = newQueryClass;
  newQueryClass.prototype._nounDef = aNounDef;

  let newNullClass = function (aCollection) {
    GlodaNullQueryClass.call(this);
    this.collection = aCollection;
  };
  newNullClass.prototype = new GlodaNullQueryClass();
  newNullClass.prototype._queryClass = newNullClass;
  newNullClass.prototype._nounDef = aNounDef;

  let newExplicitClass = function (aCollection) {
    GlodaExplicitQueryClass.call(this);
    this.collection = aCollection;
  };
  newExplicitClass.prototype = new GlodaExplicitQueryClass();
  newExplicitClass.prototype._queryClass = newExplicitClass;
  newExplicitClass.prototype._nounDef = aNounDef;

  let newWildcardClass = function (aCollection) {
    GlodaWildcardQueryClass.call(this);
    this.collection = aCollection;
  };
  newWildcardClass.prototype = new GlodaWildcardQueryClass();
  newWildcardClass.prototype._queryClass = newWildcardClass;
  newWildcardClass.prototype._nounDef = aNounDef;

  return [newQueryClass, newNullClass, newExplicitClass, newWildcardClass];
}