@builder6/query-mongodb 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,760 @@
1
+ const { ObjectId } = require('mongodb');
2
+
3
+ const createGroupFieldName = (groupIndex) => '___group_key_' + groupIndex;
4
+
5
+ // much more complicated than it should be because braindead mongo
6
+ // doesn't support integer division by itself
7
+ // so I'm doing (dividend - (dividend MOD divisor)) / divisor
8
+ const divInt = (dividend, divisor) => ({
9
+ $divide: [subtractMod(dividend, divisor), divisor],
10
+ });
11
+
12
+ const subtractMod = (a, b) => ({
13
+ $subtract: [
14
+ a,
15
+ {
16
+ $mod: [a, b],
17
+ },
18
+ ],
19
+ });
20
+
21
+ const createGroupKeyPipeline = (
22
+ selector,
23
+ groupInterval,
24
+ groupIndex,
25
+ contextOptions
26
+ ) => {
27
+ const { timezoneOffset } = contextOptions;
28
+
29
+ const wrapGroupKey = (keyExpr) => ({
30
+ $addFields: { [createGroupFieldName(groupIndex)]: keyExpr },
31
+ });
32
+
33
+ const prefix = (s) => '$' + s;
34
+
35
+ const pipe = (...args) => {
36
+ let result = Array.from(args);
37
+ result.groupIndex = groupIndex;
38
+ return result;
39
+ };
40
+
41
+ if (groupInterval) {
42
+ const numericInterval = parseInt(Number(groupInterval));
43
+ if (numericInterval) {
44
+ return pipe(wrapGroupKey(subtractMod(prefix(selector), numericInterval)));
45
+ } else {
46
+ // timezone adjusted field
47
+ const tafield = {
48
+ $subtract: [prefix(selector), timezoneOffset * 60 * 1000],
49
+ };
50
+
51
+ switch (groupInterval) {
52
+ case 'year':
53
+ return pipe(
54
+ wrapGroupKey({
55
+ $year: tafield,
56
+ })
57
+ );
58
+ case 'quarter':
59
+ return pipe(
60
+ {
61
+ // need to pre-calculate month(date)+2, because the divInt logic
62
+ // will reuse the field and we don't want to calculate it multiple
63
+ // times
64
+ $addFields: {
65
+ ___mp2: {
66
+ $add: [
67
+ {
68
+ $month: tafield,
69
+ },
70
+ 2,
71
+ ],
72
+ },
73
+ },
74
+ },
75
+ wrapGroupKey(divInt('$___mp2', 3))
76
+ );
77
+ case 'month':
78
+ return pipe(
79
+ wrapGroupKey({
80
+ $month: tafield,
81
+ })
82
+ );
83
+ case 'day':
84
+ return pipe(
85
+ wrapGroupKey({
86
+ $dayOfMonth: tafield,
87
+ })
88
+ );
89
+ case 'dayOfWeek':
90
+ return pipe(
91
+ wrapGroupKey({
92
+ $subtract: [
93
+ {
94
+ $dayOfWeek: tafield, // correct in that it's sunday to saturday, but it's 1-7 (must be 0-6)
95
+ },
96
+ 1,
97
+ ],
98
+ })
99
+ );
100
+ case 'hour':
101
+ return pipe(
102
+ wrapGroupKey({
103
+ $hour: tafield,
104
+ })
105
+ );
106
+ case 'minute':
107
+ return pipe(
108
+ wrapGroupKey({
109
+ $minute: tafield,
110
+ })
111
+ );
112
+ case 'second':
113
+ return pipe(
114
+ wrapGroupKey({
115
+ $second: tafield,
116
+ })
117
+ );
118
+ default:
119
+ // unknown grouping operator, ignoring
120
+ return pipe(wrapGroupKey(prefix(selector)));
121
+ }
122
+ }
123
+ } else {
124
+ return pipe(wrapGroupKey(prefix(selector)));
125
+ }
126
+ };
127
+
128
+ const createGroupStagePipeline = (
129
+ includeDataItems,
130
+ countingSeparately,
131
+ itemProjection,
132
+ groupKeyPipeline
133
+ ) => {
134
+ let result = {
135
+ $group: {
136
+ // must use _id at this point for the group key
137
+ _id: '$' + createGroupFieldName(groupKeyPipeline.groupIndex),
138
+ },
139
+ };
140
+ if (!countingSeparately) {
141
+ // this method of counting results in the number of data items in the group
142
+ // if the group has sub-groups, it can't be used
143
+ result.$group.count = {
144
+ $sum: 1,
145
+ };
146
+ }
147
+ if (includeDataItems) {
148
+ // include items directly if we're expected to do so, and if this is the
149
+ // most deeply nested group in case there are several
150
+ result.$group.items = {
151
+ $push: itemProjection,
152
+ };
153
+ }
154
+
155
+ return groupKeyPipeline.concat([result]);
156
+ };
157
+
158
+ const createGroupingPipeline = (
159
+ desc,
160
+ includeDataItems,
161
+ countingSeparately,
162
+ groupKeyPipeline,
163
+ itemProjection = '$$CURRENT'
164
+ ) => {
165
+ let projectStage = {
166
+ $project: {
167
+ // rename _id to key
168
+ _id: 0,
169
+ key: '$_id',
170
+ },
171
+ };
172
+ let sortStage = {
173
+ $sort: {
174
+ key: desc ? -1 : 1,
175
+ },
176
+ };
177
+
178
+ let pipeline = createGroupStagePipeline(
179
+ includeDataItems,
180
+ countingSeparately,
181
+ itemProjection,
182
+ groupKeyPipeline
183
+ ).concat([projectStage, sortStage]);
184
+
185
+ if (!countingSeparately) {
186
+ // this method of counting results in the number of data items in the group
187
+ // if the group has sub-groups, it can't be used
188
+ projectStage.$project.count = 1;
189
+ }
190
+
191
+ if (includeDataItems) {
192
+ // include items directly if we're expected to do so, and if this is the
193
+ // most deeply nested group in case there are several
194
+ projectStage.$project.items = 1;
195
+ } else {
196
+ // add null items field otherwise
197
+ pipeline.push({
198
+ $addFields: {
199
+ items: null, // only null works, not [] or leaving out items altogether
200
+ },
201
+ });
202
+ }
203
+
204
+ return pipeline;
205
+ };
206
+
207
+ const createSkipTakePipeline = (skip, take) => {
208
+ let pipeline = [];
209
+
210
+ if (skip)
211
+ pipeline.push({
212
+ $skip: skip,
213
+ });
214
+ if (take)
215
+ pipeline.push({
216
+ $limit: take,
217
+ });
218
+
219
+ return pipeline;
220
+ };
221
+
222
+ const createCountPipeline = () => {
223
+ return [
224
+ {
225
+ $count: 'count',
226
+ },
227
+ ];
228
+ };
229
+
230
+ const createMatchPipeline = (selector, value) => [
231
+ { $match: { [selector]: value } },
232
+ ];
233
+
234
+ const construct = (fieldName, operator, compValue) => ({
235
+ [fieldName]: { [operator]: compValue },
236
+ });
237
+
238
+ const constructRegex = (fieldName, regex, caseInsensitive) => ({
239
+ [fieldName]: { $regex: regex, $options: caseInsensitive ? 'i' : '' },
240
+ });
241
+
242
+ const isCorrectFilterOperatorStructure = (element, operator) =>
243
+ element.reduce(
244
+ (r, v) => {
245
+ if (r.previous) return { ok: r.ok, previous: false };
246
+ else
247
+ return {
248
+ ok: r.ok && typeof v === 'string' && v.toLowerCase() === operator,
249
+ previous: true,
250
+ };
251
+ },
252
+ { ok: true, previous: true }
253
+ ).ok;
254
+
255
+ const isAndChainWithIncompleteAnds = (element) => {
256
+ if (!Array.isArray(element)) return false;
257
+ if (element.length < 2) return false;
258
+ if (!Array.isArray(element[0])) return false;
259
+ // this is important to prevent endless recursion
260
+ if (isCorrectFilterOperatorStructure(element, 'and')) return false;
261
+ return element.reduce(
262
+ (r, v) =>
263
+ r &&
264
+ ((typeof v === 'string' && v.toLowerCase() === 'and') ||
265
+ Array.isArray(v)),
266
+ true
267
+ );
268
+ };
269
+
270
+ function* _fixAndChainWithIncompleteAnds(chain) {
271
+ // the function assumes that
272
+ // isAndChainWithIncompleteAnds(chain) === true
273
+ let firstDone = false;
274
+ let expectAnd = true;
275
+ for (const item of chain) {
276
+ if (!firstDone) {
277
+ yield item;
278
+ firstDone = true;
279
+ } else {
280
+ if (expectAnd) {
281
+ if (typeof item === 'string') {
282
+ yield 'and';
283
+ expectAnd = false;
284
+ } else {
285
+ yield 'and';
286
+ yield item;
287
+ }
288
+ } else {
289
+ if (typeof item !== 'string') {
290
+ yield item;
291
+ expectAnd = true;
292
+ }
293
+ }
294
+ }
295
+ }
296
+ }
297
+
298
+ const fixAndChainWithIncompleteAnds = (element) =>
299
+ Array.from(_fixAndChainWithIncompleteAnds(element));
300
+
301
+ // eslint-disable-next-line complexity
302
+ const parseFilter = (element, contextOptions = {}) => {
303
+ // Element can be a string denoting a field name - I don't know if that's a case
304
+ // supported by the widgets in any way, but it seems conceivable that somebody constructs
305
+ // an expression like [ "!", "boolValueField" ]
306
+ // In the string case, I return a truth-checking filter.
307
+ //
308
+ // Element can be an array with two items
309
+ // For two items:
310
+ // 0: unary operator
311
+ // 1: operand
312
+ //
313
+ // Element can be an array with an odd number of items
314
+ // For three items:
315
+ // 0: operand 1 - this is described as the "getter" in the docs - i.e. field name -
316
+ // but in the cases of "and" and "or" it could be another nested element
317
+ // 1: operator
318
+ // 2: operand 2 - the value for comparison - for "and" and "or" can be a nested element
319
+ //
320
+ // For more than three items, it's assumed that this is a chain of "or" or "and" -
321
+ // either one or the other, no combinations
322
+ // 0: operand 1
323
+ // 1: "or" or "and"
324
+ // 2: operand 2
325
+ // 3: "or" or "and" - must be the same as (1) - see comment in test "incorrect operator chain"
326
+ // 4: operand 3
327
+ // .... etc
328
+ //
329
+
330
+ const { caseInsensitiveRegex } = contextOptions;
331
+
332
+ const rval = (match, fieldList) => ({ match, fieldList });
333
+
334
+ if (typeof element === 'string') {
335
+ const nf = checkNestedField(element);
336
+ const fieldName = nf ? nf.filterFieldName : element;
337
+ return rval(construct(fieldName, '$eq', true), [element]);
338
+ } else if (Array.isArray(element)) {
339
+ if (element.length === 1 && Array.isArray(element[0])) {
340
+ // assuming a nested array in this case:
341
+ // the pivot grid sometimes does this
342
+ // [ [ "field", "=", 5 ] ]
343
+ return parseFilter(element[0], contextOptions);
344
+ } else if (element.length === 2) {
345
+ // unary operator - only one supported
346
+ if (element[0] === '!') {
347
+ const { match, fieldList } = parseFilter(element[1], contextOptions);
348
+ if (match)
349
+ return rval(
350
+ {
351
+ $nor: [match],
352
+ },
353
+ fieldList
354
+ );
355
+ else return null;
356
+ } else if (isAndChainWithIncompleteAnds(element))
357
+ return parseFilter(
358
+ fixAndChainWithIncompleteAnds(element),
359
+ contextOptions
360
+ );
361
+ else return null;
362
+ } else {
363
+ if (isAndChainWithIncompleteAnds(element))
364
+ return parseFilter(
365
+ fixAndChainWithIncompleteAnds(element),
366
+ contextOptions
367
+ );
368
+ else if (element.length % 2 === 1) {
369
+ // odd number of elements - let's see what the operator is
370
+ const operator = String(element[1]).toLowerCase();
371
+
372
+ if (['and', 'or'].includes(operator)) {
373
+ if (isCorrectFilterOperatorStructure(element, operator)) {
374
+ // all operators are the same - build a list of conditions from the nested
375
+ // items, combine with the operator
376
+ let result = element.reduce(
377
+ (r, v) => {
378
+ if (r.previous) return { ...r, previous: false };
379
+ else {
380
+ const nestedResult = parseFilter(v, contextOptions);
381
+ const nestedFilter = nestedResult && nestedResult.match;
382
+ const fieldList = nestedResult ? nestedResult.fieldList : [];
383
+ if (nestedFilter) r.list.push(nestedFilter);
384
+ return {
385
+ list: r.list,
386
+ fieldList: r.fieldList.concat(fieldList),
387
+ previous: true,
388
+ };
389
+ }
390
+ },
391
+ { list: [], fieldList: [], previous: false }
392
+ );
393
+
394
+ return rval({ ['$' + operator]: result.list }, result.fieldList);
395
+ } else return null;
396
+ } else {
397
+ if (element.length === 3) {
398
+ const nf = checkNestedField(element[0]);
399
+ const fieldName = nf ? nf.filterFieldName : element[0];
400
+
401
+ switch (operator) {
402
+ case '=':
403
+ return rval(construct(fieldName, '$eq', element[2]), [
404
+ element[0],
405
+ ]);
406
+ case '<>':
407
+ return rval(construct(fieldName, '$ne', element[2]), [
408
+ element[0],
409
+ ]);
410
+ case '>':
411
+ return rval(construct(fieldName, '$gt', element[2]), [
412
+ element[0],
413
+ ]);
414
+ case '>=':
415
+ return rval(construct(fieldName, '$gte', element[2]), [
416
+ element[0],
417
+ ]);
418
+ case '<':
419
+ return rval(construct(fieldName, '$lt', element[2]), [
420
+ element[0],
421
+ ]);
422
+ case '<=':
423
+ return rval(construct(fieldName, '$lte', element[2]), [
424
+ element[0],
425
+ ]);
426
+ case 'startswith':
427
+ return rval(
428
+ constructRegex(
429
+ fieldName,
430
+ '^' + element[2],
431
+ caseInsensitiveRegex
432
+ ),
433
+ [element[0]]
434
+ );
435
+ case 'endswith':
436
+ return rval(
437
+ constructRegex(
438
+ fieldName,
439
+ element[2] + '$',
440
+ caseInsensitiveRegex
441
+ ),
442
+ [element[0]]
443
+ );
444
+ case 'contains':
445
+ return rval(
446
+ constructRegex(fieldName, element[2], caseInsensitiveRegex),
447
+ [element[0]]
448
+ );
449
+ case 'notcontains':
450
+ return rval(
451
+ constructRegex(
452
+ fieldName,
453
+ '^((?!' + element[2] + ').)*$',
454
+ caseInsensitiveRegex
455
+ ),
456
+ [element[0]]
457
+ );
458
+ case 'equalsobjectid':
459
+ return rval(
460
+ construct(fieldName, '$eq', new ObjectId(element[2])),
461
+ [element[0]]
462
+ );
463
+ default:
464
+ return null;
465
+ }
466
+ } else return null;
467
+ }
468
+ } else return null;
469
+ }
470
+ } else return null;
471
+ };
472
+
473
+ const createFilterPipeline = (filter, contextOptions) => {
474
+ const dummy = {
475
+ pipeline: [],
476
+ fieldList: [],
477
+ };
478
+
479
+ if (filter) {
480
+ const result = parseFilter(filter, contextOptions);
481
+ const match = result && result.match;
482
+ const fieldList = result ? result.fieldList : [];
483
+ if (match)
484
+ return {
485
+ pipeline: [
486
+ {
487
+ $match: match,
488
+ },
489
+ ],
490
+ fieldList: fieldList,
491
+ };
492
+ else return dummy;
493
+ } else return dummy;
494
+ };
495
+
496
+ const createSortPipeline = (sort) =>
497
+ sort
498
+ ? [
499
+ {
500
+ $sort: sort.reduce(
501
+ (r, v) => ({ ...r, [v.selector]: v.desc ? -1 : 1 }),
502
+ {}
503
+ ),
504
+ },
505
+ ]
506
+ : [];
507
+
508
+ const createSummaryPipeline = (summary) => {
509
+ if (summary) {
510
+ let gc = { _id: null };
511
+ for (const s of summary) {
512
+ switch (s.summaryType) {
513
+ case 'sum':
514
+ case 'avg':
515
+ case 'min':
516
+ case 'max':
517
+ gc[`___${s.summaryType}${s.selector}`] = {
518
+ [`$${s.summaryType}`]: `$${s.selector}`,
519
+ };
520
+ break;
521
+ case 'count':
522
+ gc.___count = { $sum: 1 };
523
+ break;
524
+ default:
525
+ console.error(`Invalid summary type '${s.summaryType}', ignoring`);
526
+ break;
527
+ }
528
+ }
529
+ return [
530
+ {
531
+ $group: gc,
532
+ },
533
+ ];
534
+ } else return [];
535
+ };
536
+
537
+ const createSearchPipeline = (expr, op, val, contextOptions) => {
538
+ const dummy = {
539
+ pipeline: [],
540
+ fieldList: [],
541
+ };
542
+
543
+ if (!expr || !op || !val) return dummy;
544
+
545
+ let criteria;
546
+ if (typeof expr === 'string') criteria = [expr, op, val];
547
+ else if (expr.length > 0) {
548
+ criteria = [];
549
+ for (const exprItem of expr) {
550
+ if (criteria.length) criteria.push('or');
551
+ criteria.push([exprItem, op, val]);
552
+ }
553
+ } else return dummy;
554
+
555
+ return createFilterPipeline(criteria, contextOptions);
556
+ };
557
+
558
+ const createSelectProjectExpression = (fields, explicitId = false) => {
559
+ if (fields && fields.length > 0) {
560
+ let project = {};
561
+ if (explicitId) project._id = '$_id';
562
+ for (const field of fields) project[field] = '$' + field;
563
+ return project;
564
+ } else return undefined;
565
+ };
566
+
567
+ const createSelectPipeline = (fields, contextOptions) => {
568
+ if (fields && fields.length > 0) {
569
+ return [
570
+ {
571
+ $project: createSelectProjectExpression(fields, contextOptions),
572
+ },
573
+ ];
574
+ } else return [];
575
+ };
576
+
577
+ // check whether any of the fields have a path structure ("field.Nested")
578
+ // and a recognized element for the nested part
579
+ // if so, I need to add the nested fields to the pipeline for filtering
580
+ const nestedFieldRegex = /^([^.]+)\.(year|quarter|month|dayofweek|day)$/i;
581
+
582
+ const checkNestedField = (fieldName) => {
583
+ const match = nestedFieldRegex.exec(fieldName);
584
+ if (!match) return undefined;
585
+ return {
586
+ base: match[1],
587
+ nested: match[2],
588
+ filterFieldName: `___${match[1]}_${match[2]}`,
589
+ };
590
+ };
591
+
592
+ const createAddNestedFieldsPipeline = (fieldNames, contextOptions) => {
593
+ const { timezoneOffset } = contextOptions;
594
+ // constructing a pipeline that potentially has two parts, because the
595
+ // quarter calculation has two steps
596
+ // both parts will be wrapped in { $addFields: PART } after this
597
+ // reduce call completes
598
+ const pr = fieldNames.reduce(
599
+ (r, v) => {
600
+ const nf = checkNestedField(v);
601
+
602
+ if (nf) {
603
+ // ignore all unknown cases - perhaps people have actual db fields
604
+ // with . in them
605
+ const nestedFunction = nf.nested.toLowerCase();
606
+ if (
607
+ ['year', 'quarter', 'month', 'day', 'dayofweek'].includes(
608
+ nestedFunction
609
+ )
610
+ ) {
611
+ // timezone adjusted field
612
+ const tafield = {
613
+ $subtract: ['$' + nf.base, timezoneOffset * 60 * 1000],
614
+ };
615
+
616
+ switch (nestedFunction) {
617
+ case 'year':
618
+ r.pipeline[1][nf.filterFieldName] = {
619
+ $year: tafield,
620
+ };
621
+ r.nestedFields.push(nf.filterFieldName);
622
+ break;
623
+ case 'quarter': {
624
+ const tempField = `___${nf.base}_mp2`;
625
+ r.pipeline[0][tempField] = {
626
+ $add: [
627
+ {
628
+ $month: tafield,
629
+ },
630
+ 2,
631
+ ],
632
+ };
633
+ r.nestedFields.push(tempField);
634
+ r.pipeline[1][nf.filterFieldName] = divInt('$' + tempField, 3);
635
+ r.nestedFields.push(nf.filterFieldName);
636
+ break;
637
+ }
638
+ case 'month':
639
+ r.pipeline[1][nf.filterFieldName] = {
640
+ $month: tafield,
641
+ };
642
+ r.nestedFields.push(nf.filterFieldName);
643
+ break;
644
+ case 'day':
645
+ r.pipeline[1][nf.filterFieldName] = {
646
+ $dayOfMonth: tafield,
647
+ };
648
+ r.nestedFields.push(nf.filterFieldName);
649
+ break;
650
+ case 'dayofweek':
651
+ r.pipeline[1][nf.filterFieldName] = {
652
+ $subtract: [
653
+ {
654
+ $dayOfWeek: tafield,
655
+ },
656
+ 1,
657
+ ],
658
+ };
659
+ r.nestedFields.push(nf.filterFieldName);
660
+ break;
661
+ default:
662
+ console.error('Hit a completely impossible default case');
663
+ }
664
+ }
665
+ }
666
+ return r;
667
+ },
668
+ {
669
+ pipeline: [{}, {}],
670
+ nestedFields: [],
671
+ }
672
+ );
673
+ [1, 0].forEach((i) => {
674
+ if (Object.getOwnPropertyNames(pr.pipeline[i]).length === 0) {
675
+ pr.pipeline.splice(i, 1); // nothing in this part, remove
676
+ } else {
677
+ pr.pipeline[i] = {
678
+ $addFields: pr.pipeline[i],
679
+ };
680
+ }
681
+ });
682
+
683
+ return pr;
684
+ };
685
+
686
+ const createCompleteFilterPipeline = (
687
+ searchExpr,
688
+ searchOperation,
689
+ searchValue,
690
+ filter,
691
+ contextOptions
692
+ ) => {
693
+ // this pipeline has the search options, the function also returns
694
+ // a list of fields that are being accessed
695
+ const searchPipeline = createSearchPipeline(
696
+ searchExpr,
697
+ searchOperation,
698
+ searchValue,
699
+ contextOptions
700
+ );
701
+ // and the same for the filter option
702
+ const filterPipeline = createFilterPipeline(filter, contextOptions);
703
+
704
+ // this pipeline adds fields in case there are nested elements:
705
+ // dateField.Month
706
+ const addNestedFieldsPipelineDetails = createAddNestedFieldsPipeline(
707
+ searchPipeline.fieldList.concat(filterPipeline.fieldList),
708
+ contextOptions
709
+ );
710
+
711
+ return {
712
+ pipeline: addNestedFieldsPipelineDetails.pipeline.concat(
713
+ searchPipeline.pipeline,
714
+ filterPipeline.pipeline
715
+ ),
716
+ nestedFields: addNestedFieldsPipelineDetails.nestedFields,
717
+ };
718
+ };
719
+
720
+ const createRemoveNestedFieldsPipeline = (nestedFields) => {
721
+ if (nestedFields.length === 0) return [];
722
+
723
+ let pd = {};
724
+ for (const f of nestedFields) pd[f] = 0;
725
+ return [
726
+ {
727
+ $project: pd,
728
+ },
729
+ ];
730
+ };
731
+
732
+ module.exports = {
733
+ createGroupFieldName,
734
+ createGroupKeyPipeline,
735
+ createGroupingPipeline,
736
+ createSkipTakePipeline,
737
+ createCountPipeline,
738
+ createMatchPipeline,
739
+ createSortPipeline,
740
+ createSummaryPipeline,
741
+ createSelectProjectExpression,
742
+ createSelectPipeline,
743
+ createCompleteFilterPipeline,
744
+ createRemoveNestedFieldsPipeline,
745
+ testing: {
746
+ divInt,
747
+ subtractMod,
748
+ createGroupStagePipeline,
749
+ construct,
750
+ constructRegex,
751
+ parseFilter,
752
+ createFilterPipeline,
753
+ createSearchPipeline,
754
+ checkNestedField,
755
+ createAddNestedFieldsPipeline,
756
+ isAndChainWithIncompleteAnds,
757
+ fixAndChainWithIncompleteAnds,
758
+ isCorrectFilterOperatorStructure,
759
+ },
760
+ };