@bedrockio/model 0.1.33 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,343 @@
1
+ import mongoose from 'mongoose';
2
+ import { groupBy } from 'lodash';
3
+
4
+ import { ReferenceError } from './errors';
5
+ import { getInnerField } from './utils';
6
+
7
+ const { ObjectId: SchemaObjectId } = mongoose.Schema.Types;
8
+
9
+ export function applyDeleteHooks(schema, definition) {
10
+ let { onDelete: deleteHooks } = definition;
11
+
12
+ if (!deleteHooks) {
13
+ return;
14
+ }
15
+
16
+ const cleanLocal = validateCleanLocal(deleteHooks, schema);
17
+ const cleanForeign = validateCleanForeign(deleteHooks);
18
+ const errorHook = validateError(deleteHooks);
19
+
20
+ let references;
21
+
22
+ const deleteFn = schema.methods.delete;
23
+ const restoreFn = schema.methods.restore;
24
+
25
+ schema.method('delete', async function () {
26
+ if (errorHook) {
27
+ references ||= getAllReferences(this);
28
+ await errorOnForeignReferences(this, {
29
+ errorHook,
30
+ cleanForeign,
31
+ references,
32
+ });
33
+ }
34
+ try {
35
+ await deleteLocalReferences(this, cleanLocal);
36
+ await deleteForeignReferences(this, cleanForeign);
37
+ } catch (error) {
38
+ await restoreLocalReferences(this, cleanLocal);
39
+ await restoreForeignReferences(this);
40
+ throw error;
41
+ }
42
+ await deleteFn.apply(this, arguments);
43
+ });
44
+
45
+ schema.method('restore', async function () {
46
+ await restoreLocalReferences(this, cleanLocal);
47
+ await restoreForeignReferences(this);
48
+ await restoreFn.apply(this, arguments);
49
+ });
50
+
51
+ schema.add({
52
+ deletedRefs: [
53
+ {
54
+ _id: 'ObjectId',
55
+ ref: 'String',
56
+ },
57
+ ],
58
+ });
59
+ }
60
+
61
+ // Clean Hook
62
+
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.');
70
+ }
71
+ if (typeof local === 'string') {
72
+ local = [local];
73
+ }
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}".`);
78
+ }
79
+ }
80
+ return local;
81
+ }
82
+
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
+ }
99
+ }
100
+ }
101
+ return foreign;
102
+ }
103
+
104
+ function validateError(deleteHooks) {
105
+ let { errorOnReferenced } = deleteHooks;
106
+ if (!errorOnReferenced) {
107
+ return;
108
+ }
109
+
110
+ if (errorOnReferenced === true) {
111
+ errorOnReferenced = {};
112
+ }
113
+
114
+ return errorOnReferenced;
115
+ }
116
+
117
+ // Error on references
118
+
119
+ async function errorOnForeignReferences(doc, options) {
120
+ const { errorHook, cleanForeign, references } = options;
121
+
122
+ if (!errorHook) {
123
+ return;
124
+ }
125
+
126
+ const { only, except } = errorHook;
127
+
128
+ assertModelNames(only);
129
+ assertModelNames(except);
130
+
131
+ const results = [];
132
+
133
+ for (let { model, paths } of references) {
134
+ if (referenceIsAllowed(errorHook, model)) {
135
+ continue;
136
+ }
137
+
138
+ const $or = paths
139
+ .filter((path) => {
140
+ if (cleanForeign) {
141
+ return cleanForeign[model.modelName] !== path;
142
+ }
143
+ return true;
144
+ })
145
+ .map((path) => {
146
+ return {
147
+ [path]: doc.id,
148
+ };
149
+ });
150
+
151
+ if (!$or.length) {
152
+ continue;
153
+ }
154
+
155
+ const docs = await model.find({ $or }, { _id: 1 }).lean();
156
+
157
+ if (docs.length > 0) {
158
+ const ids = docs.map((doc) => {
159
+ return String(doc._id);
160
+ });
161
+ results.push({
162
+ ids,
163
+ model,
164
+ count: ids.length,
165
+ });
166
+ }
167
+ }
168
+
169
+ if (results.length) {
170
+ const { modelName } = doc.constructor;
171
+ const refNames = results.map((reference) => {
172
+ return reference.model.modelName;
173
+ });
174
+ throw new ReferenceError(
175
+ `Refusing to delete ${modelName} referenced by ${refNames}.`,
176
+ results
177
+ );
178
+ }
179
+ }
180
+
181
+ function referenceIsAllowed(errorHook, model) {
182
+ const { only, except } = errorHook;
183
+ if (only) {
184
+ return !only.includes(model.modelName);
185
+ } else if (except) {
186
+ return except.includes(model.modelName);
187
+ } else {
188
+ return false;
189
+ }
190
+ }
191
+
192
+ function assertModelNames(arr = []) {
193
+ for (let val of arr) {
194
+ if (!mongoose.models[val]) {
195
+ throw new Error(`Unknown model "${val}".`);
196
+ }
197
+ }
198
+ }
199
+
200
+ function getAllReferences(doc) {
201
+ const targetName = doc.constructor.modelName;
202
+ return Object.values(mongoose.models)
203
+ .map((model) => {
204
+ const paths = getModelReferences(model, targetName);
205
+ return { model, paths };
206
+ })
207
+ .filter(({ paths }) => {
208
+ return paths.length > 0;
209
+ });
210
+ }
211
+
212
+ function getModelReferences(model, targetName) {
213
+ const paths = [];
214
+ model.schema.eachPath((schemaPath, schemaType) => {
215
+ if (schemaType instanceof SchemaObjectId && schemaPath[0] !== '_') {
216
+ const { ref, refPath } = schemaType.options;
217
+ let refs;
218
+ if (ref) {
219
+ refs = [ref];
220
+ } else if (refPath) {
221
+ refs = model.schema.path(refPath).options.enum;
222
+ } else {
223
+ throw new Error(
224
+ `Cannot derive refs for ${model.modelName}#${schemaPath}.`
225
+ );
226
+ }
227
+ if (refs.includes(targetName)) {
228
+ paths.push(schemaPath);
229
+ }
230
+ }
231
+ });
232
+ return paths;
233
+ }
234
+
235
+ // Deletion
236
+
237
+ async function deleteLocalReferences(doc, arr) {
238
+ if (!arr) {
239
+ return;
240
+ }
241
+ for (let name of arr) {
242
+ await doc.populate(name);
243
+ const value = doc.get(name);
244
+ const arr = Array.isArray(value) ? value : [value];
245
+
246
+ for (let sub of arr) {
247
+ await sub.delete();
248
+ }
249
+ }
250
+ }
251
+
252
+ async function deleteForeignReferences(doc, refs) {
253
+ if (!refs) {
254
+ return;
255
+ }
256
+ const { id } = doc;
257
+ if (!id) {
258
+ throw new Error(`Refusing to apply delete hook to document without id.`);
259
+ }
260
+ for (let [modelName, arg] of Object.entries(refs)) {
261
+ const Model = mongoose.models[modelName];
262
+ if (typeof arg === 'string') {
263
+ await runDeletes(Model, doc, {
264
+ [arg]: id,
265
+ });
266
+ } else {
267
+ const { $and, $or } = arg;
268
+ if ($and) {
269
+ await runDeletes(Model, doc, {
270
+ $and: mapArrayQuery($and, id),
271
+ });
272
+ } else if ($or) {
273
+ await runDeletes(Model, doc, {
274
+ $or: mapArrayQuery($or, id),
275
+ });
276
+ }
277
+ }
278
+ }
279
+ }
280
+
281
+ async function runDeletes(Model, refDoc, query) {
282
+ const docs = await Model.find(query);
283
+ for (let doc of docs) {
284
+ await doc.delete();
285
+ refDoc.deletedRefs.push({
286
+ _id: doc.id,
287
+ ref: doc.constructor.modelName,
288
+ });
289
+ }
290
+ }
291
+
292
+ function mapArrayQuery(arr, id) {
293
+ return arr.map((refName) => {
294
+ return {
295
+ [refName]: id,
296
+ };
297
+ });
298
+ }
299
+
300
+ // Restore
301
+
302
+ async function restoreLocalReferences(refDoc, arr) {
303
+ if (!arr) {
304
+ return;
305
+ }
306
+ for (let name of arr) {
307
+ const { ref } = getInnerField(refDoc.constructor.schema.obj, name);
308
+ const value = refDoc.get(name);
309
+ const ids = Array.isArray(value) ? value : [value];
310
+ const Model = mongoose.models[ref];
311
+
312
+ // @ts-ignore
313
+ const docs = await Model.findDeleted({
314
+ _id: { $in: ids },
315
+ });
316
+
317
+ for (let doc of docs) {
318
+ await doc.restore();
319
+ }
320
+ }
321
+ }
322
+
323
+ async function restoreForeignReferences(refDoc) {
324
+ const grouped = groupBy(refDoc.deletedRefs, 'ref');
325
+
326
+ for (let [modelName, refs] of Object.entries(grouped)) {
327
+ const ids = refs.map((ref) => {
328
+ return ref._id;
329
+ });
330
+ const Model = mongoose.models[modelName];
331
+
332
+ // @ts-ignore
333
+ const docs = await Model.findDeleted({
334
+ _id: { $in: ids },
335
+ });
336
+
337
+ for (let doc of docs) {
338
+ await doc.restore();
339
+ }
340
+ }
341
+
342
+ refDoc.deletedRefs = [];
343
+ }
package/src/disallowed.js CHANGED
@@ -1,27 +1,6 @@
1
1
  import warn from './warn';
2
2
 
3
3
  export function applyDisallowed(schema) {
4
- schema.method(
5
- 'remove',
6
- function () {
7
- warn(
8
- 'The "remove" method on documents is disallowed due to ambiguity.',
9
- 'To permanently delete a document use "destroy", otherwise "delete".'
10
- );
11
- throw new Error('Method not allowed.');
12
- },
13
- {
14
- suppressWarning: true,
15
- }
16
- );
17
-
18
- schema.method('update', function () {
19
- warn(
20
- 'The "update" method on documents is deprecated. Use "updateOne" instead.'
21
- );
22
- throw new Error('Method not allowed.');
23
- });
24
-
25
4
  schema.method('deleteOne', function () {
26
5
  warn(
27
6
  'The "deleteOne" method on documents is disallowed due to ambiguity',
@@ -30,14 +9,6 @@ export function applyDisallowed(schema) {
30
9
  throw new Error('Method not allowed.');
31
10
  });
32
11
 
33
- schema.static('remove', function () {
34
- warn(
35
- 'The "remove" method on models is disallowed due to ambiguity.',
36
- 'To permanently delete a document use "destroyMany", otherwise "deleteMany".'
37
- );
38
- throw new Error('Method not allowed.');
39
- });
40
-
41
12
  schema.static('findOneAndRemove', function () {
42
13
  warn(
43
14
  'The "findOneAndRemove" method on models is disallowed due to ambiguity.',
package/src/query.js ADDED
@@ -0,0 +1,15 @@
1
+ // Wrapper that allows a mongoose query object to be returned
2
+ // to access chained methods while still resolving with a
3
+ // transformed value. Allows methods to behave like other find
4
+ // methods and importantly allow custom population with the same API.
5
+ export function wrapQuery(query, fn) {
6
+ const runQuery = query.then.bind(query);
7
+ query.then = async (resolve, reject) => {
8
+ try {
9
+ resolve(await fn(runQuery()));
10
+ } catch (err) {
11
+ reject(err);
12
+ }
13
+ };
14
+ return query;
15
+ }
package/src/schema.js CHANGED
@@ -8,8 +8,8 @@ import { applySlug } from './slug';
8
8
  import { applySearch } from './search';
9
9
  import { applyAssign } from './assign';
10
10
  import { applyInclude } from './include';
11
- import { applyReferences } from './references';
12
11
  import { applySoftDelete } from './soft-delete';
12
+ import { applyDeleteHooks } from './delete-hooks';
13
13
  import { applyDisallowed } from './disallowed';
14
14
 
15
15
  import {
@@ -37,7 +37,10 @@ export function createSchema(definition, options = {}) {
37
37
  createdAt: 'Date',
38
38
  updatedAt: 'Date',
39
39
  deletedAt: 'Date',
40
- deleted: { type: 'Boolean', default: false },
40
+ deleted: {
41
+ type: 'Boolean',
42
+ default: false,
43
+ },
41
44
  }),
42
45
  {
43
46
  timestamps: true,
@@ -47,10 +50,12 @@ export function createSchema(definition, options = {}) {
47
50
  }
48
51
  );
49
52
 
53
+ // Soft Delete needs to be applied
54
+ // first for hooks to work correctly.
55
+ applySoftDelete(schema);
50
56
  applyValidation(schema, definition);
57
+ applyDeleteHooks(schema, definition);
51
58
  applySearch(schema, definition);
52
- applySoftDelete(schema);
53
- applyReferences(schema);
54
59
  applyDisallowed(schema);
55
60
  applyInclude(schema);
56
61
  applyAssign(schema);
package/src/search.js CHANGED
@@ -7,6 +7,7 @@ import { isDateField, isNumberField, getField } from './utils';
7
7
  import { SEARCH_DEFAULTS } from './const';
8
8
  import { OBJECT_ID_SCHEMA } from './validation';
9
9
  import { debug } from './env';
10
+ import { wrapQuery } from './query';
10
11
 
11
12
  import warn from './warn';
12
13
 
@@ -48,33 +49,20 @@ export function applySearch(schema, definition) {
48
49
  .skip(skip)
49
50
  .limit(limit);
50
51
 
51
- // The following construct is awkward but it allows the mongoose query
52
- // object to be returned while still ultimately resolving with metadata
53
- // so that this method can behave like other find methods and importantly
54
- // allow custom population with the same API.
55
-
56
- const runQuery = mQuery.then.bind(mQuery);
57
-
58
- mQuery.then = async (resolve, reject) => {
59
- try {
60
- const [data, total] = await Promise.all([
61
- runQuery(),
62
- this.countDocuments(query),
63
- ]);
64
- resolve({
65
- data,
66
- meta: {
67
- total,
68
- skip,
69
- limit,
70
- },
71
- });
72
- } catch (err) {
73
- reject(err);
74
- }
75
- };
76
-
77
- return mQuery;
52
+ return wrapQuery(mQuery, async (promise) => {
53
+ const [data, total] = await Promise.all([
54
+ promise,
55
+ this.countDocuments(query),
56
+ ]);
57
+ return {
58
+ data,
59
+ meta: {
60
+ total,
61
+ skip,
62
+ limit,
63
+ },
64
+ };
65
+ });
78
66
  });
79
67
  }
80
68
 
@@ -4,6 +4,8 @@ import { checkSelects } from './include';
4
4
  import { hasReadAccess } from './access';
5
5
  import { getField, getInnerField } from './utils';
6
6
 
7
+ const DISALLOWED_FIELDS = ['deleted', 'deletedRefs'];
8
+
7
9
  export const serializeOptions = {
8
10
  getters: true,
9
11
  versionKey: false,
@@ -18,6 +20,8 @@ function transformField(obj, field, options) {
18
20
  if (Array.isArray(obj)) {
19
21
  for (let el of obj) {
20
22
  transformField(el, field, options);
23
+ // Delete ids in array elements.
24
+ delete el.id;
21
25
  }
22
26
  } else if (isPlainObject(obj)) {
23
27
  for (let [key, val] of Object.entries(obj)) {
@@ -34,7 +38,7 @@ function isAllowedField(key, field, options) {
34
38
  if (key[0] === '_') {
35
39
  // Strip internal keys like _id and __v
36
40
  return false;
37
- } else if (key === 'deleted') {
41
+ } else if (DISALLOWED_FIELDS.includes(key)) {
38
42
  // Strip "deleted" field which defaults
39
43
  // to false and should not be exposed.
40
44
  return false;