monastery 1.31.3 → 1.31.7

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 CHANGED
@@ -0,0 +1,46 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.
4
+
5
+ ### [1.31.7](https://github.com/boycce/monastery/compare/1.31.6...1.31.7) (2022-02-28)
6
+
7
+
8
+ ### Bug Fixes
9
+
10
+ * refactored crud ops ([e7f3f78](https://github.com/boycce/monastery/commit/e7f3f784e123e4a66586a4d9e733d5cac477b98b))
11
+
12
+ ### [1.31.6](https://github.com/boycce/monastery/compare/1.31.5...1.31.6) (2022-02-25)
13
+
14
+
15
+ ### Bug Fixes
16
+
17
+ * added partial unqiue index tests ([ff6f193](https://github.com/boycce/monastery/commit/ff6f1938e333407ee17895873d2b42fa5263d7e3))
18
+ * scripts ([bc32680](https://github.com/boycce/monastery/commit/bc326809098ae24686158a0386fbbd6671d86c98))
19
+
20
+ ### [1.31.5](https://github.com/boycce/monastery/compare/1.31.4...1.31.5) (2022-02-15)
21
+
22
+
23
+ ### Bug Fixes
24
+
25
+ * scripts ([417ba13](https://github.com/boycce/monastery/commit/417ba13c1a0862f76fadf97d6d6d063a74e196bd))
26
+
27
+ ### 1.31.4 (2022-02-15)
28
+
29
+
30
+ ### Bug Fixes
31
+
32
+ * _callAfterFind bug ([e62472c](https://github.com/boycce/monastery/commit/e62472c191119135839b6d9e42b7f060bc7a508d))
33
+ * .eslintrc.json ([5362585](https://github.com/boycce/monastery/commit/53625857bbf798db97eddad9385799cb1ded97e2))
34
+ * docs ([d8f4e15](https://github.com/boycce/monastery/commit/d8f4e15913f672295cc118fda11ec23a412b4c62))
35
+ * docs ([1a5118e](https://github.com/boycce/monastery/commit/1a5118e4b389b55d30bf987991b59638b11613a9))
36
+ * docs ([564572c](https://github.com/boycce/monastery/commit/564572ce33dfb35fdd131e81d1dcc655f024b26e))
37
+ * docs ([12d37d3](https://github.com/boycce/monastery/commit/12d37d3b2d0b5ec577f9ed4deb9c929c8ea52a36))
38
+ * docs and nav links ([0eabcf0](https://github.com/boycce/monastery/commit/0eabcf0cd9a119a6ab1a07b92634e316995a2a83))
39
+ * model-crud ([d421709](https://github.com/boycce/monastery/commit/d421709a70e6611c78e049e98268153c9bafae6d))
40
+ * normalise afterFind ([0ab7f43](https://github.com/boycce/monastery/commit/0ab7f43f25b599e07d9ae751dc3bac8550e53c24))
41
+ * normalised rule arguments and context ([6ba48da](https://github.com/boycce/monastery/commit/6ba48da3b9c643620cebf5442e60bd0318d6780f))
42
+ * package scripts ([f7935af](https://github.com/boycce/monastery/commit/f7935afb0181ddb3e397bf804b34c841589dfcf0))
43
+ * semver ([6f14909](https://github.com/boycce/monastery/commit/6f14909f4405cf26dc04a8603cc3bac232b96798))
44
+ * standard-version ([4627694](https://github.com/boycce/monastery/commit/46276948f76b22eae946147f488c7e734a88c023))
45
+ * standard-version ([f553b08](https://github.com/boycce/monastery/commit/f553b08445eb7dd2e85f6bb447e2bb0bc38dda34))
46
+ * util bug, updated tests ([bec1887](https://github.com/boycce/monastery/commit/bec1887f56cb8582b606a066c913c191362a61b0))
package/docs/Gemfile CHANGED
@@ -1,10 +1,4 @@
1
- # Old
2
- # source 'https://rubygems.org'
3
- # gem 'github-pages', group: :jekyll_plugins
4
-
5
- # Below pulls the latest remote_theme in development
6
1
  source 'https://rubygems.org'
7
- gem "github-docs", git: "https://github.com/boycce/github-docs"
8
- group :jekyll_plugins do
9
- gem "jekyll-remote-theme", "~> 0.4.2"
10
- end
2
+
3
+ # Development (strict gem versions used)
4
+ gem 'github-pages', group: :jekyll_plugins
package/docs/_config.yml CHANGED
@@ -2,6 +2,7 @@ remote_theme: boycce/github-docs
2
2
  title: Monastery
3
3
  description: A straight forward MongoDB ODM built upon MonkJS
4
4
  github_url: "https://github.com/boycce/monastery"
5
+ port: 4001
5
6
  basedir: "docs"
6
7
 
7
8
  # Aux links for the naviation.
@@ -1,6 +1,7 @@
1
1
  ---
2
2
  title: Schema
3
3
  nav_order: 4
4
+ has_children: true
4
5
  ---
5
6
 
6
7
  # Schema
@@ -166,6 +167,24 @@ await db.user.insert({
166
167
  }
167
168
  ```
168
169
 
170
+ Since unique indexes by default don't allow mutliple documents with `null`, you use a partial index (less performant), e.g.
171
+
172
+ ```js
173
+
174
+ schema.fields = {
175
+ index: {
176
+ name: {
177
+ type: 'string',
178
+ index: {
179
+ type: 'unique',
180
+ partialFilterExpression: {
181
+ email: { $type: 'string' }
182
+ }
183
+ }
184
+ }
185
+ }
186
+ ```
187
+
169
188
  ### Custom validation rules
170
189
 
171
190
  You are able to define custom validation rules to use. (`this` will refer to the data object passed in)
@@ -1,6 +1,7 @@
1
1
  ---
2
2
  title: Rules
3
3
  nav_order: 5
4
+ parent: Schema
4
5
  ---
5
6
 
6
7
  # Validation Rules
package/lib/model-crud.js CHANGED
@@ -2,15 +2,15 @@ let util = require('./util')
2
2
 
3
3
  module.exports = {
4
4
 
5
- insert: function(opts, cb) {
5
+ insert: async function(opts, cb) {
6
6
  /**
7
7
  * Inserts document(s) with monk after validating data & before hooks.
8
8
  * @param {object} opts
9
9
  * @param {object|array} <opts.data> - documents to insert
10
10
  * @param {array|string|false} <opts.blacklist> - augment schema.insertBL, `false` will remove all blacklisting
11
11
  * @param {boolean} <opts.respond> - automatically call res.json/error (requires opts.req)
12
- * @param {array|string|false} validateUndefined - ignore all required fields during insert, or
13
- * undefined subdocument required fields that have a defined parent/grandparent during update
12
+ * @param {array|string|false} validateUndefined - validates all 'required' undefined fields, true by
13
+ * default, but false on update
14
14
  * @param {array|string|true} <opts.skipValidation> - skip validation for this field name(s)
15
15
  * @param {boolean} <opts.timestamps> - whether `createdAt` and `updatedAt` are automatically inserted
16
16
  * @param {any} <opts.any> - any mongodb option
@@ -18,44 +18,36 @@ module.exports = {
18
18
  * @this model
19
19
  * @return promise
20
20
  */
21
- opts = opts || {}
22
- opts.insert = true
23
- opts.model = this
24
- let req = opts.req
25
- let data = opts.data = util.isDefined(opts.data)? opts.data : util.isDefined((req||{}).body) ? req.body : {}
26
- let options = util.omit(opts, [
27
- 'data', 'insert', 'model', 'respond', 'validateUndefined', 'skipValidation', 'blacklist'
28
- ])
29
21
  if (cb && !util.isFunction(cb)) {
30
22
  throw new Error(`The callback passed to ${this.name}.insert() is not a function`)
31
23
  }
32
- return util.parseData(data).then(data => {
33
- opts.data = data
34
- return this.validate(data, { ...opts })
35
-
36
- }).then(data => {
37
- return util.runSeries(this.beforeInsert.map(f => f.bind(opts, data))).then(() => data)
38
-
39
- }).then(data => {
40
- return this._insert(data, options)
41
-
42
- }).then(data => {
43
- return util.runSeries(this.afterInsert.map(f => f.bind(opts, data))).then(() => data)
44
-
45
- // Success/error
46
- }).then(data => {
47
- if (cb) cb(null, data)
48
- else if (opts.req && opts.respond) opts.req.res.json(data)
49
- else return Promise.resolve(data)
50
-
51
- }).catch(err => {
24
+ try {
25
+ opts = await this._queryObject(opts, 'insert')
26
+ let options = util.omit(opts, [
27
+ 'data', 'insert', 'model', 'respond', 'validateUndefined', 'skipValidation', 'blacklist'
28
+ ])
29
+
30
+ // Validate
31
+ opts.data = await this.validate(opts.data||{}, { ...opts })
32
+
33
+ // Insert
34
+ await util.runSeries(this.beforeInsert.map(f => f.bind(opts, opts.data)))
35
+ let response = await this._insert(opts.data, options)
36
+ await util.runSeries(this.afterInsert.map(f => f.bind(opts, response)))
37
+
38
+ // Success/error
39
+ if (cb) cb(null, response)
40
+ else if (opts.req && opts.respond) opts.req.res.json(response)
41
+ else return Promise.resolve(response)
42
+
43
+ } catch (err) {
52
44
  if (cb) cb(err)
53
45
  else if (opts && opts.req && opts.respond) opts.req.res.error(err)
54
46
  else throw err
55
- })
47
+ }
56
48
  },
57
49
 
58
- find: function(opts, cb, one) {
50
+ find: async function(opts, cb, one) {
59
51
  /**
60
52
  * Finds document(s) with monk, also auto populates
61
53
  * @param {object} opts
@@ -69,19 +61,15 @@ module.exports = {
69
61
  * @this model
70
62
  * @return promise
71
63
  */
72
- let options, lookups = []
73
64
  if (cb && !util.isFunction(cb)) {
74
65
  throw new Error(`The callback passed to ${this.name}.find() is not a function`)
75
66
  }
76
- return new Promise((resolve, reject) => {
77
- opts = this._queryObject(opts)
78
- opts.one = one || opts.one
79
- // Operation options
80
- options = util.omit(opts, ['blacklist', 'one', 'populate', 'project', 'query', 'respond'])
81
- options.sort = options.sort || { 'createdAt': -1 }
82
- options.skip = Math.max(0, options.skip || 0)
83
- options.limit = opts.one? 1 : parseInt(options.limit || this.manager.limit || 0)
67
+ try {
68
+ opts = await this._queryObject(opts, 'find', one)
69
+ let lookups = []
70
+ let options = util.omit(opts, ['blacklist', 'one', 'populate', 'project', 'query', 'respond'])
84
71
  options.addFields = options.addFields || {}
72
+
85
73
  // Project, or use blacklisting
86
74
  if (opts.project) {
87
75
  // Can be an inclusion or exclusion projection
@@ -105,14 +93,10 @@ module.exports = {
105
93
  // options.projection.score = { $meta: 'textScore' }
106
94
  // options.sort = { score: { $meta: 'textScore' }}
107
95
  // }
108
- // Sort string passed
109
- if (util.isString(options.sort)) {
110
- let name = (options.sort.match(/([a-z0-9-_]+)/) || [])[0]
111
- let order = (options.sort.match(/:(-?[0-9])/) || [])[1]
112
- options.sort = { [name]: parseInt(order || 1) }
113
- }
114
96
  // Wanting to populate?
115
- if (opts.populate) {
97
+ if (!opts.populate) {
98
+ var response = await this[`_find${opts.one? 'One' : ''}`](opts.query, options)
99
+ } else {
116
100
  loop: for (let item of opts.populate) {
117
101
  let path = util.isObject(item)? item.as : item
118
102
  // Blacklisted?
@@ -148,7 +132,7 @@ module.exports = {
148
132
  }
149
133
  // console.log(1, options.projection)
150
134
  // console.log(2, lookups)
151
- let aggreagte = [
135
+ let aggregate = [
152
136
  { $match: opts.query },
153
137
  { $sort: options.sort },
154
138
  { $skip: options.skip },
@@ -157,49 +141,45 @@ module.exports = {
157
141
  ...util.isEmpty(options.addFields)? [] : [{ $addFields: options.addFields }],
158
142
  { $project: options.projection }
159
143
  ]
160
- var operation = this._aggregate.bind(this._collection, aggreagte)
161
- this.info('aggregate', JSON.stringify(aggreagte))
162
-
163
- // Normal operation
164
- } else {
165
- operation = this[opts.one? '_findOne' : '_find'].bind(this._collection, opts.query, options)
144
+ response = await this._aggregate(aggregate)
145
+ this.info('aggregate', JSON.stringify(aggregate))
166
146
  }
167
- operation()
168
- .then(data => resolve(data))
169
- .catch(err => reject(err))
170
147
 
171
- }).then(data => {
172
- if (opts.one && util.isArray(data)) data = data[0]
148
+ if (opts.one && util.isArray(response)) response = response[0]
173
149
  // (Not using) Project works with lookup.
174
150
  // Remove blacklisted properties from joined models, because subpiplines with 'project' are slower
175
151
  // if (opts.populate) this._depreciated_removeBlacklisted(data, opts.populate, options.projection)
176
- return this._processAfterFind(data, options.projection, opts)
152
+ response = await this._processAfterFind(response, options.projection, opts)
177
153
 
178
- }).then(data => {
179
- if (cb) cb(null, data)
180
- else if (opts.req && opts.respond) opts.req.res.json(data)
181
- else return Promise.resolve(data)
154
+ // Success
155
+ if (cb) cb(null, response)
156
+ else if (opts.req && opts.respond) opts.req.res.json(response)
157
+ else return Promise.resolve(response)
182
158
 
183
- }).catch(err => {
159
+ } catch (err) {
184
160
  if (cb) cb(err)
185
161
  else if (opts && opts.req && opts.respond) opts.req.res.error(err)
186
162
  else throw err
187
- })
163
+ }
188
164
  },
189
165
 
190
- findOne: function(opts, cb) {
166
+ findOne: async function(opts, cb) {
191
167
  return this.find(opts, cb, true)
192
168
  },
193
169
 
194
- update: function(opts, cb) {
170
+ // findOneAndUpdate: function(opts, cb) {
171
+ // return this._findOneAndUpdate(opts, cb)
172
+ // },
173
+
174
+ update: async function(opts, cb) {
195
175
  /**
196
176
  * Updates document(s) with monk after validating data & before hooks.
197
177
  * @param {object} opts
198
178
  * @param {object} <opts.query> - mongodb query object
199
179
  * @param {object|array} <opts.data> - mongodb document update object(s)
200
180
  * @param {boolean} <opts.respond> - automatically call res.json/error (requires opts.req)
201
- * @param {array|string|false} validateUndefined - ignore all required fields during insert, or
202
- * undefined subdocument required fields that have a defined parent/grandparent during update
181
+ * @param {array|string|false} validateUndefined - validates all 'required' undefined fields, true by
182
+ * default, but false on update
203
183
  * @param {array|string|true} <opts.skipValidation> - skip validation for this field name(s)
204
184
  * @param {boolean} <opts.timestamps> - whether `updatedAt` is automatically updated
205
185
  * @param {array|string|false} <opts.blacklist> - augment schema.updateBL, `false` will remove all blacklisting
@@ -208,78 +188,51 @@ module.exports = {
208
188
  * @this model
209
189
  * @return promise(data)
210
190
  */
211
- let data, options, operators
212
191
  if (cb && !util.isFunction(cb)) {
213
192
  throw new Error(`The callback passed to ${this.name}.update() is not a function`)
214
193
  }
215
- return new Promise((resolve, reject) => {
216
- opts = this._queryObject(opts)
217
- opts.update = true
218
- opts.model = this
219
- let req = opts.req
220
- data = opts.data = util.isDefined(opts.data)? opts.data : util.isDefined((req||{}).body) ? req.body : undefined
221
- operators = util.pluck(opts, [/^\$/])
222
- // Operation options
223
- options = util.omit(opts, ['data', 'query', 'respond', 'validateUndefined', 'skipValidation', 'blacklist'])
224
- options.sort = options.sort || { 'createdAt': -1 }
225
- options.limit = parseInt(options.limit || 0)
226
- // Sort string passed
227
- if (util.isString(options.sort)) {
228
- let name = (options.sort.match(/([a-z0-9-_]+)/) || [])[0]
229
- let order = (options.sort.match(/:(-?[0-9])/) || [])[1]
230
- options.sort = { [name]: parseInt(order || 1) }
194
+ try {
195
+ opts = await this._queryObject(opts, 'update')
196
+ let response = null
197
+ let operators = util.pluck(opts, [/^\$/])
198
+ let options = util.omit(opts, ['data', 'query', 'respond', 'validateUndefined', 'skipValidation', 'blacklist'])
199
+
200
+ // Validate
201
+ if (util.isDefined(opts.data)) opts.data = await this.validate(opts.data, { ...opts })
202
+ if (!util.isDefined(opts.data) && util.isEmpty(operators)) {
203
+ throw new Error(`Please pass an update operator to ${this.name}.update(), e.g. data, $unset, etc`)
231
204
  }
232
- util.parseData(data).then(d => resolve(d))
233
-
234
- }).then(data => {
235
- opts.data = data
236
- if (util.isDefined(data)) return this.validate(data, { ...opts })
237
- else return Promise.resolve(data)
238
-
239
- }).then(data => {
240
- if (util.isDefined(data) && (!data || util.isEmpty(data))) {
205
+ if (util.isDefined(opts.data) && (!opts.data || util.isEmpty(opts.data))) {
241
206
  throw new Error(`No valid data passed to ${this.name}.update({ data: .. })`)
242
207
  }
243
- if (!util.isDefined(data) && util.isEmpty(operators)) {
244
- throw new Error(`Please pass an update operator to ${this.name}.update(), e.g. data, $unset, etc`)
245
- }
246
- return util.runSeries(this.beforeUpdate.map(f => f.bind(opts, data||{}))).then(() => data)
247
-
248
- }).then(data => {
249
- if (data && operators['$set']) {
208
+ // Hook: beforeUpdate
209
+ await util.runSeries(this.beforeUpdate.map(f => f.bind(opts, opts.data||{})))
210
+ if (opts.data && operators['$set']) {
250
211
  this.warn(`'$set' fields take precedence over the data fields for \`${this.name}.update()\``)
251
212
  }
252
- if (data || operators['$set']) {
253
- operators['$set'] = {
254
- ...data,
255
- ...(operators['$set'] || {}),
256
- }
213
+ if (opts.data || operators['$set']) {
214
+ operators['$set'] = { ...opts.data, ...(operators['$set'] || {}) }
257
215
  }
258
- return this._update(opts.query, operators, options).then(output => {
259
- if (!output.n) return null
260
- let response = Object.assign(Object.create({ _output: output }), operators['$set']||{})
261
- output = data = null // Cleanup (just incase)
262
- return response
263
- })
216
+ // Update
217
+ let update = await this._update(opts.query, operators, options)
218
+ if (update.n) response = Object.assign(Object.create({ _output: update }), operators['$set']||{})
264
219
 
265
- }).then(data => {
266
- if (!data) return data
267
- return util.runSeries(this.afterUpdate.map(f => f.bind(opts, data))).then(() => data)
220
+ // Hook: afterUpdate
221
+ if (update.n) await util.runSeries(this.afterUpdate.map(f => f.bind(opts, response)))
268
222
 
269
- // Success/error
270
- }).then(data => {
271
- if (cb) cb(null, data)
272
- else if (opts.req && opts.respond) opts.req.res.json(data)
273
- else return Promise.resolve(data)
223
+ // Success
224
+ if (cb) cb(null, response)
225
+ else if (opts.req && opts.respond) opts.req.res.json(response)
226
+ else return response
274
227
 
275
- }).catch(err => {
228
+ } catch (err) {
276
229
  if (cb) cb(err)
277
230
  else if (opts && opts.req && opts.respond) opts.req.res.error(err)
278
231
  else throw err
279
- })
232
+ }
280
233
  },
281
234
 
282
- remove: function(opts, cb) {
235
+ remove: async function(opts, cb) {
283
236
  /**
284
237
  * Remove document(s) with monk after before hooks.
285
238
  * @param {object} opts
@@ -290,73 +243,84 @@ module.exports = {
290
243
  * @this model
291
244
  * @return promise
292
245
  */
293
- let options
294
246
  if (cb && !util.isFunction(cb)) {
295
247
  throw new Error(`The callback passed to ${this.name}.remove() is not a function`)
296
248
  }
297
- return new Promise((resolve, reject) => {
298
- opts = this._queryObject(opts)
299
- opts.remove = true
300
- opts.model = this
249
+ try {
250
+ opts = await this._queryObject(opts, 'remove')
251
+ let options = util.omit(opts, ['query', 'respond'])
301
252
  if (util.isEmpty(opts.query)) throw new Error('Please specify opts.query')
302
- // Operation options
303
- options = util.omit(opts, ['query', 'respond'])
304
- options.sort = options.sort || { 'createdAt': -1 }
305
- options.limit = parseInt(options.limit || 1)
306
- // Sort string passed
307
- if (util.isString(options.sort)) {
308
- let name = (options.sort.match(/([a-z0-9-_]+)/) || [])[0]
309
- let order = (options.sort.match(/:(-?[0-9])/) || [])[1]
310
- options.sort = { [name]: parseInt(order || 1) }
311
- }
312
- util.runSeries(this.beforeRemove.map(f => f.bind(opts)))
313
- .then(() => resolve())
314
- .catch(e => reject(e))
315
-
316
- }).then(() => {
317
- return this._remove(opts.query, options)
318
253
 
319
- }).then(data => {
320
- return util.runSeries(this.afterRemove.map(f => f.bind(opts))).then(() => data)
254
+ // Remove
255
+ await util.runSeries(this.beforeRemove.map(f => f.bind(opts)))
256
+ let response = await this._remove(opts.query, options)
257
+ await util.runSeries(this.afterRemove.map(f => f.bind(response)))
321
258
 
322
- // Success/error
323
- }).then(data => {
324
- if (cb) cb(null, data)
325
- else if (opts.req && opts.respond) opts.req.res.json(data)
326
- else return Promise.resolve(data)
259
+ // Success
260
+ if (cb) cb(null, response)
261
+ else if (opts.req && opts.respond) opts.req.res.json(response)
262
+ else return Promise.resolve(response)
327
263
 
328
- }).catch(err => {
264
+ } catch (err) {
329
265
  if (cb) cb(err)
330
266
  else if (opts && opts.req && opts.respond) opts.req.res.error(err)
331
267
  else throw err
332
- })
268
+ }
333
269
  },
334
270
 
335
- _queryObject: function(opts) {
271
+ _queryObject: async function(opts, type, one) {
336
272
  /**
337
- * Extract the query id from opts, opts.query
338
- * @param {MongoId|ID string|Query object} opts
339
- * @return opts
273
+ * Normalise options
274
+ * @param {MongoId|string|object} opts
275
+ * @param {string} type - operation type
276
+ * @param {boolean} one - return one document
277
+ * @this model
278
+ * @return {Promise} opts
340
279
  *
341
- * opts == string|MongodID - treated as an id
280
+ * opts == string|MongodId - treated as an id
342
281
  * opts == undefined|null|false - throw error
343
282
  * opts.query == string|MongodID - treated as an id
344
283
  * opts.query == undefined|null|false - throw error
345
284
  */
346
- let isIdType = (o) => util.isId(o) || util.isString(o)
347
- if (isIdType(opts)) opts = { query: { _id: opts || '' }}
348
- if (isIdType((opts||{}).query)) opts.query = { _id: opts.query || '' }
349
- if (!util.isObject(opts) || !util.isObject(opts.query)) {
350
- throw new Error('Please pass an object or MongoId to options.query')
351
- }
352
- // For security, if _id is set and undefined, throw an error
353
- if (typeof opts.query._id == 'undefined' && opts.query.hasOwnProperty('_id')) {
354
- throw new Error('Please pass an object or MongoId to options.query')
285
+
286
+ // Query
287
+ if (type != 'insert') {
288
+ let isIdType = (o) => util.isId(o) || util.isString(o)
289
+ if (isIdType(opts)) {
290
+ opts = { query: { _id: opts || '' }}
291
+ }
292
+ if (isIdType((opts||{}).query)) {
293
+ opts.query = { _id: opts.query || '' }
294
+ }
295
+ if (!util.isObject(opts) || !util.isObject(opts.query)) {
296
+ throw new Error('Please pass an object or MongoId to options.query')
297
+ }
298
+ // For security, if _id is set and undefined, throw an error
299
+ if (typeof opts.query._id == 'undefined' && opts.query.hasOwnProperty('_id')) {
300
+ throw new Error('Please pass an object or MongoId to options.query')
301
+ }
302
+ if (util.isId(opts.query._id)) opts.query._id = this.manager.id(opts.query._id)
303
+ if (isIdType(opts.query._id) || one) opts.one = true
304
+ opts.query = util.removeUndefined(opts.query)
305
+
306
+ // Query options
307
+ opts.limit = opts.one? 1 : parseInt(opts.limit || (type == 'remove' ? 1 : this.manager.limit || 0))
308
+ opts.skip = Math.max(0, opts.skip || 0)
309
+ opts.sort = opts.sort || { 'createdAt': -1 }
310
+ if (util.isString(opts.sort)) {
311
+ let name = (opts.sort.match(/([a-z0-9-_]+)/) || [])[0]
312
+ let order = (opts.sort.match(/:(-?[0-9])/) || [])[1]
313
+ opts.sort = { [name]: parseInt(order || 1) }
314
+ }
355
315
  }
356
- // Remove undefined query parameters
357
- opts.query = util.removeUndefined(opts.query)
358
- if (util.isId(opts.query._id)) opts.query._id = this.manager.id(opts.query._id)
359
- if (isIdType(opts.query._id)) opts.one = true
316
+
317
+ // Data
318
+ if (!opts) opts = {}
319
+ if (!util.isDefined(opts.data) && util.isDefined((opts.req||{}).body)) opts.data = opts.req.body
320
+ if (util.isDefined(opts.data)) opts.data = await util.parseData(opts.data)
321
+
322
+ opts[type] = true
323
+ opts.model = this
360
324
  return opts
361
325
  },
362
326
 
@@ -12,8 +12,8 @@ module.exports = {
12
12
  * @param {boolean(false)} update - are we validating for insert or update?
13
13
  * @param {array|string|false} blacklist - augment schema blacklist, `false` will remove all blacklisting
14
14
  * @param {array|string} projection - only return these fields, ignores blacklist
15
- * @param {array|string|false} validateUndefined - ignore all required fields during insert, or undefined
16
- * subdocument required fields that have a defined parent/grandparent during update
15
+ * @param {array|string|false} validateUndefined - validates all 'required' undefined fields, true by
16
+ * default, but false on update
17
17
  * @param {array|string|true} skipValidation - skip validation on these fields
18
18
  * @param {boolean} timestamps - whether `createdAt` and `updatedAt` are inserted, or `updatedAt` is
19
19
  * updated, depending on the `options.update` value
@@ -176,11 +176,11 @@ module.exports = {
176
176
  } else if (util.isSubdocument(field)) {
177
177
  // Object schema errors
178
178
  errors.push(...(verrors = this._validateRules(dataRoot, schema, value, opts, path2)))
179
- // Recurse if data value is a subdocument, or when inserting, or when updating deep properties (non-root)
179
+ // Recurse if inserting, value is a subdocument, or a deep property (todo: not dot-notation)
180
180
  if (
181
- util.isObject(value) ||
182
181
  opts.insert ||
183
- ((path2||'').match(/\./) && (util.isDefined(opts.validateUndefined) ? opts.validateUndefined : true))
182
+ util.isObject(value) ||
183
+ (util.isDefined(opts.validateUndefined) ? opts.validateUndefined : (path2||'').match(/\./))
184
184
  ) {
185
185
  var res = this._validateFields(dataRoot, field, value, opts, path2)
186
186
  errors.push(...res[0])
@@ -260,13 +260,14 @@ module.exports = {
260
260
  ruleArg = ruleArg === true? undefined : ruleArg
261
261
  let rule = this.rules[ruleName] || rules[ruleName]
262
262
  let fieldName = path.match(/[^.]+$/)[0]
263
+ let isDeepProp = path.match(/\./) // todo: not dot-notation
263
264
  let ruleMessageKey = this._getMostSpecificKeyMatchingPath(this.messages, path)
264
265
  let ruleMessage = ruleMessageKey && this.messages[ruleMessageKey][ruleName]
265
- let validateUndefined = util.isDefined(opts.validateUndefined) ? opts.validateUndefined : rule.validateUndefined
266
+ let validateUndefined = util.isDefined(opts.validateUndefined) ? opts.validateUndefined : opts.insert || isDeepProp
266
267
  if (!ruleMessage) ruleMessage = rule.message
267
268
 
268
- // Ignore undefined (if updated root property, or ignoring)
269
- if (typeof value === 'undefined' && (!validateUndefined || (opts.update && !path.match(/\./)))) return
269
+ // Undefined value
270
+ if (typeof value === 'undefined' && (!validateUndefined || !rule.validateUndefined)) return
270
271
 
271
272
  // Ignore null (if nullObject is set on objects or arrays)
272
273
  if (value === null && (field.isObject || field.isArray) && field.nullObject) return
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "monastery",
3
3
  "description": "⛪ A straight forward MongoDB ODM built around Monk",
4
4
  "author": "Ricky Boyce",
5
- "version": "1.31.3",
5
+ "version": "1.31.7",
6
6
  "license": "MIT",
7
7
  "repository": "github:boycce/monastery",
8
8
  "homepage": "https://boycce.github.io/monastery/",
@@ -23,7 +23,7 @@
23
23
  "mong": "nodemon resources/mong.js",
24
24
  "minor": "standard-version --release-as minor && npm publish",
25
25
  "patch": "standard-version --release-as patch && npm publish",
26
- "release": "standard-version && npm publish",
26
+ "release": "standard-version && npm publish && git push --tags",
27
27
  "test": "npm run lint && jest",
28
28
  "test-one-example": "jest -t images"
29
29
  },
@@ -46,11 +46,13 @@
46
46
  "supertest": "4.0.2"
47
47
  },
48
48
  "standard-version": {
49
+ "infile": "changelog.md",
49
50
  "releaseCommitMessageFormat": "{{currentTag}}",
50
51
  "sign": true,
51
52
  "skip": {
52
53
  "changelog": false,
53
54
  "tag": false
54
- }
55
+ },
56
+ "tag-prefix": ""
55
57
  }
56
58
  }
package/test/model.js CHANGED
@@ -189,6 +189,55 @@ module.exports = function(monastery, opendb) {
189
189
  db.close()
190
190
  })
191
191
 
192
+ test('model unique indexes', async () => {
193
+ let db = (await opendb(null)).db
194
+ // Setup: Drop previously tested collections
195
+ if ((await db._db.listCollections().toArray()).find(o => o.name == 'userUniqueIndex')) {
196
+ await db._db.collection('userUniqueIndex').drop()
197
+ }
198
+
199
+ // Partial unique indexes (allows mulitple null values)
200
+ await db.model('userUniqueIndex', {
201
+ waitForIndexes: true,
202
+ fields: {
203
+ email: {
204
+ type: 'string',
205
+ index: {
206
+ type: 'unique',
207
+ partialFilterExpression: {
208
+ email: { $type: 'string' }
209
+ }
210
+ }
211
+ },
212
+ }
213
+ })
214
+
215
+ let indexes2 = await db._db.collection('userUniqueIndex').indexes()
216
+ expect(indexes2[0]).toMatchObject({ v: 2, key: { _id: 1 }, name: '_id_' })
217
+ expect(indexes2[1]).toMatchObject({ v: 2, unique: true, key: { email: 1 }, name: 'email_1' })
218
+
219
+ await expect(db.userUniqueIndex.insert({ data: { 'email': 'ricky@orchid.co.nz' }})).resolves.toEqual({
220
+ _id: expect.any(Object),
221
+ email: 'ricky@orchid.co.nz'
222
+ })
223
+
224
+ await expect(db.userUniqueIndex.insert({ data: { 'email': 'ricky@orchid.co.nz' }})).rejects.toThrow(
225
+ /E11000 duplicate key error collection: monastery.userUniqueIndex index: email_1 dup key: {/
226
+ )
227
+
228
+ await expect(db.userUniqueIndex.insert({ data: { 'email': null }})).resolves.toEqual({
229
+ _id: expect.any(Object),
230
+ email: null
231
+ })
232
+
233
+ await expect(db.userUniqueIndex.insert({ data: { 'email': null }})).resolves.toEqual({
234
+ _id: expect.any(Object),
235
+ email: null
236
+ })
237
+
238
+ db.close()
239
+ })
240
+
192
241
  test('model subdocument indexes', async () => {
193
242
  // Setup: Need to test different types of indexes
194
243
  let db = (await opendb(null)).db
package/test/validate.js CHANGED
@@ -26,17 +26,18 @@ module.exports = function(monastery, opendb) {
26
26
  meta: { rule: 'required', model: 'user', field: 'name' }
27
27
  })
28
28
 
29
- // Required error (insert, and with ignoreRequired)
30
- await expect(user.validate({}, { validateUndefined: false })).resolves.toEqual({})
31
- await expect(user.validate({}, { validateUndefined: false, update: true })).resolves.toEqual({})
32
-
33
29
  // No required error (update)
34
30
  await expect(user.validate({}, { update: true })).resolves.toEqual({})
35
31
 
36
32
  // Type error (string)
37
33
  await expect(user.validate({ name: 1 })).resolves.toEqual({ name: '1' })
38
34
  await expect(user.validate({ name: 1.123 })).resolves.toEqual({ name: '1.123' })
39
- await expect(user.validate({ name: undefined }, { validateUndefined: false })).resolves.toEqual({})
35
+ await expect(user.validate({ name: undefined })).rejects.toContainEqual({
36
+ status: '400',
37
+ title: 'name',
38
+ detail: 'This field is required.',
39
+ meta: { rule: 'required', model: 'user', field: 'name' }
40
+ })
40
41
  await expect(user.validate({ name: null })).rejects.toContainEqual({
41
42
  status: '400',
42
43
  title: 'name',
@@ -65,20 +66,19 @@ module.exports = function(monastery, opendb) {
65
66
  await expect(usernum.validate({ amount: 0 })).resolves.toEqual({ amount: 0 })
66
67
  await expect(usernum.validate({ amount: '0' })).resolves.toEqual({ amount: 0 })
67
68
  await expect(usernum2.validate({ amount: '' })).resolves.toEqual({ amount: null })
68
- await expect(usernum.validate({ amount: undefined }, { validateUndefined: false })).resolves.toEqual({})
69
69
  await expect(usernum.validate({ amount: false })).rejects.toEqual([{
70
70
  status: '400',
71
71
  title: 'amount',
72
72
  detail: 'Value was not a number.',
73
73
  meta: { rule: 'isNumber', model: 'usernum', field: 'amount' }
74
74
  }])
75
- await expect(usernum.validate({ amount: null })).rejects.toEqual([{
75
+ await expect(usernum.validate({ amount: undefined })).rejects.toEqual([{
76
76
  status: '400',
77
77
  title: 'amount',
78
78
  detail: 'This field is required.',
79
79
  meta: { rule: 'required', model: 'usernum', field: 'amount' },
80
80
  }])
81
- await expect(usernum.validate({ amount: null }, { validateUndefined: false })).rejects.toEqual([{
81
+ await expect(usernum.validate({ amount: null })).rejects.toEqual([{
82
82
  status: '400',
83
83
  title: 'amount',
84
84
  detail: 'This field is required.',
@@ -309,6 +309,41 @@ module.exports = function(monastery, opendb) {
309
309
  .rejects.toContainEqual(error)
310
310
  })
311
311
 
312
+ test('validation array schema errors', async () => {
313
+ // Setup
314
+ let db = (await opendb(false)).db
315
+ function arrayWithSchema(array, schema) {
316
+ array.schema = schema
317
+ return array
318
+ }
319
+ let user = db.model('user', { fields: {
320
+ animals: arrayWithSchema(
321
+ [{ type: 'string' }],
322
+ { required: true, minLength: 2 },
323
+ )
324
+ }})
325
+
326
+ // MinLength error
327
+ await expect(user.validate({
328
+ animals: [],
329
+ })).rejects.toContainEqual({
330
+ status: '400',
331
+ title: 'animals',
332
+ detail: 'This field is required.',
333
+ meta: { rule: 'required', model: 'user', field: 'animals' }
334
+ })
335
+
336
+ // MinLength error
337
+ await expect(user.validate({
338
+ animals: ['dog'],
339
+ })).rejects.toContainEqual({
340
+ status: '400',
341
+ title: 'animals',
342
+ detail: 'Value needs to contain a minimum of 2 items.',
343
+ meta: { rule: 'minLength', model: 'user', field: 'animals' }
344
+ })
345
+ })
346
+
312
347
  test('validation getMostSpecificKeyMatchingPath', async () => {
313
348
  let fn = validate._getMostSpecificKeyMatchingPath
314
349
  let mock = {
@@ -870,7 +905,7 @@ module.exports = function(monastery, opendb) {
870
905
  db.close()
871
906
  })
872
907
 
873
- test('validation options', async () => {
908
+ test('validation option skipValidation', async () => {
874
909
  let db = (await opendb(false)).db
875
910
  let user = db.model('user', { fields: {
876
911
  name: { type: 'string', required: true }
@@ -955,6 +990,66 @@ module.exports = function(monastery, opendb) {
955
990
  })
956
991
  })
957
992
 
993
+ test('validation option validateUndefined', async () => {
994
+ // ValidateUndefined runs required rules on all fields, `true` for insert, `false` for update.
995
+
996
+ // Setup
997
+ let db = (await opendb(false)).db
998
+ let user = db.model('user', { fields: {
999
+ date: { type: 'number' },
1000
+ name: { type: 'string', required: true },
1001
+ }})
1002
+ let usernum = db.model('usernum', { fields: {
1003
+ amount: { type: 'number', required: true }
1004
+ }})
1005
+ let userdeep = db.model('userdeep', { fields: {
1006
+ date: { type: 'number' },
1007
+ name: {
1008
+ first: { type: 'string', required: true },
1009
+ },
1010
+ names: [{
1011
+ first: { type: 'string', required: true },
1012
+ }]
1013
+ }})
1014
+ let errorRequired = {
1015
+ status: '400',
1016
+ title: 'name',
1017
+ detail: 'This field is required.',
1018
+ meta: expect.any(Object),
1019
+ }
1020
+
1021
+ // Required error for undefined
1022
+ await expect(user.validate({}))
1023
+ .rejects.toEqual([errorRequired])
1024
+ await expect(user.validate({}, { update: true, validateUndefined: true }))
1025
+ .rejects.toEqual([errorRequired])
1026
+ await expect(userdeep.validate({}))
1027
+ .rejects.toEqual([{ ...errorRequired, title: 'name.first' }])
1028
+ await expect(userdeep.validate({ name: {} }, { update: true }))
1029
+ .rejects.toEqual([{ ...errorRequired, title: 'name.first' }])
1030
+ await expect(userdeep.validate({ names: [{}] }, { update: true }))
1031
+ .rejects.toEqual([{ ...errorRequired, title: 'names.0.first' }])
1032
+
1033
+ // Required error for null
1034
+ await expect(user.validate({ name: null }, { update: true }))
1035
+ .rejects.toEqual([errorRequired])
1036
+ await expect(usernum.validate({ amount: null }, { update: true }))
1037
+ .rejects.toEqual([{ ...errorRequired, title: 'amount' }])
1038
+ await expect(user.validate({ name: null }, { update: true, validateUndefined: true }))
1039
+ .rejects.toEqual([errorRequired])
1040
+
1041
+ // Skip required error
1042
+ await expect(user.validate({ name: undefined }, { validateUndefined: false })).resolves.toEqual({})
1043
+ await expect(user.validate({}, { validateUndefined: false })).resolves.toEqual({})
1044
+ await expect(user.validate({}, { update: true })).resolves.toEqual({})
1045
+ await expect(user.validate({}, { update: true, validateUndefined: false })).resolves.toEqual({})
1046
+ await expect(userdeep.validate({}, { update: true })).resolves.toEqual({})
1047
+ await expect(userdeep.validate({ name: {} }, { update: true, validateUndefined: false }))
1048
+ .resolves.toEqual({ name: {} })
1049
+ await expect(userdeep.validate({ names: [{}] }, { update: true, validateUndefined: false }))
1050
+ .resolves.toEqual({ names: [{}] })
1051
+ })
1052
+
958
1053
  test('validation hooks', async () => {
959
1054
  let db = (await opendb(null)).db
960
1055
  let user = db.model('user', {
package/CHANGELOG.md DELETED
@@ -1,23 +0,0 @@
1
- # Changelog
2
-
3
- All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.
4
-
5
- ### 1.31.3 (2022-02-15)
6
-
7
-
8
- ### Bug Fixes
9
-
10
- * _callAfterFind bug ([e62472c](https://github.com/boycce/monastery/commit/e62472c191119135839b6d9e42b7f060bc7a508d))
11
- * .eslintrc.json ([5362585](https://github.com/boycce/monastery/commit/53625857bbf798db97eddad9385799cb1ded97e2))
12
- * docs ([d8f4e15](https://github.com/boycce/monastery/commit/d8f4e15913f672295cc118fda11ec23a412b4c62))
13
- * docs ([1a5118e](https://github.com/boycce/monastery/commit/1a5118e4b389b55d30bf987991b59638b11613a9))
14
- * docs ([564572c](https://github.com/boycce/monastery/commit/564572ce33dfb35fdd131e81d1dcc655f024b26e))
15
- * docs ([12d37d3](https://github.com/boycce/monastery/commit/12d37d3b2d0b5ec577f9ed4deb9c929c8ea52a36))
16
- * docs and nav links ([0eabcf0](https://github.com/boycce/monastery/commit/0eabcf0cd9a119a6ab1a07b92634e316995a2a83))
17
- * model-crud ([d421709](https://github.com/boycce/monastery/commit/d421709a70e6611c78e049e98268153c9bafae6d))
18
- * normalise afterFind ([0ab7f43](https://github.com/boycce/monastery/commit/0ab7f43f25b599e07d9ae751dc3bac8550e53c24))
19
- * normalised rule arguments and context ([6ba48da](https://github.com/boycce/monastery/commit/6ba48da3b9c643620cebf5442e60bd0318d6780f))
20
- * package scripts ([f7935af](https://github.com/boycce/monastery/commit/f7935afb0181ddb3e397bf804b34c841589dfcf0))
21
- * semver ([6f14909](https://github.com/boycce/monastery/commit/6f14909f4405cf26dc04a8603cc3bac232b96798))
22
- * standard-version ([f553b08](https://github.com/boycce/monastery/commit/f553b08445eb7dd2e85f6bb447e2bb0bc38dda34))
23
- * util bug, updated tests ([bec1887](https://github.com/boycce/monastery/commit/bec1887f56cb8582b606a066c913c191362a61b0))