@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.
- package/README.md +24 -0
- package/dist/cjs/access.js +4 -0
- package/dist/cjs/assign.js +2 -3
- package/dist/cjs/disallowed.js +40 -0
- package/dist/cjs/include.js +3 -2
- package/dist/cjs/load.js +15 -3
- package/dist/cjs/schema.js +29 -12
- package/dist/cjs/search.js +16 -16
- package/dist/cjs/serialization.js +2 -3
- package/dist/cjs/soft-delete.js +234 -55
- package/dist/cjs/testing.js +8 -0
- package/dist/cjs/validation.js +109 -43
- package/package.json +9 -7
- package/src/access.js +3 -0
- package/src/disallowed.js +63 -0
- package/src/include.js +1 -0
- package/src/load.js +12 -1
- package/src/schema.js +25 -5
- package/src/search.js +21 -10
- package/src/soft-delete.js +238 -85
- package/src/testing.js +7 -0
- package/src/validation.js +134 -43
- package/types/access.d.ts +7 -0
- package/types/access.d.ts.map +1 -0
- package/types/assign.d.ts +2 -0
- package/types/assign.d.ts.map +1 -0
- package/types/const.d.ts +9 -0
- package/types/const.d.ts.map +1 -0
- package/types/disallowed.d.ts +2 -0
- package/types/disallowed.d.ts.map +1 -0
- package/types/errors.d.ts +9 -0
- package/types/errors.d.ts.map +1 -0
- package/types/include.d.ts +4 -0
- package/types/include.d.ts.map +1 -0
- package/types/index.d.ts +6 -0
- package/types/index.d.ts.map +1 -0
- package/types/load.d.ts +15 -0
- package/types/load.d.ts.map +1 -0
- package/types/references.d.ts +2 -0
- package/types/references.d.ts.map +1 -0
- package/types/schema.d.ts +71 -0
- package/types/schema.d.ts.map +1 -0
- package/types/search.d.ts +303 -0
- package/types/search.d.ts.map +1 -0
- package/types/serialization.d.ts +6 -0
- package/types/serialization.d.ts.map +1 -0
- package/types/slug.d.ts +2 -0
- package/types/slug.d.ts.map +1 -0
- package/types/soft-delete.d.ts +4 -0
- package/types/soft-delete.d.ts.map +1 -0
- package/types/testing.d.ts +11 -0
- package/types/testing.d.ts.map +1 -0
- package/types/utils.d.ts +8 -0
- package/types/utils.d.ts.map +1 -0
- package/types/validation.d.ts +13 -0
- package/types/validation.d.ts.map +1 -0
- package/types/warn.d.ts +2 -0
- package/types/warn.d.ts.map +1 -0
- package/babel.config.cjs +0 -41
- package/jest.config.js +0 -8
- package/test/assign.test.js +0 -225
- package/test/definitions/custom-model.json +0 -9
- package/test/definitions/special-category.json +0 -18
- package/test/include.test.js +0 -896
- package/test/load.test.js +0 -47
- package/test/references.test.js +0 -71
- package/test/schema.test.js +0 -919
- package/test/search.test.js +0 -652
- package/test/serialization.test.js +0 -748
- package/test/setup.js +0 -27
- package/test/slug.test.js +0 -112
- package/test/soft-delete.test.js +0 -333
- 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.
|
|
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
|
-
"
|
|
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.
|
|
27
|
-
"@bedrockio/yada": "^1.0.
|
|
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.
|
|
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
|
-
|
|
55
|
-
|
|
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
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
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(
|
|
85
|
-
keyword: yd
|
|
86
|
-
|
|
87
|
-
|
|
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
|
|
96
|
+
limit: yd
|
|
97
|
+
.number()
|
|
98
|
+
.positive()
|
|
99
|
+
.default(limit)
|
|
100
|
+
.description('Limits the number of results.'),
|
|
90
101
|
...rest,
|
|
91
102
|
};
|
|
92
103
|
}
|
package/src/soft-delete.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
'
|
|
161
|
-
function
|
|
162
|
-
|
|
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.
|
|
170
|
-
'
|
|
171
|
-
function () {
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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.
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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(
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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') {
|