Skip to content

Commit 7416ff7

Browse files
authored
feat: option to exclude certain paths
* Added excludes option * Added deep removal of excluded paths in json-patch-operations * Moved filtering inside condition to get a little performance if 'excludes' option is not used * Extracted ambiguous functionality from getArrayFromPath() * Reworked some property names. Extraced methods for better readability. * Moved some comments. * Added documentation examples * Added link to excludes chapter
1 parent f5e18a0 commit 7416ff7

File tree

4 files changed

+356
-17
lines changed

4 files changed

+356
-17
lines changed

README.md

Lines changed: 45 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ To use **mongoose-patch-history** for an existing mongoose schema you can simply
1414

1515
```javascript
1616
import mongoose, { Schema } from 'mongoose'
17-
import patchHistory from 'mongoose-patch-history'
17+
import patchHistory from 'mongoose-patch-history'
1818

1919
/* or the following if not running your app with babel:
2020
const patchHistory = require('mongoose-patch-history').default;
@@ -148,8 +148,8 @@ post.rollback(patches[1].id, {}, false)
148148

149149
The `rollback` method will throw an Error when invoked with an ObjectId that is
150150

151-
* not a patch of the document
152-
* the latest patch of the document
151+
- not a patch of the document
152+
- the latest patch of the document
153153

154154
## Options
155155

@@ -160,17 +160,19 @@ PostSchema.plugin(patchHistory, {
160160
})
161161
```
162162

