monastery 2.2.3 → 3.0.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/docs/readme.md CHANGED
@@ -2,16 +2,26 @@
2
2
 
3
3
  [![NPM](https://img.shields.io/npm/v/monastery.svg)](https://www.npmjs.com/package/monastery) [![Build Status](https://travis-ci.com/boycce/monastery.svg?branch=master)](https://app.travis-ci.com/github/boycce/monastery)
4
4
 
5
+ > v3.0 has been released 🎉 refer to [breaking changes](#v3-breaking-changes) below when upgrading from v2.x.
6
+
5
7
  ## Features
6
8
 
7
- * User friendly API design, built around the awesome [Monk](https://automattic.github.io/monk/)
8
- * Simple CRUD operations with model population
9
- * Model validation deriving from your model definitions
10
- * Custom error messages can be defined in your model definition
11
- * Normalised error responses ready for client consumption
12
- * Automatic mongodb index setup
13
- * CRUD operations can accept bracket (multipart/form-data) and dot notation data formats, you can also mix these together
9
+ * User friendly API design, *inspired by SailsJS*
10
+ * Simple CRUD operations, with simple but fast model population
11
+ * Model validation controlled by your model definitions
12
+ * Normalized error responses objects ready for client consumption
13
+ * Custom error messages can be defined in your model definitions
14
+ * Blacklist sensitive fields once in your model definition, or per operation
15
+ * Model methods can accept data in bracket (multipart/form) and dot notation, you can also mix these together
16
+ * Automatic Mongo index creation
17
+
18
+ #### Why Monastery over Mongoose?
14
19
 
20
+ * User friendly API designed for busy agencies, allowing you to quickly build projects without distractions
21
+ * Model schema and configurations are all defined within a single object (model definition)
22
+ * You can blacklist/exclude sensitive model fields in the model definition for each CRUD operation
23
+ * Model population uses a single aggregation call instead of multiple queries for faster responses
24
+ * Errors throw normalized error objects that contain the model, field, error message etc, handy in the client
15
25
 
16
26
  ## Install
17
27
 
@@ -26,9 +36,8 @@ $ npm install --save monastery
26
36
  ```javascript
27
37
  import monastery from 'monastery'
28
38
 
29
- // Initialise a monastery manager
39
+ // Initialize a monastery manager
30
40
  const db = monastery('localhost/mydb')
31
- // const db = monastery('user:pass@localhost:port/mydb')
32
41
 
33
42
  // Define a model
34
43
  db.model('user', {
@@ -41,18 +50,16 @@ db.model('user', {
41
50
  })
42
51
 
43
52
  // Insert some data
44
- db.user.insert({
45
- data: {
46
- name: 'Martin Luther',
47
- pets: ['sparky', 'tiny'],
48
- address: { city: 'Eisleben' },
49
- points: [[1, 5], [3, 1]]
50
- }
51
-
52
- }).then(data => {
53
- // valid data..
54
-
55
- }).catch(errs => {
53
+ try {
54
+ const newUser = await db.user.insert({
55
+ data: {
56
+ name: 'Martin Luther',
57
+ pets: ['sparky', 'tiny'],
58
+ address: { city: 'Eisleben' },
59
+ points: [[1, 5], [3, 1]]
60
+ }
61
+ })
62
+ } catch (errs) {
56
63
  // [{
57
64
  // detail: "Value needs to be at least 10 characters long.",
58
65
  // status: "400",
@@ -63,31 +70,26 @@ db.user.insert({
63
70
  // rule: "minLength"
64
71
  // }
65
72
  // }]
66
- })
73
+ }
67
74
  ```
68
- ## Versions
69
-
70
- - Monk: `v7.3.4`
71
- - MongoDB NodeJS driver: `v3.7.4` ([MongoDB compatibility](https://www.mongodb.com/docs/drivers/node/current/compatibility/#compatibility))
72
- - MongoDB: [`v5.0.0`](https://www.mongodb.com/docs/v5.0/reference/) [(`v6.0.0` partial support)](https://www.mongodb.com/docs/v6.0/reference/)
73
-
74
- ## Debugging
75
+ ## Version Compatibility
75
76
 
76
- This package uses [debug](https://github.com/visionmedia/debug) which allows you to set different levels of output via the `DEBUG` environment variable. Due to known limations `monastery:warning` and `monastery:error` are forced on, you can however disable these via [manager settings](./manager).
77
-
78
- ```bash
79
- $ DEBUG=monastery:info # shows operation information
80
- ```
77
+ You can view MongoDB's [compatibility table here](https://www.mongodb.com/docs/drivers/node/current/compatibility/), and see all of MongoDB NodeJS Driver [releases here](https://mongodb.github.io/node-mongodb-native/)
81
78
 
82
- To run isolated tests with Jest:
79
+ | Monastery | Mongo NodeJS Driver | MongoDB Server | Node |
80
+ | :------------------- | :-----------------: | :---------------: | ------------------: |
81
+ | `3.x` | [`5.9.x`](https://mongodb.github.io/node-mongodb-native/5.9/) | `>=3.6 <=7.x` | `>=14.x <=latest` |
82
+ | `2.x` | [`3.7.x`](https://mongodb.github.io/node-mongodb-native/3.7/api/) | `>=2.6 <=6.x` | `>=4.x <=14.x` |
83
83
 
84
- ```bash
85
- npm run dev -- -t 'Model indexes'
86
- ```
87
84
 
88
- ## Contributing
85
+ ## v3 Breaking Changes
89
86
 
90
- Coming soon...
87
+ - Removed callback functions on all model methods, you can use the returned promise instead
88
+ - model.update() now returns the following _update property: `{ acknowledged: true, matchedCount: 1, modifiedCount: 1, upsertedCount: 0, upsertedId: null }` instead of `{ n: 1, nModified: 1, ok: 1 }`
89
+ - model.remove() now returns `{ acknowledged: true, deletedCount: 1 }`, instead of `{ results: {n:1, ok:1} }`
90
+ - Models are now added to db.models instead of db.model, e.g. db.models.user
91
+ - MongoDB connection can be found here db.db changed from db._db
92
+ - model._indexes() now returns collection._indexes() not collection._indexInformation()
91
93
 
92
94
  ## Roadmap
93
95
 
@@ -103,19 +105,38 @@ Coming soon...
103
105
  - ~~Ability to change ACL default on the manager~~
104
106
  - ~~Public db.arrayWithSchema method~~
105
107
  - ~~Added support for array population~~
108
+ - ~~MongoClient instances can now be reused when initializing the manager, e.g. `monastery(mongoClient)`, handy for migrate-mongo~~
106
109
  - Change population warnings into errors
107
110
  - Global after/before hooks
108
- - before hooks can receive a data array, remove this
109
- - docs: Make the implicit ID query conversion more apparent
110
- - Split away from Monk so we can update the MongoDB NodeJS Driver version
111
+ - Before hooks can receive a data array, remove this
112
+ - Docs: Make the implicit ID query conversion more apparent
113
+ - ~~Split away from Monk so we can update the MongoDB NodeJS Driver version~~
111
114
  - Add a warning if an invalid model is referenced in jthe schema
112
115
  - Remove leading forward slashes from custom image paths (AWS adds this as a seperate folder)
113
- - double check await db.model.remove({ query: idfromparam }) doesnt cause issues for null, undefined or '', but continue to allow {}
114
- - ~~can't insert/update model id (maybe we can allow this and add _id to default insert/update blacklists)~~
116
+ - Double check await db.model.remove({ query: idfromparam }) doesnt cause issues for null, undefined or '', but continue to allow {}
117
+ - ~~Can't insert/update model id (maybe we can allow this and add _id to default insert/update blacklists)~~
115
118
  - timstamps are blacklisted by default (instead of the `timestamps` opt), and can be switched off via blacklisting
116
119
  - Allow rules on image types, e.g. `required`
117
- - test importing of models
118
- - Docs: model.methods
120
+ - Test importing of models
121
+ - ~~Docs: model.methods~~
122
+ - ~~Convert hooks to promises~~
123
+ - ~~added `model.count()`~~
124
+
125
+ ## Debugging
126
+
127
+ This package uses [debug](https://github.com/visionmedia/debug) which allows you to set different levels of output via the `DEBUG` environment variable. Due to known limations `monastery:warning` and `monastery:error` are forced on, you can however disable these via [manager settings](./manager).
128
+
129
+ ```bash
130
+ $ DEBUG=monastery:info # shows operation information
131
+ ```
132
+
133
+ ## Contributing
134
+
135
+ All pull requests are welcome. To run isolated tests with Jest:
136
+
137
+ ```bash
138
+ npm run dev -- -t 'Model indexes'
139
+ ```
119
140
 
120
141
  ## Special Thanks
121
142
 
@@ -123,4 +144,4 @@ Coming soon...
123
144
 
124
145
  ## License
125
146
 
126
- Copyright 2020 Ricky Boyce. Code released under the MIT license.
147
+ Copyright 2024 Ricky Boyce. Code released under the MIT license.
@@ -0,0 +1,324 @@
1
+ const util = require('./util.js')
2
+
3
+ function Collection (manager, name, options) {
4
+ this.col = null
5
+ this.manager = manager
6
+ this.name = name
7
+ this.options = options
8
+ }
9
+
10
+ Collection.prototype._middleware = async function (args) {
11
+ /**
12
+ * Modfy the arguments before passing them to the MongoDB driver collection operations
13
+ * @return {Object} args
14
+ */
15
+ const objectsToCast = ['operations', 'query', 'data', 'update']
16
+ let { fields, opts, query } = args
17
+ if (!opts) args.opts = opts = {}
18
+
19
+ // Get the collection
20
+ this.col = this.col || this.manager.db.collection(this.name)
21
+
22
+ // Query: convert strings to ObjectIds
23
+ if (query) {
24
+ if (typeof query === 'string' || typeof query.toHexString === 'function') {
25
+ args.query = {_id: args.query}
26
+ }
27
+ }
28
+
29
+ // Options: setup
30
+ if (typeof opts === 'string' || Array.isArray(opts)) {
31
+ throw new Error('You can no longer pass an array or string `projection` to find()')
32
+ } else {
33
+ // MongoDB 5.0 docs seem to be a little off, projection is still included in `opts`...
34
+ if (opts.fields) opts.projection = _fields(opts.fields, 0)
35
+ if (opts.sort) opts.sort = _fields(opts.sort, -1)
36
+ delete opts.fields
37
+ }
38
+
39
+ // Options: use collection defaults
40
+ for (let key in this.options) {
41
+ if (typeof opts[key] === 'undefined') {
42
+ opts[key] = this.options[key]
43
+ }
44
+ }
45
+
46
+ // Options: castIds (defaults on)
47
+ if (opts.castIds !== false) {
48
+ for (const key of objectsToCast) {
49
+ if (args[key]) args[key] = util.cast(args[key])
50
+ }
51
+ }
52
+
53
+ // Fields: convert strings/arrays to objects, e.g. 'name _id' -> {name: 1, _id: 1}
54
+ if (fields && !util.isObject(fields)) {
55
+ let fieldsArray = typeof fields === 'string' ? fields.split(' ') : (fields || [])
56
+ args.fields = fieldsArray.reduce((acc, fieldName) => {
57
+ acc[fieldName] = 1
58
+ return acc
59
+ }, {})
60
+ }
61
+
62
+ function _fields (obj, numberWhenMinus) {
63
+ if (!Array.isArray(obj) && typeof obj === 'object') return obj
64
+ obj = typeof obj === 'string' ? obj.split(' ') : (obj || [])
65
+ let fields = {}
66
+ for (let i = 0, l = obj.length; i < l; i++) {
67
+ if (obj[i][0] === '-') fields[obj[i].substr(1)] = numberWhenMinus
68
+ else fields[obj[i]] = 1
69
+ }
70
+ return fields
71
+ }
72
+
73
+ return args
74
+ }
75
+
76
+ Collection.prototype.aggregate = async function (stages, opts) {
77
+ const args = await this._middleware({ stages, opts })
78
+ return this.col.aggregate(args.stages, args.opts).toArray()
79
+ }
80
+
81
+ Collection.prototype.bulkWrite = async function (operations, opts) {
82
+ const args = await this._middleware({ operations, opts })
83
+ return this.col.bulkWrite(args.operations, args.opts)
84
+ }
85
+
86
+ Collection.prototype.count = async function (query, opts) {
87
+ const args = await this._middleware({ query, opts })
88
+ const { estimate, ..._opts } = args.opts
89
+ if (estimate) {
90
+ return this.col.estimatedDocumentCount(_opts)
91
+ } else {
92
+ return this.col.countDocuments(args.query, _opts)
93
+ }
94
+ }
95
+
96
+ Collection.prototype.createIndex = async function (indexSpec, opts) {
97
+ const args = await this._middleware({ indexSpec, opts })
98
+ return await this.col.createIndex(args.indexSpec, args.opts)
99
+ }
100
+
101
+ Collection.prototype.createIndexes = async function (indexSpecs, opts) {
102
+ const args = await this._middleware({ indexSpecs, opts }) // doesn't currently accept string or array parsing.
103
+ return await this.col.createIndexes(args.indexSpecs, args.opts)
104
+ }
105
+
106
+ Collection.prototype.distinct = async function (field, query, opts) {
107
+ const args = await this._middleware({ field, query, opts })
108
+ return this.col.distinct(args.field, args.query, args.opts)
109
+ }
110
+
111
+ Collection.prototype.drop = async function () {
112
+ try {
113
+ await this._middleware({})
114
+ await this.col.drop()
115
+ // this.col = null
116
+ } catch (err) {
117
+ if (err?.message == 'ns not found') return 'ns not found'
118
+ throw err
119
+ }
120
+ }
121
+
122
+ Collection.prototype.dropIndex = async function (name, opts) {
123
+ const args = await this._middleware({ name, opts })
124
+ return await this.col.dropIndex(args.name, args.opts)
125
+ }
126
+
127
+ Collection.prototype.dropIndexes = async function () {
128
+ await this._middleware({})
129
+ return this.col.dropIndexes()
130
+ }
131
+
132
+ Collection.prototype.find = async function (query, opts) {
133
+ // v3.0 - removed the abillity to pass an array or string to opts as `opts.projection`
134
+ // v3.0 - find().each() is removed, use `opts.stream` instead
135
+ const args = await this._middleware({ query, opts })
136
+ query = args.query
137
+ opts = args.opts
138
+ const rawCursor = opts.rawCursor
139
+
140
+ // Get the raw cursor
141
+ const cursor = this.col.find(query, opts)
142
+
143
+ // If a raw cursor is requested, return it now
144
+ if (rawCursor) return cursor
145
+
146
+ // If no stream is requested, return now the array of results
147
+ if (!opts.stream) return cursor.toArray()
148
+
149
+ if (typeof opts.stream !== 'function') {
150
+ throw new Error('opts.stream must be a function')
151
+ }
152
+
153
+ const stream = cursor.stream()
154
+ return new Promise((resolve, reject) => {
155
+ let closed = false
156
+ let finished = false
157
+ let processing = 0
158
+ function close () {
159
+ closed = true
160
+ processing -= 1
161
+ cursor.close()
162
+ }
163
+ function pause () {
164
+ processing += 1
165
+ stream.pause()
166
+ }
167
+ function resume () {
168
+ processing -= 1
169
+ stream.resume()
170
+ if (processing === 0 && finished) done()
171
+ }
172
+ function done () {
173
+ finished = true
174
+ if (processing <= 0) resolve()
175
+ }
176
+
177
+ stream.on('close', done)
178
+ stream.on('end', done)
179
+ stream.on('error', (err) => reject(err))
180
+ stream.on('data', (doc) => {
181
+ if (!closed) opts.stream(doc, { close, pause, resume })
182
+ })
183
+ })
184
+ }
185
+
186
+ Collection.prototype.findOne = async function (query, opts) {
187
+ const args = await this._middleware({ query, opts })
188
+ const docs = await this.col.find(args.query, args.opts).limit(1).toArray()
189
+ return docs?.[0] || null
190
+ }
191
+
192
+ Collection.prototype.findOneAndDelete = async function (query, opts) {
193
+ const args = await this._middleware({ query, opts })
194
+ const doc = await this.col.findOneAndDelete(args.query, args.opts)
195
+
196
+ if (doc && typeof doc.value !== 'undefined') return doc.value
197
+ if (doc.ok && doc.lastErrorObject && doc.lastErrorObject.n === 0) return null
198
+ return doc
199
+ }
200
+
201
+ Collection.prototype.findOneAndUpdate = async function (query, update, opts) {
202
+ const args = await this._middleware({ query, update, opts })
203
+ let method = 'findOneAndUpdate'
204
+
205
+ if (typeof args.opts?.returnDocument === 'undefined') {
206
+ args.opts.returnDocument = 'after'
207
+ }
208
+ if (typeof args.opts?.returnOriginal !== 'undefined') {
209
+ this.manager.warn('The `returnOriginal` option is deprecated, use `returnDocument` instead.')
210
+ args.opts.returnDocument = args.opts.returnOriginal ? 'before' : 'after'
211
+ }
212
+ if (args.opts.replaceOne || args.opts.replace) {
213
+ method = 'findOneAndReplace'
214
+ }
215
+
216
+ const doc = await this.col[method](args.query, args.update, args.opts)
217
+ if (doc && typeof doc.value !== 'undefined') return doc.value
218
+ if (doc.ok && doc.lastErrorObject && doc.lastErrorObject.n === 0) return null
219
+ return doc
220
+ }
221
+
222
+ Collection.prototype.geoHaystackSearch = async function (x, y, opts) {
223
+ // https://www.mongodb.com/docs/manual/geospatial-queries/
224
+ throw new Error('geoHaystackSearch is depreciated in MongoDB 4.0, use geospatial queries instead, e.g. $geoWithin')
225
+ }
226
+
227
+ Collection.prototype.indexInformation = async function (opts) {
228
+ try {
229
+ const args = await this._middleware({ opts })
230
+ return await this.col.indexInformation(args.opts)
231
+ } catch (e) {
232
+ // col.indexInformation() throws an error if the collection is created yet...
233
+ if (e?.message.match(/ns does not exist/)) return {}
234
+ else throw new Error(e)
235
+ }
236
+ }
237
+
238
+ Collection.prototype.indexes = async function (opts) {
239
+ try {
240
+ const args = await this._middleware({ opts })
241
+ return await this.col.indexes(args.opts)
242
+ } catch (e) {
243
+ // col.indexes() throws an error if the collection is created yet...
244
+ if (e?.message.match(/ns does not exist/)) return []
245
+ else throw new Error(e)
246
+ }
247
+ }
248
+
249
+ Collection.prototype.insert = async function (data, opts) {
250
+ const args = await this._middleware({ data, opts })
251
+ const arrayInsert = Array.isArray(args.data)
252
+
253
+
254
+ if (arrayInsert && args.data.length === 0) {
255
+ return Promise.resolve([])
256
+ }
257
+
258
+ const doc = await this.col[`insert${arrayInsert ? 'Many' : 'One'}`](args.data, args.opts)
259
+ if (!doc) return
260
+
261
+ // Starting MongoDB 4 the `insert` method only returns the _id, rather than the whole doc.
262
+ // We need to return the whole doc for consistency with previous versions.
263
+ const output = util.deepCopy(args.data)
264
+ if (arrayInsert) {
265
+ for (let i=output.length; i--;) {
266
+ output[i]._id = doc.insertedIds[i]
267
+ }
268
+ } else {
269
+ output._id = doc.insertedId
270
+ }
271
+
272
+ return output
273
+ }
274
+
275
+ Collection.prototype.mapReduce = async function (map, reduce, opts) {
276
+ // https://www.mongodb.com/docs/manual/reference/method/db.collection.mapReduce/
277
+ throw new Error('mapReduce is depreciated in MongoDB 5.0, use aggregation pipeline instead')
278
+ }
279
+
280
+ Collection.prototype.remove = async function (query, opts) {
281
+ const args = await this._middleware({ query, opts })
282
+ const method = args.opts.single || args.opts.multi === false ? 'deleteOne' : 'deleteMany'
283
+ return this.col[method](args.query, args.opts)
284
+ }
285
+
286
+ Collection.prototype.stats = async function (opts) {
287
+ const args = await this._middleware({ opts })
288
+ return this.col.stats(args.opts)
289
+ }
290
+
291
+ Collection.prototype.update = async function (query, update, opts) {
292
+ // v3.0 - returns now an object with the following properties:
293
+ // {
294
+ // acknowledged: true,
295
+ // matchedCount: 0,
296
+ // modifiedCount: 0,
297
+ // upsertedCount: 1,
298
+ // upsertedId: expect.any(ObjectId),
299
+ // }
300
+ // was:
301
+ // {
302
+ // result: {
303
+ // ok: 1,
304
+ // n: 1,
305
+ // nModified: 1,
306
+ // upserted: [{ _id: expect.any(ObjectId) }],
307
+ // }
308
+ // }
309
+ const args = await this._middleware({ query, update, opts })
310
+ let method = args.opts.multi || args.opts.single === false ? 'updateMany' : 'updateOne'
311
+
312
+ if (args.opts.replace || args.opts.replaceOne) {
313
+ if (args.opts.multi || args.opts.single === false) {
314
+ throw new Error('The `replace` option is only available for single updates.')
315
+ }
316
+ method = 'replaceOne'
317
+ }
318
+
319
+ const doc = await this.col[method](args.query, args.update, args.opts)
320
+ // return doc?.result || doc
321
+ return doc
322
+ }
323
+
324
+ module.exports = Collection