@bedrockio/model 0.1.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.
- package/CHANGELOG.md +60 -0
- package/README.md +932 -0
- package/babel.config.cjs +41 -0
- package/dist/cjs/access.js +66 -0
- package/dist/cjs/assign.js +50 -0
- package/dist/cjs/const.js +16 -0
- package/dist/cjs/errors.js +17 -0
- package/dist/cjs/include.js +222 -0
- package/dist/cjs/index.js +62 -0
- package/dist/cjs/load.js +40 -0
- package/dist/cjs/package.json +1 -0
- package/dist/cjs/references.js +104 -0
- package/dist/cjs/schema.js +277 -0
- package/dist/cjs/search.js +266 -0
- package/dist/cjs/serialization.js +55 -0
- package/dist/cjs/slug.js +47 -0
- package/dist/cjs/soft-delete.js +192 -0
- package/dist/cjs/testing.js +33 -0
- package/dist/cjs/utils.js +73 -0
- package/dist/cjs/validation.js +313 -0
- package/dist/cjs/warn.js +13 -0
- package/jest-mongodb-config.js +10 -0
- package/jest.config.js +8 -0
- package/package.json +53 -0
- package/src/access.js +60 -0
- package/src/assign.js +45 -0
- package/src/const.js +9 -0
- package/src/errors.js +9 -0
- package/src/include.js +209 -0
- package/src/index.js +5 -0
- package/src/load.js +37 -0
- package/src/references.js +101 -0
- package/src/schema.js +286 -0
- package/src/search.js +263 -0
- package/src/serialization.js +49 -0
- package/src/slug.js +45 -0
- package/src/soft-delete.js +234 -0
- package/src/testing.js +29 -0
- package/src/utils.js +63 -0
- package/src/validation.js +329 -0
- package/src/warn.js +7 -0
- package/test/assign.test.js +225 -0
- package/test/definitions/custom-model.json +9 -0
- package/test/definitions/special-category.json +18 -0
- package/test/include.test.js +896 -0
- package/test/load.test.js +47 -0
- package/test/references.test.js +71 -0
- package/test/schema.test.js +919 -0
- package/test/search.test.js +652 -0
- package/test/serialization.test.js +748 -0
- package/test/setup.js +27 -0
- package/test/slug.test.js +112 -0
- package/test/soft-delete.test.js +333 -0
- package/test/validation.test.js +1925 -0
package/README.md
ADDED
|
@@ -0,0 +1,932 @@
|
|
|
1
|
+
# @bedrockio/model
|
|
2
|
+
|
|
3
|
+
Bedrock utilities for model creation.
|
|
4
|
+
|
|
5
|
+
- [Install](#install)
|
|
6
|
+
- [Dependencies](#dependencies)
|
|
7
|
+
- [Usage](#usage)
|
|
8
|
+
- [Schemas](#schemas)
|
|
9
|
+
- [Schema Extensions](#schema-extensions)
|
|
10
|
+
- [Attributes](#attributes)
|
|
11
|
+
- [Scopes](#scopes)
|
|
12
|
+
- [Tuples](#tuples)
|
|
13
|
+
- [Array Extensions](#array-extensions)
|
|
14
|
+
- [Features](#features)
|
|
15
|
+
- [Soft Delete](#soft-delete)
|
|
16
|
+
- [Validation](#validation)
|
|
17
|
+
- [Search](#search)
|
|
18
|
+
- [Includes](#includes)
|
|
19
|
+
- [Access Control](#access-control)
|
|
20
|
+
- [References](#references)
|
|
21
|
+
- [Assign](#assign)
|
|
22
|
+
- [Slugs](#slugs)
|
|
23
|
+
- [Testing](#testing)
|
|
24
|
+
|
|
25
|
+
## Install
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
yarn install @bedrockio/model
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Dependencies
|
|
32
|
+
|
|
33
|
+
Peer dependencies must be installed:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
yarn install mongoose
|
|
37
|
+
yarn install @bedrockio/yada
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Usage
|
|
41
|
+
|
|
42
|
+
Bedrock models are defined as flat JSON files to allow static analysis and inspection. They can be further extended to allow more functionality. The most straightforward way to load models is to use `loadModelDir` that points to the directory where JSON definitions exist:
|
|
43
|
+
|
|
44
|
+
```js
|
|
45
|
+
const { loadModelDir } = require('@bedrockio/model');
|
|
46
|
+
model.exports = loadModelDir('path/to/definitions/');
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Models that need to be extended can use the `createSchema` method with the definition and add to the schema as needed:
|
|
50
|
+
|
|
51
|
+
```js
|
|
52
|
+
const mongoose = require('mongoose');
|
|
53
|
+
const definition = require('./definitions/user.json');
|
|
54
|
+
|
|
55
|
+
const schema = createSchema(definition);
|
|
56
|
+
|
|
57
|
+
schema.virtual('name').get(function () {
|
|
58
|
+
return [this.firstName, this.lastName].join(' ');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
module.exports = mongoose.model('User', schema);
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
They can then be loaded individually alongside other models:
|
|
65
|
+
|
|
66
|
+
```js
|
|
67
|
+
const { loadModelDir } = require('@bedrockio/model');
|
|
68
|
+
model.exports = {
|
|
69
|
+
User: require('./user'),
|
|
70
|
+
...loadModelDir('./definitions');
|
|
71
|
+
}
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Schemas
|
|
75
|
+
|
|
76
|
+
The `attributes` field of model definitions can be considered equivalent to Mongoose, but defined in JSON with extended features:
|
|
77
|
+
|
|
78
|
+
```js
|
|
79
|
+
{
|
|
80
|
+
"attributes": {
|
|
81
|
+
// Shortcut for the syntax below.
|
|
82
|
+
"name1": "String",
|
|
83
|
+
// Defines further parameters on the type.
|
|
84
|
+
"name2": {
|
|
85
|
+
"type": "String",
|
|
86
|
+
"trim": true,
|
|
87
|
+
},
|
|
88
|
+
"email": {
|
|
89
|
+
"type": "String",
|
|
90
|
+
// Validation shortcuts
|
|
91
|
+
"validate": "email",
|
|
92
|
+
// Access control
|
|
93
|
+
"readAccess": ["admin"],
|
|
94
|
+
"writeAccess": ["admin"],
|
|
95
|
+
},
|
|
96
|
+
"tags": [
|
|
97
|
+
{
|
|
98
|
+
"type": "String"
|
|
99
|
+
}
|
|
100
|
+
],
|
|
101
|
+
// Arrays of mixed type
|
|
102
|
+
"mixed": [
|
|
103
|
+
{
|
|
104
|
+
"type": "Mixed"
|
|
105
|
+
}
|
|
106
|
+
],
|
|
107
|
+
// Extended tuple syntax
|
|
108
|
+
"location": ["Number", "Number"]
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Links:
|
|
114
|
+
|
|
115
|
+
- [Validation](#validation)
|
|
116
|
+
- [Access Control](#access-control)
|
|
117
|
+
|
|
118
|
+
### Schema Extensions
|
|
119
|
+
|
|
120
|
+
This package provides a number of extensions to assist schema creation outside the scope of Mongoose.
|
|
121
|
+
|
|
122
|
+
#### Attributes
|
|
123
|
+
|
|
124
|
+
Objects are easily defined with their attributes directly on the field:
|
|
125
|
+
|
|
126
|
+
```js
|
|
127
|
+
{
|
|
128
|
+
"profile": {
|
|
129
|
+
"firstName": "String",
|
|
130
|
+
"lastName": "String",
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
However it is common to need to add an option like `required` to an object schema. In Mongoose this is technically written as:
|
|
136
|
+
|
|
137
|
+
```js
|
|
138
|
+
{
|
|
139
|
+
"profile": {
|
|
140
|
+
"type": {
|
|
141
|
+
"firstName": "String",
|
|
142
|
+
"lastName": "String",
|
|
143
|
+
},
|
|
144
|
+
"required": true
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
However in complex cases this can be obtuse and difficult to remember. A more explicit syntax is allowed here:
|
|
150
|
+
|
|
151
|
+
```js
|
|
152
|
+
{
|
|
153
|
+
"profile": {
|
|
154
|
+
"type": "Object",
|
|
155
|
+
"attributes": {
|
|
156
|
+
"firstName": "String",
|
|
157
|
+
"lastName": "String",
|
|
158
|
+
},
|
|
159
|
+
"required": true
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
The type `Object` and `attributes` is a signal to create the correct schema for the above type. This can also be similarly used for `Array` for an array of objects:
|
|
165
|
+
|
|
166
|
+
```js
|
|
167
|
+
{
|
|
168
|
+
"profiles": {
|
|
169
|
+
"type": "Array",
|
|
170
|
+
"attributes": {
|
|
171
|
+
"firstName": "String",
|
|
172
|
+
"lastName": "String",
|
|
173
|
+
},
|
|
174
|
+
"writeAccess": "none"
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
In the above example the `writeAccess` applies to the array itself, not individual fields. Note that for an array of primitives the correct syntax is:
|
|
180
|
+
|
|
181
|
+
```js
|
|
182
|
+
{
|
|
183
|
+
"tokens": {
|
|
184
|
+
"type": ["String"],
|
|
185
|
+
"writeAccess": "none"
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
#### Scopes
|
|
191
|
+
|
|
192
|
+
One common need is to define multiple fields with the same options. A custom type `Scope` helps make this possible:
|
|
193
|
+
|
|
194
|
+
```js
|
|
195
|
+
{
|
|
196
|
+
"$private": {
|
|
197
|
+
"type": "Scope",
|
|
198
|
+
"readAccess": "none",
|
|
199
|
+
"writeAccess": "none",
|
|
200
|
+
"attributes": {
|
|
201
|
+
"firstName": "String",
|
|
202
|
+
"lastName": "String",
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
};
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
This syntax expands into the following:
|
|
209
|
+
|
|
210
|
+
```js
|
|
211
|
+
{
|
|
212
|
+
"firstName": {
|
|
213
|
+
"type": "String",
|
|
214
|
+
"readAccess": "none",
|
|
215
|
+
"writeAccess": "none",
|
|
216
|
+
},
|
|
217
|
+
"lastName": {
|
|
218
|
+
"type": "String",
|
|
219
|
+
"readAccess": "none",
|
|
220
|
+
"writeAccess": "none",
|
|
221
|
+
}
|
|
222
|
+
};
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
Note that the name `$private` is arbitrary. The `$` helps distinguish it from real fields, but it can be anything as the property is removed.
|
|
226
|
+
|
|
227
|
+
#### Tuples
|
|
228
|
+
|
|
229
|
+
Array fields that have more than one element are considered a "tuple". They will enforce an exact length and specific type for each element.
|
|
230
|
+
|
|
231
|
+
```js
|
|
232
|
+
{
|
|
233
|
+
"location": ["Number", "Number"],
|
|
234
|
+
}
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
This will map to the following:
|
|
238
|
+
|
|
239
|
+
```js
|
|
240
|
+
{
|
|
241
|
+
"location": {
|
|
242
|
+
"type": ["Mixed"],
|
|
243
|
+
"validator": // ...
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
Where `validator` is a special validator that enforces both the exact array length and content types.
|
|
249
|
+
|
|
250
|
+
Note that Mongoose [does not provide a way to enforce array elements of specific mixed types](https://github.com/Automattic/mongoose/issues/10894), requiring the `Mixed` type instead.
|
|
251
|
+
|
|
252
|
+
#### Array Extensions
|
|
253
|
+
|
|
254
|
+
A common need is to validate the length of an array or make it required by enforcing a minimum length of 1. However this does not exist in Mongoose:
|
|
255
|
+
|
|
256
|
+
```js
|
|
257
|
+
{
|
|
258
|
+
"tokens": {
|
|
259
|
+
"type": ["String"],
|
|
260
|
+
"required": true
|
|
261
|
+
}
|
|
262
|
+
};
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
The above syntax will not do anything as the default for arrays is always `[]` so the field will always exist. It also suffers from being ambiguous (is the array required or the elements inside?). An extension is provided here for explicit handling of this case:
|
|
266
|
+
|
|
267
|
+
```js
|
|
268
|
+
{
|
|
269
|
+
"tokens": {
|
|
270
|
+
"type": ["String"],
|
|
271
|
+
"minLength": 1,
|
|
272
|
+
"maxLength": 2
|
|
273
|
+
}
|
|
274
|
+
};
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
A custom validator will be created to enforce the array length, bringing parity with `minLength` and `maxLength` on strings.
|
|
278
|
+
|
|
279
|
+
### Gotchas
|
|
280
|
+
|
|
281
|
+
#### The `type` field is a special:
|
|
282
|
+
|
|
283
|
+
```js
|
|
284
|
+
{
|
|
285
|
+
"location": {
|
|
286
|
+
"type": "String",
|
|
287
|
+
"coordinates": ["Number"],
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
Given the above schema, let's say you want to add a default. The appropriate schema would be:
|
|
293
|
+
|
|
294
|
+
```js
|
|
295
|
+
{
|
|
296
|
+
"location": {
|
|
297
|
+
"type": {
|
|
298
|
+
"type": "String",
|
|
299
|
+
"coordinates": ["Number"],
|
|
300
|
+
},
|
|
301
|
+
"default": {
|
|
302
|
+
"type": "Point",
|
|
303
|
+
"coordinates": [0, 0],
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
However this is not a valid definition in Mongoose, which instead sees `type` and `default` as individual fields. A type definition and object schema unfortunately cannot be disambiguated in this case. [Syntax extentsions](#syntax-extensions) provides an escape hatch here:
|
|
310
|
+
|
|
311
|
+
```js
|
|
312
|
+
{
|
|
313
|
+
"location": {
|
|
314
|
+
"type": "Object",
|
|
315
|
+
"attributes": {
|
|
316
|
+
"type": "String",
|
|
317
|
+
"coordinates": ["Number"],
|
|
318
|
+
},
|
|
319
|
+
"default": {
|
|
320
|
+
"type": "Point",
|
|
321
|
+
"coordinates": [0, 0],
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
This will manually create a new nested subschema.
|
|
328
|
+
|
|
329
|
+
## Features
|
|
330
|
+
|
|
331
|
+
### Soft Delete
|
|
332
|
+
|
|
333
|
+
The soft delete module ensures that no documents are permanently deleted by default and provides helpful methods to query on and restore deleted documents. "Soft deletion" means that deleted documents have the properties `deleted` and `deletedAt`.
|
|
334
|
+
|
|
335
|
+
#### Instance Methods
|
|
336
|
+
|
|
337
|
+
- `delete` - Soft deletes the document.
|
|
338
|
+
- `restore` - Restores a soft deleted document.
|
|
339
|
+
- `destroy` - Deletes the document permanently.
|
|
340
|
+
|
|
341
|
+
#### Static Methods
|
|
342
|
+
|
|
343
|
+
- `deleteOne` - Soft deletes a single document.
|
|
344
|
+
- `deleteMany` - Soft deletes multiple documents.
|
|
345
|
+
- `restoreOne` - Restores a single document.
|
|
346
|
+
- `restoreMany` - Restores multiple documents.
|
|
347
|
+
- `destroyOne` - Permanently deletes a single document.
|
|
348
|
+
- `destroyMany` - Permanently deletes multiple documents. Be careful with this one.
|
|
349
|
+
|
|
350
|
+
#### Query Deleted Documents
|
|
351
|
+
|
|
352
|
+
- `findDeleted`
|
|
353
|
+
- `findOneDeleted`
|
|
354
|
+
- `findByIdDeleted`
|
|
355
|
+
- `existsDeleted`
|
|
356
|
+
- `countDocumentsDeleted`
|
|
357
|
+
|
|
358
|
+
#### Query All Documents
|
|
359
|
+
|
|
360
|
+
- `findWithDeleted`
|
|
361
|
+
- `findOneWithDeleted`
|
|
362
|
+
- `findByIdWithDeleted`
|
|
363
|
+
- `existsWithDeleted`
|
|
364
|
+
- `countDocumentsWithDeleted`
|
|
365
|
+
|
|
366
|
+
#### Other Static Methods
|
|
367
|
+
|
|
368
|
+
- `findOneAndDelete` - The soft equivalent of the [Mongoose method](https://mongoosejs.com/docs/api/model.html#model_Model-findOneAndDelete). Fetches the current data before deleting and returns the document.
|
|
369
|
+
- `findByIdAndDelete` - The soft equivalent of the [Mongoose method](https://mongoosejs.com/docs/api/model.html#model_Model-findByIdAndDelete). Fetches the current data before deleting and returns the document.
|
|
370
|
+
|
|
371
|
+
#### Disallowed Methods
|
|
372
|
+
|
|
373
|
+
Due to ambiguity with the soft delete module, the following methods will throw an error:
|
|
374
|
+
|
|
375
|
+
- `Document.remove` - Use `Document.delete` or `Document.destroy` instead.
|
|
376
|
+
- `Document.deleteOne` - Use `Document.delete` or `Model.deleteOne` instead.
|
|
377
|
+
|
|
378
|
+
- `Model.findOneAndRemove` - Use `Model.findOneAndDelete` instead.
|
|
379
|
+
- `Model.findByIdAndRemove` - Use `Model.findByIdAndDelete` instead.
|
|
380
|
+
|
|
381
|
+
### Validation
|
|
382
|
+
|
|
383
|
+
Models are extended with methods that allow complex validation that derives from the schema. Bedrock validation is generally used at the API level:
|
|
384
|
+
|
|
385
|
+
```js
|
|
386
|
+
const Router = require('@koa/router');
|
|
387
|
+
const router = new Router();
|
|
388
|
+
|
|
389
|
+
router.post(
|
|
390
|
+
'/',
|
|
391
|
+
validateBody(
|
|
392
|
+
User.getCreateValidation({
|
|
393
|
+
password: yd.string().password().required(),
|
|
394
|
+
})
|
|
395
|
+
),
|
|
396
|
+
async (ctx) => {
|
|
397
|
+
// ....
|
|
398
|
+
}
|
|
399
|
+
);
|
|
400
|
+
```
|
|
401
|
+
|
|
402
|
+
In the above example `getCreateValidation` returns a [yada](https://github.com/bedrockio/yada) schema that is validated in the `validateBody` middleware. The `password` field is an additional field that is appended to the create schema.
|
|
403
|
+
|
|
404
|
+
There are 3 main methods to generate schemas:
|
|
405
|
+
|
|
406
|
+
- `getCreateValidation`: Validates all fields while disallowing reserved fields like `id`, `createdAt`, and `updatedAt`.
|
|
407
|
+
- `getUpdateValidation`: Validates all fields as optional (ie. they will not be validated if they don't exist on the object). Additionally will strip out reserved fields to allow created objects to be passed in. Unknown fields will also be stripped out rather than error to allow virtuals to be passed in.
|
|
408
|
+
- `getSearchValidation`: Validates fields for use with [search](#search). The generated validation has a number of properties:
|
|
409
|
+
- In addition to the base field schemas, arrays or ranges are also allowed. See [search](#search) for more.
|
|
410
|
+
- The special fields `limit`, `sort`, `keyword`, `include`, and `ids` are also allowed.
|
|
411
|
+
- Array fields are "unwound". This means that for example given an array field `categories`, input may be either a string or an array of strings.
|
|
412
|
+
|
|
413
|
+
#### Named Validations
|
|
414
|
+
|
|
415
|
+
Named validations can be specified on the model:
|
|
416
|
+
|
|
417
|
+
```json
|
|
418
|
+
{
|
|
419
|
+
"email": {
|
|
420
|
+
"type": "String",
|
|
421
|
+
"validate": "email"
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
```
|
|
425
|
+
|
|
426
|
+
Validator functions are derived from [yada](https://github.com/bedrockio/yada#methods). Note that:
|
|
427
|
+
|
|
428
|
+
- `email` - Will additionally downcase any input.
|
|
429
|
+
- `password` - Is not supported as it requires options to be passed and is not a field stored directly in the database.
|
|
430
|
+
- `mongo` - Is instead represented in the models as `ObjectId` to have parity with `type`.
|
|
431
|
+
- `min` - Defined instead directly on the field with `minLength` for strings and `min` for numbers.
|
|
432
|
+
- `max` - Defined instead directly on the field with `maxLength` for strings and `max` for numbers.
|
|
433
|
+
|
|
434
|
+
### Search
|
|
435
|
+
|
|
436
|
+
Models are extended with a `search` method that allows for complex searching:
|
|
437
|
+
|
|
438
|
+
```js
|
|
439
|
+
const { data, meta } = await User.search();
|
|
440
|
+
```
|
|
441
|
+
|
|
442
|
+
The method takes the following options:
|
|
443
|
+
|
|
444
|
+
- `limit` - Limit for the query. Will be output in `meta`.
|
|
445
|
+
- `sort` - The sort for the query as an object containing a `field` and an `order` of `"asc"` or `"desc"`. May also be an array.
|
|
446
|
+
- `include` - Allows [include](#includes) based population.
|
|
447
|
+
- `keyword` - A keyword to perform a [keyword search](#keyword-search).
|
|
448
|
+
- `ids` - An array of document ids to search on.
|
|
449
|
+
- `fields` - Used by [keyword search](#keyword-search). Generally for internal use.
|
|
450
|
+
|
|
451
|
+
Any other fields passed in will be forwarded to `find`. The return value contains the found documents in `data` and `meta` which contains metadata about the search:
|
|
452
|
+
|
|
453
|
+
- `total` The total document count for the query.
|
|
454
|
+
- `limit` The limit for the query.
|
|
455
|
+
- `skip` The number skipped.
|
|
456
|
+
|
|
457
|
+
#### Advanced Searching
|
|
458
|
+
|
|
459
|
+
Input to `search` will execute the optimal mongo query and supports several advanced features:
|
|
460
|
+
|
|
461
|
+
- Array fields will be executed using `$in`.
|
|
462
|
+
- Javascript regular expressions will map to `$regex` which allows for [more advanced PCRE compatible features](https://docs.mongodb.com/manual/reference/operator/query/regex/#pcre-vs-javascript).
|
|
463
|
+
- Nested objects will be automatically flattened to query subdocuments:
|
|
464
|
+
|
|
465
|
+
```
|
|
466
|
+
{
|
|
467
|
+
profile: {
|
|
468
|
+
age: 20
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
```
|
|
472
|
+
|
|
473
|
+
will be flattened to:
|
|
474
|
+
|
|
475
|
+
```
|
|
476
|
+
{
|
|
477
|
+
'profile.age': 20
|
|
478
|
+
}
|
|
479
|
+
```
|
|
480
|
+
|
|
481
|
+
#### Range Based Search
|
|
482
|
+
|
|
483
|
+
Additionally, date and number fields allow range queries in the form:
|
|
484
|
+
|
|
485
|
+
```
|
|
486
|
+
age: {
|
|
487
|
+
gt: 1
|
|
488
|
+
lt: 2
|
|
489
|
+
}
|
|
490
|
+
```
|
|
491
|
+
|
|
492
|
+
A range query can use `lt`, `gt`, or both. Additionally `lte` and `gte` will query on less/greater than or equal values.
|
|
493
|
+
|
|
494
|
+
#### Keyword Search
|
|
495
|
+
|
|
496
|
+
Passing `keyword` to the search method will perform a keyword search. To use this feature a `fields` key must be present on the model definition:
|
|
497
|
+
|
|
498
|
+
```json
|
|
499
|
+
{
|
|
500
|
+
"attributes": {
|
|
501
|
+
"name": {
|
|
502
|
+
"type": "String"
|
|
503
|
+
}
|
|
504
|
+
"email": {
|
|
505
|
+
"type": "String"
|
|
506
|
+
}
|
|
507
|
+
},
|
|
508
|
+
"search": {
|
|
509
|
+
"fields": [
|
|
510
|
+
"name",
|
|
511
|
+
"email",
|
|
512
|
+
]
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
```
|
|
516
|
+
|
|
517
|
+
This will use the `$or` operator to search on multiple fields. If `fields` is not defined then a Mongo text query will be attempted:
|
|
518
|
+
|
|
519
|
+
```
|
|
520
|
+
{
|
|
521
|
+
$text: {
|
|
522
|
+
$search: keyword
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
```
|
|
526
|
+
|
|
527
|
+
Note that this will fail unless a text index is defined on the model.
|
|
528
|
+
|
|
529
|
+
#### Search Validation
|
|
530
|
+
|
|
531
|
+
The [validation](#validation) generated for search using `getSearchValidation` is inherently looser and allows more fields to be passed to allow complex searches compatible with the above.
|
|
532
|
+
|
|
533
|
+
### Includes
|
|
534
|
+
|
|
535
|
+
Populating foreign documents with [populate](https://mongoosejs.com/docs/populate.html) is a powerful feature of mongoose. In the past Bedrock has made use of the [autopopulate](https://plugins.mongoosejs.io/plugins/autopopulate) plugin, however has since moved away from this for two reasons:
|
|
536
|
+
|
|
537
|
+
1. Document population is highly situational. In complex real world applications a document may require deep population or none at all, however autopopulate does not allow this level of control.
|
|
538
|
+
2. Although circular references usually are the result of bad data modeling, in some cases they cannot be avoided. Autopopulate will keep loading these references until it reaches a maximum depth, even when the fetched data is redundant.
|
|
539
|
+
|
|
540
|
+
Both of these issues have major performance implications which result in slower queries and more unneeded data transfer over the wire.
|
|
541
|
+
|
|
542
|
+
For this reason calling `populate` manually is highly preferable, however in complex situations this can easily be a lot of overhead. The include module attempts to greatly streamline this process by adding an `include` method to queries:
|
|
543
|
+
|
|
544
|
+
```js
|
|
545
|
+
const product = await Product.findById(id).include([
|
|
546
|
+
'name',
|
|
547
|
+
'shop.email',
|
|
548
|
+
'shop.user.name',
|
|
549
|
+
'shop.user.address.line1',
|
|
550
|
+
'shop.customers.tags',
|
|
551
|
+
]);
|
|
552
|
+
```
|
|
553
|
+
|
|
554
|
+
This method accepts a string or array of strings that will map to a `populate` call that can be far more complex:
|
|
555
|
+
|
|
556
|
+
```js
|
|
557
|
+
const product = await Product.findById(id).populate([
|
|
558
|
+
{
|
|
559
|
+
select: ['name'],
|
|
560
|
+
populate: [
|
|
561
|
+
{
|
|
562
|
+
path: 'shop',
|
|
563
|
+
select: ['email'],
|
|
564
|
+
populate: [
|
|
565
|
+
{
|
|
566
|
+
path: 'user',
|
|
567
|
+
select: ['name', 'address.line1'],
|
|
568
|
+
populate: [],
|
|
569
|
+
},
|
|
570
|
+
{
|
|
571
|
+
path: 'customers',
|
|
572
|
+
select: ['tags'],
|
|
573
|
+
},
|
|
574
|
+
],
|
|
575
|
+
},
|
|
576
|
+
],
|
|
577
|
+
},
|
|
578
|
+
]);
|
|
579
|
+
```
|
|
580
|
+
|
|
581
|
+
In addition to brevity, one major advantage of using `include` is that the caller does not need to know whether the documents are subdocuments or foreign references. As Bedrock has knowledge of the schemas, it is able to build the appropriate call to `populate` for you.
|
|
582
|
+
|
|
583
|
+
#### Excluding Fields
|
|
584
|
+
|
|
585
|
+
Fields can be excluded rather than included using `-`:
|
|
586
|
+
|
|
587
|
+
```js
|
|
588
|
+
const user = await User.findById(id).include('-profile');
|
|
589
|
+
```
|
|
590
|
+
|
|
591
|
+
The above will return all fields except `profile`. Note that:
|
|
592
|
+
|
|
593
|
+
- Excluding fields only affects the `select` option. Foreign fields must still be passed, otherwise they will be returned unpopulated.
|
|
594
|
+
- An excluded field on a foreign reference will implicitly be populated. This means that passing `-profile.name` where `profile` is a foreign field will populate `profile` but exclude `name`.
|
|
595
|
+
|
|
596
|
+
#### Wildcards
|
|
597
|
+
|
|
598
|
+
Multiple fields can be selected using wildcards:
|
|
599
|
+
|
|
600
|
+
- `*` - Matches anything except `.`.
|
|
601
|
+
- `**` - Matches anything including `.`.
|
|
602
|
+
|
|
603
|
+
```js
|
|
604
|
+
// Assuming a schema of:
|
|
605
|
+
// {
|
|
606
|
+
// "firstName": "String"
|
|
607
|
+
// "lastName": "String"
|
|
608
|
+
// }
|
|
609
|
+
const user = await User.findById(id).include('*Name');
|
|
610
|
+
```
|
|
611
|
+
|
|
612
|
+
The example above will select both `firstName` and `lastName`.
|
|
613
|
+
|
|
614
|
+
```js
|
|
615
|
+
// Assuming a schema of:
|
|
616
|
+
// {
|
|
617
|
+
// "profile1": {
|
|
618
|
+
// "address": {
|
|
619
|
+
// "phone": "String"
|
|
620
|
+
// }
|
|
621
|
+
// },
|
|
622
|
+
// "profile2": {
|
|
623
|
+
// "address": {
|
|
624
|
+
// "phone": "String"
|
|
625
|
+
// }
|
|
626
|
+
// }
|
|
627
|
+
// }
|
|
628
|
+
const user = await User.findById(id).include('**.phone');
|
|
629
|
+
```
|
|
630
|
+
|
|
631
|
+
This example above will select both `profile1.address.phone` and `profile2.address.phone`. Compare this to `*` which will not match here.
|
|
632
|
+
|
|
633
|
+
Note that wildcards do not implicitly populate foreign fields. For example passing `p*` where `profile` is a foreign field will include all fields matching `p*` but it will not populate the `profile` field. In this case an array must be used instead:
|
|
634
|
+
|
|
635
|
+
```js
|
|
636
|
+
const user = await User.findById(id).include(['p*', 'profile']);
|
|
637
|
+
```
|
|
638
|
+
|
|
639
|
+
#### Searching with includes
|
|
640
|
+
|
|
641
|
+
Note that [search](#search), which returns a query, can also use `include`:
|
|
642
|
+
|
|
643
|
+
```js
|
|
644
|
+
const user = await User.search({
|
|
645
|
+
firstName: 'Frank',
|
|
646
|
+
}).include('profile');
|
|
647
|
+
```
|
|
648
|
+
|
|
649
|
+
#### Include as a Filter
|
|
650
|
+
|
|
651
|
+
Additionally `include` is flagged as a special parameter for filters, allowing the following equivalent syntax on `search` as well as all `find` methods:
|
|
652
|
+
|
|
653
|
+
```js
|
|
654
|
+
const user = await User.find({
|
|
655
|
+
firstName: 'Frank',
|
|
656
|
+
include: 'profile',
|
|
657
|
+
});
|
|
658
|
+
```
|
|
659
|
+
|
|
660
|
+
#### Validation with includes
|
|
661
|
+
|
|
662
|
+
The [validation](#validation) methods additionally allow `include` as a special field on generated schemas. This allows the client to drive document inclusion on a case by case basis. For example, given a typical Bedrock setup:
|
|
663
|
+
|
|
664
|
+
```js
|
|
665
|
+
const Router = require('@koa/router');
|
|
666
|
+
const router = new Router();
|
|
667
|
+
|
|
668
|
+
router.post('/', validateBody(User.getSearchValidation()), async (ctx) => {
|
|
669
|
+
const { data, meta } = await User.search(ctx.request.body);
|
|
670
|
+
ctx.body = {
|
|
671
|
+
data,
|
|
672
|
+
};
|
|
673
|
+
});
|
|
674
|
+
```
|
|
675
|
+
|
|
676
|
+
The `getSearchValidation` will allow the `include` property to be passed, letting the client populate documents as they require. Note that the fields a client is able to include is subject to [access control](#access-control).
|
|
677
|
+
|
|
678
|
+
### Access Control
|
|
679
|
+
|
|
680
|
+
This package applies two forms of access control:
|
|
681
|
+
|
|
682
|
+
#### Read Access
|
|
683
|
+
|
|
684
|
+
Read access influences how documents are serialized. Fields that have been denied access will be stripped out. Additionally it will influence the validation schema for `getSearchValidation`. Fields that have been denied access are not allowed to be searched on and will throw an error.
|
|
685
|
+
|
|
686
|
+
#### Write Access
|
|
687
|
+
|
|
688
|
+
Write access influences validation in `getCreateValidation` and `getUpdateValidation`. Fields that have been denied access will throw an error unless they are identical to what is already set on the document. Note that in the case of `getCreateValidation` no document has been created yet so a denied field will always result in an error if passed.
|
|
689
|
+
|
|
690
|
+
#### Defining Access
|
|
691
|
+
|
|
692
|
+
Access is defined in schemas with the `readAccess` and `writeAccess` options:
|
|
693
|
+
|
|
694
|
+
```js
|
|
695
|
+
{
|
|
696
|
+
"name": {
|
|
697
|
+
"type": "String",
|
|
698
|
+
"readAccess": "none"
|
|
699
|
+
"writeAccess": "none"
|
|
700
|
+
},
|
|
701
|
+
}
|
|
702
|
+
```
|
|
703
|
+
|
|
704
|
+
This may be either a string or an array of strings. For multiple fields with the same access types, use a [scope](#scopes).
|
|
705
|
+
|
|
706
|
+
##### Access on Arrays
|
|
707
|
+
|
|
708
|
+
Note that on array fields the following schema is often used:
|
|
709
|
+
|
|
710
|
+
```js
|
|
711
|
+
{
|
|
712
|
+
"tokens": [
|
|
713
|
+
{
|
|
714
|
+
"type": "String",
|
|
715
|
+
"readAccess": "none",
|
|
716
|
+
},
|
|
717
|
+
],
|
|
718
|
+
};
|
|
719
|
+
```
|
|
720
|
+
|
|
721
|
+
However this is not technically correct as the `readAccess` above is referring to the `tokens` array instead of individual elements. The correct schema is technically written:
|
|
722
|
+
|
|
723
|
+
```js
|
|
724
|
+
{
|
|
725
|
+
"tokens": {
|
|
726
|
+
"type": ["String"],
|
|
727
|
+
"readAccess": "none",
|
|
728
|
+
},
|
|
729
|
+
}
|
|
730
|
+
```
|
|
731
|
+
|
|
732
|
+
However this is overhead and hard to remember, so `readAccess` and `writeAccess` will be hoisted to the array field itself as a special case. Note that only these two fields will be hoisted as other fields like `validate` and `default` are correctly defined on the string itself.
|
|
733
|
+
|
|
734
|
+
#### Access Types
|
|
735
|
+
|
|
736
|
+
`readAccess` and `writeAccess` can specify any token. However a few special tokens exist:
|
|
737
|
+
|
|
738
|
+
- `all` - Allows access to anyone. This token is reserved for clarity but is not required as it is the default.
|
|
739
|
+
- `none` - Allows access to no-one.
|
|
740
|
+
- `self` - See [document based access](#document-based-access).
|
|
741
|
+
- `user` - See [document based access](#document-based-access).
|
|
742
|
+
- `owner` - See [document based access](#document-based-access).
|
|
743
|
+
|
|
744
|
+
Any other token will use [scope based access](#scope-based-access).
|
|
745
|
+
|
|
746
|
+
##### Scope Based Access
|
|
747
|
+
|
|
748
|
+
A non-reserved token specified in `readAccess` or `writeAccess` will test against scopes in the generated validations or when serializing:
|
|
749
|
+
|
|
750
|
+
```js
|
|
751
|
+
// In validation middleware:
|
|
752
|
+
const schema = User.getCreateSchema();
|
|
753
|
+
await schema.validate(ctx.request.body, {
|
|
754
|
+
scopes: authUser.getScopes(),
|
|
755
|
+
// Also accepted:
|
|
756
|
+
scope: '...',
|
|
757
|
+
});
|
|
758
|
+
// In routes:
|
|
759
|
+
document.toObject({
|
|
760
|
+
scopes: authUser.getScopes(),
|
|
761
|
+
// Also accepted:
|
|
762
|
+
scope: '...',
|
|
763
|
+
});
|
|
764
|
+
```
|
|
765
|
+
|
|
766
|
+
Note that scopes are just literal strings. For example a route already checking that the user is admin may simply pass `.toObject({ scope: 'admin' })`. However for more complex cases scopes are typically derived from the authUser's roles.
|
|
767
|
+
|
|
768
|
+
##### Document Based Access
|
|
769
|
+
|
|
770
|
+
Will compare a `document` or it's properties against the id of an `authUser`.
|
|
771
|
+
|
|
772
|
+
Document based access allows 3 different tokens:
|
|
773
|
+
|
|
774
|
+
- `self` - Compares `authUser.id == document.id`.
|
|
775
|
+
- `user` - Compares `authUser.id == document.user.id`.
|
|
776
|
+
- `owner` - Compares `authUser.id == document.owner.id`.
|
|
777
|
+
|
|
778
|
+
Using document based access comes with some requirements:
|
|
779
|
+
|
|
780
|
+
1. Read access must use `.toObject({ authUser })`. Note that the document is not required here as a reference is already kept.
|
|
781
|
+
2. Write access must use `schema.validate(body, { authUser, document })`.
|
|
782
|
+
|
|
783
|
+
#### Examples
|
|
784
|
+
|
|
785
|
+
For clarity, here are a few examples about how document based access control should be used:
|
|
786
|
+
|
|
787
|
+
##### Example 1
|
|
788
|
+
|
|
789
|
+
A user is allowed to update their own date of birth, but not their email which is set after verification:
|
|
790
|
+
|
|
791
|
+
```js
|
|
792
|
+
// user.json
|
|
793
|
+
{
|
|
794
|
+
"email": {
|
|
795
|
+
"type": "String",
|
|
796
|
+
"writeAccess": "none"
|
|
797
|
+
},
|
|
798
|
+
"dob": {
|
|
799
|
+
"type": "String",
|
|
800
|
+
"writeAccess": "self"
|
|
801
|
+
},
|
|
802
|
+
}
|
|
803
|
+
```
|
|
804
|
+
|
|
805
|
+
##### Example 2
|
|
806
|
+
|
|
807
|
+
A user is allowed to update the name of their own shop and admins can as well. However, only admins can set the owner of the shop:
|
|
808
|
+
|
|
809
|
+
```json
|
|
810
|
+
// shop.json
|
|
811
|
+
{
|
|
812
|
+
"name": {
|
|
813
|
+
"type": "String",
|
|
814
|
+
"writeAccess": ["owner", "admin"]
|
|
815
|
+
},
|
|
816
|
+
"owner": {
|
|
817
|
+
"type": "ObjectId",
|
|
818
|
+
"ref": "User",
|
|
819
|
+
"writeAccess": "admin"
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
```
|
|
823
|
+
|
|
824
|
+
##### Example 3
|
|
825
|
+
|
|
826
|
+
A user is allowed to update the fact that they have received their medical report, but nothing else. The medical report is received externally so even admins are not allowed to change the user they belong to.
|
|
827
|
+
|
|
828
|
+
The difference with `owner` here is the name only, however both options exist as a `user` defined on a schema does not necessarily represent the document's owner, as this example illustrates:
|
|
829
|
+
|
|
830
|
+
```js
|
|
831
|
+
// medical-report.json
|
|
832
|
+
{
|
|
833
|
+
"received": {
|
|
834
|
+
"type": "String",
|
|
835
|
+
"writeAccess": "user"
|
|
836
|
+
},
|
|
837
|
+
"user": {
|
|
838
|
+
"type": "ObjectId",
|
|
839
|
+
"ref": "User",
|
|
840
|
+
"writeAccess": "none"
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
```
|
|
844
|
+
|
|
845
|
+
#### Notes on Read Access
|
|
846
|
+
|
|
847
|
+
Note that all forms of read access require that `.toObject` is called on the document with special parameters, however this method is called on internal serialization including both `JSON.stringify` and logging to the console. For this reason it will never fail even if it cannot perform the correct access checks. Instead any fields with `readAccess` defined on them will be stripped out.
|
|
848
|
+
|
|
849
|
+
#### Notes on Write Access
|
|
850
|
+
|
|
851
|
+
Note that `self` is generally only meaningful on a User model as it will always check the document is the same as `authUser`.
|
|
852
|
+
|
|
853
|
+
### References
|
|
854
|
+
|
|
855
|
+
This module adds a single method `assertNoReferences` to the schema. This is useful on delete operations to throw an error if there are external references to the document. It takes an options object with a single option `except` as an array:
|
|
856
|
+
|
|
857
|
+
```js
|
|
858
|
+
router.delete('/:id', async (ctx) => {
|
|
859
|
+
const { shop } = ctx.state;
|
|
860
|
+
try {
|
|
861
|
+
await shop.assertNoReferences({
|
|
862
|
+
except: [AuditEntry],
|
|
863
|
+
});
|
|
864
|
+
} catch (err) {
|
|
865
|
+
console.info(err.references);
|
|
866
|
+
ctx.throw(400, err.message);
|
|
867
|
+
}
|
|
868
|
+
await user.delete();
|
|
869
|
+
ctx.status = 204;
|
|
870
|
+
});
|
|
871
|
+
```
|
|
872
|
+
|
|
873
|
+
The above example will throw an error if shop is referenced externally (for example by a a `Product`), however it will not count `AuditEntry` references. Note that a `references` property on the thrown error will contain the found references.
|
|
874
|
+
|
|
875
|
+
### Assign
|
|
876
|
+
|
|
877
|
+
Applies a single instance method `assign` to documents:
|
|
878
|
+
|
|
879
|
+
```js
|
|
880
|
+
user.assign(ctx.request.body);
|
|
881
|
+
// Compare to:
|
|
882
|
+
Object.assign(user, ctx.request.body);
|
|
883
|
+
```
|
|
884
|
+
|
|
885
|
+
This is functionally identical to `Object.assign` with the exception that `ObjectId` reference fields can be unset by passing falsy values. This method is provided as `undefined` cannot be represented in JSON which requires using either a `null` or empty string, both of which would be stored in the database if naively assigned with `Object.assign`.
|
|
886
|
+
|
|
887
|
+
### Slugs
|
|
888
|
+
|
|
889
|
+
A common requirement is to allow slugs on documents to serve as ids for human readable URLs. To load a single document this way the naive approach would be to run a search on all documents matching the `slug` then pull the first one off.
|
|
890
|
+
|
|
891
|
+
This module simplifies this by assuming a `slug` field on a model and adding a `findByIdOrSlug` method that allows searching on both:
|
|
892
|
+
|
|
893
|
+
```js
|
|
894
|
+
const post = await Post.findByIdOrSlug(str);
|
|
895
|
+
```
|
|
896
|
+
|
|
897
|
+
Note that soft delete methods are also applied:
|
|
898
|
+
|
|
899
|
+
- `findByIdOrSlugDeleted`
|
|
900
|
+
- `findByIdOrSlugWithDeleted`
|
|
901
|
+
|
|
902
|
+
Also note that as Mongo ids are represented as 24 byte hexadecimal a collision is possible:
|
|
903
|
+
|
|
904
|
+
- `deadbeefdeadbeefdeadbeef`
|
|
905
|
+
- `cafecafecafecafecafecafe`
|
|
906
|
+
|
|
907
|
+
However the likelyhood of such collisions on a slug are acceptably small.
|
|
908
|
+
|
|
909
|
+
## Testing
|
|
910
|
+
|
|
911
|
+
A helper `createTestModel` is exported to allow quickly building models for testing:
|
|
912
|
+
|
|
913
|
+
```js
|
|
914
|
+
const { createTestModel } = require('@bedrockio/model');
|
|
915
|
+
|
|
916
|
+
const User = createTestModel({
|
|
917
|
+
name: 'String',
|
|
918
|
+
});
|
|
919
|
+
mk;
|
|
920
|
+
```
|
|
921
|
+
|
|
922
|
+
Note that a unique model name will be generated to prevent clashing with other models. This can be accessed with `Model.modelName` or to make tests more readable it can be overridden:
|
|
923
|
+
|
|
924
|
+
```js
|
|
925
|
+
const { createTestModel } = require('@bedrockio/model');
|
|
926
|
+
|
|
927
|
+
const Post = createTestModel('Post', {
|
|
928
|
+
name: 'String',
|
|
929
|
+
});
|
|
930
|
+
```
|
|
931
|
+
|
|
932
|
+
Make sure in this case that the model name is unique.
|