163-
* `mongoose` :pushpin: _required_ <br/>
163+
- `mongoose` :pushpin: _required_ <br/>
164164
The mongoose instance to work with
165-
* `name` :pushpin: _required_ <br/>
165+
- `name` :pushpin: _required_ <br/>
166166
String where the names of both patch model and patch collection are generated from. By default, model name is the pascalized version and collection name is an undercore separated version
167-
* `removePatches` <br/>
167+
- `removePatches` <br/>
168168
Removes patches when origin document is removed. Default: `true`
169-
* `transforms` <br/>
169+
- `transforms` <br/>
170170
An array of two functions that generate model and collection name based on the `name` option. Default: An array of [humps](https://github.com/domchristie/humps).pascalize and [humps](https://github.com/domchristie/humps).decamelize
171-
* `includes` <br/>
171+
- `includes` <br/>
172172
Property definitions that will be included in the patch schema. Read more about includes in the next chapter of the documentation. Default: `{}`
173-
* `trackOriginalValue` <br/>
173+
- `excludes` <br/>
174+
Property paths that will be excluded in patches. Read more about excludes in the [excludes chapter of the documentation](https://github.com/codepunkt/mongoose-patch-history#excludes). Default: `[]`
175+
- `trackOriginalValue` <br/>
174176
If enabled, the original value will be stored in the change patches under the attribute `originalValue`. Default: `false`
175177

176178
### Includes
@@ -203,7 +205,7 @@ There is an additional option that allows storing information in the patch docum
203205
204206
```javascript
205207
// save user as _user in versioned documents
206-
PostSchema.virtual('user').set(function(user) {
208+
PostSchema.virtual('user').set(function (user) {
207209
this._user = user
208210
})
209211

@@ -262,3 +264,36 @@ Post.findOneAndUpdate(
262264
{ _user: mongoose.Types.ObjectId() }
263265
)
264266
```
267+
268+
### Excludes
269+
270+
```javascript
271+
PostSchema.plugin(patchHistory, {
272+
mongoose,
273+
name: 'postPatches',
274+
excludes: [
275+
'/path/to/hidden/property',
276+
'/path/into/array/*/property',
277+
'/path/to/one/array/1/element',
278+
],
279+
})
280+
281+
// Properties
282+
// /path/to/hidden: included
283+
// /path/to/hidden/property: excluded
284+
// /path/to/hidden/property/nesting: excluded
285+
286+
// Array element properties
287+
// /path/into/array/0: included
288+
// /path/into/array/345345/property: excluded
289+
// /path/to/one/array/0/element: included
290+
// /path/to/one/array/1/element: excluded
291+
```
292+
293+
This will exclude the given properties and _all nested_ paths. Excluding `/` however will not work, since then you can just disable the plugin.
294+
295+
- If a property is `{}` or `undefined` after processing all excludes statements, it will _not_ be included in the patch.
296+
- Arrays work a little different. Since json-patch-operations work on the array index, array elements that are `{}` or `undefined` are still added to the patch. This brings support for later `remove` or `replace` operations to work as intended.<br/>
297+
The `ARRAY_WILDCARD` `*` matches every array element.
298+
299+
If there are any bugs experienced with the `excludes` feature please write an issue so we can fix it!

lib/index.js

Lines changed: 123 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,17 @@ Object.defineProperty(exports, "__esModule", {
55
});
66
exports.RollbackError = undefined;
77

8+
var _typeof = typeof Symbol === "function" && typeof Symbol.iterator === "symbol" ? function (obj) { return typeof obj; } : function (obj) { return obj && typeof Symbol === "function" && obj.constructor === Symbol && obj !== Symbol.prototype ? "symbol" : typeof obj; };
9+
810
exports.default = function (schema, opts) {
911
var options = (0, _lodash.merge)({}, defaultOptions, opts);
1012

1113
// get _id type from schema
1214
options._idType = schema.tree._id.type;
1315

16+
// transform excludes option
17+
options.excludes = options.excludes.map(getArrayFromPath);
18+
1419
// validate parameters
1520
(0, _assert2.default)(options.mongoose, '`mongoose` option must be defined');
1621
(0, _assert2.default)(options.name, '`name` option must be defined');
@@ -122,6 +127,16 @@ exports.default = function (schema, opts) {
122127
var ref = document._id;
123128

124129
var ops = _fastJsonPatch2.default.compare(document.isNew ? {} : document._original || {}, toJSON(document.data()));
130+
if (options.excludes.length > 0) {
131+
ops = ops.filter(function (op) {
132+
var pathArray = getArrayFromPath(op.path);
133+
return !options.excludes.some(function (exclude) {
134+
return isPathContained(exclude, pathArray);
135+
}) && options.excludes.every(function (exclude) {
136+
return deepRemovePath(op, exclude);
137+
});
138+
});
139+
}
125140

126141
// don't save a patch when there are no changes to save
127142
if (!ops.length) {
@@ -330,14 +345,118 @@ var createPatchModel = function createPatchModel(options) {
330345

331346
var defaultOptions = {
332347
includes: {},
348+
excludes: [],
333349
removePatches: true,
334350
transforms: [_humps.pascalize, _humps.decamelize],
335351
trackOriginalValue: false
352+
};
353+
354+
var ARRAY_INDEX_WILDCARD = '*';
355+
356+
/**
357+
* Splits a json-patch-path of form `/path/to/object` to an array `['path', 'to', 'object']`.
358+
* Note: `/` is returned as `[]`
359+
*
360+
* @param {string} path Path to split
361+
*/
362+
var getArrayFromPath = function getArrayFromPath(path) {
363+
return path.replace(/^\//, '').split('/');
364+
};
365+
366+
/**
367+
* Checks the provided `json-patch-operation` on `excludePath`. This check joins the `path` and `value` property of the `operation` and removes any hit.
368+
*
369+
* @param {import('fast-json-patch').Operation} patch operation to check with `excludePath`
370+
* @param {String[]} excludePath Path to property to remove from value of `operation`
371+
*
372+
* @return `false` if `patch.value` is `{}` or `undefined` after remove, `true` in any other case
373+
*/
374+
var deepRemovePath = function deepRemovePath(patch, excludePath) {
375+
var operationPath = sanitizeEmptyPath(getArrayFromPath(patch.path));
376+
377+
if (isPathContained(operationPath, excludePath)) {
378+
var value = patch.value;
379+
380+
// because the paths overlap start at patchPath.length
381+
// e.g.: patch: { path:'/object', value:{ property: 'test' } }
382+
// pathToExclude: '/object/property'
383+
// need to start at array idx 1, because value starts at idx 0
384+
385+
var _loop = function _loop(i) {
386+
if (excludePath[i] === ARRAY_INDEX_WILDCARD && Array.isArray(value)) {
387+
// start over with each array element and make a fresh check
388+
// Note: it can happen that array elements are rendered to: {}
389+
// we need to keep them to keep the order of array elements consistent
390+
value.forEach(function (elem) {
391+
deepRemovePath({ path: '/', value: elem }, excludePath.slice(i + 1));
392+
});
393+
394+
// If the patch value has turned to {} return false so this patch can be filtered out
395+
if (Object.keys(patch.value).length === 0) return {
396+
v: false
397+
};
398+
return {
399+
v: true
400+
};
401+
}
402+
value = value[excludePath[i]];
403+
404+
if (typeof value === 'undefined') return {
405+
v: true
406+
};
407+
};
408+
409+
for (var i = operationPath.length; i < excludePath.length - 1; i++) {
410+
var _ret = _loop(i);
411+
412+
if ((typeof _ret === 'undefined' ? 'undefined' : _typeof(_ret)) === "object") return _ret.v;
413+
}
414+
if (typeof value[excludePath[excludePath.length - 1]] === 'undefined') return true;else {
415+
delete value[excludePath[excludePath.length - 1]];
416+
// If the patch value has turned to {} return false so this patch can be filtered out
417+
if (Object.keys(patch.value).length === 0) return false;
418+
}
419+
}
420+
return true;
421+
};
422+
423+
/**
424+
* Sanitizes a path `['']` to be used with `isPathContained()`
425+
* @param {String[]} path
426+
*/
427+
var sanitizeEmptyPath = function sanitizeEmptyPath(path) {
428+
return path.length === 1 && path[0] === '' ? [] : path;
429+
};
430+
431+
// Checks if 'fractionPath' is contained in fullPath
432+
// Exp. 1: fractionPath '/path/to', fullPath '/path/to/object' => true
433+
// Exp. 2: fractionPath '/arrayPath/*/property', fullPath '/arrayPath/1/property' => true
434+
var isPathContained = function isPathContained(fractionPath, fullPath) {
435+
return fractionPath.every(function (entry, idx) {
436+
return entryIsIdentical(entry, fullPath[idx]) || matchesArrayWildcard(entry, fullPath[idx]);
437+
});
438+
};
439+
440+
var entryIsIdentical = function entryIsIdentical(entry1, entry2) {
441+
return entry1 === entry2;
442+
};
443+
444+
var matchesArrayWildcard = function matchesArrayWildcard(entry1, entry2) {
445+
return isArrayIndexWildcard(entry1) && isIntegerGreaterEqual0(entry2);
446+
};
447+
448+
var isArrayIndexWildcard = function isArrayIndexWildcard(entry) {
449+
return entry === ARRAY_INDEX_WILDCARD;
450+
};
451+
452+
var isIntegerGreaterEqual0 = function isIntegerGreaterEqual0(entry) {
453+
return Number.isInteger(Number(entry)) && Number(entry) >= 0;
454+
};
336455

337-
// used to convert bson to json - especially ObjectID references need
338-
// to be converted to hex strings so that the jsonpatch `compare` method
339-
// works correctly
340-
};var toJSON = function toJSON(obj) {
456+
// used to convert bson to json - especially ObjectID references need
457+
// to be converted to hex strings so that the jsonpatch `compare` method
458+
// works correctly
459+
var toJSON = function toJSON(obj) {
341460
return JSON.parse(JSON.stringify(obj));
342461
};
343462

src/index.js

Lines changed: 98 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,11 +35,95 @@ const createPatchModel = options => {
3535

3636
const defaultOptions = {
3737
includes: {},
38+
excludes: [],
3839
removePatches: true,
3940
transforms: [pascalize, decamelize],
4041
trackOriginalValue: false,
4142
}
4243

44+
const ARRAY_INDEX_WILDCARD = '*'
45+
46+
/**
47+
* Splits a json-patch-path of form `/path/to/object` to an array `['path', 'to', 'object']`.
48+
* Note: `/` is returned as `[]`
49+
*
50+
* @param {string} path Path to split
51+
*/
52+
const getArrayFromPath = path => path.replace(/^\//, '').split('/')
53+
54+
/**
55+
* Checks the provided `json-patch-operation` on `excludePath`. This check joins the `path` and `value` property of the `operation` and removes any hit.
56+
*
57+
* @param {import('fast-json-patch').Operation} patch operation to check with `excludePath`
58+
* @param {String[]} excludePath Path to property to remove from value of `operation`
59+
*
60+
* @return `false` if `patch.value` is `{}` or `undefined` after remove, `true` in any other case
61+
*/
62+
const deepRemovePath = (patch, excludePath) => {
63+
const operationPath = sanitizeEmptyPath(getArrayFromPath(patch.path))
64+
65+
if (isPathContained(operationPath, excludePath)) {
66+
let value = patch.value
67+
68+
// because the paths overlap start at patchPath.length
69+
// e.g.: patch: { path:'/object', value:{ property: 'test' } }
70+
// pathToExclude: '/object/property'
71+
// need to start at array idx 1, because value starts at idx 0
72+
for (let i = operationPath.length; i < excludePath.length - 1; i++) {
73+
if (excludePath[i] === ARRAY_INDEX_WILDCARD && Array.isArray(value)) {
74+
// start over with each array element and make a fresh check
75+
// Note: it can happen that array elements are rendered to: {}
76+
// we need to keep them to keep the order of array elements consistent
77+
value.forEach(elem => {
78+
deepRemovePath({ path: '/', value: elem }, excludePath.slice(i + 1))
79+
})
80+
81+
// If the patch value has turned to {} return false so this patch can be filtered out
82+
if (Object.keys(patch.value).length === 0) return false
83+
return true
84+
}
85+
value = value[excludePath[i]]
86+
87+
if (typeof value === 'undefined') return true
88+
}
89+
if (typeof value[excludePath[excludePath.length - 1]] === 'undefined')
90+
return true
91+
else {
92+
delete value[excludePath[excludePath.length - 1]]
93+
// If the patch value has turned to {} return false so this patch can be filtered out
94+
if (Object.keys(patch.value).length === 0) return false
95+
}
96+
}
97+
return true
98+
}
99+
100+
/**
101+
* Sanitizes a path `['']` to be used with `isPathContained()`
102+
* @param {String[]} path
103+
*/
104+
const sanitizeEmptyPath = path =>
105+
path.length === 1 && path[0] === '' ? [] : path
106+
107+
// Checks if 'fractionPath' is contained in fullPath
108+
// Exp. 1: fractionPath '/path/to', fullPath '/path/to/object' => true
109+
// Exp. 2: fractionPath '/arrayPath/*/property', fullPath '/arrayPath/1/property' => true
110+
const isPathContained = (fractionPath, fullPath) =>
111+
fractionPath.every(
112+
(entry, idx) =>
113+
entryIsIdentical(entry, fullPath[idx]) ||
114+
matchesArrayWildcard(entry, fullPath[idx])
115+
)
116+
117+
const entryIsIdentical = (entry1, entry2) => entry1 === entry2
118+
119+
const matchesArrayWildcard = (entry1, entry2) =>
120+
isArrayIndexWildcard(entry1) && isIntegerGreaterEqual0(entry2)
121+
122+
const isArrayIndexWildcard = entry => entry === ARRAY_INDEX_WILDCARD
123+
124+
const isIntegerGreaterEqual0 = entry =>
125+
Number.isInteger(Number(entry)) && Number(entry) >= 0
126+
43127
// used to convert bson to json - especially ObjectID references need
44128
// to be converted to hex strings so that the jsonpatch `compare` method
45129
// works correctly
@@ -65,6 +149,9 @@ export default function(schema, opts) {
65149
// get _id type from schema
66150
options._idType = schema.tree._id.type
67151

152+
// transform excludes option
153+
options.excludes = options.excludes.map(getArrayFromPath)
154+
68155
// validate parameters
69156
assert(options.mongoose, '`mongoose` option must be defined')
70157
assert(options.name, '`name` option must be defined')
@@ -167,10 +254,20 @@ export default function(schema, opts) {
167254
// added to the associated patch collection
168255
function createPatch(document, queryOptions = {}) {
169256
const { _id: ref } = document
170-
const ops = jsonpatch.compare(
257+
let ops = jsonpatch.compare(
171258
document.isNew ? {} : document._original || {},
172259
toJSON(document.data())
173260
)
261+
if (options.excludes.length > 0) {
262+
ops = ops.filter(op => {
263+
const pathArray = getArrayFromPath(op.path)
264+
return (
265+
!options.excludes.some(exclude =>
266+
isPathContained(exclude, pathArray)
267+
) && options.excludes.every(exclude => deepRemovePath(op, exclude))
268+
)
269+
})
270+
}
174271

175272
// don't save a patch when there are no changes to save
176273
if (!ops.length) {

0 commit comments

Comments
 (0)