vanta-api 1.4.0 → 1.4.2
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.
- package/package.json +1 -1
- package/src/api-features.js +554 -146
- package/src/errorHandler.js +0 -2
package/package.json
CHANGED
package/src/api-features.js
CHANGED
|
@@ -15,6 +15,7 @@ const logger = winston.createLogger({
|
|
|
15
15
|
});
|
|
16
16
|
|
|
17
17
|
const RESERVED_QUERY_KEYS = ["page", "limit", "sort", "fields", "populate", "q"];
|
|
18
|
+
const LOGICAL_OPERATORS = ["$and", "$or", "$nor"];
|
|
18
19
|
|
|
19
20
|
export class ApiFeatures {
|
|
20
21
|
constructor(model, query = {}, userRole = "") {
|
|
@@ -32,7 +33,10 @@ export class ApiFeatures {
|
|
|
32
33
|
|
|
33
34
|
filter() {
|
|
34
35
|
const queryFilters = this._parseQueryFilters();
|
|
35
|
-
const mergedFilters = this._deepMergeFilters(
|
|
36
|
+
const mergedFilters = this._deepMergeFilters(
|
|
37
|
+
queryFilters,
|
|
38
|
+
this.manualFilters
|
|
39
|
+
);
|
|
36
40
|
const sanitizedFilters = this._sanitizeFilters(mergedFilters);
|
|
37
41
|
const safeFilters = this._applySecurityFilters(sanitizedFilters);
|
|
38
42
|
|
|
@@ -60,15 +64,19 @@ export class ApiFeatures {
|
|
|
60
64
|
|
|
61
65
|
if (!safeQ) return this;
|
|
62
66
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
67
|
+
const conditions = fields
|
|
68
|
+
.filter((field) => typeof field === "string" && field.trim())
|
|
69
|
+
.map((field) => ({
|
|
70
|
+
[field]: { $regex: safeQ, $options: "i" },
|
|
71
|
+
}));
|
|
72
|
+
|
|
73
|
+
if (conditions.length) {
|
|
74
|
+
this.pipeline.push({
|
|
75
|
+
$match: {
|
|
76
|
+
$or: conditions,
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
}
|
|
72
80
|
|
|
73
81
|
return this;
|
|
74
82
|
}
|
|
@@ -81,7 +89,7 @@ export class ApiFeatures {
|
|
|
81
89
|
|
|
82
90
|
String(this.query.sort)
|
|
83
91
|
.split(",")
|
|
84
|
-
.map((
|
|
92
|
+
.map((part) => part.trim())
|
|
85
93
|
.filter(Boolean)
|
|
86
94
|
.forEach((part) => {
|
|
87
95
|
const direction = part.startsWith("-") ? -1 : 1;
|
|
@@ -155,9 +163,10 @@ export class ApiFeatures {
|
|
|
155
163
|
|
|
156
164
|
for (const populateItem of populateList) {
|
|
157
165
|
this._addPopulateStages({
|
|
158
|
-
model: this.model,
|
|
159
166
|
populateItem,
|
|
160
|
-
|
|
167
|
+
parentPath: "",
|
|
168
|
+
parentIsArray: false,
|
|
169
|
+
schema: this.model.schema,
|
|
161
170
|
allowedPopulate,
|
|
162
171
|
});
|
|
163
172
|
}
|
|
@@ -173,7 +182,7 @@ export class ApiFeatures {
|
|
|
173
182
|
logger.info("Pipeline:", this.pipeline);
|
|
174
183
|
}
|
|
175
184
|
|
|
176
|
-
if (this.pipeline.length > (securityConfig.maxPipelineStages ||
|
|
185
|
+
if (this.pipeline.length > (securityConfig.maxPipelineStages || 80)) {
|
|
177
186
|
throw new HandleERROR("Too many pipeline stages", 400);
|
|
178
187
|
}
|
|
179
188
|
|
|
@@ -216,6 +225,420 @@ export class ApiFeatures {
|
|
|
216
225
|
}
|
|
217
226
|
}
|
|
218
227
|
|
|
228
|
+
_addPopulateStages({
|
|
229
|
+
populateItem,
|
|
230
|
+
parentPath = "",
|
|
231
|
+
parentIsArray = false,
|
|
232
|
+
schema = null,
|
|
233
|
+
allowedPopulate = [],
|
|
234
|
+
}) {
|
|
235
|
+
if (!populateItem || !populateItem.path) return;
|
|
236
|
+
|
|
237
|
+
const path = populateItem.path;
|
|
238
|
+
const fullPath = parentPath ? `${parentPath}.${path}` : path;
|
|
239
|
+
|
|
240
|
+
if (!this._isPopulateAllowed(fullPath, allowedPopulate)) {
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const info = this._getPopulateInfo({
|
|
245
|
+
schema,
|
|
246
|
+
path,
|
|
247
|
+
fullPath,
|
|
248
|
+
parentPath,
|
|
249
|
+
parentIsArray,
|
|
250
|
+
populateItem,
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
if (!parentIsArray) {
|
|
254
|
+
this.pipeline.push({
|
|
255
|
+
$lookup: {
|
|
256
|
+
from: info.collection,
|
|
257
|
+
localField: info.localField,
|
|
258
|
+
foreignField: "_id",
|
|
259
|
+
as: info.as,
|
|
260
|
+
},
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
if (!info.isArray) {
|
|
264
|
+
this.pipeline.push({
|
|
265
|
+
$unwind: {
|
|
266
|
+
path: `$${info.as}`,
|
|
267
|
+
preserveNullAndEmptyArrays: true,
|
|
268
|
+
},
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (populateItem.populate) {
|
|
273
|
+
const nestedList = this._normalizeNestedPopulate(populateItem.populate);
|
|
274
|
+
|
|
275
|
+
for (const nested of nestedList) {
|
|
276
|
+
this._addPopulateStages({
|
|
277
|
+
populateItem: nested,
|
|
278
|
+
parentPath: info.as,
|
|
279
|
+
parentIsArray: info.isArray,
|
|
280
|
+
schema: info.refSchema,
|
|
281
|
+
allowedPopulate,
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if (populateItem.select) {
|
|
287
|
+
this._applyPopulateSelect({
|
|
288
|
+
path: info.as,
|
|
289
|
+
select: populateItem.select,
|
|
290
|
+
isArray: info.isArray,
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const tempLookupName = this._makeTempLookupName(fullPath);
|
|
298
|
+
|
|
299
|
+
this.pipeline.push({
|
|
300
|
+
$lookup: {
|
|
301
|
+
from: info.collection,
|
|
302
|
+
localField: info.localField,
|
|
303
|
+
foreignField: "_id",
|
|
304
|
+
as: tempLookupName,
|
|
305
|
+
},
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
this.pipeline.push({
|
|
309
|
+
$set: {
|
|
310
|
+
[parentPath]: {
|
|
311
|
+
$map: {
|
|
312
|
+
input: { $ifNull: [`$${parentPath}`, []] },
|
|
313
|
+
as: "item",
|
|
314
|
+
in: {
|
|
315
|
+
$mergeObjects: [
|
|
316
|
+
"$$item",
|
|
317
|
+
{
|
|
318
|
+
[path]: {
|
|
319
|
+
$first: {
|
|
320
|
+
$filter: {
|
|
321
|
+
input: `$${tempLookupName}`,
|
|
322
|
+
as: "joined",
|
|
323
|
+
cond: {
|
|
324
|
+
$eq: ["$$joined._id", `$$item.${path}`],
|
|
325
|
+
},
|
|
326
|
+
},
|
|
327
|
+
},
|
|
328
|
+
},
|
|
329
|
+
},
|
|
330
|
+
],
|
|
331
|
+
},
|
|
332
|
+
},
|
|
333
|
+
},
|
|
334
|
+
},
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
this.pipeline.push({ $unset: tempLookupName });
|
|
338
|
+
|
|
339
|
+
if (populateItem.populate) {
|
|
340
|
+
const nestedList = this._normalizeNestedPopulate(populateItem.populate);
|
|
341
|
+
|
|
342
|
+
for (const nested of nestedList) {
|
|
343
|
+
this._addNestedPopulateInsideArrayItem({
|
|
344
|
+
arrayPath: parentPath,
|
|
345
|
+
objectPath: path,
|
|
346
|
+
populateItem: nested,
|
|
347
|
+
allowedPopulate,
|
|
348
|
+
schema: info.refSchema,
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
if (populateItem.select) {
|
|
354
|
+
this._applyNestedObjectSelectInsideArray({
|
|
355
|
+
arrayPath: parentPath,
|
|
356
|
+
objectPath: path,
|
|
357
|
+
select: populateItem.select,
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
_addNestedPopulateInsideArrayItem({
|
|
363
|
+
arrayPath,
|
|
364
|
+
objectPath,
|
|
365
|
+
populateItem,
|
|
366
|
+
allowedPopulate = [],
|
|
367
|
+
schema = null,
|
|
368
|
+
}) {
|
|
369
|
+
if (!populateItem || !populateItem.path) return;
|
|
370
|
+
|
|
371
|
+
const childPath = populateItem.path;
|
|
372
|
+
const fullPath = `${arrayPath}.${objectPath}.${childPath}`;
|
|
373
|
+
|
|
374
|
+
if (!this._isPopulateAllowed(fullPath, allowedPopulate)) {
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const info = this._getPopulateInfo({
|
|
379
|
+
schema,
|
|
380
|
+
path: childPath,
|
|
381
|
+
fullPath,
|
|
382
|
+
parentPath: `${arrayPath}.${objectPath}`,
|
|
383
|
+
parentIsArray: true,
|
|
384
|
+
populateItem,
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
const tempLookupName = this._makeTempLookupName(fullPath);
|
|
388
|
+
|
|
389
|
+
this.pipeline.push({
|
|
390
|
+
$lookup: {
|
|
391
|
+
from: info.collection,
|
|
392
|
+
localField: `${arrayPath}.${objectPath}.${childPath}`,
|
|
393
|
+
foreignField: "_id",
|
|
394
|
+
as: tempLookupName,
|
|
395
|
+
},
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
this.pipeline.push({
|
|
399
|
+
$set: {
|
|
400
|
+
[arrayPath]: {
|
|
401
|
+
$map: {
|
|
402
|
+
input: { $ifNull: [`$${arrayPath}`, []] },
|
|
403
|
+
as: "item",
|
|
404
|
+
in: {
|
|
405
|
+
$mergeObjects: [
|
|
406
|
+
"$$item",
|
|
407
|
+
{
|
|
408
|
+
[objectPath]: {
|
|
409
|
+
$cond: [
|
|
410
|
+
{ $ne: [`$$item.${objectPath}`, null] },
|
|
411
|
+
{
|
|
412
|
+
$mergeObjects: [
|
|
413
|
+
`$$item.${objectPath}`,
|
|
414
|
+
{
|
|
415
|
+
[childPath]: {
|
|
416
|
+
$first: {
|
|
417
|
+
$filter: {
|
|
418
|
+
input: `$${tempLookupName}`,
|
|
419
|
+
as: "joined",
|
|
420
|
+
cond: {
|
|
421
|
+
$eq: [
|
|
422
|
+
"$$joined._id",
|
|
423
|
+
`$$item.${objectPath}.${childPath}`,
|
|
424
|
+
],
|
|
425
|
+
},
|
|
426
|
+
},
|
|
427
|
+
},
|
|
428
|
+
},
|
|
429
|
+
},
|
|
430
|
+
],
|
|
431
|
+
},
|
|
432
|
+
`$$item.${objectPath}`,
|
|
433
|
+
],
|
|
434
|
+
},
|
|
435
|
+
},
|
|
436
|
+
],
|
|
437
|
+
},
|
|
438
|
+
},
|
|
439
|
+
},
|
|
440
|
+
},
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
this.pipeline.push({ $unset: tempLookupName });
|
|
444
|
+
|
|
445
|
+
if (populateItem.populate) {
|
|
446
|
+
const nestedList = this._normalizeNestedPopulate(populateItem.populate);
|
|
447
|
+
|
|
448
|
+
for (const nested of nestedList) {
|
|
449
|
+
this._addNestedPopulateInsideArrayItem({
|
|
450
|
+
arrayPath,
|
|
451
|
+
objectPath: `${objectPath}.${childPath}`,
|
|
452
|
+
populateItem: nested,
|
|
453
|
+
allowedPopulate,
|
|
454
|
+
schema: info.refSchema,
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
if (populateItem.select) {
|
|
460
|
+
this._applyNestedObjectSelectInsideArray({
|
|
461
|
+
arrayPath,
|
|
462
|
+
objectPath: `${objectPath}.${childPath}`,
|
|
463
|
+
select: populateItem.select,
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
_getPopulateInfo({
|
|
469
|
+
schema = null,
|
|
470
|
+
path,
|
|
471
|
+
fullPath,
|
|
472
|
+
parentPath = "",
|
|
473
|
+
parentIsArray = false,
|
|
474
|
+
populateItem = {},
|
|
475
|
+
}) {
|
|
476
|
+
const schemaPath = schema?.path?.(path);
|
|
477
|
+
|
|
478
|
+
const isArray =
|
|
479
|
+
populateItem.isArray === true ||
|
|
480
|
+
schemaPath?.instance === "Array" ||
|
|
481
|
+
Array.isArray(schemaPath?.options?.type);
|
|
482
|
+
|
|
483
|
+
const refModelName =
|
|
484
|
+
populateItem.ref ||
|
|
485
|
+
populateItem.modelName ||
|
|
486
|
+
schemaPath?.options?.ref ||
|
|
487
|
+
schemaPath?.caster?.options?.ref ||
|
|
488
|
+
(Array.isArray(schemaPath?.options?.type)
|
|
489
|
+
? schemaPath.options.type[0]?.ref
|
|
490
|
+
: undefined) ||
|
|
491
|
+
this._inferModelNameFromPath(path);
|
|
492
|
+
|
|
493
|
+
const collection =
|
|
494
|
+
populateItem.collection ||
|
|
495
|
+
populateItem.from ||
|
|
496
|
+
this._resolveCollectionName(refModelName);
|
|
497
|
+
|
|
498
|
+
const refSchema = this._resolveRegisteredSchema(refModelName);
|
|
499
|
+
|
|
500
|
+
return {
|
|
501
|
+
path,
|
|
502
|
+
fullPath,
|
|
503
|
+
refModelName,
|
|
504
|
+
collection,
|
|
505
|
+
refSchema,
|
|
506
|
+
isArray,
|
|
507
|
+
localField: parentIsArray ? fullPath : fullPath,
|
|
508
|
+
as: fullPath,
|
|
509
|
+
parentPath,
|
|
510
|
+
parentIsArray,
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
_applyPopulateSelect({ path, select, isArray }) {
|
|
515
|
+
const parsed = this._parseSelect(select);
|
|
516
|
+
|
|
517
|
+
if (!parsed.fields.length) return;
|
|
518
|
+
|
|
519
|
+
if (parsed.mode === "exclude") {
|
|
520
|
+
this.pipeline.push({
|
|
521
|
+
$unset: parsed.fields.map((field) => `${path}.${field}`),
|
|
522
|
+
});
|
|
523
|
+
return;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
const includeFields = this._ensureIdField(parsed.fields);
|
|
527
|
+
|
|
528
|
+
if (isArray) {
|
|
529
|
+
const selectedObject = {};
|
|
530
|
+
|
|
531
|
+
for (const field of includeFields) {
|
|
532
|
+
selectedObject[field] = `$$item.${field}`;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
this.pipeline.push({
|
|
536
|
+
$set: {
|
|
537
|
+
[path]: {
|
|
538
|
+
$map: {
|
|
539
|
+
input: { $ifNull: [`$${path}`, []] },
|
|
540
|
+
as: "item",
|
|
541
|
+
in: selectedObject,
|
|
542
|
+
},
|
|
543
|
+
},
|
|
544
|
+
},
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
return;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
const selectedObject = {};
|
|
551
|
+
|
|
552
|
+
for (const field of includeFields) {
|
|
553
|
+
selectedObject[field] = `$${path}.${field}`;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
this.pipeline.push({
|
|
557
|
+
$set: {
|
|
558
|
+
[path]: {
|
|
559
|
+
$cond: [{ $ne: [`$${path}`, null] }, selectedObject, `$${path}`],
|
|
560
|
+
},
|
|
561
|
+
},
|
|
562
|
+
});
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
_applyNestedObjectSelectInsideArray({ arrayPath, objectPath, select }) {
|
|
566
|
+
const parsed = this._parseSelect(select);
|
|
567
|
+
|
|
568
|
+
if (!parsed.fields.length) return;
|
|
569
|
+
|
|
570
|
+
if (parsed.mode === "exclude") {
|
|
571
|
+
this.pipeline.push({
|
|
572
|
+
$unset: parsed.fields.map(
|
|
573
|
+
(field) => `${arrayPath}.${objectPath}.${field}`
|
|
574
|
+
),
|
|
575
|
+
});
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
const includeFields = this._ensureIdField(parsed.fields);
|
|
580
|
+
const selectedObject = {};
|
|
581
|
+
|
|
582
|
+
for (const field of includeFields) {
|
|
583
|
+
selectedObject[field] = `$$item.${objectPath}.${field}`;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
this.pipeline.push({
|
|
587
|
+
$set: {
|
|
588
|
+
[arrayPath]: {
|
|
589
|
+
$map: {
|
|
590
|
+
input: { $ifNull: [`$${arrayPath}`, []] },
|
|
591
|
+
as: "item",
|
|
592
|
+
in: {
|
|
593
|
+
$mergeObjects: [
|
|
594
|
+
"$$item",
|
|
595
|
+
{
|
|
596
|
+
[objectPath]: {
|
|
597
|
+
$cond: [
|
|
598
|
+
{ $ne: [`$$item.${objectPath}`, null] },
|
|
599
|
+
selectedObject,
|
|
600
|
+
`$$item.${objectPath}`,
|
|
601
|
+
],
|
|
602
|
+
},
|
|
603
|
+
},
|
|
604
|
+
],
|
|
605
|
+
},
|
|
606
|
+
},
|
|
607
|
+
},
|
|
608
|
+
},
|
|
609
|
+
});
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
_parseSelect(select = "") {
|
|
613
|
+
const fields = String(select)
|
|
614
|
+
.split(/\s+/)
|
|
615
|
+
.map((field) => field.trim())
|
|
616
|
+
.filter(Boolean)
|
|
617
|
+
.filter((field) => {
|
|
618
|
+
const cleanField = field.replace(/^-/, "");
|
|
619
|
+
return !this._isForbiddenField(cleanField);
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
const hasInclude = fields.some((field) => !field.startsWith("-"));
|
|
623
|
+
const hasExclude = fields.some((field) => field.startsWith("-"));
|
|
624
|
+
|
|
625
|
+
if (hasInclude && hasExclude) {
|
|
626
|
+
throw new HandleERROR(
|
|
627
|
+
"Cannot mix include and exclude in populate select",
|
|
628
|
+
400
|
|
629
|
+
);
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
return {
|
|
633
|
+
mode: hasExclude ? "exclude" : "include",
|
|
634
|
+
fields: fields.map((field) => field.replace(/^-/, "")),
|
|
635
|
+
};
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
_ensureIdField(fields = []) {
|
|
639
|
+
return fields.includes("_id") ? fields : ["_id", ...fields];
|
|
640
|
+
}
|
|
641
|
+
|
|
219
642
|
_sanitization() {
|
|
220
643
|
for (const key of Object.keys(this.query)) {
|
|
221
644
|
if (
|
|
@@ -344,7 +767,7 @@ export class ApiFeatures {
|
|
|
344
767
|
out[key] &&
|
|
345
768
|
typeof out[key] === "object" &&
|
|
346
769
|
!Array.isArray(out[key]) &&
|
|
347
|
-
!
|
|
770
|
+
!LOGICAL_OPERATORS.includes(key)
|
|
348
771
|
) {
|
|
349
772
|
out[key] = this._deepMergeFilters(out[key], value);
|
|
350
773
|
} else {
|
|
@@ -356,13 +779,27 @@ export class ApiFeatures {
|
|
|
356
779
|
}
|
|
357
780
|
|
|
358
781
|
_applySecurityFilters(filters = {}) {
|
|
359
|
-
const
|
|
782
|
+
const cleanNode = (node) => {
|
|
783
|
+
if (Array.isArray(node)) {
|
|
784
|
+
return node.map(cleanNode);
|
|
785
|
+
}
|
|
360
786
|
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
787
|
+
if (!node || typeof node !== "object") {
|
|
788
|
+
return node;
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
const result = {};
|
|
364
792
|
|
|
365
|
-
|
|
793
|
+
for (const [key, value] of Object.entries(node)) {
|
|
794
|
+
if (this._isForbiddenField(key)) continue;
|
|
795
|
+
|
|
796
|
+
result[key] = cleanNode(value);
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
return result;
|
|
800
|
+
};
|
|
801
|
+
|
|
802
|
+
return cleanNode(filters);
|
|
366
803
|
}
|
|
367
804
|
|
|
368
805
|
_normalizePopulateInput(input = "") {
|
|
@@ -388,16 +825,7 @@ export class ApiFeatures {
|
|
|
388
825
|
if (!trimmed) return;
|
|
389
826
|
|
|
390
827
|
if (trimmed.includes(".")) {
|
|
391
|
-
|
|
392
|
-
const root = { path: parts[0] };
|
|
393
|
-
let current = root;
|
|
394
|
-
|
|
395
|
-
for (const part of parts.slice(1)) {
|
|
396
|
-
current.populate = { path: part };
|
|
397
|
-
current = current.populate;
|
|
398
|
-
}
|
|
399
|
-
|
|
400
|
-
normalized.push(root);
|
|
828
|
+
normalized.push(this._dotPathToPopulate(trimmed));
|
|
401
829
|
} else {
|
|
402
830
|
normalized.push({ path: trimmed });
|
|
403
831
|
}
|
|
@@ -411,7 +839,7 @@ export class ApiFeatures {
|
|
|
411
839
|
}
|
|
412
840
|
|
|
413
841
|
if (item && typeof item === "object" && item.path) {
|
|
414
|
-
normalized.push(item);
|
|
842
|
+
normalized.push(this._normalizePopulateObject(item));
|
|
415
843
|
}
|
|
416
844
|
};
|
|
417
845
|
|
|
@@ -420,159 +848,105 @@ export class ApiFeatures {
|
|
|
420
848
|
return this._dedupePopulate(normalized);
|
|
421
849
|
}
|
|
422
850
|
|
|
423
|
-
|
|
424
|
-
const
|
|
851
|
+
_normalizePopulateObject(item) {
|
|
852
|
+
const normalized = { ...item };
|
|
425
853
|
|
|
426
|
-
|
|
427
|
-
|
|
854
|
+
if (typeof normalized.path === "string") {
|
|
855
|
+
normalized.path = normalized.path.trim();
|
|
428
856
|
}
|
|
429
857
|
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
_addPopulateStages({ model, populateItem, parentAlias, allowedPopulate }) {
|
|
434
|
-
const path = populateItem.path;
|
|
435
|
-
const fullPath = parentAlias ? `${parentAlias}.${path}` : path;
|
|
436
|
-
|
|
437
|
-
if (!this._isPopulateAllowed(fullPath, allowedPopulate)) {
|
|
438
|
-
return;
|
|
858
|
+
if (typeof normalized.select === "string") {
|
|
859
|
+
normalized.select = normalized.select.trim();
|
|
439
860
|
}
|
|
440
861
|
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
this.pipeline.push({
|
|
444
|
-
$lookup: {
|
|
445
|
-
from: info.collection,
|
|
446
|
-
localField: info.localField,
|
|
447
|
-
foreignField: "_id",
|
|
448
|
-
as: info.as,
|
|
449
|
-
},
|
|
450
|
-
});
|
|
451
|
-
|
|
452
|
-
if (!info.isArray) {
|
|
453
|
-
this.pipeline.push({
|
|
454
|
-
$unwind: {
|
|
455
|
-
path: `$${info.as}`,
|
|
456
|
-
preserveNullAndEmptyArrays: true,
|
|
457
|
-
},
|
|
458
|
-
});
|
|
862
|
+
if (normalized.populate) {
|
|
863
|
+
normalized.populate = this._normalizeNestedPopulate(normalized.populate);
|
|
459
864
|
}
|
|
460
865
|
|
|
461
|
-
|
|
462
|
-
const project = this._buildPopulateProjection(populateItem.select, info.as);
|
|
463
|
-
|
|
464
|
-
if (Object.keys(project).length) {
|
|
465
|
-
this.pipeline.push({ $project: project });
|
|
466
|
-
}
|
|
467
|
-
}
|
|
468
|
-
|
|
469
|
-
if (populateItem.populate) {
|
|
470
|
-
const nestedList = this._normalizeNestedPopulate(populateItem.populate);
|
|
471
|
-
|
|
472
|
-
for (const nested of nestedList) {
|
|
473
|
-
this._addPopulateStages({
|
|
474
|
-
model: info.refModel,
|
|
475
|
-
populateItem: nested,
|
|
476
|
-
parentAlias: info.as,
|
|
477
|
-
allowedPopulate,
|
|
478
|
-
});
|
|
479
|
-
}
|
|
480
|
-
}
|
|
866
|
+
return normalized;
|
|
481
867
|
}
|
|
482
868
|
|
|
483
869
|
_normalizeNestedPopulate(input) {
|
|
484
870
|
if (!input) return [];
|
|
485
871
|
|
|
486
|
-
if (Array.isArray(input)) {
|
|
487
|
-
return input.flatMap((item) => this._normalizeNestedPopulate(item));
|
|
488
|
-
}
|
|
489
|
-
|
|
490
872
|
if (typeof input === "string") {
|
|
491
873
|
return this._normalizePopulateInput(input);
|
|
492
874
|
}
|
|
493
875
|
|
|
876
|
+
if (Array.isArray(input)) {
|
|
877
|
+
return input
|
|
878
|
+
.flatMap((item) => {
|
|
879
|
+
if (typeof item === "string") return this._normalizePopulateInput(item);
|
|
880
|
+
if (item && typeof item === "object" && item.path) {
|
|
881
|
+
return [this._normalizePopulateObject(item)];
|
|
882
|
+
}
|
|
883
|
+
return [];
|
|
884
|
+
})
|
|
885
|
+
.filter(Boolean);
|
|
886
|
+
}
|
|
887
|
+
|
|
494
888
|
if (typeof input === "object" && input.path) {
|
|
495
|
-
return [input];
|
|
889
|
+
return [this._normalizePopulateObject(input)];
|
|
496
890
|
}
|
|
497
891
|
|
|
498
892
|
return [];
|
|
499
893
|
}
|
|
500
894
|
|
|
501
|
-
|
|
502
|
-
const
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
}
|
|
895
|
+
_dotPathToPopulate(path) {
|
|
896
|
+
const parts = path
|
|
897
|
+
.split(".")
|
|
898
|
+
.map((part) => part.trim())
|
|
899
|
+
.filter(Boolean);
|
|
507
900
|
|
|
508
|
-
const
|
|
901
|
+
const root = { path: parts[0] };
|
|
902
|
+
let current = root;
|
|
509
903
|
|
|
510
|
-
const
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
(Array.isArray(schemaPath.options?.type)
|
|
514
|
-
? schemaPath.options.type[0]?.ref
|
|
515
|
-
: undefined);
|
|
516
|
-
|
|
517
|
-
if (!refModelName) {
|
|
518
|
-
throw new HandleERROR(`Populate path has no ref: ${path}`, 400);
|
|
904
|
+
for (const part of parts.slice(1)) {
|
|
905
|
+
current.populate = { path: part };
|
|
906
|
+
current = current.populate;
|
|
519
907
|
}
|
|
520
908
|
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
const as = parentAlias ? `${parentAlias}.${path}` : path;
|
|
524
|
-
|
|
525
|
-
return {
|
|
526
|
-
refModel,
|
|
527
|
-
collection: this._resolveCollectionName(refModelName, refModel),
|
|
528
|
-
localField: as,
|
|
529
|
-
foreignField: "_id",
|
|
530
|
-
as,
|
|
531
|
-
isArray,
|
|
532
|
-
};
|
|
909
|
+
return root;
|
|
533
910
|
}
|
|
534
911
|
|
|
535
|
-
|
|
536
|
-
const
|
|
912
|
+
_dedupePopulate(items) {
|
|
913
|
+
const map = new Map();
|
|
914
|
+
|
|
915
|
+
for (const item of items) {
|
|
916
|
+
if (!item?.path) continue;
|
|
537
917
|
|
|
538
|
-
|
|
539
|
-
|
|
918
|
+
if (!map.has(item.path)) {
|
|
919
|
+
map.set(item.path, item);
|
|
920
|
+
continue;
|
|
921
|
+
}
|
|
540
922
|
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
return refModel.collection.name;
|
|
923
|
+
const existing = map.get(item.path);
|
|
924
|
+
map.set(item.path, this._mergePopulateOptions(existing, item));
|
|
544
925
|
}
|
|
545
926
|
|
|
546
|
-
return
|
|
927
|
+
return [...map.values()];
|
|
547
928
|
}
|
|
548
929
|
|
|
549
|
-
|
|
550
|
-
const
|
|
551
|
-
.split(" ")
|
|
552
|
-
.map((field) => field.trim())
|
|
553
|
-
.filter(Boolean);
|
|
554
|
-
|
|
555
|
-
const hasInclude = fields.some((field) => !field.startsWith("-"));
|
|
556
|
-
const hasExclude = fields.some((field) => field.startsWith("-"));
|
|
930
|
+
_mergePopulateOptions(a, b) {
|
|
931
|
+
const merged = { ...a, ...b };
|
|
557
932
|
|
|
558
|
-
if (
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
933
|
+
if (a.populate || b.populate) {
|
|
934
|
+
const aList = Array.isArray(a.populate)
|
|
935
|
+
? a.populate
|
|
936
|
+
: a.populate
|
|
937
|
+
? [a.populate]
|
|
938
|
+
: [];
|
|
564
939
|
|
|
565
|
-
|
|
940
|
+
const bList = Array.isArray(b.populate)
|
|
941
|
+
? b.populate
|
|
942
|
+
: b.populate
|
|
943
|
+
? [b.populate]
|
|
944
|
+
: [];
|
|
566
945
|
|
|
567
|
-
|
|
568
|
-
const cleanField = field.replace(/^-/, "");
|
|
569
|
-
|
|
570
|
-
if (this._isForbiddenField(cleanField)) continue;
|
|
571
|
-
|
|
572
|
-
project[`${alias}.${cleanField}`] = field.startsWith("-") ? 0 : 1;
|
|
946
|
+
merged.populate = this._dedupePopulate([...aList, ...bList]);
|
|
573
947
|
}
|
|
574
948
|
|
|
575
|
-
return
|
|
949
|
+
return merged;
|
|
576
950
|
}
|
|
577
951
|
|
|
578
952
|
_isPopulateAllowed(path, allowedPopulate = []) {
|
|
@@ -583,6 +957,36 @@ export class ApiFeatures {
|
|
|
583
957
|
);
|
|
584
958
|
}
|
|
585
959
|
|
|
960
|
+
_resolveCollectionName(refModelName = "") {
|
|
961
|
+
return pluralize(String(refModelName).toLowerCase());
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
_resolveRegisteredSchema(refModelName = "") {
|
|
965
|
+
const connection = this.model?.db || mongoose.connection;
|
|
966
|
+
const registeredModel =
|
|
967
|
+
connection.models?.[refModelName] || mongoose.models?.[refModelName];
|
|
968
|
+
|
|
969
|
+
return registeredModel?.schema || null;
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
_inferModelNameFromPath(path = "") {
|
|
973
|
+
let clean = String(path).split(".").pop();
|
|
974
|
+
|
|
975
|
+
clean = clean.replace(/Ids$/i, "");
|
|
976
|
+
clean = clean.replace(/Id$/i, "");
|
|
977
|
+
clean = pluralize.singular(clean);
|
|
978
|
+
|
|
979
|
+
return clean
|
|
980
|
+
.split(/[_-\s]+/)
|
|
981
|
+
.filter(Boolean)
|
|
982
|
+
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
|
983
|
+
.join("");
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
_makeTempLookupName(path = "") {
|
|
987
|
+
return `__vanta_lookup_${String(path).replace(/[^a-zA-Z0-9]/g, "_")}`;
|
|
988
|
+
}
|
|
989
|
+
|
|
586
990
|
_isForbiddenField(field) {
|
|
587
991
|
return (securityConfig.forbiddenFields || []).includes(field);
|
|
588
992
|
}
|
|
@@ -593,7 +997,11 @@ export class ApiFeatures {
|
|
|
593
997
|
"$skip" in stage ||
|
|
594
998
|
"$limit" in stage ||
|
|
595
999
|
"$sort" in stage ||
|
|
596
|
-
"$project" in stage
|
|
1000
|
+
"$project" in stage ||
|
|
1001
|
+
"$lookup" in stage ||
|
|
1002
|
+
"$unwind" in stage ||
|
|
1003
|
+
"$set" in stage ||
|
|
1004
|
+
"$unset" in stage
|
|
597
1005
|
);
|
|
598
1006
|
});
|
|
599
1007
|
}
|