immutable-json-patch
Version:
Immutable JSON patch with support for reverting operations
694 lines (650 loc) • 21.5 kB
JavaScript
(function (global, factory) {
typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) :
typeof define === 'function' && define.amd ? define(['exports'], factory) :
(global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.immutableJSONPatch = {}));
})(this, (function (exports) { 'use strict';
function isJSONArray(value) {
return Array.isArray(value);
}
function isJSONObject(value) {
return value !== null && typeof value === 'object' && (value.constructor === undefined ||
// for example Object.create(null)
value.constructor.name === 'Object') // do not match on classes or Array
;
}
function isJSONPatchOperation(value) {
// @ts-ignore
return value && typeof value === 'object' ? typeof value.op === 'string' : false;
}
function isJSONPatchAdd(value) {
// @ts-ignore
return value && typeof value === 'object' ? value.op === 'add' : false;
}
function isJSONPatchRemove(value) {
// @ts-ignore
return value && typeof value === 'object' ? value.op === 'remove' : false;
}
function isJSONPatchReplace(value) {
// @ts-ignore
return value && typeof value === 'object' ? value.op === 'replace' : false;
}
function isJSONPatchCopy(value) {
// @ts-ignore
return value && typeof value === 'object' ? value.op === 'copy' : false;
}
function isJSONPatchMove(value) {
// @ts-ignore
return value && typeof value === 'object' ? value.op === 'move' : false;
}
function isJSONPatchTest(value) {
// @ts-ignore
return value && typeof value === 'object' ? value.op === 'test' : false;
}
/**
* Test deep equality of two JSON values, objects, or arrays
*/
// TODO: write unit tests
function isEqual(a, b) {
// FIXME: this function will return false for two objects with the same keys
// but different order of keys
return JSON.stringify(a) === JSON.stringify(b);
}
/**
* Test whether two values are strictly equal
*/
function strictEqual(a, b) {
return a === b;
}
/**
* Get all but the last items from an array
*/
// TODO: write unit tests
function initial(array) {
return array.slice(0, array.length - 1);
}
/**
* Get the last item from an array
*/
// TODO: write unit tests
function last(array) {
return array[array.length - 1];
}
/**
* Test whether array1 starts with array2
* @param array1
* @param array2
* @param [isEqual] Optional function to check equality
*/
function startsWith(array1, array2) {
let isEqual = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : strictEqual;
if (array1.length < array2.length) {
return false;
}
for (let i = 0; i < array2.length; i++) {
if (!isEqual(array1[i], array2[i])) {
return false;
}
}
return true;
}
/**
* Test whether a value is an Object or an Array (and not a primitive JSON value)
*/
// TODO: write unit tests
function isObjectOrArray(value) {
return typeof value === 'object' && value !== null;
}
/**
* Immutability helpers
*
* inspiration:
*
* https://www.npmjs.com/package/seamless-immutable
* https://www.npmjs.com/package/ih
* https://www.npmjs.com/package/mutatis
* https://github.com/mariocasciaro/object-path-immutable
*/
/**
* Shallow clone of an Object, Array, or value
* Symbols are cloned too.
*/
function shallowClone(value) {
if (isJSONArray(value)) {
// copy array items
const copy = value.slice();
// copy all symbols
Object.getOwnPropertySymbols(value).forEach(symbol => {
// @ts-ignore
copy[symbol] = value[symbol];
});
return copy;
}
if (isJSONObject(value)) {
// copy object properties
const copy = {
...value
};
// copy all symbols
Object.getOwnPropertySymbols(value).forEach(symbol => {
// @ts-ignore
copy[symbol] = value[symbol];
});
return copy;
}
return value;
}
/**
* Update a value in an object in an immutable way.
* If the value is unchanged, the original object will be returned
*/
function applyProp(object, key, value) {
// @ts-ignore
if (object[key] === value) {
// return original object unchanged when the new value is identical to the old one
return object;
}
const updatedObject = shallowClone(object);
// @ts-ignore
updatedObject[key] = value;
return updatedObject;
}
/**
* helper function to get a nested property in an object or array
*
* @return Returns the field when found, or undefined when the path doesn't exist
*/
function getIn(object, path) {
let value = object;
let i = 0;
while (i < path.length) {
if (isJSONObject(value)) {
value = value[path[i]];
} else if (isJSONArray(value)) {
value = value[Number.parseInt(path[i])];
} else {
value = undefined;
}
i++;
}
return value;
}
/**
* helper function to replace a nested property in an object with a new value
* without mutating the object itself.
*
* @param object
* @param path
* @param value
* @param [createPath=false]
* If true, `path` will be created when (partly) missing in
* the object. For correctly creating nested Arrays or
* Objects, the function relies on `path` containing number
* in case of array indexes.
* If false (default), an error will be thrown when the
* path doesn't exist.
* @return Returns a new, updated object or array
*/
function setIn(object, path, value) {
let createPath = arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : false;
if (path.length === 0) {
return value;
}
const key = path[0];
// @ts-ignore
const updatedValue = setIn(object ? object[key] : undefined, path.slice(1), value, createPath);
if (isJSONObject(object) || isJSONArray(object)) {
return applyProp(object, key, updatedValue);
}
if (createPath) {
const newObject = IS_INTEGER_REGEX.test(key) ? [] : {};
// @ts-ignore
newObject[key] = updatedValue;
return newObject;
}
throw new Error('Path does not exist');
}
const IS_INTEGER_REGEX = /^\d+$/;
/**
* helper function to replace a nested property in an object with a new value
* without mutating the object itself.
*
* @return Returns a new, updated object or array
*/
function updateIn(object, path, transform) {
if (path.length === 0) {
return transform(object);
}
if (!isObjectOrArray(object)) {
throw new Error("Path doesn't exist");
}
const key = path[0];
// @ts-ignore
const updatedValue = updateIn(object[key], path.slice(1), transform);
// @ts-ignore
return applyProp(object, key, updatedValue);
}
/**
* helper function to delete a nested property in an object
* without mutating the object itself.
*
* @return Returns a new, updated object or array
*/
function deleteIn(object, path) {
if (path.length === 0) {
return object;
}
if (!isObjectOrArray(object)) {
throw new Error('Path does not exist');
}
if (path.length === 1) {
const key = path[0];
if (!(key in object)) {
// key doesn't exist. return object unchanged
return object;
}
const updatedObject = shallowClone(object);
if (isJSONArray(updatedObject)) {
updatedObject.splice(Number.parseInt(key), 1);
}
if (isJSONObject(updatedObject)) {
delete updatedObject[key];
}
return updatedObject;
}
const key = path[0];
// @ts-ignore
const updatedValue = deleteIn(object[key], path.slice(1));
// @ts-ignore
return applyProp(object, key, updatedValue);
}
/**
* Insert a new item in an array at a specific index.
* Example usage:
*
* insertAt({arr: [1,2,3]}, ['arr', '2'], 'inserted') // [1,2,'inserted',3]
*/
function insertAt(document, path, value) {
const parentPath = path.slice(0, path.length - 1);
const index = path[path.length - 1];
return updateIn(document, parentPath, items => {
if (!Array.isArray(items)) {
throw new TypeError(`Array expected at path ${JSON.stringify(parentPath)}`);
}
const updatedItems = shallowClone(items);
updatedItems.splice(Number.parseInt(index), 0, value);
return updatedItems;
});
}
/**
* Transform a JSON object, traverse over the whole object,
* and allow replacing Objects/Arrays/values.
*/
function transform(document, callback) {
let path = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : [];
const updated1 = callback(document, path);
if (isJSONArray(updated1)) {
let updated2;
for (let i = 0; i < updated1.length; i++) {
const before = updated1[i];
// we stringify the index here, so the path only contains strings and can be safely
// stringified/parsed to JSONPointer without loosing information.
// We do not want to rely on path keys being numeric/string.
const after = transform(before, callback, path.concat(String(i)));
if (after !== before) {
if (!updated2) {
updated2 = shallowClone(updated1);
}
updated2[i] = after;
}
}
return updated2 || updated1;
}
if (isJSONObject(updated1)) {
let updated2;
for (const key in updated1) {
// biome-ignore lint/suspicious/noPrototypeBuiltins: keep using the old way for now for backward compatibility
if (Object.hasOwnProperty.call(updated1, key)) {
const before = updated1[key];
const after = transform(before, callback, path.concat(key));
if (after !== before) {
if (!updated2) {
updated2 = shallowClone(updated1);
}
updated2[key] = after;
}
}
}
return updated2 || updated1;
}
return updated1;
}
/**
* Test whether a path exists in a JSON object
* @return Returns true if the path exists, else returns false
*/
function existsIn(document, path) {
if (document === undefined) {
return false;
}
if (path.length === 0) {
return true;
}
if (document === null) {
return false;
}
// @ts-ignore
return existsIn(document[path[0]], path.slice(1));
}
/**
* Parse a JSON Pointer
*/
function parseJSONPointer(pointer) {
const path = pointer.split('/');
path.shift(); // remove the first empty entry
return path.map(p => p.replace(/~1/g, '/').replace(/~0/g, '~'));
}
/**
* Compile a JSON Pointer
*/
function compileJSONPointer(path) {
return path.map(compileJSONPointerProp).join('');
}
/**
* Compile a single path property from a JSONPath
*/
function compileJSONPointerProp(pathProp) {
return `/${String(pathProp).replace(/~/g, '~0').replace(/\//g, '~1')}`;
}
/**
* Append a path property to a JSONPointer
*/
function appendToJSONPointer(pointer, pathProp) {
return pointer + compileJSONPointerProp(pathProp);
}
/**
* Test whether `pointer` starts with `searchPointer`
*/
function startsWithJSONPointer(pointer, searchPointer) {
return pointer.startsWith(searchPointer) && (pointer.length === searchPointer.length || pointer[searchPointer.length] === '/');
}
/**
* Apply a patch to a JSON object
* The original JSON object will not be changed,
* instead, the patch is applied in an immutable way
*/
function immutableJSONPatch(document, operations, options) {
let updatedDocument = document;
for (let i = 0; i < operations.length; i++) {
validateJSONPatchOperation(operations[i]);
let operation = operations[i];
// TODO: test before
if (options?.before) {
const result = options.before(updatedDocument, operation);
if (result !== undefined) {
if (result.document !== undefined) {
updatedDocument = result.document;
}
// @ts-ignore
if (result.json !== undefined) {
// TODO: deprecated since v5.0.0. Cleanup this warning some day
throw new Error('Deprecation warning: returned object property ".json" has been renamed to ".document"');
}
if (result.operation !== undefined) {
operation = result.operation;
}
}
}
const previousDocument = updatedDocument;
const path = parsePath(updatedDocument, operation.path);
if (operation.op === 'add') {
updatedDocument = add(updatedDocument, path, operation.value);
} else if (operation.op === 'remove') {
updatedDocument = remove(updatedDocument, path);
} else if (operation.op === 'replace') {
updatedDocument = replace(updatedDocument, path, operation.value);
} else if (operation.op === 'copy') {
updatedDocument = copy(updatedDocument, path, parseFrom(operation.from));
} else if (operation.op === 'move') {
updatedDocument = move(updatedDocument, path, parseFrom(operation.from));
} else if (operation.op === 'test') {
test(updatedDocument, path, operation.value);
} else {
throw new Error(`Unknown JSONPatch operation ${JSON.stringify(operation)}`);
}
// TODO: test after
if (options?.after) {
const result = options.after(updatedDocument, operation, previousDocument);
if (result !== undefined) {
updatedDocument = result;
}
}
}
return updatedDocument;
}
/**
* Replace an existing item
*/
function replace(document, path, value) {
return existsIn(document, path) ? setIn(document, path, value) : document;
}
/**
* Remove an item or property
*/
function remove(document, path) {
return deleteIn(document, path);
}
/**
* Add an item or property
*/
function add(document, path, value) {
if (isArrayItem(document, path)) {
return insertAt(document, path, value);
}
return setIn(document, path, value);
}
/**
* Copy a value
*/
function copy(document, path, from) {
const value = getIn(document, from);
if (isArrayItem(document, path)) {
return insertAt(document, path, value);
}
return setIn(document, path, value);
}
/**
* Move a value
*/
function move(document, path, from) {
const value = getIn(document, from);
const removedJson = deleteIn(document, from);
return isArrayItem(removedJson, path) ? insertAt(removedJson, path, value) : setIn(removedJson, path, value);
}
/**
* Test whether the data contains the provided value at the specified path.
* Throws an error when the test fails
*/
function test(document, path, value) {
if (value === undefined) {
throw new Error(`Test failed: no value provided (path: "${compileJSONPointer(path)}")`);
}
if (!existsIn(document, path)) {
throw new Error(`Test failed: path not found (path: "${compileJSONPointer(path)}")`);
}
const actualValue = getIn(document, path);
if (!isEqual(actualValue, value)) {
throw new Error(`Test failed, value differs (path: "${compileJSONPointer(path)}")`);
}
}
function isArrayItem(document, path) {
if (path.length === 0) {
return false;
}
const parent = getIn(document, initial(path));
return Array.isArray(parent);
}
/**
* Resolve the path index of an array, resolves indexes '-'
* @returns Returns the resolved path
*/
function resolvePathIndex(document, path) {
if (last(path) !== '-') {
return path;
}
const parentPath = initial(path);
const parent = getIn(document, parentPath);
// @ts-ignore
return parentPath.concat(parent.length);
}
/**
* Validate a JSONPatch operation.
* Throws an error when there is an issue
*/
function validateJSONPatchOperation(operation) {
// TODO: write unit tests
const ops = ['add', 'remove', 'replace', 'copy', 'move', 'test'];
if (!ops.includes(operation.op)) {
throw new Error(`Unknown JSONPatch op ${JSON.stringify(operation.op)}`);
}
if (typeof operation.path !== 'string') {
throw new Error(`Required property "path" missing or not a string in operation ${JSON.stringify(operation)}`);
}
if (operation.op === 'copy' || operation.op === 'move') {
if (typeof operation.from !== 'string') {
throw new Error(`Required property "from" missing or not a string in operation ${JSON.stringify(operation)}`);
}
}
}
function parsePath(document, pointer) {
return resolvePathIndex(document, parseJSONPointer(pointer));
}
function parseFrom(fromPointer) {
return parseJSONPointer(fromPointer);
}
/**
* Create the inverse of a set of json patch operations
* @param document
* @param operations Array with JSON patch actions
* @param [options]
* @return Returns the operations to revert the changes
*/
function revertJSONPatch(document, operations, options) {
let allRevertOperations = [];
const before = (document, operation) => {
let revertOperations;
const path = parsePath(document, operation.path);
if (operation.op === 'add') {
revertOperations = revertAdd(document, path);
} else if (operation.op === 'remove') {
revertOperations = revertRemove(document, path);
} else if (operation.op === 'replace') {
revertOperations = revertReplace(document, path);
} else if (operation.op === 'copy') {
revertOperations = revertCopy(document, path);
} else if (operation.op === 'move') {
revertOperations = revertMove(document, path, parseFrom(operation.from));
} else if (operation.op === 'test') {
revertOperations = [];
} else {
throw new Error(`Unknown JSONPatch operation ${JSON.stringify(operation)}`);
}
let updatedJson;
if (options?.before) {
const res = options.before(document, operation, revertOperations);
if (res?.revertOperations) {
revertOperations = res.revertOperations;
}
if (res?.document) {
updatedJson = res.document;
}
// @ts-ignore
if (res?.json) {
// TODO: deprecated since v5.0.0. Cleanup this warning some day
throw new Error('Deprecation warning: returned object property ".json" has been renamed to ".document"');
}
}
allRevertOperations = revertOperations.concat(allRevertOperations);
if (updatedJson !== undefined) {
return {
document: updatedJson
};
}
};
immutableJSONPatch(document, operations, {
before
});
return allRevertOperations;
}
function revertReplace(document, path) {
return existsIn(document, path) ? [{
op: 'replace',
path: compileJSONPointer(path),
value: getIn(document, path)
}] : [];
}
function revertRemove(document, path) {
return [{
op: 'add',
path: compileJSONPointer(path),
value: getIn(document, path)
}];
}
function revertAdd(document, path) {
if (isArrayItem(document, path) || !existsIn(document, path)) {
return [{
op: 'remove',
path: compileJSONPointer(path)
}];
}
return revertReplace(document, path);
}
function revertCopy(document, path) {
return revertAdd(document, path);
}
function revertMove(document, path, from) {
if (path.length < from.length && startsWith(from, path)) {
// replacing the parent with the child
return [{
op: 'replace',
path: compileJSONPointer(path),
value: document
}];
}
const move = {
op: 'move',
from: compileJSONPointer(path),
path: compileJSONPointer(from)
};
if (!isArrayItem(document, path) && existsIn(document, path)) {
// the move replaces an existing value in an object
return [move, ...revertRemove(document, path)];
}
return [move];
}
exports.appendToJSONPointer = appendToJSONPointer;
exports.compileJSONPointer = compileJSONPointer;
exports.compileJSONPointerProp = compileJSONPointerProp;
exports.deleteIn = deleteIn;
exports.existsIn = existsIn;
exports.getIn = getIn;
exports.immutableJSONPatch = immutableJSONPatch;
exports.insertAt = insertAt;
exports.isJSONArray = isJSONArray;
exports.isJSONObject = isJSONObject;
exports.isJSONPatchAdd = isJSONPatchAdd;
exports.isJSONPatchCopy = isJSONPatchCopy;
exports.isJSONPatchMove = isJSONPatchMove;
exports.isJSONPatchOperation = isJSONPatchOperation;
exports.isJSONPatchRemove = isJSONPatchRemove;
exports.isJSONPatchReplace = isJSONPatchReplace;
exports.isJSONPatchTest = isJSONPatchTest;
exports.parseFrom = parseFrom;
exports.parseJSONPointer = parseJSONPointer;
exports.parsePath = parsePath;
exports.revertJSONPatch = revertJSONPatch;
exports.setIn = setIn;
exports.startsWithJSONPointer = startsWithJSONPointer;
exports.transform = transform;
exports.updateIn = updateIn;
}));
//# sourceMappingURL=immutableJSONPatch.js.map