@bedrockio/model 0.1.0 → 0.1.1

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.
Files changed (73) hide show
  1. package/README.md +24 -0
  2. package/dist/cjs/access.js +4 -0
  3. package/dist/cjs/assign.js +2 -3
  4. package/dist/cjs/disallowed.js +40 -0
  5. package/dist/cjs/include.js +3 -2
  6. package/dist/cjs/load.js +15 -3
  7. package/dist/cjs/schema.js +29 -12
  8. package/dist/cjs/search.js +16 -16
  9. package/dist/cjs/serialization.js +2 -3
  10. package/dist/cjs/soft-delete.js +234 -55
  11. package/dist/cjs/testing.js +8 -0
  12. package/dist/cjs/validation.js +109 -43
  13. package/package.json +9 -7
  14. package/src/access.js +3 -0
  15. package/src/disallowed.js +63 -0
  16. package/src/include.js +1 -0
  17. package/src/load.js +12 -1
  18. package/src/schema.js +25 -5
  19. package/src/search.js +21 -10
  20. package/src/soft-delete.js +238 -85
  21. package/src/testing.js +7 -0
  22. package/src/validation.js +134 -43
  23. package/types/access.d.ts +7 -0
  24. package/types/access.d.ts.map +1 -0
  25. package/types/assign.d.ts +2 -0
  26. package/types/assign.d.ts.map +1 -0
  27. package/types/const.d.ts +9 -0
  28. package/types/const.d.ts.map +1 -0
  29. package/types/disallowed.d.ts +2 -0
  30. package/types/disallowed.d.ts.map +1 -0
  31. package/types/errors.d.ts +9 -0
  32. package/types/errors.d.ts.map +1 -0
  33. package/types/include.d.ts +4 -0
  34. package/types/include.d.ts.map +1 -0
  35. package/types/index.d.ts +6 -0
  36. package/types/index.d.ts.map +1 -0
  37. package/types/load.d.ts +15 -0
  38. package/types/load.d.ts.map +1 -0
  39. package/types/references.d.ts +2 -0
  40. package/types/references.d.ts.map +1 -0
  41. package/types/schema.d.ts +71 -0
  42. package/types/schema.d.ts.map +1 -0
  43. package/types/search.d.ts +303 -0
  44. package/types/search.d.ts.map +1 -0
  45. package/types/serialization.d.ts +6 -0
  46. package/types/serialization.d.ts.map +1 -0
  47. package/types/slug.d.ts +2 -0
  48. package/types/slug.d.ts.map +1 -0
  49. package/types/soft-delete.d.ts +4 -0
  50. package/types/soft-delete.d.ts.map +1 -0
  51. package/types/testing.d.ts +11 -0
  52. package/types/testing.d.ts.map +1 -0
  53. package/types/utils.d.ts +8 -0
  54. package/types/utils.d.ts.map +1 -0
  55. package/types/validation.d.ts +13 -0
  56. package/types/validation.d.ts.map +1 -0
  57. package/types/warn.d.ts +2 -0
  58. package/types/warn.d.ts.map +1 -0
  59. package/babel.config.cjs +0 -41
  60. package/jest.config.js +0 -8
  61. package/test/assign.test.js +0 -225
  62. package/test/definitions/custom-model.json +0 -9
  63. package/test/definitions/special-category.json +0 -18
  64. package/test/include.test.js +0 -896
  65. package/test/load.test.js +0 -47
  66. package/test/references.test.js +0 -71
  67. package/test/schema.test.js +0 -919
  68. package/test/search.test.js +0 -652
  69. package/test/serialization.test.js +0 -748
  70. package/test/setup.js +0 -27
  71. package/test/slug.test.js +0 -112
  72. package/test/soft-delete.test.js +0 -333
  73. package/test/validation.test.js +0 -1925
