@bedrockio/model 0.2.17 → 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.
@@ -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
- queries = [];
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.17",
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": "^6.9.0 || ^7.6.4"
34
+ "mongoose": "^7.6.4"
35
35
  },
36
36
  "devDependencies": {
37
37
  "@babel/cli": "^7.20.7",
@@ -13,9 +13,8 @@ export function applyDeleteHooks(schema, definition) {
13
13
  return;
14
14
  }
15
15
 
16
- const cleanLocal = validateCleanLocal(deleteHooks, schema);
17
- const cleanForeign = validateCleanForeign(deleteHooks);
18
16
  const errorHook = validateError(deleteHooks);
17
+ const cleanHooks = validateCleanHooks(deleteHooks, schema);
19
18
 
20
19
  let references;
21
20
 
@@ -27,24 +26,21 @@ export function applyDeleteHooks(schema, definition) {
27
26
  references ||= getAllReferences(this);
28
27
  await errorOnForeignReferences(this, {
29
28
  errorHook,
30
- cleanForeign,
29
+ cleanHooks,
31
30
  references,
32
31
  });
33
32
  }
34
33
  try {
35
- await deleteLocalReferences(this, cleanLocal);
36
- await deleteForeignReferences(this, cleanForeign);
34
+ await deleteReferences(this, cleanHooks);
37
35
  } catch (error) {
38
- await restoreLocalReferences(this, cleanLocal);
39
- await restoreForeignReferences(this);
36
+ await restoreReferences(this, cleanHooks);
40
37
  throw error;
41
38
  }
42
39
  await deleteFn.apply(this, arguments);
43
40
  });
44
41
 
45
42
  schema.method('restore', async function () {
46
- await restoreLocalReferences(this, cleanLocal);
47
- await restoreForeignReferences(this);
43
+ await restoreReferences(this, cleanHooks);
48
44
  await restoreFn.apply(this, arguments);
49
45
  });
50
46
 
