@bedrockio/model 0.2.18 → 0.2.19
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/README.md +116 -7
- package/dist/cjs/include.js +12 -8
- package/dist/cjs/search.js +203 -4
- package/package.json +2 -2
- package/src/include.js +13 -8
- package/src/search.js +232 -5
- package/types/load.d.ts +1 -67
- package/types/load.d.ts.map +1 -1
- package/types/schema.d.ts +3 -7
- package/types/schema.d.ts.map +1 -1
- package/types/search.d.ts.map +1 -1
package/README.md
CHANGED
|
@@ -11,7 +11,7 @@ Bedrock utilities for model creation.
|
|
|
11
11
|
- [Scopes](#scopes)
|
|
12
12
|
- [Tuples](#tuples)
|
|
13
13
|
- [Array Extensions](#array-extensions)
|
|
14
|
-
- [
|
|
14
|
+
- [Modules](#modules)
|
|
15
15
|
- [Soft Delete](#soft-delete)
|
|
16
16
|
- [Validation](#validation)
|
|
17
17
|
- [Search](#search)
|
|
@@ -352,7 +352,7 @@ unfortunately cannot be disambiguated in this case.
|
|
|
352
352
|
|
|
353
353
|
This will manually create a new nested subschema.
|
|
354
354
|
|
|
355
|
-
##
|
|
355
|
+
## Modules
|
|
356
356
|
|
|
357
357
|
### Soft Delete
|
|
358
358
|
|
|
@@ -531,8 +531,6 @@ The method takes the following options:
|
|
|
531
531
|
- `include` - Allows [include](#includes) based population.
|
|
532
532
|
- `keyword` - A keyword to perform a [keyword search](#keyword-search).
|
|
533
533
|
- `ids` - An array of document ids to search on.
|
|
534
|
-
- `fields` - Used by [keyword search](#keyword-search). Generally for internal
|
|
535
|
-
use.
|
|
536
534
|
|
|
537
535
|
Any other fields passed in will be forwarded to `find`. The return value
|
|
538
536
|
contains the found documents in `data` and `meta` which contains metadata about
|
|
@@ -603,8 +601,8 @@ this feature a `fields` key must be present on the model definition:
|
|
|
603
601
|
}
|
|
604
602
|
```
|
|
605
603
|
|
|
606
|
-
This will use the `$or` operator to search on multiple fields. If
|
|
607
|
-
|
|
604
|
+
This will use the `$or` operator to search on multiple fields. If the model has
|
|
605
|
+
a text index applied, then a Mongo text query will be attempted:
|
|
608
606
|
|
|
609
607
|
```
|
|
610
608
|
{
|
|
@@ -614,7 +612,118 @@ not defined then a Mongo text query will be attempted:
|
|
|
614
612
|
}
|
|
615
613
|
```
|
|
616
614
|
|
|
617
|
-
|
|
615
|
+
#### Keyword Field Caching
|
|
616
|
+
|
|
617
|
+
A common problem with search is filtering on fields belonging to foreign models.
|
|
618
|
+
The search module helps to alleviate this issue by allowing a simple way to
|
|
619
|
+
cache foreign fields on the model to allow filtering on them.
|
|
620
|
+
|
|
621
|
+
```json
|
|
622
|
+
{
|
|
623
|
+
"attributes": {
|
|
624
|
+
"user": {
|
|
625
|
+
"type": "ObjectId",
|
|
626
|
+
"ref": "User"
|
|
627
|
+
}
|
|
628
|
+
},
|
|
629
|
+
"search": {
|
|
630
|
+
"cache": {
|
|
631
|
+
"cachedUserName": {
|
|
632
|
+
"type": "String",
|
|
633
|
+
"path": "user.name"
|
|
634
|
+
}
|
|
635
|
+
},
|
|
636
|
+
"fields": ["cachedUserName"]
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
```
|
|
640
|
+
|
|
641
|
+
The above example is equivalent to creating a field called `cachedUserName` and
|
|
642
|
+
updating it when a document is saved:
|
|
643
|
+
|
|
644
|
+
```js
|
|
645
|
+
schema.add({
|
|
646
|
+
cachedUserName: 'String',
|
|
647
|
+
});
|
|
648
|
+
schema.pre('save', function () {
|
|
649
|
+
await this.populate('user');
|
|
650
|
+
this.cachedUserName = this.user.name;
|
|
651
|
+
});
|
|
652
|
+
```
|
|
653
|
+
|
|
654
|
+
Specifying a foreign path in `fields` serves as a shortcut to manually defining
|
|
655
|
+
the cached fields:
|
|
656
|
+
|
|
657
|
+
```json
|
|
658
|
+
// Equivalent to the above example.
|
|
659
|
+
{
|
|
660
|
+
"attributes": {
|
|
661
|
+
"user": {
|
|
662
|
+
"type": "ObjectId",
|
|
663
|
+
"ref": "User"
|
|
664
|
+
}
|
|
665
|
+
},
|
|
666
|
+
"search": {
|
|
667
|
+
"fields": ["user.name"]
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
```
|
|
671
|
+
|
|
672
|
+
##### Syncing Search Fields
|
|
673
|
+
|
|
674
|
+
When first applying or making changes to defined cached search fields, existing
|
|
675
|
+
documents will be out of sync. The static method `syncSearchFields` is provided
|
|
676
|
+
to synchronize them:
|
|
677
|
+
|
|
678
|
+
```js
|
|
679
|
+
// Find and update any documents that do not have
|
|
680
|
+
// existing cached fields. Generally called when
|
|
681
|
+
// adding a cached field.
|
|
682
|
+
await Model.syncSearchFields();
|
|
683
|
+
|
|
684
|
+
// Force an update on ALL documents to resync their
|
|
685
|
+
// cached fields. Generally called to force a cache
|
|
686
|
+
// refresh.
|
|
687
|
+
await Model.syncSearchFields({
|
|
688
|
+
force: true,
|
|
689
|
+
});
|
|
690
|
+
```
|
|
691
|
+
|
|
692
|
+
##### Lazy Cached Fields
|
|
693
|
+
|
|
694
|
+
Cached fields can be made lazy:
|
|
695
|
+
|
|
696
|
+
```json
|
|
697
|
+
{
|
|
698
|
+
"attributes": {
|
|
699
|
+
"user": {
|
|
700
|
+
"type": "ObjectId",
|
|
701
|
+
"ref": "User"
|
|
702
|
+
}
|
|
703
|
+
},
|
|
704
|
+
"search": {
|
|
705
|
+
"cache": {
|
|
706
|
+
"cachedUserName": {
|
|
707
|
+
"lazy": true,
|
|
708
|
+
"path": "user.name"
|
|
709
|
+
}
|
|
710
|
+
},
|
|
711
|
+
"fields": ["user.name"]
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
```
|
|
715
|
+
|
|
716
|
+
Lazy cached fields will not update themselves once set. They can only be updated
|
|
717
|
+
by forcing a sync:
|
|
718
|
+
|
|
719
|
+
```js
|
|
720
|
+
await Model.syncSearchFields({
|
|
721
|
+
force: true,
|
|
722
|
+
});
|
|
723
|
+
```
|
|
724
|
+
|
|
725
|
+
Making fields lazy alleviates performance impact on writes and allows caches to
|
|
726
|
+
be updated at another time (such as a background job).
|
|
618
727
|
|
|
619
728
|
#### Search Validation
|
|
620
729
|
|
package/dist/cjs/include.js
CHANGED
|
@@ -242,15 +242,19 @@ function setNodePath(node, options) {
|
|
|
242
242
|
node[key] = null;
|
|
243
243
|
}
|
|
244
244
|
} else if (type === 'virtual') {
|
|
245
|
-
node[key] ||= {};
|
|
246
245
|
const virtual = schema.virtual(key);
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
246
|
+
// @ts-ignore
|
|
247
|
+
const ref = virtual.options.ref;
|
|
248
|
+
if (ref) {
|
|
249
|
+
node[key] ||= {};
|
|
250
|
+
setNodePath(node[key], {
|
|
251
|
+
// @ts-ignore
|
|
252
|
+
modelName: ref,
|
|
253
|
+
path: path.slice(parts.length),
|
|
254
|
+
depth: depth + 1,
|
|
255
|
+
exclude
|
|
256
|
+
});
|
|
257
|
+
}
|
|
254
258
|
halt = true;
|
|
255
259
|
} else if (type !== 'nested') {
|
|
256
260
|
throw new Error(`Unknown path on ${modelName}: ${key}.`);
|
package/dist/cjs/search.js
CHANGED
|
@@ -16,15 +16,19 @@ var _env = require("./env");
|
|
|
16
16
|
var _query = require("./query");
|
|
17
17
|
var _warn = _interopRequireDefault(require("./warn"));
|
|
18
18
|
function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
|
|
19
|
+
const {
|
|
20
|
+
SchemaTypes
|
|
21
|
+
} = _mongoose.default;
|
|
19
22
|
const {
|
|
20
23
|
ObjectId
|
|
21
24
|
} = _mongoose.default.Types;
|
|
22
25
|
function applySearch(schema, definition) {
|
|
23
26
|
validateDefinition(definition);
|
|
27
|
+
applySearchCache(schema, definition);
|
|
24
28
|
schema.static('search', function search(body = {}) {
|
|
25
29
|
const options = {
|
|
26
30
|
..._const.SEARCH_DEFAULTS,
|
|
27
|
-
...definition.search,
|
|
31
|
+
...definition.search?.query,
|
|
28
32
|
...body
|
|
29
33
|
};
|
|
30
34
|
const {
|
|
@@ -33,7 +37,6 @@ function applySearch(schema, definition) {
|
|
|
33
37
|
skip = 0,
|
|
34
38
|
limit,
|
|
35
39
|
sort,
|
|
36
|
-
fields,
|
|
37
40
|
...rest
|
|
38
41
|
} = options;
|
|
39
42
|
const query = {};
|
|
@@ -43,7 +46,7 @@ function applySearch(schema, definition) {
|
|
|
43
46
|
};
|
|
44
47
|
}
|
|
45
48
|
if (keyword) {
|
|
46
|
-
Object.assign(query, buildKeywordQuery(schema, keyword, fields));
|
|
49
|
+
Object.assign(query, buildKeywordQuery(schema, keyword, definition.search?.fields));
|
|
47
50
|
}
|
|
48
51
|
Object.assign(query, normalizeQuery(rest, schema.obj));
|
|
49
52
|
if (_env.debug) {
|
|
@@ -162,7 +165,7 @@ function buildKeywordQuery(schema, keyword, fields) {
|
|
|
162
165
|
} else if (hasTextIndex(schema)) {
|
|
163
166
|
queries = [getTextQuery(keyword)];
|
|
164
167
|
} else {
|
|
165
|
-
|
|
168
|
+
throw new Error('No keyword fields defined.');
|
|
166
169
|
}
|
|
167
170
|
if (ObjectId.isValid(keyword)) {
|
|
168
171
|
queries.push({
|
|
@@ -284,4 +287,200 @@ function parseRegexQuery(str) {
|
|
|
284
287
|
$regex,
|
|
285
288
|
$options
|
|
286
289
|
};
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Search field caching
|
|
293
|
+
|
|
294
|
+
function applySearchCache(schema, definition) {
|
|
295
|
+
normalizeCacheFields(schema, definition);
|
|
296
|
+
if (!definition.search?.cache) {
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
createCacheFields(schema, definition);
|
|
300
|
+
applyCacheHook(schema, definition);
|
|
301
|
+
schema.static('syncSearchFields', async function syncSearchFields(options = {}) {
|
|
302
|
+
assertIncludeModule(this);
|
|
303
|
+
const {
|
|
304
|
+
force
|
|
305
|
+
} = options;
|
|
306
|
+
const {
|
|
307
|
+
cache = {}
|
|
308
|
+
} = definition.search || {};
|
|
309
|
+
const paths = getCachePaths(definition);
|
|
310
|
+
const cachedFields = Object.keys(cache);
|
|
311
|
+
if (!cachedFields.length) {
|
|
312
|
+
throw new Error('No search fields to sync.');
|
|
313
|
+
}
|
|
314
|
+
const query = {};
|
|
315
|
+
if (!force) {
|
|
316
|
+
const $or = Object.entries(cache).map(entry => {
|
|
317
|
+
const [cachedField, def] = entry;
|
|
318
|
+
const {
|
|
319
|
+
base
|
|
320
|
+
} = def;
|
|
321
|
+
return {
|
|
322
|
+
[base]: {
|
|
323
|
+
$exists: true
|
|
324
|
+
},
|
|
325
|
+
[cachedField]: {
|
|
326
|
+
$exists: false
|
|
327
|
+
}
|
|
328
|
+
};
|
|
329
|
+
});
|
|
330
|
+
query.$or = $or;
|
|
331
|
+
}
|
|
332
|
+
const docs = await this.find(query).include(paths);
|
|
333
|
+
const ops = docs.map(doc => {
|
|
334
|
+
return {
|
|
335
|
+
updateOne: {
|
|
336
|
+
filter: {
|
|
337
|
+
_id: doc._id
|
|
338
|
+
},
|
|
339
|
+
update: {
|
|
340
|
+
$set: getUpdates(doc, paths, definition)
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
};
|
|
344
|
+
});
|
|
345
|
+
return await this.bulkWrite(ops);
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
function normalizeCacheFields(schema, definition) {
|
|
349
|
+
const {
|
|
350
|
+
fields,
|
|
351
|
+
cache = {}
|
|
352
|
+
} = definition.search || {};
|
|
353
|
+
if (!fields) {
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
const normalized = [];
|
|
357
|
+
for (let path of fields) {
|
|
358
|
+
if (isForeignField(schema, path)) {
|
|
359
|
+
const cacheName = generateCacheFieldName(path);
|
|
360
|
+
const type = resolveSchemaType(schema, path);
|
|
361
|
+
const base = getRefBase(schema, path);
|
|
362
|
+
cache[cacheName] = {
|
|
363
|
+
type,
|
|
364
|
+
base,
|
|
365
|
+
path: path
|
|
366
|
+
};
|
|
367
|
+
normalized.push(cacheName);
|
|
368
|
+
} else {
|
|
369
|
+
normalized.push(path);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
definition.search.cache = cache;
|
|
373
|
+
definition.search.fields = normalized;
|
|
374
|
+
}
|
|
375
|
+
function createCacheFields(schema, definition) {
|
|
376
|
+
for (let [cachedField, def] of Object.entries(definition.search.cache)) {
|
|
377
|
+
// Fall back to string type for virtuals or not defined.
|
|
378
|
+
const {
|
|
379
|
+
type = 'String'
|
|
380
|
+
} = def;
|
|
381
|
+
schema.add({
|
|
382
|
+
[cachedField]: type
|
|
383
|
+
});
|
|
384
|
+
schema.obj[cachedField] = {
|
|
385
|
+
type,
|
|
386
|
+
readAccess: 'none'
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
function applyCacheHook(schema, definition) {
|
|
391
|
+
schema.pre('save', async function () {
|
|
392
|
+
assertIncludeModule(this.constructor);
|
|
393
|
+
assertAssignModule(this.constructor);
|
|
394
|
+
const doc = this;
|
|
395
|
+
const paths = getCachePaths(definition, (cachedField, def) => {
|
|
396
|
+
if (def.lazy) {
|
|
397
|
+
return !(0, _lodash.get)(doc, cachedField);
|
|
398
|
+
} else {
|
|
399
|
+
return true;
|
|
400
|
+
}
|
|
401
|
+
});
|
|
402
|
+
await this.include(paths);
|
|
403
|
+
this.assign(getUpdates(this, paths, definition));
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
function resolveSchemaType(schema, path) {
|
|
407
|
+
if (!path.includes('.')) {
|
|
408
|
+
return (0, _lodash.get)(schema.obj, path)?.type;
|
|
409
|
+
}
|
|
410
|
+
const field = getRefField(schema, path);
|
|
411
|
+
if (field) {
|
|
412
|
+
const {
|
|
413
|
+
type,
|
|
414
|
+
rest
|
|
415
|
+
} = field;
|
|
416
|
+
const Model = _mongoose.default.models[type.options.ref];
|
|
417
|
+
return resolveSchemaType(Model.schema, rest.join('.'));
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
function isForeignField(schema, path) {
|
|
421
|
+
if (!path.includes('.')) {
|
|
422
|
+
return false;
|
|
423
|
+
}
|
|
424
|
+
return !!getRefField(schema, path);
|
|
425
|
+
}
|
|
426
|
+
function getRefBase(schema, path) {
|
|
427
|
+
const field = getRefField(schema, path);
|
|
428
|
+
if (field) {
|
|
429
|
+
return field.base.join('.');
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
function getRefField(schema, path) {
|
|
433
|
+
const split = path.split('.');
|
|
434
|
+
for (let i = 1; i < split.length; i++) {
|
|
435
|
+
const base = split.slice(0, i);
|
|
436
|
+
const rest = split.slice(i);
|
|
437
|
+
const type = schema.path(base);
|
|
438
|
+
if (type instanceof SchemaTypes.ObjectId) {
|
|
439
|
+
return {
|
|
440
|
+
type,
|
|
441
|
+
base,
|
|
442
|
+
rest
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
function getUpdates(doc, paths, definition) {
|
|
448
|
+
const updates = {};
|
|
449
|
+
const entries = Object.entries(definition.search.cache).filter(entry => {
|
|
450
|
+
return paths.includes(entry[1].path);
|
|
451
|
+
});
|
|
452
|
+
for (let [cachedField, def] of entries) {
|
|
453
|
+
// doc.get will not return virtuals (even with specified options),
|
|
454
|
+
// so use lodash to ensure they are included here.
|
|
455
|
+
// https://mongoosejs.com/docs/api/document.html#Document.prototype.get()
|
|
456
|
+
updates[cachedField] = (0, _lodash.get)(doc, def.path);
|
|
457
|
+
}
|
|
458
|
+
return updates;
|
|
459
|
+
}
|
|
460
|
+
function getCachePaths(definition, filter) {
|
|
461
|
+
filter ||= () => true;
|
|
462
|
+
const {
|
|
463
|
+
cache
|
|
464
|
+
} = definition.search || {};
|
|
465
|
+
return Object.entries(cache).filter(entry => {
|
|
466
|
+
return filter(...entry);
|
|
467
|
+
}).map(entry => {
|
|
468
|
+
return entry[1].path;
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
function generateCacheFieldName(field) {
|
|
472
|
+
return `cached${(0, _lodash.upperFirst)((0, _lodash.camelCase)(field))}`;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// Assertions
|
|
476
|
+
|
|
477
|
+
function assertIncludeModule(Model) {
|
|
478
|
+
if (!Model.schema.methods.include) {
|
|
479
|
+
throw new Error('Include module is required for cached search fields.');
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
function assertAssignModule(Model) {
|
|
483
|
+
if (!Model.schema.methods.assign) {
|
|
484
|
+
throw new Error('Assign module is required for cached search fields.');
|
|
485
|
+
}
|
|
287
486
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bedrockio/model",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.19",
|
|
4
4
|
"description": "Bedrock utilities for model creation.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"scripts": {
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
},
|
|
32
32
|
"peerDependencies": {
|
|
33
33
|
"@bedrockio/yada": "^1.0.39",
|
|
34
|
-
"mongoose": "^
|
|
34
|
+
"mongoose": "^7.6.4"
|
|
35
35
|
},
|
|
36
36
|
"devDependencies": {
|
|
37
37
|
"@babel/cli": "^7.20.7",
|
package/src/include.js
CHANGED
|
@@ -228,15 +228,20 @@ function setNodePath(node, options) {
|
|
|
228
228
|
node[key] = null;
|
|
229
229
|
}
|
|
230
230
|
} else if (type === 'virtual') {
|
|
231
|
-
node[key] ||= {};
|
|
232
231
|
const virtual = schema.virtual(key);
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
232
|
+
// @ts-ignore
|
|
233
|
+
const ref = virtual.options.ref;
|
|
234
|
+
|
|
235
|
+
if (ref) {
|
|
236
|
+
node[key] ||= {};
|
|
237
|
+
setNodePath(node[key], {
|
|
238
|
+
// @ts-ignore
|
|
239
|
+
modelName: ref,
|
|
240
|
+
path: path.slice(parts.length),
|
|
241
|
+
depth: depth + 1,
|
|
242
|
+
exclude,
|
|
243
|
+
});
|
|
244
|
+
}
|
|
240
245
|
halt = true;
|
|
241
246
|
} else if (type !== 'nested') {
|
|
242
247
|
throw new Error(`Unknown path on ${modelName}: ${key}.`);
|
package/src/search.js
CHANGED
|
@@ -1,7 +1,15 @@
|
|
|
1
1
|
import yd from '@bedrockio/yada';
|
|
2
2
|
import logger from '@bedrockio/logger';
|
|
3
3
|
import mongoose from 'mongoose';
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
get,
|
|
6
|
+
pick,
|
|
7
|
+
isEmpty,
|
|
8
|
+
camelCase,
|
|
9
|
+
upperFirst,
|
|
10
|
+
escapeRegExp,
|
|
11
|
+
isPlainObject,
|
|
12
|
+
} from 'lodash';
|
|
5
13
|
|
|
6
14
|
import { isDateField, isNumberField, getField } from './utils';
|
|
7
15
|
import { SEARCH_DEFAULTS } from './const';
|
|
@@ -11,19 +19,21 @@ import { wrapQuery } from './query';
|
|
|
11
19
|
|
|
12
20
|
import warn from './warn';
|
|
13
21
|
|
|
22
|
+
const { SchemaTypes } = mongoose;
|
|
14
23
|
const { ObjectId } = mongoose.Types;
|
|
15
24
|
|
|
16
25
|
export function applySearch(schema, definition) {
|
|
17
26
|
validateDefinition(definition);
|
|
27
|
+
applySearchCache(schema, definition);
|
|
18
28
|
|
|
19
29
|
schema.static('search', function search(body = {}) {
|
|
20
30
|
const options = {
|
|
21
31
|
...SEARCH_DEFAULTS,
|
|
22
|
-
...definition.search,
|
|
32
|
+
...definition.search?.query,
|
|
23
33
|
...body,
|
|
24
34
|
};
|
|
25
35
|
|
|
26
|
-
const { ids, keyword, skip = 0, limit, sort,
|
|
36
|
+
const { ids, keyword, skip = 0, limit, sort, ...rest } = options;
|
|
27
37
|
|
|
28
38
|
const query = {};
|
|
29
39
|
|
|
@@ -32,7 +42,10 @@ export function applySearch(schema, definition) {
|
|
|
32
42
|
}
|
|
33
43
|
|
|
34
44
|
if (keyword) {
|
|
35
|
-
Object.assign(
|
|
45
|
+
Object.assign(
|
|
46
|
+
query,
|
|
47
|
+
buildKeywordQuery(schema, keyword, definition.search?.fields)
|
|
48
|
+
);
|
|
36
49
|
}
|
|
37
50
|
|
|
38
51
|
Object.assign(query, normalizeQuery(rest, schema.obj));
|
|
@@ -172,7 +185,7 @@ function buildKeywordQuery(schema, keyword, fields) {
|
|
|
172
185
|
} else if (hasTextIndex(schema)) {
|
|
173
186
|
queries = [getTextQuery(keyword)];
|
|
174
187
|
} else {
|
|
175
|
-
|
|
188
|
+
throw new Error('No keyword fields defined.');
|
|
176
189
|
}
|
|
177
190
|
|
|
178
191
|
if (ObjectId.isValid(keyword)) {
|
|
@@ -300,3 +313,217 @@ function parseRegexQuery(str) {
|
|
|
300
313
|
$options,
|
|
301
314
|
};
|
|
302
315
|
}
|
|
316
|
+
|
|
317
|
+
// Search field caching
|
|
318
|
+
|
|
319
|
+
function applySearchCache(schema, definition) {
|
|
320
|
+
normalizeCacheFields(schema, definition);
|
|
321
|
+
|
|
322
|
+
if (!definition.search?.cache) {
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
createCacheFields(schema, definition);
|
|
327
|
+
applyCacheHook(schema, definition);
|
|
328
|
+
|
|
329
|
+
schema.static(
|
|
330
|
+
'syncSearchFields',
|
|
331
|
+
async function syncSearchFields(options = {}) {
|
|
332
|
+
assertIncludeModule(this);
|
|
333
|
+
|
|
334
|
+
const { force } = options;
|
|
335
|
+
const { cache = {} } = definition.search || {};
|
|
336
|
+
|
|
337
|
+
const paths = getCachePaths(definition);
|
|
338
|
+
|
|
339
|
+
const cachedFields = Object.keys(cache);
|
|
340
|
+
|
|
341
|
+
if (!cachedFields.length) {
|
|
342
|
+
throw new Error('No search fields to sync.');
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const query = {};
|
|
346
|
+
|
|
347
|
+
if (!force) {
|
|
348
|
+
const $or = Object.entries(cache).map((entry) => {
|
|
349
|
+
const [cachedField, def] = entry;
|
|
350
|
+
const { base } = def;
|
|
351
|
+
return {
|
|
352
|
+
[base]: {
|
|
353
|
+
$exists: true,
|
|
354
|
+
},
|
|
355
|
+
[cachedField]: {
|
|
356
|
+
$exists: false,
|
|
357
|
+
},
|
|
358
|
+
};
|
|
359
|
+
});
|
|
360
|
+
query.$or = $or;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const docs = await this.find(query).include(paths);
|
|
364
|
+
|
|
365
|
+
const ops = docs.map((doc) => {
|
|
366
|
+
return {
|
|
367
|
+
updateOne: {
|
|
368
|
+
filter: {
|
|
369
|
+
_id: doc._id,
|
|
370
|
+
},
|
|
371
|
+
update: {
|
|
372
|
+
$set: getUpdates(doc, paths, definition),
|
|
373
|
+
},
|
|
374
|
+
},
|
|
375
|
+
};
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
return await this.bulkWrite(ops);
|
|
379
|
+
}
|
|
380
|
+
);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function normalizeCacheFields(schema, definition) {
|
|
384
|
+
const { fields, cache = {} } = definition.search || {};
|
|
385
|
+
if (!fields) {
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const normalized = [];
|
|
390
|
+
|
|
391
|
+
for (let path of fields) {
|
|
392
|
+
if (isForeignField(schema, path)) {
|
|
393
|
+
const cacheName = generateCacheFieldName(path);
|
|
394
|
+
const type = resolveSchemaType(schema, path);
|
|
395
|
+
const base = getRefBase(schema, path);
|
|
396
|
+
cache[cacheName] = {
|
|
397
|
+
type,
|
|
398
|
+
base,
|
|
399
|
+
path: path,
|
|
400
|
+
};
|
|
401
|
+
normalized.push(cacheName);
|
|
402
|
+
} else {
|
|
403
|
+
normalized.push(path);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
definition.search.cache = cache;
|
|
408
|
+
definition.search.fields = normalized;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function createCacheFields(schema, definition) {
|
|
412
|
+
for (let [cachedField, def] of Object.entries(definition.search.cache)) {
|
|
413
|
+
// Fall back to string type for virtuals or not defined.
|
|
414
|
+
const { type = 'String' } = def;
|
|
415
|
+
schema.add({
|
|
416
|
+
[cachedField]: type,
|
|
417
|
+
});
|
|
418
|
+
schema.obj[cachedField] = {
|
|
419
|
+
type,
|
|
420
|
+
readAccess: 'none',
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function applyCacheHook(schema, definition) {
|
|
426
|
+
schema.pre('save', async function () {
|
|
427
|
+
assertIncludeModule(this.constructor);
|
|
428
|
+
assertAssignModule(this.constructor);
|
|
429
|
+
|
|
430
|
+
const doc = this;
|
|
431
|
+
const paths = getCachePaths(definition, (cachedField, def) => {
|
|
432
|
+
if (def.lazy) {
|
|
433
|
+
return !get(doc, cachedField);
|
|
434
|
+
} else {
|
|
435
|
+
return true;
|
|
436
|
+
}
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
await this.include(paths);
|
|
440
|
+
this.assign(getUpdates(this, paths, definition));
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
function resolveSchemaType(schema, path) {
|
|
445
|
+
if (!path.includes('.')) {
|
|
446
|
+
return get(schema.obj, path)?.type;
|
|
447
|
+
}
|
|
448
|
+
const field = getRefField(schema, path);
|
|
449
|
+
if (field) {
|
|
450
|
+
const { type, rest } = field;
|
|
451
|
+
const Model = mongoose.models[type.options.ref];
|
|
452
|
+
return resolveSchemaType(Model.schema, rest.join('.'));
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function isForeignField(schema, path) {
|
|
457
|
+
if (!path.includes('.')) {
|
|
458
|
+
return false;
|
|
459
|
+
}
|
|
460
|
+
return !!getRefField(schema, path);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function getRefBase(schema, path) {
|
|
464
|
+
const field = getRefField(schema, path);
|
|
465
|
+
if (field) {
|
|
466
|
+
return field.base.join('.');
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function getRefField(schema, path) {
|
|
471
|
+
const split = path.split('.');
|
|
472
|
+
for (let i = 1; i < split.length; i++) {
|
|
473
|
+
const base = split.slice(0, i);
|
|
474
|
+
const rest = split.slice(i);
|
|
475
|
+
const type = schema.path(base);
|
|
476
|
+
if (type instanceof SchemaTypes.ObjectId) {
|
|
477
|
+
return {
|
|
478
|
+
type,
|
|
479
|
+
base,
|
|
480
|
+
rest,
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function getUpdates(doc, paths, definition) {
|
|
487
|
+
const updates = {};
|
|
488
|
+
|
|
489
|
+
const entries = Object.entries(definition.search.cache).filter((entry) => {
|
|
490
|
+
return paths.includes(entry[1].path);
|
|
491
|
+
});
|
|
492
|
+
for (let [cachedField, def] of entries) {
|
|
493
|
+
// doc.get will not return virtuals (even with specified options),
|
|
494
|
+
// so use lodash to ensure they are included here.
|
|
495
|
+
// https://mongoosejs.com/docs/api/document.html#Document.prototype.get()
|
|
496
|
+
updates[cachedField] = get(doc, def.path);
|
|
497
|
+
}
|
|
498
|
+
return updates;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function getCachePaths(definition, filter) {
|
|
502
|
+
filter ||= () => true;
|
|
503
|
+
const { cache } = definition.search || {};
|
|
504
|
+
return Object.entries(cache)
|
|
505
|
+
.filter((entry) => {
|
|
506
|
+
return filter(...entry);
|
|
507
|
+
})
|
|
508
|
+
.map((entry) => {
|
|
509
|
+
return entry[1].path;
|
|
510
|
+
});
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
function generateCacheFieldName(field) {
|
|
514
|
+
return `cached${upperFirst(camelCase(field))}`;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// Assertions
|
|
518
|
+
|
|
519
|
+
function assertIncludeModule(Model) {
|
|
520
|
+
if (!Model.schema.methods.include) {
|
|
521
|
+
throw new Error('Include module is required for cached search fields.');
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
function assertAssignModule(Model) {
|
|
526
|
+
if (!Model.schema.methods.assign) {
|
|
527
|
+
throw new Error('Assign module is required for cached search fields.');
|
|
528
|
+
}
|
|
529
|
+
}
|
package/types/load.d.ts
CHANGED
|
@@ -4,73 +4,7 @@
|
|
|
4
4
|
* @param {string} name
|
|
5
5
|
* @returns mongoose.Model
|
|
6
6
|
*/
|
|
7
|
-
export function loadModel(definition: object, name: string):
|
|
8
|
-
[x: string]: any;
|
|
9
|
-
}, {
|
|
10
|
-
autoIndex?: boolean;
|
|
11
|
-
autoCreate?: boolean;
|
|
12
|
-
bufferCommands?: boolean;
|
|
13
|
-
bufferTimeoutMS?: number;
|
|
14
|
-
capped?: number | boolean | {
|
|
15
|
-
size?: number;
|
|
16
|
-
max?: number;
|
|
17
|
-
autoIndexId?: boolean;
|
|
18
|
-
};
|
|
19
|
-
collation?: mongoose.mongo.CollationOptions;
|
|
20
|
-
collectionOptions?: mongoose.mongo.CreateCollectionOptions;
|
|
21
|
-
timeseries?: mongoose.mongo.TimeSeriesCollectionOptions;
|
|
22
|
-
expireAfterSeconds?: number;
|
|
23
|
-
expires?: string | number;
|
|
24
|
-
collection?: string;
|
|
25
|
-
discriminatorKey?: string;
|
|
26
|
-
excludeIndexes?: boolean;
|
|
27
|
-
id?: boolean;
|
|
28
|
-
_id?: boolean;
|
|
29
|
-
minimize?: boolean;
|
|
30
|
-
optimisticConcurrency?: boolean;
|
|
31
|
-
pluginTags?: string[];
|
|
32
|
-
read?: string;
|
|
33
|
-
writeConcern?: mongoose.mongo.WriteConcern;
|
|
34
|
-
safe?: boolean | {
|
|
35
|
-
w?: string | number;
|
|
36
|
-
wtimeout?: number;
|
|
37
|
-
j?: boolean;
|
|
38
|
-
};
|
|
39
|
-
shardKey?: Record<string, unknown>;
|
|
40
|
-
strict?: boolean | "throw";
|
|
41
|
-
strictQuery?: boolean | "throw";
|
|
42
|
-
toJSON: {
|
|
43
|
-
getters: boolean;
|
|
44
|
-
versionKey: boolean;
|
|
45
|
-
transform: (doc: any, ret: any, options: any) => void;
|
|
46
|
-
} | mongoose.ToObjectOptions<any>;
|
|
47
|
-
toObject: {
|
|
48
|
-
getters: boolean;
|
|
49
|
-
versionKey: boolean;
|
|
50
|
-
transform: (doc: any, ret: any, options: any) => void;
|
|
51
|
-
} | mongoose.ToObjectOptions<any>;
|
|
52
|
-
typeKey?: string;
|
|
53
|
-
validateBeforeSave?: boolean;
|
|
54
|
-
validateModifiedOnly?: boolean;
|
|
55
|
-
versionKey?: string | boolean;
|
|
56
|
-
selectPopulatedPaths?: boolean;
|
|
57
|
-
skipVersioning?: {
|
|
58
|
-
[key: string]: boolean;
|
|
59
|
-
};
|
|
60
|
-
storeSubdocValidationError?: boolean;
|
|
61
|
-
timestamps: boolean | mongoose.SchemaTimestampsConfig;
|
|
62
|
-
suppressReservedKeysWarning?: boolean;
|
|
63
|
-
statics?: {
|
|
64
|
-
[x: string]: any;
|
|
65
|
-
};
|
|
66
|
-
methods?: any;
|
|
67
|
-
query?: any;
|
|
68
|
-
castNonArrays?: boolean;
|
|
69
|
-
virtuals?: mongoose.SchemaOptionsVirtualsPropertyType<any, any, any>;
|
|
70
|
-
overwriteModels?: boolean;
|
|
71
|
-
}, any, any>> & {
|
|
72
|
-
[x: string]: any;
|
|
73
|
-
};
|
|
7
|
+
export function loadModel(definition: object, name: string): any;
|
|
74
8
|
/**
|
|
75
9
|
* Loads all model definitions in the given directory.
|
|
76
10
|
* Returns the full loaded model set.
|
package/types/load.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"load.d.ts","sourceRoot":"","sources":["../src/load.js"],"names":[],"mappings":"AAQA;;;;;GAKG;AACH,sCAJW,MAAM,QACN,MAAM
|
|
1
|
+
{"version":3,"file":"load.d.ts","sourceRoot":"","sources":["../src/load.js"],"names":[],"mappings":"AAQA;;;;;GAKG;AACH,sCAJW,MAAM,QACN,MAAM,OAahB;AAED;;;;GAIG;AACH,sCAFW,MAAM,mBAkBhB"}
|
package/types/schema.d.ts
CHANGED
|
@@ -6,9 +6,7 @@
|
|
|
6
6
|
* @param {mongoose.SchemaOptions} options
|
|
7
7
|
* @returns mongoose.Schema
|
|
8
8
|
*/
|
|
9
|
-
export function createSchema(definition: object, options?: mongoose.SchemaOptions): mongoose.Schema<any, mongoose.Model<any, any, any, any, any, any>, any, any, any, {
|
|
10
|
-
[x: string]: any;
|
|
11
|
-
}, {
|
|
9
|
+
export function createSchema(definition: object, options?: mongoose.SchemaOptions): mongoose.Schema<any, mongoose.Model<any, any, any, any, any, any>, any, any, any, any, {
|
|
12
10
|
autoIndex?: boolean;
|
|
13
11
|
autoCreate?: boolean;
|
|
14
12
|
bufferCommands?: boolean;
|
|
@@ -62,10 +60,8 @@ export function createSchema(definition: object, options?: mongoose.SchemaOption
|
|
|
62
60
|
storeSubdocValidationError?: boolean;
|
|
63
61
|
timestamps: boolean | mongoose.SchemaTimestampsConfig;
|
|
64
62
|
suppressReservedKeysWarning?: boolean;
|
|
65
|
-
statics?: {
|
|
66
|
-
|
|
67
|
-
};
|
|
68
|
-
methods?: any;
|
|
63
|
+
statics?: mongoose.AddThisParameter<any, mongoose.Model<any, {}, {}, {}, any, any>>;
|
|
64
|
+
methods?: mongoose.AddThisParameter<any, any> & mongoose.AnyObject;
|
|
69
65
|
query?: any;
|
|
70
66
|
castNonArrays?: boolean;
|
|
71
67
|
virtuals?: mongoose.SchemaOptionsVirtualsPropertyType<any, any, any>;
|
package/types/schema.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"schema.d.ts","sourceRoot":"","sources":["../src/schema.js"],"names":[],"mappings":"AAoBA;;;;;;;GAOG;AACH,yCAJW,MAAM,YACN,SAAS,aAAa
|
|
1
|
+
{"version":3,"file":"schema.d.ts","sourceRoot":"","sources":["../src/schema.js"],"names":[],"mappings":"AAoBA;;;;;;;GAOG;AACH,yCAJW,MAAM,YACN,SAAS,aAAa;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;aAuChC;AAED,iEAsBC"}
|
package/types/search.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"search.d.ts","sourceRoot":"","sources":["../src/search.js"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"search.d.ts","sourceRoot":"","sources":["../src/search.js"],"names":[],"mappings":"AAwBA,gEAuDC;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAyBC"}
|