Skip to content

Instantly share code, notes, and snippets.

@saswatds
Created November 23, 2021 14:04
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save saswatds/714d4c0553931d79eee7c56f24d6740c to your computer and use it in GitHub Desktop.
Save saswatds/714d4c0553931d79eee7c56f24d6740c to your computer and use it in GitHub Desktop.
JSON Patch to MongoDB Patch
/**
* NOTE: This algorithm only works for Mongodb version 4.2 where aggregate queries were introduced.
*
*/
const ZERO = 0,
ONE = 1,
unescape = (str) => str.replace(/~1/g, '/').replace(/~0/g, '~'),
parse = (pointer) => {
if (pointer === '') { return []; }
if (pointer.charAt(ZERO) !== '/') { throw new Error(`Invalid JSON pointer: ${pointer}`); }
return pointer.substring(ONE).split(/\//).map(unescape);
},
{ isEmpty } = require('./nodash');
module.exports = {
toMongo (patches) {
const update = {},
arrayUpdates = new Map(),
aggregates = [];
// For each of the patched
patches.forEach(({ op, path, value }) => {
// Get the object accessor array
const parts = parse(path),
// Detect if the path is an array or not,
// It will be an array if the last element is a number or -
isArray = (/^(([1-9]\d*)|0|-)$/).test(parts.slice(-ONE).pop());
if (isArray) {
const pa = parts.slice(ZERO, -ONE).join('.'),
i = parts.slice(-ONE).pop();
if (i === '-') {
throw new Error('Unbounded array operations are not supported');
}
arrayUpdates.has(pa) ?
arrayUpdates.get(pa).push({ i: parseInt(i, 10), op, value }) :
arrayUpdates.set(pa, [
{ i: parseInt(i, 10), op, value }
]);
return;
}
switch (op) {
case 'add':
case 'replace':
update.$set = { ...update.$set, [parts.join('.')]: value };
break;
case 'remove': {
const key = parts.join('.');
update.$unset = {
...update.$unset,
[key]: ''
};
}
break;
default:
}
});
arrayUpdates.forEach((ops, path) => {
// We will have a bunch of replace operations with some remove or adds at the end
const replaceOps = ops.filter(({ op }) => op === 'replace'),
removeOps = ops.filter(({ op }) => op === 'remove'),
addOps = ops.filter(({ op }) => op === 'add');
// Now we have these different situations
// 1. Only adds
// 2. Only removes
// 3. Only replace
// 4. Replace with add (array size increased)
// 5. Replace with remove (array size decreased)
if (!removeOps.length) {
update.$set = [
...addOps,
...replaceOps
].reduce((acc, { i, value }) => {
acc[`${path}.${i}`] = value;
return acc;
}, update.$set || {});
}
else {
// Things become very complicated here. But we can we certain that there will be
// no add operations here. We will have a bunch of replaces and then some removes
// Create splices of each replace and remove operation
const updates = [
...replaceOps,
...removeOps
].sort((a, b) => a.i - b.i).reduce((acc, { i, value = null }) => {
// If it is a consecutive number then push to the last injected value
if (acc.li === i) {
!i ?
acc.arr.push([
value
]) :
acc.arr[acc.arr.length - ONE].push(value);
}
else {
acc.arr.push({
$slice: [
`$${path}`,
acc.li,
i
]
});
acc.arr.push([
value
]);
}
acc.li = i + ONE;
return acc;
}, { li: 0, arr: [] });
// If there are some updates then perform them
aggregates.push({
$set: {
[path]: {
$concatArrays: [
...updates.arr,
{
$slice: [
`$${path}`,
updates.li,
{ $size: `$${path}` }
]
}
]
}
}
});
aggregates.push({
$addFields: {
[path]: {
$filter: {
input: `$${path}`,
as: 'd',
cond: {
$ne: [
'$$d',
null
]
}
}
}
}
});
}
});
return aggregates.length ?
(isEmpty(update) ?
[] :
[
update
]).concat(aggregates) :
update;
}
};
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment