@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.
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
- - [Features](#features)
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
- ## Features
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 `fields` is
607
- not defined then a Mongo text query will be attempted:
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
- Note that this will fail unless a text index is defined on the model.
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
 
@@ -1019,12 +1128,15 @@ deletion. They are defined in the `onDelete` field of the model definition file:
1019
1128
  }
1020
1129
  },
1021
1130
  "onDelete": {
1022
- "clean": {
1023
- "local": "profile",
1024
- "foreign": {
1025
- "Shop": "owner"
1131
+ "clean": [
1132
+ {
1133
+ "path": "profile"
1134
+ },
1135
+ {
1136
+ "ref": "Shop",
1137
+ "path": "owner"
1026
1138
  }
1027
- },
1139
+ ],
1028
1140
  "errorOnReferenced": {
1029
1141
  "except": ["AuditEntry"]
1030
1142
  }
@@ -1035,12 +1147,13 @@ deletion. They are defined in the `onDelete` field of the model definition file:
1035
1147
  #### Clean
1036
1148
 
1037
1149
  `clean` determines other associated documents that will be deleted when the main
1038
- document is deleted.
1150
+ document is deleted. It is defined as an array of operations that will be
1151
+ performed in order. Operations must contain either `path` or `paths`.
1039
1152
 
1040
1153
  #### Local References
1041
1154
 
1042
- `clean.local` specifies local refs to delete. It may be a string or array of
1043
- strings. In the above example:
1155
+ Operations that do not specify a `ref` are treated as local paths. In the above
1156
+ example:
1044
1157
 
1045
1158
  ```js
1046
1159
  user.delete();
@@ -1050,26 +1163,110 @@ await user.populate('profile');
1050
1163
  await user.profile.delete();
1051
1164
  ```
1052
1165
 
1053
- #### Foreign Reference Cleanup
1166
+ #### Foreign References
1054
1167
 
1055
- `clean.foreign` specifies foreign refs to delete. It is defined as an object
1056
- that maps foreign `ref` names to their referencing field. In the above example:
1168
+ Operations that specify a `ref` are treated as foreign references. In the above
1169
+ example:
1057
1170
 
1058
1171
  ```js
1059
1172
  user.delete();
1060
1173
 
1061
1174
  // Will implicitly run:
1062
- const shop = await Shop.find({
1175
+ const shops = await Shop.find({
1063
1176
  owner: user,
1064
1177
  });
1065
- await shop.delete();
1178
+ for (let shop of shops) {
1179
+ await shop.delete();
1180
+ }
1181
+ ```
1182
+
1183
+ #### Additional Filters
1184
+
1185
+ Operations may filter on additional fields with `query`:
1186
+
1187
+ ```json
1188
+ // user.json
1189
+ {
1190
+ "onDelete": {
1191
+ "clean": [
1192
+ {
1193
+ "ref": "Shop",
1194
+ "path": "owner",
1195
+ "query": {
1196
+ "status": "active"
1197
+ }
1198
+ }
1199
+ ]
1200
+ }
1201
+ }
1202
+ ```
1203
+
1204
+ In this example:
1205
+
1206
+ ```js
1207
+ user.delete();
1208
+
1209
+ // Will implicitly run:
1210
+ const shops = await Shop.find({
1211
+ status: 'active',
1212
+ owner: user,
1213
+ });
1214
+ for (let shop of shops) {
1215
+ await shop.delete();
1216
+ }
1066
1217
  ```
1067
1218
 
1219
+ Any query that can be serliazed as JSON is valid, however top-level `$or`
1220
+ operators have special behavior with multiple paths (see note below).
1221
+
1222
+ #### Multiple Paths
1223
+
1224
+ An operation that specified an array of `paths` will implicitly run an `$or`
1225
+ query:
1226
+
1227
+ ```json
1228
+ // user.json
1229
+ {
1230
+ "onDelete": {
1231
+ "clean": [
1232
+ {
1233
+ "ref": "Shop",
1234
+ "path": ["owner", "administrator"]
1235
+ }
1236
+ ]
1237
+ }
1238
+ }
1239
+ ```
1240
+
1241
+ In this example:
1242
+
1243
+ ```js
1244
+ user.delete();
1245
+
1246
+ // Will implicitly run:
1247
+ const shops = await Shop.find({
1248
+ $or: [
1249
+ {
1250
+ owner: user,
1251
+ },
1252
+ {
1253
+ administrator: user,
1254
+ },
1255
+ ],
1256
+ });
1257
+ for (let shop of shops) {
1258
+ await shop.delete();
1259
+ }
1260
+ ```
1261
+
1262
+ > [!WARNING] The ability to run an `$and` query with multiple paths is currently
1263
+ > not implemented.
1264
+
1068
1265
  #### Erroring on Delete
1069
1266
 
1070
1267
  The `errorOnReferenced` field helps to prevent orphaned references by defining
1071
1268
  if and how the `delete` method will error if it is being referenced by another
1072
- foreign document. In the above example:
1269
+ foreign document. In the top example:
1073
1270
 
1074
1271
  ```js
1075
1272
  user.delete();
@@ -1149,7 +1346,6 @@ const { createTestModel } = require('@bedrockio/model');
1149
1346
  const User = createTestModel({
1150
1347
  name: 'String',
1151
1348
  });
1152
- mk;
1153
1349
  ```
1154
1350
 
1155
1351
  Note that a unique model name will be generated to prevent clashing with other
@@ -19,9 +19,8 @@ function applyDeleteHooks(schema, definition) {
19
19
  if (!deleteHooks) {
20
20
  return;
21
21
  }
22
- const cleanLocal = validateCleanLocal(deleteHooks, schema);
23
- const cleanForeign = validateCleanForeign(deleteHooks);
24
22
  const errorHook = validateError(deleteHooks);
23
+ const cleanHooks = validateCleanHooks(deleteHooks, schema);
25
24
  let references;
26
25
  const deleteFn = schema.methods.delete;
27
26
  const restoreFn = schema.methods.restore;
@@ -30,23 +29,20 @@ function applyDeleteHooks(schema, definition) {
30
29
  references ||= getAllReferences(this);
31
30
  await errorOnForeignReferences(this, {
32
31
  errorHook,
33
- cleanForeign,
32
+ cleanHooks,
34
33
  references
35
34
  });
36
35
  }
37
36
  try {
38
- await deleteLocalReferences(this, cleanLocal);
39
- await deleteForeignReferences(this, cleanForeign);
37
+ await deleteReferences(this, cleanHooks);
40
38
  } catch (error) {
41
- await restoreLocalReferences(this, cleanLocal);
42
- await restoreForeignReferences(this);
39
+ await restoreReferences(this, cleanHooks);
43
40
  throw error;
44
41
  }
45
42
  await deleteFn.apply(this, arguments);
46
43
  });
47
44
  schema.method('restore', async function () {
48
- await restoreLocalReferences(this, cleanLocal);
49
- await restoreForeignReferences(this);
45
+ await restoreReferences(this, cleanHooks);
50
46
  await restoreFn.apply(this, arguments);
51
47
  });
52
48
  schema.add({
@@ -59,49 +55,58 @@ function applyDeleteHooks(schema, definition) {
59
55
 
60
56
  // Clean Hook
61
57
 
62
- function validateCleanLocal(deleteHooks, schema) {
63
- let {
64
- local
65
- } = deleteHooks.clean || {};
66
- if (!local) {
67
- return;
58
+ function validateCleanHooks(deleteHooks, schema) {
59
+ const {
60
+ clean
61
+ } = deleteHooks;
62
+ if (!clean) {
63
+ return [];
68
64
  }
69
- if (typeof local !== 'string' && !Array.isArray(local)) {
70
- throw new Error('Local delete hook must be an array.');
65
+ if (!Array.isArray(clean)) {
66
+ throw new Error('Delete clean hook must be an array.');
71
67
  }
72
- if (typeof local === 'string') {
73
- local = [local];
68
+ for (let hook of clean) {
69
+ const {
70
+ ref,
71
+ path,
72
+ paths
73
+ } = hook;
74
+ if (path && typeof path !== 'string') {
75
+ throw new Error('Clean hook path must be a string.');
76
+ } else if (paths && !Array.isArray(paths)) {
77
+ throw new Error('Clean hook paths must be an array.');
78
+ } else if (!path && !paths) {
79
+ throw new Error('Clean hook must define either "path" or "paths".');
80
+ } else if (path && paths) {
81
+ throw new Error('Clean hook may not define both "path" or "paths".');
82
+ } else if (ref && typeof ref !== 'string') {
83
+ throw new Error('Clean hook ref must be a string.');
84
+ } else if (!ref) {
85
+ validateLocalCleanHook(hook, schema);
86
+ }
74
87
  }
75
- for (let name of local) {
76
- const pathType = schema.pathType(name);
77
- if (pathType !== 'real') {
78
- throw new Error(`Delete hook has invalid local reference "${name}".`);
88
+ return clean;
89
+ }
90
+ function validateLocalCleanHook(hook, schema) {
91
+ const paths = getHookPaths(hook);
92
+ for (let path of paths) {
93
+ if (schema.pathType(path) !== 'real') {
94
+ throw new Error(`Invalid reference in local delete hook: "${path}".`);
79
95
  }
80
96
  }
81
- return local;
82
97
  }
83
- function validateCleanForeign(deleteHooks) {
98
+ function getHookPaths(hook) {
84
99
  const {
85
- foreign
86
- } = deleteHooks.clean || {};
87
- if (!foreign) {
88
- return;
89
- }
90
- if (typeof foreign !== 'object') {
91
- throw new Error('Foreign delete hook must be an object.');
92
- }
93
- for (let [modelName, arg] of Object.entries(foreign)) {
94
- if (typeof arg === 'object') {
95
- const {
96
- $and,
97
- $or
98
- } = arg;
99
- if ($and && $or) {
100
- throw new Error(`Cannot define both $or and $and in a delete hook for model ${modelName}.`);
101
- }
102
- }
100
+ path,
101
+ paths
102
+ } = hook;
103
+ if (path) {
104
+ return [path];
105
+ } else if (paths) {
106
+ return paths;
107
+ } else {
108
+ return [];
103
109
  }
104
- return foreign;
105
110
  }
106
111
  function validateError(deleteHooks) {
107
112
  let {
@@ -173,15 +178,21 @@ async function errorOnForeignReferences(doc, options) {
173
178
  }
174
179
  function referenceIsAllowed(model, options) {
175
180
  const {
176
- cleanForeign = {}
181
+ modelName
182
+ } = model;
183
+ const {
184
+ cleanHooks
177
185
  } = options;
186
+ const hasCleanHook = cleanHooks.some(hook => {
187
+ return hook.ref === modelName;
188
+ });
189
+ if (hasCleanHook) {
190
+ return true;
191
+ }
178
192
  const {
179
193
  only,
180
194
  except
181
195
  } = options?.errorHook || {};
182
- if (model.modelName in cleanForeign) {
183
- return true;
184
- }
185
196
  if (only) {
186
197
  return !only.includes(model.modelName);
187
198
  } else if (except) {
@@ -235,58 +246,61 @@ function getModelReferences(model, targetName) {
235
246
  return paths;
236
247
  }
237
248
 
238
- // Deletion
249
+ // Delete
239
250
 
240
- async function deleteLocalReferences(doc, arr) {
241
- if (!arr) {
242
- return;
243
- }
244
- for (let name of arr) {
245
- await doc.populate(name);
246
- const value = doc.get(name);
247
- if (!value) {
248
- continue;
249
- }
250
- const arr = Array.isArray(value) ? value : [value];
251
- for (let sub of arr) {
252
- await sub.delete();
251
+ async function deleteReferences(doc, hooks) {
252
+ for (let hook of hooks) {
253
+ if (hook.ref) {
254
+ await deleteForeignReferences(doc, hook);
255
+ } else {
256
+ await deleteLocalReferences(doc, hook);
253
257
  }
254
258
  }
255
259
  }
256
- async function deleteForeignReferences(doc, refs) {
257
- if (!refs) {
258
- return;
259
- }
260
+ async function deleteForeignReferences(doc, hook) {
261
+ const {
262
+ ref,
263
+ path,
264
+ paths,
265
+ query
266
+ } = hook;
260
267
  const {
261
268
  id
262
269
  } = doc;
263
270
  if (!id) {
264
271
  throw new Error(`Refusing to apply delete hook to document without id.`);
265
272
  }
266
- for (let [modelName, arg] of Object.entries(refs)) {
267
- const Model = _mongoose.default.models[modelName];
268
- if (typeof arg === 'string') {
269
- await runDeletes(Model, doc, {
270
- [arg]: id
271
- });
272
- } else if (Array.isArray(arg)) {
273
- await runDeletes(Model, doc, {
274
- $or: mapArrayQuery(arg, id)
275
- });
276
- } else {
277
- const {
278
- $and,
279
- $or
280
- } = arg;
281
- if ($and) {
282
- await runDeletes(Model, doc, {
283
- $and: mapArrayQuery($and, id)
284
- });
285
- } else if ($or) {
286
- await runDeletes(Model, doc, {
287
- $or: mapArrayQuery($or, id)
288
- });
289
- }
273
+ const Model = _mongoose.default.models[ref];
274
+ if (!Model) {
275
+ throw new Error(`Unknown model: "${ref}".`);
276
+ }
277
+ if (path) {
278
+ await runDeletes(Model, doc, {
279
+ ...query,
280
+ [path]: id
281
+ });
282
+ } else if (paths) {
283
+ await runDeletes(Model, doc, {
284
+ $or: paths.map(refName => {
285
+ return {
286
+ ...query,
287
+ [refName]: id
288
+ };
289
+ })
290
+ });
291
+ }
292
+ }
293
+ async function deleteLocalReferences(doc, hook) {
294
+ const paths = getHookPaths(hook);
295
+ await doc.populate(paths);
296
+ for (let path of paths) {
297
+ const value = doc.get(path);
298
+ if (!value) {
299
+ continue;
300
+ }
301
+ const arr = Array.isArray(value) ? value : [value];
302
+ for (let sub of arr) {
303
+ await sub.delete();
290
304
  }
291
305
  }
292
306
  }
@@ -300,27 +314,25 @@ async function runDeletes(Model, refDoc, query) {
300
314
  });
301
315
  }
302
316
  }
303
- function mapArrayQuery(arr, id) {
304
- return arr.map(refName => {
305
- return {
306
- [refName]: id
307
- };
308
- });
309
- }
310
317
 
311
318
  // Restore
312
319
 
313
- async function restoreLocalReferences(refDoc, arr) {
314
- if (!arr) {
315
- return;
320
+ async function restoreReferences(doc, hooks) {
321
+ for (let hook of hooks) {
322
+ if (hook.ref) {
323
+ await restoreForeignReferences(doc);
324
+ } else {
325
+ await restoreLocalReferences(doc, hook);
326
+ }
316
327
  }
317
- for (let name of arr) {
318
- const {
319
- ref
320
- } = (0, _utils.getInnerField)(refDoc.constructor.schema.obj, name);
321
- const value = refDoc.get(name);
322
- const ids = Array.isArray(value) ? value : [value];
323
- const Model = _mongoose.default.models[ref];
328
+ }
329
+ async function restoreForeignReferences(refDoc) {
330
+ const grouped = (0, _lodash.groupBy)(refDoc.deletedRefs, 'ref');
331
+ for (let [modelName, refs] of Object.entries(grouped)) {
332
+ const ids = refs.map(ref => {
333
+ return ref._id;
334
+ });
335
+ const Model = _mongoose.default.models[modelName];
324
336
 
325
337
  // @ts-ignore
326
338
  const docs = await Model.findDeleted({
@@ -332,14 +344,17 @@ async function restoreLocalReferences(refDoc, arr) {
332
344
  await doc.restore();
333
345
  }
334
346
  }
347
+ refDoc.deletedRefs = [];
335
348
  }
336
- async function restoreForeignReferences(refDoc) {
337
- const grouped = (0, _lodash.groupBy)(refDoc.deletedRefs, 'ref');
338
- for (let [modelName, refs] of Object.entries(grouped)) {
339
- const ids = refs.map(ref => {
340
- return ref._id;
341
- });
342
- const Model = _mongoose.default.models[modelName];
349
+ async function restoreLocalReferences(refDoc, hook) {
350
+ const paths = getHookPaths(hook);
351
+ for (let path of paths) {
352
+ const {
353
+ ref
354
+ } = (0, _utils.getInnerField)(refDoc.constructor.schema.obj, path);
355
+ const value = refDoc.get(path);
356
+ const ids = Array.isArray(value) ? value : [value];
357
+ const Model = _mongoose.default.models[ref];
343
358
 
344
359
  // @ts-ignore
345
360
  const docs = await Model.findDeleted({
@@ -351,5 +366,4 @@ async function restoreForeignReferences(refDoc) {
351
366
  await doc.restore();
352
367
  }
353
368
  }
354
- refDoc.deletedRefs = [];
355
369
  }
@@ -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
- setNodePath(node[key], {
248
- // @ts-ignore
249
- modelName: virtual.options.ref,
250
- path: path.slice(parts.length),
251
- depth: depth + 1,
252
- exclude
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}.`);