package/package.json CHANGED
@@ -1,16 +1,18 @@
1
1
  {
2
2
  "name": "@bedrockio/model",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Bedrock utilities for model creation.",
5
5
  "type": "module",
6
6
  "scripts": {
7
7
  "test": "jest",
8
8
  "lint": "eslint",
9
9
  "build": "scripts/build",
10
- "prepublishOnly": "npm run build"
10
+ "types": "tsc",
11
+ "prepublishOnly": "yarn build && yarn types"
11
12
  },
12
13
  "main": "dist/cjs/index.js",
13
14
  "module": "src/index.js",
15
+ "types": "types/index.d.ts",
14
16
  "contributors": [
15
17
  {
16
18
  "name": "Andrew Plummer",
@@ -23,8 +25,8 @@
23
25
  "url": "https://github.com/bedrockio/model"
24
26
  },
25
27
  "dependencies": {
26
- "@bedrockio/logger": "^1.0.3",
27
- "@bedrockio/yada": "^1.0.9",
28
+ "@bedrockio/logger": "^1.0.5",
29
+ "@bedrockio/yada": "^1.0.16",
28
30
  "lodash": "^4.17.21"
29
31
  },
30
32
  "peerDependencies": {
@@ -37,14 +39,14 @@
37
39
  "@bedrockio/prettier-config": "^1.0.2",
38
40
  "@shelf/jest-mongodb": "^4.1.6",
39
41
  "babel-plugin-import-replacement": "^1.0.1",
40
- "babel-plugin-lodash": "^3.3.4",
41
42
  "eslint": "^8.33.0",
42
- "eslint-plugin-bedrock": "^1.0.21",
43
+ "eslint-plugin-bedrock": "^1.0.24",
43
44
  "jest": "^29.4.1",
44
45
  "jest-environment-node": "^29.4.1",
45
46
  "mongodb": "4.13.0",
46
47
  "mongoose": "^6.9.0",
47
- "prettier-eslint": "^15.0.1"
48
+ "prettier-eslint": "^15.0.1",
49
+ "typescript": "^4.9.5"
48
50
  },
49
51
  "volta": {
50
52
  "node": "18.14.0",
package/src/access.js CHANGED
@@ -9,6 +9,9 @@ export function hasWriteAccess(allowed, options) {
9
9
  return hasAccess('write', allowed, options);
10
10
  }
11
11
 
12
+ /**
13
+ * @param {string|string[]} allowed
14
+ */
12
15
  export function hasAccess(type, allowed = 'all', options = {}) {
13
16
  if (allowed === 'all') {
14
17
  return true;
@@ -0,0 +1,63 @@
1
+ import warn from './warn';
2
+
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
+ schema.method('deleteOne', function () {
26
+ warn(
27
+ 'The "deleteOne" method on documents is disallowed due to ambiguity',
28
+ 'Use either "delete" or "deleteOne" on the model.'
29
+ );
30
+ throw new Error('Method not allowed.');
31
+ });
32
+
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
+ schema.static('findOneAndRemove', function () {
42
+ warn(
43
+ 'The "findOneAndRemove" method on models is disallowed due to ambiguity.',
44
+ 'To permanently delete a document use "findOneAndDestroy", otherwise "findOneAndDelete".'
45
+ );
46
+ throw new Error('Method not allowed.');
47
+ });
48
+
49
+ schema.static('findByIdAndRemove', function () {
50
+ warn(
51
+ 'The "findByIdAndRemove" method on models is disallowed due to ambiguity.',
52
+ 'To permanently delete a document use "findByIdAndDestroy", otherwise "findByIdAndDelete".'
53
+ );
54
+ throw new Error('Method not allowed.');
55
+ });
56
+
57
+ schema.static('count', function () {
58
+ warn(
59
+ 'The "count" method on models is deprecated. Use "countDocuments" instead.'
60
+ );
61
+ throw new Error('Method not allowed.');
62
+ });
63
+ }
package/src/include.js CHANGED
@@ -4,6 +4,7 @@ import { escapeRegExp } from 'lodash';
4
4
  import { resolveInnerField } from './utils';
5
5
  import { POPULATE_MAX_DEPTH } from './const';
6
6
 
7
+ // @ts-ignore
7
8
  // Overloading mongoose Query prototype to
8
9
  // allow an "include" method for queries.
9
10
  mongoose.Query.prototype.include = function include(paths) {
package/src/load.js CHANGED
@@ -6,6 +6,12 @@ import { startCase } from 'lodash';
6
6
 
7
7
  import { createSchema } from './schema';
8
8
 
9
+ /**
10
+ * Loads a single model by definition and name.
11
+ * @param {object} definition
12
+ * @param {string} name
13
+ * @returns mongoose.Model
14
+ */
9
15
  export function loadModel(definition, name) {
10
16
  if (!definition.attributes) {
11
17
  throw new Error(`Invalid model definition for ${name}, need attributes`);
@@ -18,13 +24,18 @@ export function loadModel(definition, name) {
18
24
  }
19
25
  }
20
26
 
27
+ /**
28
+ * Loads all model definitions in the given directory.
29
+ * Returns the full loaded model set.
30
+ * @param {string} dirPath
31
+ */
21
32
  export function loadModelDir(dirPath) {
22
33
  const files = fs.readdirSync(dirPath);
23
34
  for (const file of files) {
24
35
  const basename = path.basename(file, '.json');
25
36
  if (file.match(/\.json$/)) {
26
37
  const filePath = path.join(dirPath, file);
27
- const data = fs.readFileSync(filePath);
38
+ const data = fs.readFileSync(filePath, 'utf-8');
28
39
  const definition = JSON.parse(data);
29
40
  const modelName =
30
41
  definition.modelName || startCase(basename).replace(/\s/g, '');
package/src/schema.js CHANGED
@@ -10,6 +10,8 @@ import { applyAssign } from './assign';
10
10
  import { applyInclude } from './include';
11
11
  import { applyReferences } from './references';
12
12
  import { applySoftDelete } from './soft-delete';
13
+ import { applyDisallowed } from './disallowed';
14
+
13
15
  import {
14
16
  applyValidation,
15
17
  getNamedValidator,
@@ -23,6 +25,14 @@ export const RESERVED_FIELDS = [
23
25
  'deleted',
24
26
  ];
25
27
 
28
+ /**
29
+ * Creates a new Mongoose schema with Bedrock extensions
30
+ * applied. For more about syntax and functionality see
31
+ * [the documentation](https://github.com/bedrockio/model#schemas).
32
+ * @param {object} definition
33
+ * @param {mongoose.SchemaOptions} options
34
+ * @returns mongoose.Schema
35
+ */
26
36
  export function createSchema(definition, options = {}) {
27
37
  const schema = new mongoose.Schema(
28
38
  attributesToMongoose(
@@ -46,13 +56,14 @@ export function createSchema(definition, options = {}) {
46
56
  }
47
57
  );
48
58
 
49
- applySoftDelete(schema, definition);
50
59
  applyValidation(schema, definition);
51
- applyReferences(schema, definition);
52
- applyInclude(schema, definition);
53
60
  applySearch(schema, definition);
54
- applyAssign(schema, definition);
55
- applySlug(schema, definition);
61
+ applySoftDelete(schema);
62
+ applyReferences(schema);
63
+ applyDisallowed(schema);
64
+ applyInclude(schema);
65
+ applyAssign(schema);
66
+ applySlug(schema);
56
67
 
57
68
  return schema;
58
69
  }
@@ -185,6 +196,7 @@ function isMongooseType(type) {
185
196
 
186
197
  function applyExtensions(typedef) {
187
198
  applySyntaxExtensions(typedef);
199
+ applyUniqueExtension(typedef);
188
200
  applyTupleExtension(typedef);
189
201
  }
190
202
 
@@ -236,6 +248,14 @@ function applyTupleExtension(typedef) {
236
248
  }
237
249
  }
238
250
 
251
+ // Intercepts "unique" options and changes to "softUnique".
252
+ function applyUniqueExtension(typedef) {
253
+ if (typedef.unique === true) {
254
+ typedef.softUnique = true;
255
+ delete typedef.unique;
256
+ }
257
+ }
258
+
239
259
  function applyArrayValidators(typedef) {
240
260
  let { minLength, maxLength, validate } = typedef;
241
261
  if (minLength) {
package/src/search.js CHANGED
@@ -4,15 +4,18 @@ import { pick, isEmpty, isPlainObject } from 'lodash';
4
4
 
5
5
  import { isDateField, isNumberField, resolveField } from './utils';
6
6
  import { SEARCH_DEFAULTS } from './const';
7
+ import { OBJECT_ID_SCHEMA } from './validation';
7
8
 
8
9
  import warn from './warn';
9
10
 
10
11
  const { ObjectId } = mongoose.Types;
11
12
 
12
- const SORT_SCHEMA = yd.object({
13
- field: yd.string().required(),
14
- order: yd.string().allow('desc', 'asc').required(),
15
- });
13
+ const SORT_SCHEMA = yd
14
+ .object({
15
+ field: yd.string().required(),
16
+ order: yd.string().allow('desc', 'asc').required(),
17
+ })
18
+ .description('An object describing the sort order of results.');
16
19
 
17
20
  export function applySearch(schema, definition) {
18
21
  validateDefinition(definition);
@@ -74,19 +77,27 @@ export function applySearch(schema, definition) {
74
77
  }
75
78
 
76
79
  export function searchValidation(definition, options = {}) {
77
- const { limit, sort, ...rest } = {
80
+ options = {
78
81
  ...SEARCH_DEFAULTS,
79
82
  ...pick(definition.search, 'limit', 'sort'),
80
83
  ...options,
81
84
  };
82
85
 
86
+ const { limit, sort, ...rest } = options;
87
+
83
88
  return {
84
- ids: yd.array(yd.string().mongo()),
85
- keyword: yd.string(),
86
- include: yd.string(),
87
- skip: yd.number().default(0),
89
+ ids: yd.array(OBJECT_ID_SCHEMA),
90
+ keyword: yd
91
+ .string()
92
+ .description('A keyword to perform a text search against.'),
93
+ include: yd.string().description('Fields to be selected or populated.'),
94
+ skip: yd.number().default(0).description('Number of records to skip.'),
88
95
  sort: yd.allow(SORT_SCHEMA, yd.array(SORT_SCHEMA)).default(sort),
89
- limit: yd.number().positive().default(limit),
96
+ limit: yd
97
+ .number()
98
+ .positive()
99
+ .default(limit)
100
+ .description('Limits the number of results.'),
90
101
  ...rest,
91
102
  };
92
103
  }
@@ -1,6 +1,11 @@
1
- import warn from './warn';
2
-
3
1
  export function applySoftDelete(schema) {
2
+ applyQueries(schema);
3
+ applyUniqueConstraints(schema);
4
+ }
5
+
6
+ // Soft Delete Querying
7
+
8
+ function applyQueries(schema) {
4
9
  // Implementation
5
10
 
6
11
  schema.pre(/^find|count|exists/, function (next) {
@@ -17,12 +22,14 @@ export function applySoftDelete(schema) {
17
22
  schema.method('delete', function () {
18
23
  this.deleted = true;
19
24
  this.deletedAt = new Date();
25
+ // @ts-ignore
20
26
  return this.save();
21
27
  });
22
28
 
23
29
  schema.method('restore', function restore() {
24
30
  this.deleted = false;
25
31
  this.deletedAt = undefined;
32
+ // @ts-ignore
26
33
  return this.save();
27
34
  });
28
35
 
@@ -92,125 +99,100 @@ export function applySoftDelete(schema) {
92
99
  };
93
100
  });
94
101
 
95
- schema.static('findDeleted', function findDeleted(filter) {
96
- return this.find({
102
+ schema.static('findDeleted', function findDeleted(filter, ...rest) {
103
+ filter = {
97
104
  ...filter,
98
105
  deleted: true,
99
- });
106
+ };
107
+ return this.find(filter, ...rest);
100
108
  });
101
109
 
102
- schema.static('findOneDeleted', function findOneDeleted(filter) {
103
- return this.findOne({
110
+ schema.static('findOneDeleted', function findOneDeleted(filter, ...rest) {
111
+ filter = {
104
112
  ...filter,
105
113
  deleted: true,
106
- });
114
+ };
115
+ return this.findOne(filter, ...rest);
107
116
  });
108
117
 
109
- schema.static('findByIdDeleted', function findByIdDeleted(id) {
110
- return this.findOne({
118
+ schema.static('findByIdDeleted', function findByIdDeleted(id, ...rest) {
119
+ const filter = {
111
120
  _id: id,
112
121
  deleted: true,
113
- });
122
+ };
123
+ return this.findOne(filter, ...rest);
114
124
  });
115
125
 
116
- schema.static('existsDeleted', function existsDeleted() {
117
- return this.exists({
126
+ schema.static('existsDeleted', function existsDeleted(filter, ...rest) {
127
+ filter = {
128
+ ...filter,
118
129
  deleted: true,
119
- });
130
+ };
131
+ return this.exists(filter, ...rest);
120
132
  });
121
133
 
122
134
  schema.static(
123
135
  'countDocumentsDeleted',
124
- function countDocumentsDeleted(filter) {
125
- return this.countDocuments({
136
+ function countDocumentsDeleted(filter, ...rest) {
137
+ filter = {
126
138
  ...filter,
127
139
  deleted: true,
128
- });
140
+ };
141
+ return this.countDocuments(filter, ...rest);
129
142
  }
130
143
  );
131
144
 
132
- schema.static('findWithDeleted', function findWithDeleted(filter) {
133
- return this.find({
134
- ...filter,
135
- ...getWithDeletedQuery(),
136
- });
137
- });
138
-
139
- schema.static('findOneWithDeleted', function findOneWithDeleted(filter) {
140
- return this.findOne({
145
+ schema.static('findWithDeleted', function findWithDeleted(filter, ...rest) {
146
+ filter = {
141
147
  ...filter,
142
148
  ...getWithDeletedQuery(),
143
- });
144
- });
145
-
146
- schema.static('findByIdWithDeleted', function findByIdWithDeleted(id) {
147
- return this.findOne({
148
- _id: id,
149
- ...getWithDeletedQuery(),
150
- });
151
- });
152
-
153
- schema.static('existsWithDeleted', function existsWithDeleted() {
154
- return this.exists({
155
- ...getWithDeletedQuery(),
156
- });
149
+ };
150
+ return this.find(filter, ...rest);
157
151
  });
158
152
 
159
153
  schema.static(
160
- 'countDocumentsWithDeleted',
161
- function countDocumentsWithDeleted(filter) {
162
- return this.countDocuments({
154
+ 'findOneWithDeleted',
155
+ function findOneWithDeleted(filter, ...rest) {
156
+ filter = {
163
157
  ...filter,
164
158
  ...getWithDeletedQuery(),
165
- });
159
+ };
160
+ return this.findOne(filter, ...rest);
166
161
  }
167
162
  );
168
163
 
169
- schema.method(
170
- 'remove',
171
- function () {
172
- warn(
173
- 'The "remove" method on documents is disallowed due to ambiguity.',
174
- 'To permanently delete a document use "destroy", otherwise "delete".'
175
- );
176
- throw new Error('Method not allowed.');
177
- },
178
- {
179
- suppressWarning: true,
164
+ schema.static(
165
+ 'findByIdWithDeleted',
166
+ function findByIdWithDeleted(id, ...rest) {
167
+ const filter = {
168
+ _id: id,
169
+ ...getWithDeletedQuery(),
170
+ };
171
+ return this.findOne(filter, ...rest);
180
172
  }
181
173
  );
182
174
 
183
- schema.method('deleteOne', function () {
184
- warn(
185
- 'The "deleteOne" method on documents is disallowed due to ambiguity',
186
- 'Use either "delete" or "deleteOne" on the model.'
187
- );
188
- throw new Error('Method not allowed.');
189
- });
190
-
191
- schema.static('remove', function () {
192
- warn(
193
- 'The "remove" method on models is disallowed due to ambiguity.',
194
- 'To permanently delete a document use "destroyMany", otherwise "deleteMany".'
195
- );
196
- throw new Error('Method not allowed.');
197
- });
198
-
199
- schema.static('findOneAndRemove', function () {
200
- warn(
201
- 'The "findOneAndRemove" method on models is disallowed due to ambiguity.',
202
- 'To permanently delete a document use "findOneAndDestroy", otherwise "findOneAndDelete".'
203
- );
204
- throw new Error('Method not allowed.');
205
- });
175
+ schema.static(
176
+ 'existsWithDeleted',
177
+ function existsWithDeleted(filter, ...rest) {
178
+ filter = {
179
+ ...filter,
180
+ ...getWithDeletedQuery(),
181
+ };
182
+ return this.exists(filter, ...rest);
183
+ }
184
+ );
206
185
 
207
- schema.static('findByIdAndRemove', function () {
208
- warn(
209
- 'The "findByIdAndRemove" method on models is disallowed due to ambiguity.',
210
- 'To permanently delete a document use "findByIdAndDestroy", otherwise "findByIdAndDelete".'
211
- );
212
- throw new Error('Method not allowed.');
213
- });
186
+ schema.static(
187
+ 'countDocumentsWithDeleted',
188
+ function countDocumentsWithDeleted(filter, ...rest) {
189
+ filter = {
190
+ ...filter,
191
+ ...getWithDeletedQuery(),
192
+ };
193
+ return this.countDocuments(filter, ...rest);
194
+ }
195
+ );
214
196
  }
215
197
 
216
198
  function getDelete() {
@@ -232,3 +214,174 @@ function getWithDeletedQuery() {
232
214
  deleted: { $in: [true, false] },
233
215
  };
234
216
  }
217
+
218
+ // Unique Constraints
219
+
220
+ function applyUniqueConstraints(schema) {
221
+ const hasUnique = hasUniqueConstraints(schema);
222
+
223
+ if (!hasUnique) {
224
+ return;
225
+ }
226
+
227
+ schema.pre('save', async function () {
228
+ await assertUnique(this.toObject(), {
229
+ operation: this.isNew ? 'create' : 'update',
230
+ model: this.constructor,
231
+ schema,
232
+ });
233
+ });
234
+
235
+ schema.pre(/^(update|replace)/, async function () {
236
+ await assertUniqueForQuery(this, {
237
+ schema,
238
+ });
239
+ });
240
+
241
+ schema.pre('insertMany', async function (next, obj) {
242
+ // Note that in order to access the objects to be inserted
243
+ // we must supply the hook with at least 2 arguments, the
244
+ // first of which is the next hook. This typically appears
245
+ // as the last argument, however as we are passing an async
246
+ // function it appears to not stop the middleware if we
247
+ // don't call it directly.
248
+ await assertUnique(obj, {
249
+ operation: 'create',
250
+ model: this,
251
+ schema,
252
+ });
253
+ });
254
+ }
255
+
256
+ export async function assertUnique(obj, options) {
257
+ const { operation, model, schema } = options;
258
+ const id = getId(obj);
259
+ const objFields = resolveUnique(schema, obj);
260
+ if (Object.keys(objFields).length === 0) {
261
+ return;
262
+ }
263
+ const query = {
264
+ $or: getUniqueQueries(objFields),
265
+ ...(id && {
266
+ _id: { $ne: id },
267
+ }),
268
+ };
269
+ const found = await model.findOne(query, {}, { lean: true });
270
+ if (found) {
271
+ const { modelName } = model;
272
+ const foundFields = resolveUnique(schema, found);
273
+ const collisions = getCollisions(objFields, foundFields).join(', ');
274
+ throw new Error(
275
+ `Cannot ${operation} ${modelName}. Duplicate fields exist: ${collisions}.`
276
+ );
277
+ }
278
+ }
279
+
280
+ function getId(arg) {
281
+ const id = arg.id || arg._id;
282
+ return id ? String(id) : null;
283
+ }
284
+
285
+ // Asserts than an update or insert query will not
286
+ // result in duplicate unique fields being present
287
+ // within non-deleted documents.
288
+ async function assertUniqueForQuery(query, options) {
289
+ let update = query.getUpdate();
290
+ const operation = getOperationForQuery(update);
291
+ // Note: No need to check unique constraints
292
+ // if the operation is a delete.
293
+ if (operation === 'restore' || operation === 'update') {
294
+ const { model } = query;
295
+ const filter = query.getFilter();
296
+ if (operation === 'restore') {
297
+ // A restore operation is functionally identical to a new
298
+ // insert so we need to fetch the deleted documents with
299
+ // all fields available to check against.
300
+ const docs = await model.findWithDeleted(filter, {}, { lean: true });
301
+ update = docs.map((doc) => {
302
+ return {
303
+ ...doc,
304
+ ...update,
305
+ };
306
+ });
307
+ }
308
+ await assertUnique(update, {
309
+ ...options,
310
+ operation,
311
+ model,
312
+ });
313
+ }
314
+ }
315
+
316
+ function getOperationForQuery(update) {
317
+ if (update?.deleted === false) {
318
+ return 'restore';
319
+ } else if (update?.deleted === true) {
320
+ return 'delete';
321
+ } else {
322
+ return 'update';
323
+ }
324
+ }
325
+
326
+ export function hasUniqueConstraints(schema) {
327
+ const paths = [...Object.keys(schema.paths), ...Object.keys(schema.subpaths)];
328
+ return paths.some((key) => {
329
+ return isUniquePath(schema, key);
330
+ });
331
+ }
332
+
333
+ function isUniquePath(schema, key) {
334
+ return schema.path(key)?.options?.softUnique === true;
335
+ }
336
+
337
+ // Returns a flattened map of key -> [...values]
338
+ // consisting of only paths defined as unique on the schema.
339
+ function resolveUnique(schema, obj, map = {}, path = []) {
340
+ if (Array.isArray(obj)) {
341
+ for (let el of obj) {
342
+ resolveUnique(schema, el, map, path);
343
+ }
344
+ } else if (obj && typeof obj === 'object') {
345
+ for (let [key, val] of Object.entries(obj)) {
346
+ resolveUnique(schema, val, map, [...path, key]);
347
+ }
348
+ } else if (obj) {
349
+ const key = path.join('.');
350
+ if (isUniquePath(schema, key)) {
351
+ map[key] ||= [];
352
+ map[key].push(obj);
353
+ }
354
+ }
355
+ return map;
356
+ }
357
+
358
+ // Argument is guaranteed to be flattened.
359
+ function getUniqueQueries(obj) {
360
+ return Object.entries(obj).map(([key, val]) => {
361
+ if (val.length > 1) {
362
+ return { [key]: { $in: val } };
363
+ } else {
364
+ return { [key]: val[0] };
365
+ }
366
+ });
367
+ }
368
+
369
+ // Both arguments here are guaranteed to be flattened
370
+ // maps of key -> [values] of unique fields only.
371
+ function getCollisions(obj1, obj2) {
372
+ const collisions = [];
373
+ for (let [key, arr1] of Object.entries(obj1)) {
374
+ const arr2 = obj2[key];
375
+ if (arr2) {
376
+ const hasCollision = arr1.some((val) => {
377
+ return arr2.includes(val);
378
+ });
379
+ if (hasCollision) {
380
+ collisions.push(key);
381
+ }
382
+ }
383
+ }
384
+ return collisions;
385
+ }
386
+
387
+ // Disallowed Methods
package/src/testing.js CHANGED
@@ -5,6 +5,13 @@ import { isMongooseSchema } from './utils';
5
5
 
6
6
  let counter = 0;
7
7
 
8
+ /**
9
+ * Helper to quickly create models for testing.
10
+ * Accepts a definition's `attributes` object and
11
+ * an optional model name as the first argument.
12
+ * [Link](https://github.com/bedrockio/model#testing)
13
+ * @returns mongoose.Model
14
+ */
8
15
  export function createTestModel(...args) {
9
16
  let modelName, attributes, schema;
10
17
  if (typeof args[0] === 'string') {