@@ -60,45 +56,53 @@ export function applyDeleteHooks(schema, definition) {
60
56
 
61
57
  // Clean Hook
62
58
 
63
- function validateCleanLocal(deleteHooks, schema) {
64
- let { local } = deleteHooks.clean || {};
65
- if (!local) {
66
- return;
67
- }
68
- if (typeof local !== 'string' && !Array.isArray(local)) {
69
- throw new Error('Local delete hook must be an array.');
59
+ function validateCleanHooks(deleteHooks, schema) {
60
+ const { clean } = deleteHooks;
61
+ if (!clean) {
62
+ return [];
70
63
  }
71
- if (typeof local === 'string') {
72
- local = [local];
64
+ if (!Array.isArray(clean)) {
65
+ throw new Error('Delete clean hook must be an array.');
73
66
  }
74
- for (let name of local) {
75
- const pathType = schema.pathType(name);
76
- if (pathType !== 'real') {
77
- throw new Error(`Delete hook has invalid local reference "${name}".`);
67
+
68
+ for (let hook of clean) {
69
+ const { ref, path, paths } = hook;
70
+ if (path && typeof path !== 'string') {
71
+ throw new Error('Clean hook path must be a string.');
72
+ } else if (paths && !Array.isArray(paths)) {
73
+ throw new Error('Clean hook paths must be an array.');
74
+ } else if (!path && !paths) {
75
+ throw new Error('Clean hook must define either "path" or "paths".');
76
+ } else if (path && paths) {
77
+ throw new Error('Clean hook may not define both "path" or "paths".');
78
+ } else if (ref && typeof ref !== 'string') {
79
+ throw new Error('Clean hook ref must be a string.');
80
+ } else if (!ref) {
81
+ validateLocalCleanHook(hook, schema);
78
82
  }
79
83
  }
80
- return local;
84
+
85
+ return clean;
81
86
  }
82
87
 
83
- function validateCleanForeign(deleteHooks) {
84
- const { foreign } = deleteHooks.clean || {};
85
- if (!foreign) {
86
- return;
87
- }
88
- if (typeof foreign !== 'object') {
89
- throw new Error('Foreign delete hook must be an object.');
90
- }
91
- for (let [modelName, arg] of Object.entries(foreign)) {
92
- if (typeof arg === 'object') {
93
- const { $and, $or } = arg;
94
- if ($and && $or) {
95
- throw new Error(
96
- `Cannot define both $or and $and in a delete hook for model ${modelName}.`
97
- );
98
- }
88
+ function validateLocalCleanHook(hook, schema) {
89
+ const paths = getHookPaths(hook);
90
+ for (let path of paths) {
91
+ if (schema.pathType(path) !== 'real') {
92
+ throw new Error(`Invalid reference in local delete hook: "${path}".`);
99
93
  }
100
94
  }
101
- return foreign;
95
+ }
96
+
97
+ function getHookPaths(hook) {
98
+ const { path, paths } = hook;
99
+ if (path) {
100
+ return [path];
101
+ } else if (paths) {
102
+ return paths;
103
+ } else {
104
+ return [];
105
+ }
102
106
  }
103
107
 
104
108
  function validateError(deleteHooks) {
@@ -170,11 +174,17 @@ async function errorOnForeignReferences(doc, options) {
170
174
  }
171
175
 
172
176
  function referenceIsAllowed(model, options) {
173
- const { cleanForeign = {} } = options;
174
- const { only, except } = options?.errorHook || {};
175
- if (model.modelName in cleanForeign) {
177
+ const { modelName } = model;
178
+ const { cleanHooks } = options;
179
+
180
+ const hasCleanHook = cleanHooks.some((hook) => {
181
+ return hook.ref === modelName;
182
+ });
183
+ if (hasCleanHook) {
176
184
  return true;
177
185
  }
186
+
187
+ const { only, except } = options?.errorHook || {};
178
188
  if (only) {
179
189
  return !only.includes(model.modelName);
180
190
  } else if (except) {
@@ -227,16 +237,54 @@ function getModelReferences(model, targetName) {
227
237
  return paths;
228
238
  }
229
239
 
230
- // Deletion
240
+ // Delete
231
241
 
232
- async function deleteLocalReferences(doc, arr) {
233
- if (!arr) {
234
- return;
242
+ async function deleteReferences(doc, hooks) {
243
+ for (let hook of hooks) {
244
+ if (hook.ref) {
245
+ await deleteForeignReferences(doc, hook);
246
+ } else {
247
+ await deleteLocalReferences(doc, hook);
248
+ }
235
249
  }
236
- for (let name of arr) {
237
- await doc.populate(name);
250
+ }
251
+
252
+ async function deleteForeignReferences(doc, hook) {
253
+ const { ref, path, paths, query } = hook;
254
+
255
+ const { id } = doc;
256
+ if (!id) {
257
+ throw new Error(`Refusing to apply delete hook to document without id.`);
258
+ }
259
+
260
+ const Model = mongoose.models[ref];
261
+ if (!Model) {
262
+ throw new Error(`Unknown model: "${ref}".`);
263
+ }
264
+
265
+ if (path) {
266
+ await runDeletes(Model, doc, {
267
+ ...query,
268
+ [path]: id,
269
+ });
270
+ } else if (paths) {
271
+ await runDeletes(Model, doc, {
272
+ $or: paths.map((refName) => {
273
+ return {
274
+ ...query,
275
+ [refName]: id,
276
+ };
277
+ }),
278
+ });
279
+ }
280
+ }
281
+
282
+ async function deleteLocalReferences(doc, hook) {
283
+ const paths = getHookPaths(hook);
284
+ await doc.populate(paths);
285
+ for (let path of paths) {
286
+ const value = doc.get(path);
238
287
 
239
- const value = doc.get(name);
240
288
  if (!value) {
241
289
  continue;
242
290
  }
@@ -248,39 +296,6 @@ async function deleteLocalReferences(doc, arr) {
248
296
  }
249
297
  }
250
298
 
251
- async function deleteForeignReferences(doc, refs) {
252
- if (!refs) {
253
- return;
254
- }
255
- const { id } = doc;
256
- if (!id) {
257
- throw new Error(`Refusing to apply delete hook to document without id.`);
258
- }
259
- for (let [modelName, arg] of Object.entries(refs)) {
260
- const Model = mongoose.models[modelName];
261
- if (typeof arg === 'string') {
262
- await runDeletes(Model, doc, {
263
- [arg]: id,
264
- });
265
- } else if (Array.isArray(arg)) {
266
- await runDeletes(Model, doc, {
267
- $or: mapArrayQuery(arg, id),
268
- });
269
- } else {
270
- const { $and, $or } = arg;
271
- if ($and) {
272
- await runDeletes(Model, doc, {
273
- $and: mapArrayQuery($and, id),
274
- });
275
- } else if ($or) {
276
- await runDeletes(Model, doc, {
277
- $or: mapArrayQuery($or, id),
278
- });
279
- }
280
- }
281
- }
282
- }
283
-
284
299
  async function runDeletes(Model, refDoc, query) {
285
300
  const docs = await Model.find(query);
286
301
  for (let doc of docs) {
@@ -292,33 +307,14 @@ async function runDeletes(Model, refDoc, query) {
292
307
  }
293
308
  }
294
309
 
295
- function mapArrayQuery(arr, id) {
296
- return arr.map((refName) => {
297
- return {
298
- [refName]: id,
299
- };
300
- });
301
- }
302
-
303
310
  // Restore
304
311
 
305
- async function restoreLocalReferences(refDoc, arr) {
306
- if (!arr) {
307
- return;
308
- }
309
- for (let name of arr) {
310
- const { ref } = getInnerField(refDoc.constructor.schema.obj, name);
311
- const value = refDoc.get(name);
312
- const ids = Array.isArray(value) ? value : [value];
313
- const Model = mongoose.models[ref];
314
-
315
- // @ts-ignore
316
- const docs = await Model.findDeleted({
317
- _id: { $in: ids },
318
- });
319
-
320
- for (let doc of docs) {
321
- await doc.restore();
312
+ async function restoreReferences(doc, hooks) {
313
+ for (let hook of hooks) {
314
+ if (hook.ref) {
315
+ await restoreForeignReferences(doc);
316
+ } else {
317
+ await restoreLocalReferences(doc, hook);
322
318
  }
323
319
  }
324
320
  }
@@ -344,3 +340,23 @@ async function restoreForeignReferences(refDoc) {
344
340
 
345
341
  refDoc.deletedRefs = [];
346
342
  }
343
+
344
+ async function restoreLocalReferences(refDoc, hook) {
345
+ const paths = getHookPaths(hook);
346
+
347
+ for (let path of paths) {
348
+ const { ref } = getInnerField(refDoc.constructor.schema.obj, path);
349
+ const value = refDoc.get(path);
350
+ const ids = Array.isArray(value) ? value : [value];
351
+ const Model = mongoose.models[ref];
352
+
353
+ // @ts-ignore
354
+ const docs = await Model.findDeleted({
355
+ _id: { $in: ids },
356
+ });
357
+
358
+ for (let doc of docs) {
359
+ await doc.restore();
360
+ }
361
+ }
362
+ }
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
- setNodePath(node[key], {
234
- // @ts-ignore
235
- modelName: virtual.options.ref,
236
- path: path.slice(parts.length),
237
- depth: depth + 1,
238
- exclude,
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}.`);