UNPKG

immutable-json-patch

Version:

Immutable JSON patch with support for reverting operations

694 lines (650 loc) 21.5 kB
(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