ts-patch-mongoose 1.1.3 → 1.1.5

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/src/plugin.ts CHANGED
@@ -1,113 +1,44 @@
1
1
  import _ from 'lodash'
2
- import omit from 'omit-deep'
3
- import jsonpatch from 'fast-json-patch'
4
2
  import { assign } from 'power-assign'
5
3
 
6
- import type { CallbackError, HydratedDocument, Model, MongooseError, Schema, Types } from 'mongoose'
4
+ import type { HydratedDocument, Model, MongooseQueryMiddleware, Schema, ToObjectOptions } from 'mongoose'
7
5
 
8
6
  import type IPluginOptions from './interfaces/IPluginOptions'
9
7
  import type IContext from './interfaces/IContext'
10
8
  import type IHookContext from './interfaces/IHookContext'
11
9
 
10
+ import { createPatch, updatePatch, deletePatch } from './patch'
12
11
  import em from './em'
13
- import History from './models/History'
14
12
 
15
13
  const options = {
16
14
  document: false,
17
15
  query: true
18
16
  }
19
17
 
20
- function getObjects<T> (opts: IPluginOptions<T>, current: HydratedDocument<T>, original: HydratedDocument<T>): { currentObject: Partial<T>, originalObject: Partial<T> } {
21
- let currentObject = JSON.parse(JSON.stringify(current)) as Partial<T>
22
- let originalObject = JSON.parse(JSON.stringify(original)) as Partial<T>
23
-
24
- if (opts.omit) {
25
- currentObject = omit(currentObject, opts.omit)
26
- originalObject = omit(originalObject, opts.omit)
27
- }
28
-
29
- return { currentObject, originalObject }
30
- }
31
-
32
- async function updatePatch<T> (opts: IPluginOptions<T>, context: IContext<T>, current: HydratedDocument<T>, original: HydratedDocument<T>): Promise<void> {
33
- const { currentObject, originalObject } = getObjects(opts, current, original)
34
-
35
- if (_.isEmpty(originalObject) || _.isEmpty(currentObject)) return
36
-
37
- const patch = jsonpatch.compare(originalObject, currentObject, true)
38
-
39
- if (_.isEmpty(patch)) return
40
-
41
- if (opts.eventUpdated) {
42
- em.emit(opts.eventUpdated, { oldDoc: original, doc: current, patch })
43
- }
44
-
45
- if (opts.patchHistoryDisabled) return
46
-
47
- let version = 0
48
-
49
- const lastHistory = await History.findOne({ collectionId: original._id as Types.ObjectId }).sort('-version').exec()
50
-
51
- if (lastHistory && lastHistory.version >= 0) {
52
- version = lastHistory.version + 1
53
- }
54
-
55
- await History.create({
56
- op: context.op,
57
- modelName: context.modelName,
58
- collectionName: context.collectionName,
59
- collectionId: original._id as Types.ObjectId,
60
- patch,
61
- version
62
- })
18
+ const toObjectOptions: ToObjectOptions = {
19
+ depopulate: true,
20
+ virtuals: false
63
21
  }
64
22
 
65
- async function bulkPatch<T> (opts: IPluginOptions<T>, context: IContext<T>, eventKey: 'eventCreated' | 'eventDeleted', docsKey: 'createdDocs' | 'deletedDocs'): Promise<void> {
66
- const event = opts[eventKey]
67
- const docs = context[docsKey]
68
- const key = eventKey === 'eventCreated' ? 'doc' : 'oldDoc'
69
-
70
- if (_.isEmpty(docs) || (!event && opts.patchHistoryDisabled)) return
71
-
72
- const chunks = _.chunk(docs, 1000)
73
- for await (const chunk of chunks) {
74
- const bulk = []
75
- for (const doc of chunk) {
76
- if (event) em.emit(event, { [key]: doc })
77
-
78
- if (!opts.patchHistoryDisabled) {
79
- bulk.push({
80
- insertOne: {
81
- document: {
82
- op: context.op,
83
- modelName: context.modelName,
84
- collectionName: context.collectionName,
85
- collectionId: doc._id as Types.ObjectId,
86
- doc,
87
- version: 0
88
- }
89
- }
90
- })
91
- }
92
- }
93
-
94
- if (!opts.patchHistoryDisabled) {
95
- await History
96
- .bulkWrite(bulk, { ordered: false })
97
- .catch((err: MongooseError) => {
98
- console.error(err)
99
- })
100
- }
101
- }
102
- }
103
-
104
- async function createPatch<T> (opts: IPluginOptions<T>, context: IContext<T>): Promise<void> {
105
- await bulkPatch(opts, context, 'eventCreated', 'createdDocs')
106
- }
107
-
108
- async function deletePatch<T> (opts: IPluginOptions<T>, context: IContext<T>): Promise<void> {
109
- await bulkPatch(opts, context, 'eventDeleted', 'deletedDocs')
110
- }
23
+ const updateMethods = [
24
+ 'update',
25
+ 'updateOne',
26
+ 'replaceOne',
27
+ 'updateMany',
28
+ 'findOneAndUpdate',
29
+ 'findOneAndReplace',
30
+ 'findByIdAndUpdate'
31
+ ]
32
+
33
+ const deleteMethods = [
34
+ 'remove',
35
+ 'findOneAndDelete',
36
+ 'findOneAndRemove',
37
+ 'findByIdAndDelete',
38
+ 'findByIdAndRemove',
39
+ 'deleteOne',
40
+ 'deleteMany'
41
+ ]
111
42
 
112
43
  /**
113
44
  * @description Patch patch event emitter
@@ -122,7 +53,7 @@ export const patchEventEmitter = em
122
53
  */
123
54
  export const patchHistoryPlugin = function plugin<T> (schema: Schema<T>, opts: IPluginOptions<T>): void {
124
55
  schema.pre('save', async function (next) {
125
- const current = this.toObject({ depopulate: true }) as HydratedDocument<T>
56
+ const current = this.toObject(toObjectOptions) as HydratedDocument<T>
126
57
  const model = this.constructor as Model<T>
127
58
 
128
59
  const context: IContext<T> = {
@@ -132,19 +63,16 @@ export const patchHistoryPlugin = function plugin<T> (schema: Schema<T>, opts: I
132
63
  createdDocs: [current]
133
64
  }
134
65
 
135
- try {
136
- if (this.isNew) {
137
- await createPatch(opts, context)
138
- } else {
139
- const original = await model.findById(current._id).exec()
140
- if (original) {
141
- await updatePatch(opts, context, current, original)
142
- }
66
+ if (this.isNew) {
67
+ await createPatch(opts, context)
68
+ } else {
69
+ const original = await model.findById(current._id).exec()
70
+ if (original) {
71
+ await updatePatch(opts, context, current, original)
143
72
  }
144
- next()
145
- } catch (error) {
146
- next(error as CallbackError)
147
73
  }
74
+
75
+ next()
148
76
  })
149
77
 
150
78
  schema.post('insertMany', async function (docs) {
@@ -158,7 +86,7 @@ export const patchHistoryPlugin = function plugin<T> (schema: Schema<T>, opts: I
158
86
  await createPatch(opts, context)
159
87
  })
160
88
 
161
- schema.pre(['update', 'updateOne', 'updateMany', 'findOneAndUpdate', 'findOneAndReplace'], async function (this: IHookContext<T>, next) {
89
+ schema.pre(updateMethods as MongooseQueryMiddleware[], async function (this: IHookContext<T>, next) {
162
90
  const filter = this.getFilter()
163
91
  const update = this.getUpdate() as Record<string, Partial<T>> | null
164
92
  const options = this.getOptions()
@@ -173,54 +101,50 @@ export const patchHistoryPlugin = function plugin<T> (schema: Schema<T>, opts: I
173
101
  isNew: options.upsert && count === 0
174
102
  }
175
103
 
176
- try {
177
- const keys = _.keys(update).filter((key) => key.startsWith('$'))
178
- if (update && !_.isEmpty(keys)) {
179
- _.forEach(keys, (key) => {
180
- commands.push({ [key]: update[key] })
181
- // eslint-disable-next-line @typescript-eslint/no-dynamic-delete
182
- delete update[key]
183
- })
184
- }
185
- const cursor = this.model.find<HydratedDocument<T>>(filter).cursor()
186
- await cursor.eachAsync(async (doc) => {
187
- let current = doc.toObject({ depopulate: true }) as HydratedDocument<T>
188
- const original = doc.toObject({ depopulate: true }) as HydratedDocument<T>
189
- current = assign(current, update)
190
- _.forEach(commands, (command) => {
191
- try {
192
- current = assign(current, command)
193
- } catch (error) {
194
- // we catch assign keys that are not implemented
195
- }
196
- })
197
- await updatePatch(opts, this._context, current, original)
104
+ const keys = _.keys(update).filter((key) => key.startsWith('$'))
105
+ if (update && !_.isEmpty(keys)) {
106
+ _.forEach(keys, (key) => {
107
+ commands.push({ [key]: update[key] })
108
+ // eslint-disable-next-line @typescript-eslint/no-dynamic-delete
109
+ delete update[key]
198
110
  })
199
- next()
200
- } catch (error) {
201
- next(error as CallbackError)
202
111
  }
112
+
113
+ const cursor = this.model.find<HydratedDocument<T>>(filter).cursor()
114
+ await cursor.eachAsync(async (doc) => {
115
+ let current = doc.toObject(toObjectOptions) as HydratedDocument<T>
116
+ const original = doc.toObject(toObjectOptions) as HydratedDocument<T>
117
+
118
+ current = assign(current, update)
119
+ _.forEach(commands, (command) => {
120
+ try {
121
+ current = assign(current, command)
122
+ } catch (error) {
123
+ // we catch assign keys that are not implemented
124
+ }
125
+ })
126
+
127
+ await updatePatch(opts, this._context, current, original)
128
+ })
129
+
130
+ next()
203
131
  })
204
132
 
205
- schema.post(['update', 'updateOne', 'updateMany', 'findOneAndUpdate'], async function (this: IHookContext<T>) {
133
+ schema.post(updateMethods as MongooseQueryMiddleware[], async function (this: IHookContext<T>) {
206
134
  const update = this.getUpdate()
135
+ if (!update || !this._context.isNew) return
136
+
137
+ const found = await this.model.findOne<HydratedDocument<T>>(update).exec()
138
+ if (found) {
139
+ const current = found.toObject(toObjectOptions) as HydratedDocument<T>
140
+ this._context.createdDocs = [current]
207
141
 
208
- if (update && this._context.isNew) {
209
- const cursor = this.model.findOne<HydratedDocument<T>>(update).cursor()
210
- await cursor.eachAsync((doc) => {
211
- const current = doc.toObject({ depopulate: true }) as HydratedDocument<T>
212
- if (this._context.createdDocs) {
213
- this._context.createdDocs.push(current)
214
- } else {
215
- this._context.createdDocs = [current]
216
- }
217
- })
218
142
  await createPatch(opts, this._context)
219
143
  }
220
144
  })
221
145
 
222
- schema.pre('remove', async function (this: HydratedDocument<T>, next) {
223
- const original = this.toObject({ depopulate: true })
146
+ schema.post('remove', async function (this: HydratedDocument<T>) {
147
+ const original = this.toObject(toObjectOptions)
224
148
  const model = this.constructor as Model<T>
225
149
 
226
150
  const context: IContext<T> = {
@@ -229,18 +153,14 @@ export const patchHistoryPlugin = function plugin<T> (schema: Schema<T>, opts: I
229
153
  collectionName: opts.collectionName ?? model.collection.collectionName
230
154
  }
231
155
 
232
- try {
233
- if (opts.eventDeleted) {
234
- em.emit(opts.eventDeleted, { oldDoc: original })
235
- }
236
- await deletePatch(opts, context)
237
- next()
238
- } catch (error) {
239
- next(error as CallbackError)
156
+ if (opts.eventDeleted) {
157
+ em.emit(opts.eventDeleted, { oldDoc: original })
240
158
  }
159
+
160
+ await deletePatch(opts, context)
241
161
  })
242
162
 
243
- schema.pre(['remove', 'findOneAndDelete', 'findOneAndRemove', 'deleteOne', 'deleteMany'], options, async function (this: IHookContext<T>, next) {
163
+ schema.pre(deleteMethods as MongooseQueryMiddleware[], options, async function (this: IHookContext<T>, next) {
244
164
  const filter = this.getFilter()
245
165
  const options = this.getOptions()
246
166
  const ignore = options.__ignore as boolean
@@ -265,14 +185,14 @@ export const patchHistoryPlugin = function plugin<T> (schema: Schema<T>, opts: I
265
185
  }
266
186
  }
267
187
 
268
- if (opts.preDeleteManyCallback && _.isArray(this._context.deletedDocs) && !_.isEmpty(this._context.deletedDocs)) {
269
- await opts.preDeleteManyCallback(this._context.deletedDocs)
188
+ if (opts.preDeleteCallback && _.isArray(this._context.deletedDocs) && !_.isEmpty(this._context.deletedDocs)) {
189
+ await opts.preDeleteCallback(this._context.deletedDocs)
270
190
  }
271
191
 
272
192
  next()
273
193
  })
274
194
 
275
- schema.post(['remove', 'findOneAndDelete', 'findOneAndRemove', 'deleteOne', 'deleteMany'], options, async function (this: IHookContext<T>) {
195
+ schema.post(deleteMethods as MongooseQueryMiddleware[], options, async function (this: IHookContext<T>) {
276
196
  await deletePatch(opts, this._context)
277
197
  })
278
198
  }
package/tests/em.test.ts CHANGED
@@ -1,4 +1,4 @@
1
- import em from '../src/em'
1
+ import { patchEventEmitter } from '../src/plugin'
2
2
 
3
3
  describe('em', () => {
4
4
  it('should subscribe and count', async () => {
@@ -6,11 +6,11 @@ describe('em', () => {
6
6
  const fn = () => {
7
7
  count++
8
8
  }
9
- em.on('test', fn)
10
- em.emit('test')
9
+ patchEventEmitter.on('test', fn)
10
+ patchEventEmitter.emit('test')
11
11
  expect(count).toBe(1)
12
- em.off('test', fn)
13
- em.emit('test')
12
+ patchEventEmitter.off('test', fn)
13
+ patchEventEmitter.emit('test')
14
14
  expect(count).toBe(1)
15
15
  })
16
16
  })
@@ -0,0 +1,141 @@
1
+ import mongoose, { model } from 'mongoose'
2
+
3
+ import { getObjects, bulkPatch, updatePatch } from '../src/patch'
4
+ import { patchHistoryPlugin } from '../src/plugin'
5
+
6
+ import UserSchema from './schemas/UserSchema'
7
+
8
+ import { USER_DELETED } from './constants/events'
9
+
10
+ import type { HydratedDocument } from 'mongoose'
11
+ import type IPluginOptions from '../src/interfaces/IPluginOptions'
12
+ import type IUser from './interfaces/IUser'
13
+ import type IContext from '../src/interfaces/IContext'
14
+
15
+ import em from '../src/em'
16
+
17
+ jest.mock('../src/em', () => {
18
+ return {
19
+ emit: jest.fn()
20
+ }
21
+ })
22
+
23
+ describe('patch tests', () => {
24
+ const uri = `${globalThis.__MONGO_URI__}${globalThis.__MONGO_DB_NAME__}`
25
+
26
+ UserSchema.plugin(patchHistoryPlugin, {
27
+ eventDeleted: USER_DELETED,
28
+ patchHistoryDisabled: true
29
+ })
30
+
31
+ const User = model('User', UserSchema)
32
+
33
+ beforeAll(async () => {
34
+ await mongoose.connect(uri)
35
+ })
36
+
37
+ afterAll(async () => {
38
+ await mongoose.connection.close()
39
+ })
40
+
41
+ beforeEach(async () => {
42
+ await mongoose.connection.collection('tests').deleteMany({})
43
+ await mongoose.connection.collection('patches').deleteMany({})
44
+ })
45
+
46
+ describe('getObjects', () => {
47
+ it('should omit properties from currentObject and originalObject based on the opts', async () => {
48
+ const original = await User.create({ name: 'John', role: 'user' })
49
+ const current = await User.create({ name: 'John', role: 'admin' })
50
+
51
+ const pluginOptions = {
52
+ omit: ['createdAt']
53
+ }
54
+
55
+ const { currentObject, originalObject } = getObjects(pluginOptions, current, original)
56
+
57
+ expect(currentObject.name).toBe('John')
58
+ expect(currentObject.role).toBe('admin')
59
+ expect(currentObject.createdAt).toBeUndefined()
60
+
61
+ expect(originalObject.name).toBe('John')
62
+ expect(originalObject.role).toBe('user')
63
+ expect(originalObject.createdAt).toBeUndefined()
64
+ })
65
+
66
+ it('should not omit properties from currentObject and originalObject if opts is empty', async () => {
67
+ const original = await User.create({ name: 'John', role: 'user' })
68
+ const current = await User.create({ name: 'John', role: 'admin' })
69
+
70
+ const { currentObject, originalObject } = getObjects({}, current, original)
71
+
72
+ expect(currentObject.name).toBe('John')
73
+ expect(currentObject.role).toBe('admin')
74
+ expect(currentObject.createdAt).toBeDefined()
75
+
76
+ expect(originalObject.name).toBe('John')
77
+ expect(originalObject.role).toBe('user')
78
+ expect(originalObject.createdAt).toBeDefined()
79
+ })
80
+ })
81
+
82
+ describe('bulkPatch', () => {
83
+ it('should emit eventDeleted if opts.patchHistoryDisabled is false', async () => {
84
+ const doc = new User({ name: 'John', role: 'user' })
85
+
86
+ const pluginOptions: IPluginOptions<IUser> = {
87
+ eventDeleted: USER_DELETED,
88
+ patchHistoryDisabled: false
89
+ }
90
+
91
+ const context: IContext<IUser> = {
92
+ op: 'deleteOne',
93
+ modelName: 'User',
94
+ collectionName: 'users',
95
+ deletedDocs: [doc]
96
+ }
97
+
98
+ await bulkPatch(pluginOptions, context, 'eventDeleted', 'deletedDocs')
99
+ expect(em.emit).toHaveBeenCalled()
100
+ })
101
+
102
+ it('should emit eventDeleted if opts.patchHistoryDisabled is true', async () => {
103
+ const doc = new User({ name: 'John', role: 'user' })
104
+
105
+ const pluginOptions: IPluginOptions<IUser> = {
106
+ eventDeleted: USER_DELETED,
107
+ patchHistoryDisabled: true
108
+ }
109
+
110
+ const context: IContext<IUser> = {
111
+ op: 'deleteOne',
112
+ modelName: 'User',
113
+ collectionName: 'users',
114
+ deletedDocs: [doc]
115
+ }
116
+
117
+ await bulkPatch(pluginOptions, context, 'eventDeleted', 'deletedDocs')
118
+ expect(em.emit).toHaveBeenCalled()
119
+ })
120
+ })
121
+
122
+ describe('updatePatch', () => {
123
+ it('should return if one object is empty', async () => {
124
+ const current = await User.create({ name: 'John', role: 'user' })
125
+
126
+ const pluginOptions: IPluginOptions<IUser> = {
127
+ eventDeleted: USER_DELETED,
128
+ patchHistoryDisabled: true
129
+ }
130
+
131
+ const context: IContext<IUser> = {
132
+ op: 'updateOne',
133
+ modelName: 'User',
134
+ collectionName: 'users'
135
+ }
136
+
137
+ await updatePatch(pluginOptions, context, current, {} as HydratedDocument<IUser>)
138
+ expect(em.emit).not.toHaveBeenCalled()
139
+ })
140
+ })
141
+ })
@@ -0,0 +1,50 @@
1
+ import mongoose, { model } from 'mongoose'
2
+
3
+ import UserSchema from './schemas/UserSchema'
4
+ import { patchHistoryPlugin } from '../src/plugin'
5
+
6
+ import { USER_CREATED } from './constants/events'
7
+
8
+ const preDeleteCallbackMock = jest.fn()
9
+
10
+ describe('plugin - event created & patch history disabled', () => {
11
+ const uri = `${globalThis.__MONGO_URI__}${globalThis.__MONGO_DB_NAME__}`
12
+
13
+ UserSchema.plugin(patchHistoryPlugin, {
14
+ eventCreated: USER_CREATED,
15
+ patchHistoryDisabled: true,
16
+ preDeleteCallback: preDeleteCallbackMock
17
+ })
18
+
19
+ const User = model('User', UserSchema)
20
+
21
+ beforeAll(async () => {
22
+ await mongoose.connect(uri)
23
+ })
24
+
25
+ afterAll(async () => {
26
+ await mongoose.connection.close()
27
+ })
28
+
29
+ beforeEach(async () => {
30
+ await mongoose.connection.collection('users').deleteMany({})
31
+ await mongoose.connection.collection('history').deleteMany({})
32
+ })
33
+
34
+ it('should deleteMany and execute preDeleteCallback', async () => {
35
+ await User.create([
36
+ { name: 'John', role: 'user' },
37
+ { name: 'Jane', role: 'user' },
38
+ { name: 'Jack', role: 'user' }
39
+ ])
40
+
41
+ const users = await User.find({})
42
+ expect(users).toHaveLength(3)
43
+
44
+ const [john, jane, jack] = users
45
+
46
+ await User.deleteMany({ role: 'user' })
47
+ expect(preDeleteCallbackMock).toHaveBeenCalledTimes(1)
48
+ expect(preDeleteCallbackMock).toHaveBeenCalledWith([john, jane, jack])
49
+ })
50
+ })