├── .eslintrc.json ├── .github ├── FUNDING.yml └── workflows │ └── ci.yaml ├── .gitignore ├── LICENSE ├── README.md ├── index.d.ts ├── package.json ├── scripts └── build.mjs ├── src ├── added.js ├── arrayDiff.js ├── deleted.js ├── detailed.js ├── diff.js ├── index.js ├── preserveArray.js ├── updated.js └── utils.js ├── test ├── added.test.js ├── arrayDiff.test.js ├── deleted.test.js ├── diff.test.js ├── pollution.test.js ├── preserveArray.test.js ├── updated.test.js └── utils.test.js └── yarn.lock /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es2021": true, 5 | "node": true, 6 | "jest": true 7 | }, 8 | "extends": "eslint:recommended", 9 | "parserOptions": { 10 | "ecmaVersion": 12, 11 | "sourceType": "module" 12 | }, 13 | "rules": { 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [mattphillips] 2 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: 'GitHub CI' 2 | on: 3 | push: 4 | branches: 5 | - main 6 | pull_request: 7 | branches: 8 | - main 9 | 10 | jobs: 11 | test-node: 12 | name: Test on Node.js v${{ matrix.node-version }} 13 | strategy: 14 | fail-fast: false 15 | matrix: 16 | node-version: [12.x, 14.x, 16.x] 17 | runs-on: ubuntu-latest 18 | steps: 19 | - uses: actions/checkout@v2 20 | - name: Use Node.js ${{ matrix.node-version }} 21 | uses: actions/setup-node@v2 22 | with: 23 | node-version: ${{ matrix.node-version }} 24 | cache: yarn 25 | - run: yarn install 26 | - run: yarn test:coverage 27 | - uses: codecov/codecov-action@v2 28 | 29 | test-os: 30 | name: Test on ${{ matrix.os }} using Node.js LTS 31 | strategy: 32 | fail-fast: false 33 | matrix: 34 | os: [ubuntu-latest, windows-latest, macOS-latest] 35 | runs-on: ${{ matrix.os }} 36 | 37 | steps: 38 | - uses: actions/checkout@v2 39 | - uses: actions/setup-node@v2 40 | with: 41 | node-version: 16.x 42 | cache: yarn 43 | - run: yarn install 44 | - run: yarn test:coverage 45 | - uses: codecov/codecov-action@v2 46 | 47 | lint: 48 | name: Run ESLint 49 | runs-on: ubuntu-latest 50 | steps: 51 | - uses: actions/checkout@v2 52 | - uses: actions/setup-node@v2 53 | with: 54 | node-version: 16.x 55 | cache: yarn 56 | - run: yarn install 57 | - run: yarn lint 58 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist 3 | coverage 4 | npm-debug.log 5 | yarn-error.log 6 | dist-es 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016-present Matt Phillips 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 |

deep-object-diff

3 | 4 | ❄️ 5 | 6 | Deep diff two JavaScript Objects 7 |
8 | 9 |
10 | 11 | [![Build Status](https://github.com/mattphillips/deep-object-diff/actions/workflows/ci.yaml/badge.svg)](https://github.com/mattphillips/deep-object-diff/actions/workflows/ci.yaml) 12 | [![Code coverage](https://codecov.io/gh/mattphillips/deep-object-diff/branch/main/graph/badge.svg?token=EwnXzDGW3x)](https://codecov.io/gh/mattphillips/deep-object-diff) 13 | [![version](https://img.shields.io/npm/v/deep-object-diff.svg?style=flat-square)](https://www.npmjs.com/package/deep-object-diff) 14 | [![downloads](https://img.shields.io/npm/dm/deep-object-diff.svg?style=flat-square)](http://npm-stat.com/charts.html?package=deep-object-diff&from=2016-11-23) 15 | [![MIT License](https://img.shields.io/npm/l/deep-object-diff.svg?style=flat-square)](https://github.com/mattphillips/deep-object-diff/blob/master/LICENSE) 16 | [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat-square)](http://makeapullrequest.com) 17 | 18 | A small library that can deep diff two JavaScript Objects, including nested structures of arrays and objects. 19 | 20 | ## Installation 21 | `yarn add deep-object-diff` 22 | 23 | `npm i --save deep-object-diff` 24 | 25 | ## Functions available: 26 | - [`diff(originalObj, updatedObj)`](#diff) 27 | returns the difference of the original and updated objects 28 | 29 | - [`addedDiff(original, updatedObj)`](#addeddiff) 30 | returns only the values added to the updated object 31 | 32 | - [`deletedDiff(original, updatedObj)`](#deleteddiff) 33 | returns only the values deleted in the updated object 34 | 35 | - [`updatedDiff(original, updatedObj)`](#updateddiff) 36 | returns only the values that have been changed in the updated object 37 | 38 | - [`detailedDiff(original, updatedObj)`](#detaileddiff) 39 | returns an object with the added, deleted and updated differences 40 | 41 | ## Importing 42 | 43 | ``` js 44 | import { diff, addedDiff, deletedDiff, updatedDiff, detailedDiff } from 'deep-object-diff'; 45 | ``` 46 | 47 | ## Usage: 48 | 49 | ### `diff`: 50 | ```js 51 | const lhs = { 52 | foo: { 53 | bar: { 54 | a: ['a', 'b'], 55 | b: 2, 56 | c: ['x', 'y'], 57 | e: 100 // deleted 58 | } 59 | }, 60 | buzz: 'world' 61 | }; 62 | 63 | const rhs = { 64 | foo: { 65 | bar: { 66 | a: ['a'], // index 1 ('b') deleted 67 | b: 2, // unchanged 68 | c: ['x', 'y', 'z'], // 'z' added 69 | d: 'Hello, world!' // added 70 | } 71 | }, 72 | buzz: 'fizz' // updated 73 | }; 74 | 75 | console.log(diff(lhs, rhs)); // => 76 | /* 77 | { 78 | foo: { 79 | bar: { 80 | a: { 81 | '1': undefined 82 | }, 83 | c: { 84 | '2': 'z' 85 | }, 86 | d: 'Hello, world!', 87 | e: undefined 88 | } 89 | }, 90 | buzz: 'fizz' 91 | } 92 | */ 93 | ``` 94 | 95 | ### `addedDiff`: 96 | ```js 97 | const lhs = { 98 | foo: { 99 | bar: { 100 | a: ['a', 'b'], 101 | b: 2, 102 | c: ['x', 'y'], 103 | e: 100 // deleted 104 | } 105 | }, 106 | buzz: 'world' 107 | }; 108 | 109 | const rhs = { 110 | foo: { 111 | bar: { 112 | a: ['a'], // index 1 ('b') deleted 113 | b: 2, // unchanged 114 | c: ['x', 'y', 'z'], // 'z' added 115 | d: 'Hello, world!' // added 116 | } 117 | }, 118 | buzz: 'fizz' // updated 119 | }; 120 | 121 | console.log(addedDiff(lhs, rhs)); 122 | 123 | /* 124 | { 125 | foo: { 126 | bar: { 127 | c: { 128 | '2': 'z' 129 | }, 130 | d: 'Hello, world!' 131 | } 132 | } 133 | } 134 | */ 135 | ``` 136 | 137 | ### `deletedDiff`: 138 | ```js 139 | const lhs = { 140 | foo: { 141 | bar: { 142 | a: ['a', 'b'], 143 | b: 2, 144 | c: ['x', 'y'], 145 | e: 100 // deleted 146 | } 147 | }, 148 | buzz: 'world' 149 | }; 150 | 151 | const rhs = { 152 | foo: { 153 | bar: { 154 | a: ['a'], // index 1 ('b') deleted 155 | b: 2, // unchanged 156 | c: ['x', 'y', 'z'], // 'z' added 157 | d: 'Hello, world!' // added 158 | } 159 | }, 160 | buzz: 'fizz' // updated 161 | }; 162 | 163 | console.log(deletedDiff(lhs, rhs)); 164 | 165 | /* 166 | { 167 | foo: { 168 | bar: { 169 | a: { 170 | '1': undefined 171 | }, 172 | e: undefined 173 | } 174 | } 175 | } 176 | */ 177 | ``` 178 | 179 | ### `updatedDiff`: 180 | ```js 181 | const lhs = { 182 | foo: { 183 | bar: { 184 | a: ['a', 'b'], 185 | b: 2, 186 | c: ['x', 'y'], 187 | e: 100 // deleted 188 | } 189 | }, 190 | buzz: 'world' 191 | }; 192 | 193 | const rhs = { 194 | foo: { 195 | bar: { 196 | a: ['a'], // index 1 ('b') deleted 197 | b: 2, // unchanged 198 | c: ['x', 'y', 'z'], // 'z' added 199 | d: 'Hello, world!' // added 200 | } 201 | }, 202 | buzz: 'fizz' // updated 203 | }; 204 | 205 | console.log(updatedDiff(lhs, rhs)); 206 | 207 | /* 208 | { 209 | buzz: 'fizz' 210 | } 211 | */ 212 | ``` 213 | 214 | ### `detailedDiff`: 215 | ```js 216 | const lhs = { 217 | foo: { 218 | bar: { 219 | a: ['a', 'b'], 220 | b: 2, 221 | c: ['x', 'y'], 222 | e: 100 // deleted 223 | } 224 | }, 225 | buzz: 'world' 226 | }; 227 | 228 | const rhs = { 229 | foo: { 230 | bar: { 231 | a: ['a'], // index 1 ('b') deleted 232 | b: 2, // unchanged 233 | c: ['x', 'y', 'z'], // 'z' added 234 | d: 'Hello, world!' // added 235 | } 236 | }, 237 | buzz: 'fizz' // updated 238 | }; 239 | 240 | console.log(detailedDiff(lhs, rhs)); 241 | 242 | /* 243 | { 244 | added: { 245 | foo: { 246 | bar: { 247 | c: { 248 | '2': 'z' 249 | }, 250 | d: 'Hello, world!' 251 | } 252 | } 253 | }, 254 | deleted: { 255 | foo: { 256 | bar: { 257 | a: { 258 | '1': undefined 259 | }, 260 | e: undefined 261 | } 262 | } 263 | }, 264 | updated: { 265 | buzz: 'fizz' 266 | } 267 | } 268 | */ 269 | ``` 270 | 271 | 272 | ## License 273 | 274 | MIT 275 | -------------------------------------------------------------------------------- /index.d.ts: -------------------------------------------------------------------------------- 1 | export function diff (originalObj: object, updatedObj: object): object 2 | 3 | export function addedDiff (originalObj: object, updatedObj: object): object 4 | 5 | export function deletedDiff (originalObj: object, updatedObj: object): object 6 | 7 | export function updatedDiff (originalObj: object, updatedObj: object): object 8 | 9 | export interface DetailedDiff { 10 | added: object 11 | deleted: object 12 | updated: object 13 | } 14 | 15 | export function detailedDiff (originalObj: object, updatedObj: object): DetailedDiff 16 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "deep-object-diff", 3 | "version": "1.1.9", 4 | "description": "Deep diffs two objects, including nested structures of arrays and objects, and return the difference.", 5 | "main": "cjs/index.js", 6 | "module": "mjs/index.js", 7 | "exports": { 8 | ".": { 9 | "import": "./mjs/index.js", 10 | "require": "./cjs/index.js", 11 | "types": "./index.d.ts" 12 | } 13 | }, 14 | "types": "./index.d.ts", 15 | "scripts": { 16 | "build": "rm -rf dist && babel src -d dist/cjs && node scripts/build.mjs", 17 | "prepublish": "yarn build", 18 | "lint": "eslint src", 19 | "test": "jest", 20 | "test:coverage": "yarn test --coverage", 21 | "test:watch": "yarn test -- --watch" 22 | }, 23 | "author": "Matt Phillips", 24 | "license": "MIT", 25 | "devDependencies": { 26 | "@babel/cli": "^7.16.8", 27 | "@babel/core": "^7.16.12", 28 | "@babel/preset-env": "^7.16.11", 29 | "babel-jest": "^27.4.6", 30 | "eslint": "^8.7.0", 31 | "jest": "^27.4.7" 32 | }, 33 | "babel": { 34 | "presets": [ 35 | [ 36 | "@babel/preset-env", 37 | { 38 | "targets": { 39 | "node": "12" 40 | } 41 | } 42 | ] 43 | ] 44 | }, 45 | "repository": { 46 | "type": "git", 47 | "url": "git+https://github.com/mattphillips/deep-object-diff.git" 48 | }, 49 | "keywords": [ 50 | "diff", 51 | "object", 52 | "deep", 53 | "difference" 54 | ], 55 | "bugs": { 56 | "url": "https://github.com/mattphillips/deep-object-diff/issues" 57 | }, 58 | "homepage": "https://github.com/mattphillips/deep-object-diff#readme" 59 | } 60 | -------------------------------------------------------------------------------- /scripts/build.mjs: -------------------------------------------------------------------------------- 1 | import * as path from "path"; 2 | import * as fs from "fs"; 3 | 4 | const DIST = "dist"; 5 | const SRC = "src"; 6 | const MJS = "mjs"; 7 | const CJS = "cjs"; 8 | const PKG = "package.json"; 9 | const FILES = ["index.d.ts", "README.md", "LICENSE"]; 10 | 11 | fs.mkdirSync(path.join(DIST, MJS)); 12 | fs.readdirSync("./src").forEach((file) => fs.copyFileSync(path.join(SRC, file), path.join(DIST, MJS, file))); 13 | 14 | fs.writeFileSync(path.join(DIST, CJS, PKG), JSON.stringify({ type: "commonjs" }, null, 2)); 15 | fs.writeFileSync(path.join(DIST, MJS, PKG), JSON.stringify({ type: "module" }, null, 2)); 16 | 17 | const pkg = fs.readFileSync(PKG, "utf-8"); 18 | const json = JSON.parse(pkg); 19 | 20 | delete json.scripts; 21 | delete json.devDependencies; 22 | delete json.babel; 23 | 24 | fs.writeFileSync(path.join(DIST, PKG), JSON.stringify(json, null, 2)); 25 | 26 | FILES.forEach((file) => fs.copyFileSync(file, path.join(DIST, file))); 27 | -------------------------------------------------------------------------------- /src/added.js: -------------------------------------------------------------------------------- 1 | import { isEmpty, isObject, hasOwnProperty, makeObjectWithoutPrototype } from './utils.js'; 2 | 3 | const addedDiff = (lhs, rhs) => { 4 | 5 | if (lhs === rhs || !isObject(lhs) || !isObject(rhs)) return {}; 6 | 7 | 8 | return Object.keys(rhs).reduce((acc, key) => { 9 | if (hasOwnProperty(lhs, key)) { 10 | const difference = addedDiff(lhs[key], rhs[key]); 11 | 12 | if (isObject(difference) && isEmpty(difference)) return acc; 13 | 14 | acc[key] = difference; 15 | return acc; 16 | } 17 | 18 | acc[key] = rhs[key]; 19 | return acc; 20 | }, makeObjectWithoutPrototype()); 21 | }; 22 | 23 | export default addedDiff; 24 | -------------------------------------------------------------------------------- /src/arrayDiff.js: -------------------------------------------------------------------------------- 1 | import { isDate, isEmpty, isObject, hasOwnProperty } from './utils.js'; 2 | 3 | const diff = (lhs, rhs) => { 4 | if (lhs === rhs) return {}; // equal return no diff 5 | 6 | if (!isObject(lhs) || !isObject(rhs)) return rhs; // return updated rhs 7 | 8 | const l = lhs; 9 | const r = rhs; 10 | 11 | const deletedValues = Object.keys(l).reduce((acc, key) => { 12 | return hasOwnProperty(r, key) ? acc : { ...acc, [key]: undefined }; 13 | }, {}); 14 | 15 | if (isDate(l) || isDate(r)) { 16 | if (l.valueOf() == r.valueOf()) return {}; 17 | return r; 18 | } 19 | 20 | if (Array.isArray(r) && Array.isArray(l)) { 21 | const deletedValues = l.reduce((acc, item, index) => { 22 | return hasOwnProperty(r, index) ? acc.concat(item) : acc.concat(undefined); 23 | }, []); 24 | 25 | return r.reduce((acc, rightItem, index) => { 26 | if (!hasOwnProperty(deletedValues, index)) { 27 | return acc.concat(rightItem); 28 | } 29 | 30 | const leftItem = l[index]; 31 | const difference = diff(rightItem, leftItem); 32 | 33 | if (isObject(difference) && isEmpty(difference) && !isDate(difference)) { 34 | delete acc[index]; 35 | return acc; // return no diff 36 | } 37 | 38 | return acc.slice(0, index).concat(rightItem).concat(acc.slice(index + 1)); // return updated key 39 | }, deletedValues); 40 | } 41 | 42 | return Object.keys(r).reduce((acc, key) => { 43 | if (!hasOwnProperty(l, key)) return { ...acc, [key]: r[key] }; // return added r key 44 | 45 | const difference = diff(l[key], r[key]); 46 | 47 | if (isObject(difference) && isEmpty(difference) && !isDate(difference)) return acc; // return no diff 48 | 49 | return { ...acc, [key]: difference }; // return updated key 50 | }, deletedValues); 51 | }; 52 | 53 | export default diff; 54 | -------------------------------------------------------------------------------- /src/deleted.js: -------------------------------------------------------------------------------- 1 | import { isEmpty, isObject, hasOwnProperty, makeObjectWithoutPrototype } from './utils.js'; 2 | 3 | const deletedDiff = (lhs, rhs) => { 4 | if (lhs === rhs || !isObject(lhs) || !isObject(rhs)) return {}; 5 | 6 | return Object.keys(lhs).reduce((acc, key) => { 7 | if (hasOwnProperty(rhs, key)) { 8 | const difference = deletedDiff(lhs[key], rhs[key]); 9 | 10 | if (isObject(difference) && isEmpty(difference)) return acc; 11 | 12 | acc[key] = difference; 13 | return acc; 14 | } 15 | 16 | acc[key] = undefined; 17 | return acc; 18 | }, makeObjectWithoutPrototype()); 19 | }; 20 | 21 | export default deletedDiff; 22 | -------------------------------------------------------------------------------- /src/detailed.js: -------------------------------------------------------------------------------- 1 | import addedDiff from './added.js'; 2 | import deletedDiff from './deleted.js'; 3 | import updatedDiff from './updated.js'; 4 | 5 | const detailedDiff = (lhs, rhs) => ({ 6 | added: addedDiff(lhs, rhs), 7 | deleted: deletedDiff(lhs, rhs), 8 | updated: updatedDiff(lhs, rhs), 9 | }); 10 | 11 | export default detailedDiff; 12 | -------------------------------------------------------------------------------- /src/diff.js: -------------------------------------------------------------------------------- 1 | import { isDate, isEmptyObject, isObject, hasOwnProperty, makeObjectWithoutPrototype } from './utils.js'; 2 | 3 | const diff = (lhs, rhs) => { 4 | if (lhs === rhs) return {}; // equal return no diff 5 | 6 | if (!isObject(lhs) || !isObject(rhs)) return rhs; // return updated rhs 7 | 8 | const deletedValues = Object.keys(lhs).reduce((acc, key) => { 9 | if (!hasOwnProperty(rhs, key)) { 10 | acc[key] = undefined; 11 | 12 | } 13 | 14 | return acc; 15 | }, makeObjectWithoutPrototype()); 16 | 17 | if (isDate(lhs) || isDate(rhs)) { 18 | if (lhs.valueOf() == rhs.valueOf()) return {}; 19 | return rhs; 20 | } 21 | 22 | return Object.keys(rhs).reduce((acc, key) => { 23 | if (!hasOwnProperty(lhs, key)){ 24 | acc[key] = rhs[key]; // return added r key 25 | return acc; 26 | } 27 | 28 | const difference = diff(lhs[key], rhs[key]); 29 | 30 | // If the difference is empty, and the lhs is an empty object or the rhs is not an empty object 31 | if (isEmptyObject(difference) && !isDate(difference) && (isEmptyObject(lhs[key]) || !isEmptyObject(rhs[key]))) 32 | return acc; // return no diff 33 | 34 | acc[key] = difference // return updated key 35 | return acc; // return updated key 36 | }, deletedValues); 37 | }; 38 | 39 | export default diff; 40 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import diff from './diff.js'; 2 | import addedDiff from './added.js'; 3 | import deletedDiff from './deleted.js'; 4 | import updatedDiff from './updated.js'; 5 | import detailedDiff from './detailed.js'; 6 | 7 | export { 8 | addedDiff, 9 | diff, 10 | deletedDiff, 11 | updatedDiff, 12 | detailedDiff 13 | }; 14 | -------------------------------------------------------------------------------- /src/preserveArray.js: -------------------------------------------------------------------------------- 1 | import { isObject, hasOwnProperty } from './utils.js'; 2 | 3 | const getLargerArray = (l, r) => l.length > r.length ? l : r; 4 | 5 | const preserve = (diff, left, right) => { 6 | 7 | if (!isObject(diff)) return diff; 8 | 9 | return Object.keys(diff).reduce((acc, key) => { 10 | 11 | const leftArray = left[key]; 12 | const rightArray = right[key]; 13 | 14 | if (Array.isArray(leftArray) && Array.isArray(rightArray)) { 15 | const array = [...getLargerArray(leftArray, rightArray)]; 16 | return { 17 | ...acc, 18 | [key]: array.reduce((acc2, item, index) => { 19 | if (hasOwnProperty(diff[key], index)) { 20 | acc2[index] = preserve(diff[key][index], leftArray[index], rightArray[index]); // diff recurse and check for nested arrays 21 | return acc2; 22 | } 23 | 24 | delete acc2[index]; // no diff aka empty 25 | return acc2; 26 | }, array) 27 | }; 28 | } 29 | 30 | return { 31 | ...acc, 32 | [key]: diff[key] 33 | }; 34 | }, {}); 35 | }; 36 | 37 | export default preserve; 38 | -------------------------------------------------------------------------------- /src/updated.js: -------------------------------------------------------------------------------- 1 | import { isDate, isEmptyObject, isObject, hasOwnProperty, makeObjectWithoutPrototype } from './utils.js'; 2 | 3 | const updatedDiff = (lhs, rhs) => { 4 | if (lhs === rhs) return {}; 5 | 6 | if (!isObject(lhs) || !isObject(rhs)) return rhs; 7 | 8 | if (isDate(lhs) || isDate(rhs)) { 9 | if (lhs.valueOf() == rhs.valueOf()) return {}; 10 | return rhs; 11 | } 12 | 13 | return Object.keys(rhs).reduce((acc, key) => { 14 | if (hasOwnProperty(lhs, key)) { 15 | const difference = updatedDiff(lhs[key], rhs[key]); 16 | 17 | // If the difference is empty, and the lhs is an empty object or the rhs is not an empty object 18 | if (isEmptyObject(difference) && !isDate(difference) && (isEmptyObject(lhs[key]) || !isEmptyObject(rhs[key]))) 19 | return acc; // return no diff 20 | 21 | acc[key] = difference; 22 | return acc; 23 | } 24 | 25 | return acc; 26 | }, makeObjectWithoutPrototype()); 27 | }; 28 | 29 | export default updatedDiff; 30 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | export const isDate = d => d instanceof Date; 2 | export const isEmpty = o => Object.keys(o).length === 0; 3 | export const isObject = o => o != null && typeof o === 'object'; 4 | export const hasOwnProperty = (o, ...args) => Object.prototype.hasOwnProperty.call(o, ...args) 5 | export const isEmptyObject = (o) => isObject(o) && isEmpty(o); 6 | export const makeObjectWithoutPrototype = () => Object.create(null); 7 | -------------------------------------------------------------------------------- /test/added.test.js: -------------------------------------------------------------------------------- 1 | import addedDiff from '../src/added'; 2 | 3 | describe('.addedDiff', () => { 4 | 5 | describe('base case', () => { 6 | describe('equal', () => { 7 | test.each([ 8 | ['int', 1], 9 | ['string', 'a'], 10 | ['boolean', true], 11 | ['null', null], 12 | ['undefined', undefined], 13 | ['object', { a: 1 }], 14 | ['array', [1]], 15 | ['function', () => ({})], 16 | ['date', new Date()], 17 | ])('returns empty object when given values of type %s are equal', (type, value) => { 18 | expect(addedDiff(value, value)).toEqual({}); 19 | }); 20 | }); 21 | 22 | describe('not equal and not object', () => { 23 | test.each([ 24 | [1, 2], 25 | ['a', 'b'], 26 | [true, false], 27 | ['hello', null], 28 | ['hello', undefined], 29 | [null, undefined], 30 | [undefined, null], 31 | [null, { a: 1 }], 32 | ['872983', { areaCode: '+44', number: '872983' }], 33 | [100, () => ({})], 34 | [() => ({}), 100], 35 | [new Date('2017-01-01'), new Date('2017-01-02')], 36 | ])('returns empty object when values are not equal (%s, %s)', (lhs, rhs) => { 37 | expect(addedDiff(lhs, rhs)).toEqual({}); 38 | }); 39 | }); 40 | }); 41 | 42 | describe('recursive case', () => { 43 | describe('object', () => { 44 | test('returns empty object when given objects are updated', () => { 45 | expect(addedDiff({ a: 1 }, { a: 2 })).toEqual({}); 46 | }); 47 | 48 | test('returns empty object when right hand side has deletion', () => { 49 | expect(addedDiff({ a: 1, b: 2 }, { a: 1 })).toEqual({}); 50 | }); 51 | 52 | test('returns subset of right hand side value when a key value has been added to the root', () => { 53 | expect(addedDiff({ a: 1 }, { a: 1, b: 2 })).toEqual({ b: 2 }); 54 | }); 55 | 56 | test('returns subset of right hand side value when a key value has been added deeply', () => { 57 | expect(addedDiff({ a: { b: 1} }, { a: { b: 1, c: 2 } })).toEqual({ a: { c: 2 } }); 58 | }); 59 | 60 | test('returns subset of right hand side with added date', () => { 61 | expect(addedDiff({}, { date: new Date('2016') })).toEqual({ date: new Date('2016') }); 62 | }); 63 | }); 64 | 65 | describe('arrays', () => { 66 | test('returns empty object when array is updated', () => { 67 | expect(addedDiff([1], [2])).toEqual({}); 68 | }); 69 | 70 | test('returns empty object when right hand side array has deletions', () => { 71 | expect(addedDiff([1, 2, 3], [1, 3])).toEqual({}); 72 | }); 73 | 74 | test('returns subset of right hand side array as object of indices to value when right hand side array has additions', () => { 75 | expect(addedDiff([1, 2, 3], [1, 2, 3, 9])).toEqual({ 3: 9 }); 76 | }); 77 | 78 | test('returns subset of right hand side with added date', () => { 79 | expect(addedDiff([], [new Date('2016')])).toEqual({ 0: new Date('2016') }); 80 | }); 81 | }); 82 | 83 | describe('object create null', () => { 84 | test('returns subset of right hand side value when a key value has been added to the root', () => { 85 | const lhs = Object.create(null); 86 | const rhs = Object.create(null); 87 | lhs.a = 1; 88 | rhs.a = 1; 89 | rhs.b = 2; 90 | expect(addedDiff(lhs, rhs)).toEqual({ b: 2 }); 91 | }); 92 | 93 | test('returns subset of right hand side value when a key value has been added deeply', () => { 94 | const lhs = Object.create(null); 95 | const rhs = Object.create(null); 96 | lhs.a = { b: 1}; 97 | rhs.a = { b: 1, c: 2 }; 98 | expect(addedDiff(lhs, rhs)).toEqual({ a: { c: 2 } }); 99 | }); 100 | 101 | test('returns subset of right hand side with added date', () => { 102 | const lhs = Object.create(null); 103 | const rhs = Object.create(null); 104 | rhs.date = new Date('2016'); 105 | expect(addedDiff(lhs, rhs)).toEqual({ date: new Date('2016') }); 106 | }); 107 | }); 108 | 109 | describe('object with non-function hasOwnProperty property', () => { 110 | test('can represent the property in diff despite it being part of Object.prototype', () => { 111 | const lhs = {}; 112 | const rhs = { hasOwnProperty: true }; 113 | expect(addedDiff(lhs, rhs)).toEqual({ hasOwnProperty: true }); 114 | }); 115 | }); 116 | }); 117 | }); 118 | -------------------------------------------------------------------------------- /test/arrayDiff.test.js: -------------------------------------------------------------------------------- 1 | import diff from '../src/arrayDiff'; 2 | 3 | describe('.arrayDiff', () => { 4 | 5 | describe('base case', () => { 6 | describe('equal', () => { 7 | test.each([ 8 | ['int', 1], 9 | ['string', 'a'], 10 | ['boolean', true], 11 | ['null', null], 12 | ['undefined', undefined], 13 | ['object', { a: 1 }], 14 | ['array', [1]], 15 | ['function', () => ({})], 16 | ['date', new Date()], 17 | ['date with milliseconds', new Date('2017-01-01T00:00:00.637Z')], 18 | ])('returns empty object when given values of type %s are equal', (type, value) => { 19 | expect(diff(value, value)).toEqual({}); 20 | }); 21 | }); 22 | 23 | describe('not equal and not object', () => { 24 | test.each([ 25 | [1, 2], 26 | ['a', 'b'], 27 | [true, false], 28 | ['hello', null], 29 | ['hello', undefined], 30 | [null, undefined], 31 | [undefined, null], 32 | [null, { a: 1 }], 33 | ['872983', { areaCode: '+44', number: '872983' }], 34 | [100, () => ({})], 35 | [() => ({}), 100], 36 | [new Date('2017-01-01'), new Date('2017-01-02')], 37 | [new Date('2017-01-01T00:00:00.636Z'), new Date('2017-01-01T00:00:00.637Z')], 38 | ])('returns right hand side value when different to left hand side value (%s, %s)', (lhs, rhs) => { 39 | expect(diff(lhs, rhs)).toEqual(rhs); 40 | }); 41 | }); 42 | }); 43 | 44 | describe('recursive case', () => { 45 | describe('object', () => { 46 | test('returns right hand side value when given objects are different', () => { 47 | expect(diff({ a: 1 }, { a: 2 })).toEqual({ a: 2 }); 48 | }); 49 | 50 | test('returns right hand side value when right hand side value is null', () => { 51 | expect(diff({ a: 1 }, { a: null })).toEqual({ a: null }); 52 | }); 53 | 54 | test('returns subset of right hand side value when sibling objects differ', () => { 55 | expect(diff({ a: { b: 1 }, c: 2 }, { a: { b: 1 }, c: 3 })).toEqual({ c: 3 }); 56 | }); 57 | 58 | test('returns subset of right hand side value when nested values differ', () => { 59 | expect(diff({ a: { b: 1, c: 2} }, { a: { b: 1, c: 3 } })).toEqual({ a: { c: 3 } }); 60 | }); 61 | 62 | test('returns subset of right hand side value when nested values differ at multiple paths', () => { 63 | expect(diff({ a: { b: 1 }, c: 2, d: { e: 100 } }, { a: { b: 99 }, c: 3, d: { e: 100 } })).toEqual({ a: { b: 99 }, c: 3 }); 64 | }); 65 | 66 | test('returns subset of right hand side value when a key value has been deleted', () => { 67 | expect(diff({ a: { b: 1 }, c: 2, d: { e: 100 } }, { a: { b: 1 }, c: 2, d: {} })).toEqual({ d: { e: undefined } }); 68 | }); 69 | 70 | test('returns subset of right hand side value when a key value has been added', () => { 71 | expect(diff({ a: 1 }, { a: 1, b: 2 })).toEqual({ b: 2 }); 72 | }); 73 | 74 | test('returns keys as undefined when deleted from right hand side', () => { 75 | expect(diff({ a: 1, b: { c: 2 }}, { a: 1 })).toEqual({ b: undefined }); 76 | }); 77 | }); 78 | 79 | describe('arrays', () => { 80 | test('returns right hand side value as object of indices to value when arrays are different', () => { 81 | expect(diff([1], [2])).toEqual([2]); 82 | }); 83 | 84 | test('returns subset of right hand side array as object of indices to value when arrays differs at multiple indicies', () => { 85 | const expected = [9, 8, 3]; 86 | delete expected['2']; 87 | expect(diff([1, 2, 3], [9, 8, 3])).toEqual(expected); 88 | }); 89 | 90 | test('returns subset of right hand side array as object of indices to value when right hand side array has deletions', () => { 91 | const expected = [1, 3, undefined]; 92 | delete expected['0']; 93 | expect(diff([1, 2, 3], [1, 3])).toEqual(expected); 94 | }); 95 | 96 | test('returns subset of right hand side array as object of indices to value when right hand side array has additions', () => { 97 | const expected = [1, 2, 3, 9]; 98 | delete expected['0']; 99 | delete expected['1']; 100 | delete expected['2']; 101 | expect(diff([1, 2, 3], [1, 2, 3, 9])).toEqual(expected); 102 | }); 103 | }); 104 | 105 | describe('date', () => { 106 | const lhs = new Date('2016'); 107 | const rhs = new Date('2017'); 108 | 109 | test('returns empty object when dates are equal', () => { 110 | expect(diff(new Date('2016'), new Date('2016'))).toEqual({}); 111 | }); 112 | 113 | test('returns right hand side date when updated', () => { 114 | expect(diff({ date: lhs }, { date: rhs })).toEqual({ date: rhs }); 115 | expect(diff([lhs], [rhs])).toEqual([rhs]); 116 | }); 117 | 118 | test('returns undefined when date deleted', () => { 119 | expect(diff({ date: lhs }, {})).toEqual({ date: undefined }); 120 | expect(diff([lhs], [])).toEqual([undefined]); 121 | }); 122 | 123 | test('returns right hand side when date is added', () => { 124 | expect(diff({}, { date: rhs })).toEqual({ date: rhs }); 125 | expect(diff([], [rhs])).toEqual([rhs]); 126 | }); 127 | }); 128 | 129 | describe('object create null', () => { 130 | test('returns right hand side value when given objects are different', () => { 131 | const lhs = Object.create(null); 132 | lhs.a = 1; 133 | const rhs = Object.create(null); 134 | rhs.a = 2; 135 | expect(diff(lhs, rhs)).toEqual({ a: 2 }); 136 | }); 137 | 138 | test('returns subset of right hand side value when sibling objects differ', () => { 139 | const lhs = Object.create(null); 140 | lhs.a = { b: 1 }; 141 | lhs.c = 2; 142 | const rhs = Object.create(null); 143 | rhs.a = { b: 1 }; 144 | rhs.c = 3; 145 | expect(diff(lhs, rhs)).toEqual({ c: 3 }); 146 | }); 147 | 148 | test('returns subset of right hand side value when nested values differ', () => { 149 | const lhs = Object.create(null); 150 | lhs.a = { b: 1, c: 2}; 151 | const rhs = Object.create(null); 152 | rhs.a = { b: 1, c: 3 }; 153 | expect(diff(lhs, rhs)).toEqual({ a: { c: 3 } }); 154 | }); 155 | 156 | test('returns subset of right hand side value when nested values differ at multiple paths', () => { 157 | const lhs = Object.create(null); 158 | lhs.a = { b: 1 }; 159 | lhs.c = 2; 160 | const rhs = Object.create(null); 161 | rhs.a = { b: 99 }; 162 | rhs.c = 3; 163 | expect(diff(lhs, rhs)).toEqual({ a: { b: 99 }, c: 3 }); 164 | }); 165 | 166 | test('returns subset of right hand side value when a key value has been deleted', () => { 167 | const lhs = Object.create(null); 168 | lhs.a = { b: 1 }; 169 | lhs.c = 2; 170 | const rhs = Object.create(null); 171 | rhs.a = { b: 1 }; 172 | expect(diff(lhs, rhs)).toEqual({ c: undefined }); 173 | }); 174 | 175 | test('returns subset of right hand side value when a key value has been added', () => { 176 | const lhs = Object.create(null); 177 | lhs.a = 1; 178 | const rhs = Object.create(null); 179 | rhs.a = 1; 180 | rhs.b = 2; 181 | expect(diff(lhs, rhs)).toEqual({ b: 2 }); 182 | }); 183 | }); 184 | }); 185 | }); 186 | -------------------------------------------------------------------------------- /test/deleted.test.js: -------------------------------------------------------------------------------- 1 | import deletedDiff from '../src/deleted'; 2 | 3 | describe('.deletedDiff', () => { 4 | 5 | describe('base case', () => { 6 | describe('equal', () => { 7 | test.each([ 8 | ['int', 1], 9 | ['string', 'a'], 10 | ['boolean', true], 11 | ['null', null], 12 | ['undefined', undefined], 13 | ['object', { a: 1 }], 14 | ['array', [1]], 15 | ['function', () => ({})], 16 | ['date', new Date()], 17 | ])('returns empty object when given values of type %s are equal', (type, value) => { 18 | expect(deletedDiff(value, value)).toEqual({}); 19 | }); 20 | }); 21 | 22 | describe('not equal and not object', () => { 23 | test.each([ 24 | [1, 2], 25 | ['a', 'b'], 26 | [true, false], 27 | ['hello', null], 28 | ['hello', undefined], 29 | [null, undefined], 30 | [undefined, null], 31 | [null, { a: 1 }], 32 | ['872983', { areaCode: '+44', number: '872983' }], 33 | [100, () => ({})], 34 | [() => ({}), 100], 35 | [new Date('2017-01-01'), new Date('2017-01-02')], 36 | ])('returns empty object when given values: %s %s are unequal', (lhs, rhs) => { 37 | expect(deletedDiff(lhs, rhs)).toEqual({}); 38 | }); 39 | }); 40 | }); 41 | 42 | describe('recursive case', () => { 43 | describe('object', () => { 44 | test('returns empty object when rhs has been updated', () => { 45 | expect(deletedDiff({ a: 1 }, { a: 2 })).toEqual({}); 46 | }); 47 | 48 | test('returns empty object when right hand side has been added to', () => { 49 | expect(deletedDiff({ a: 1 }, { a: 1, b: 2 })).toEqual({}); 50 | }); 51 | 52 | test('returns keys as undefined when deleted from right hand side root', () => { 53 | expect(deletedDiff({ a: 1, b: { c: 2 }}, { a: 1 })).toEqual({ b: undefined }); 54 | }); 55 | 56 | test('returns keys as undefined when deeply deleted from right hand side', () => { 57 | expect(deletedDiff({ a: { b: 1 }, c: 2, d: { e: 100 } }, { a: { b: 1 }, c: 2, d: {} })).toEqual({ d: { e: undefined } }); 58 | }); 59 | 60 | test('returns subset of right hand side with deleted date', () => { 61 | expect(deletedDiff({ date: new Date('2016') }, {})).toEqual({ date: undefined }); 62 | }); 63 | }); 64 | 65 | describe('arrays', () => { 66 | test('returns empty object when rhs array has been updated', () => { 67 | expect(deletedDiff([1], [2])).toEqual({}); 68 | }); 69 | 70 | test('returns empty object when right hand side array has additions', () => { 71 | expect(deletedDiff([1, 2, 3], [1, 2, 3, 9])).toEqual({}); 72 | }); 73 | 74 | test('returns subset of right hand side array as object of indices to value when right hand side array has deletions', () => { 75 | expect(deletedDiff([1, 2, 3], [1, 3])).toEqual({ 2: undefined }); 76 | }); 77 | 78 | test('returns subset of right hand side with added date', () => { 79 | expect(deletedDiff([new Date('2016')], [])).toEqual({ 0: undefined }); 80 | }); 81 | }); 82 | 83 | describe('object create null', () => { 84 | test('returns keys as undefined when deleted from right hand side root', () => { 85 | const lhs = Object.create(null); 86 | const rhs = Object.create(null); 87 | lhs.a = 1; 88 | lhs.b = 2; 89 | rhs.a = 1; 90 | expect(deletedDiff(lhs, rhs)).toEqual({ b: undefined }); 91 | }); 92 | 93 | test('returns keys as undefined when deeply deleted from right hand side', () => { 94 | const lhs = Object.create(null); 95 | const rhs = Object.create(null); 96 | lhs.a = { b: 1 }; 97 | lhs.c = { d: 100 }; 98 | rhs.a = { b: 1 }; 99 | rhs.c = {}; 100 | expect(deletedDiff(lhs, rhs)).toEqual({ c: { d: undefined } }); 101 | }); 102 | 103 | test('returns subset of right hand side with deleted date', () => { 104 | const lhs = Object.create(null); 105 | const rhs = Object.create(null); 106 | lhs.date = new Date('2016'); 107 | expect(deletedDiff({ date: new Date('2016') }, rhs)).toEqual({ date: undefined }); 108 | }); 109 | }); 110 | 111 | describe('object with non-function hasOwnProperty property', () => { 112 | test('can represent the property in diff despite it being part of Object.prototype', () => { 113 | const lhs = { hasOwnProperty: true }; 114 | const rhs = {}; 115 | expect(deletedDiff(lhs, rhs)).toEqual({ hasOwnProperty: undefined }); 116 | }); 117 | }); 118 | }); 119 | }); 120 | -------------------------------------------------------------------------------- /test/diff.test.js: -------------------------------------------------------------------------------- 1 | import diff from '../src/diff'; 2 | 3 | describe('.diff', () => { 4 | 5 | describe('base case', () => { 6 | describe('equal', () => { 7 | test.each([ 8 | ['int', 1], 9 | ['string', 'a'], 10 | ['boolean', true], 11 | ['null', null], 12 | ['undefined', undefined], 13 | ['object', { a: 1 }], 14 | ['array', [1]], 15 | ['function', () => ({})], 16 | ['date', new Date()], 17 | ['date with milliseconds', new Date('2017-01-01T00:00:00.637Z')], 18 | ])('returns empty object when given values of type %s are equal', (type, value) => { 19 | expect(diff(value, value)).toEqual({}); 20 | }); 21 | }); 22 | 23 | describe('not equal and not object', () => { 24 | test.each([ 25 | [1, 2], 26 | ['a', 'b'], 27 | [true, false], 28 | ['hello', null], 29 | ['hello', undefined], 30 | [null, undefined], 31 | [undefined, null], 32 | [null, { a: 1 }], 33 | ['872983', { areaCode: '+44', number: '872983' }], 34 | [100, () => ({})], 35 | [() => ({}), 100], 36 | [new Date('2017-01-01'), new Date('2017-01-02')], 37 | [new Date('2017-01-01T00:00:00.636Z'), new Date('2017-01-01T00:00:00.637Z')], 38 | ])('returns right hand side value when different to left hand side value (%s, %s)', (lhs, rhs) => { 39 | expect(diff(lhs, rhs)).toEqual(rhs); 40 | }); 41 | }); 42 | }); 43 | 44 | describe('recursive case', () => { 45 | describe('object', () => { 46 | test("return right hand side empty object value when left hand side has been updated", () => { 47 | expect(diff({ a: 1 }, { a: {} })).toEqual({ a: {} }); 48 | }); 49 | 50 | test('returns right hand side value when given objects are different', () => { 51 | expect(diff({ a: 1 }, { a: 2 })).toEqual({ a: 2 }); 52 | }); 53 | 54 | test('returns right hand side value when right hand side value is null', () => { 55 | expect(diff({ a: 1 }, { a: null })).toEqual({ a: null }); 56 | }); 57 | 58 | test('returns subset of right hand side value when sibling objects differ', () => { 59 | expect(diff({ a: { b: 1 }, c: 2 }, { a: { b: 1 }, c: 3 })).toEqual({ c: 3 }); 60 | }); 61 | 62 | test('returns subset of right hand side value when nested values differ', () => { 63 | expect(diff({ a: { b: 1, c: 2} }, { a: { b: 1, c: 3 } })).toEqual({ a: { c: 3 } }); 64 | }); 65 | 66 | test('returns subset of right hand side value when nested values differ at multiple paths', () => { 67 | expect(diff({ a: { b: 1 }, c: 2, d: { e: 100 } }, { a: { b: 99 }, c: 3, d: { e: 100 } })).toEqual({ a: { b: 99 }, c: 3 }); 68 | }); 69 | 70 | test('returns subset of right hand side value when a key value has been deleted', () => { 71 | expect(diff({ a: { b: 1 }, c: 2, d: { e: 100 } }, { a: { b: 1 }, c: 2, d: {} })).toEqual({ d: { e: undefined } }); 72 | }); 73 | 74 | test('returns subset of right hand side value when a key value has been added', () => { 75 | expect(diff({ a: 1 }, { a: 1, b: 2 })).toEqual({ b: 2 }); 76 | }); 77 | 78 | test('returns keys as undefined when deleted from right hand side', () => { 79 | expect(diff({ a: 1, b: { c: 2 }}, { a: 1 })).toEqual({ b: undefined }); 80 | }); 81 | }); 82 | 83 | describe('arrays', () => { 84 | test("return right hand side empty object value when left hand side has been updated", () => { 85 | expect(diff([{ a: 1 }], [{ a: {} }])).toEqual({ 0: { a: {} } }); 86 | }); 87 | test('returns right hand side value as object of indices to value when arrays are different', () => { 88 | expect(diff([1], [2])).toEqual({ 0: 2 }); 89 | }); 90 | 91 | test('returns subset of right hand side array as object of indices to value when arrays differs at multiple indicies', () => { 92 | expect(diff([1, 2, 3], [9, 8, 3])).toEqual({ 0: 9, 1: 8 }); 93 | }); 94 | 95 | test('returns subset of right hand side array as object of indices to value when right hand side array has deletions', () => { 96 | expect(diff([1, 2, 3], [1, 3])).toEqual({ 1: 3, 2: undefined }); 97 | }); 98 | 99 | test('returns subset of right hand side array as object of indices to value when right hand side array has additions', () => { 100 | expect(diff([1, 2, 3], [1, 2, 3, 9])).toEqual({ 3: 9 }); 101 | }); 102 | }); 103 | 104 | describe('date', () => { 105 | const lhs = new Date('2016'); 106 | const rhs = new Date('2017'); 107 | 108 | test('returns empty object when dates are equal', () => { 109 | expect(diff(new Date('2016'), new Date('2016'))).toEqual({}); 110 | }); 111 | 112 | test('returns right hand side date when updated', () => { 113 | expect(diff({ date: lhs }, { date: rhs })).toEqual({ date: rhs }); 114 | expect(diff([lhs], [rhs])).toEqual({ 0: rhs }); 115 | }); 116 | 117 | test('returns undefined when date deleted', () => { 118 | expect(diff({ date: lhs }, {})).toEqual({ date: undefined }); 119 | expect(diff([lhs], [])).toEqual({ 0: undefined }); 120 | }); 121 | 122 | test('returns right hand side when date is added', () => { 123 | expect(diff({}, { date: rhs })).toEqual({ date: rhs }); 124 | expect(diff([], [rhs])).toEqual({ 0: rhs }); 125 | }); 126 | }); 127 | 128 | describe('object create null', () => { 129 | test('returns right hand side value when given objects are different', () => { 130 | const lhs = Object.create(null); 131 | lhs.a = 1; 132 | const rhs = Object.create(null); 133 | rhs.a = 2; 134 | expect(diff(lhs, rhs)).toEqual({ a: 2 }); 135 | }); 136 | 137 | test('returns subset of right hand side value when sibling objects differ', () => { 138 | const lhs = Object.create(null); 139 | lhs.a = { b: 1 }; 140 | lhs.c = 2; 141 | const rhs = Object.create(null); 142 | rhs.a = { b: 1 }; 143 | rhs.c = 3; 144 | expect(diff(lhs, rhs)).toEqual({ c: 3 }); 145 | }); 146 | 147 | test('returns subset of right hand side value when nested values differ', () => { 148 | const lhs = Object.create(null); 149 | lhs.a = { b: 1, c: 2}; 150 | const rhs = Object.create(null); 151 | rhs.a = { b: 1, c: 3 }; 152 | expect(diff(lhs, rhs)).toEqual({ a: { c: 3 } }); 153 | }); 154 | 155 | test('returns subset of right hand side value when nested values differ at multiple paths', () => { 156 | const lhs = Object.create(null); 157 | lhs.a = { b: 1 }; 158 | lhs.c = 2; 159 | const rhs = Object.create(null); 160 | rhs.a = { b: 99 }; 161 | rhs.c = 3; 162 | expect(diff(lhs, rhs)).toEqual({ a: { b: 99 }, c: 3 }); 163 | }); 164 | 165 | test('returns subset of right hand side value when a key value has been deleted', () => { 166 | const lhs = Object.create(null); 167 | lhs.a = { b: 1 }; 168 | lhs.c = 2; 169 | const rhs = Object.create(null); 170 | rhs.a = { b: 1 }; 171 | expect(diff(lhs, rhs)).toEqual({ c: undefined }); 172 | }); 173 | 174 | test('returns subset of right hand side value when a key value has been added', () => { 175 | const lhs = Object.create(null); 176 | lhs.a = 1; 177 | const rhs = Object.create(null); 178 | rhs.a = 1; 179 | rhs.b = 2; 180 | expect(diff(lhs, rhs)).toEqual({ b: 2 }); 181 | }); 182 | }); 183 | }); 184 | }); 185 | -------------------------------------------------------------------------------- /test/pollution.test.js: -------------------------------------------------------------------------------- 1 | import addedDiff from "../src/added"; 2 | import updatedDiff from "../src/updated"; 3 | import diff from "../src/diff"; 4 | import deletedDiff from "../src/deleted"; 5 | 6 | describe("Prototype pollution", () => { 7 | describe("diff", () => { 8 | test("should not pollute returned diffs prototype", () => { 9 | const l = { role: "user" }; 10 | const r = JSON.parse('{ "role": "user", "__proto__": { "role": "admin" } }'); 11 | const difference = diff(l, r); 12 | 13 | expect(l.role).toBe("user"); 14 | expect(r.role).toBe("user"); 15 | expect(difference.role).toBeUndefined(); 16 | }); 17 | 18 | test("should not pollute returned diffs prototype on nested diffs", () => { 19 | const l = { about: { role: "user" } }; 20 | const r = JSON.parse('{ "about": { "__proto__": { "role": "admin" } } }'); 21 | const difference = addedDiff(l, r); 22 | 23 | expect(l.about.role).toBe("user"); 24 | expect(r.about.role).toBeUndefined(); 25 | expect(difference.about.role).toBeUndefined(); 26 | }); 27 | }); 28 | 29 | describe("addedDiff", () => { 30 | test("addedDiff should not pollute returned diffs prototype", () => { 31 | const l = { role: "user" }; 32 | const r = JSON.parse('{ "__proto__": { "role": "admin" } }'); 33 | const difference = addedDiff(l, r); 34 | 35 | expect(l.role).toBe("user"); 36 | expect(r.role).toBeUndefined(); 37 | expect(difference.role).toBeUndefined(); 38 | }); 39 | 40 | test("should not pollute returned diffs prototype on nested diffs", () => { 41 | const l = { about: { role: "user" } }; 42 | const r = JSON.parse('{ "about": { "__proto__": { "role": "admin" } } }'); 43 | const difference = addedDiff(l, r); 44 | 45 | expect(l.about.role).toBe("user"); 46 | expect(r.about.role).toBeUndefined(); 47 | expect(difference.about.role).toBeUndefined(); 48 | }); 49 | }); 50 | 51 | test("updatedDiff should not pollute returned diffs prototype", () => { 52 | const l = { role: "user" }; 53 | const r = JSON.parse('{ "role": "user", "__proto__": { "role": "admin" } }'); 54 | const difference = updatedDiff(l, r); 55 | 56 | expect(l.role).toBe("user"); 57 | expect(r.role).toBe("user"); 58 | expect(difference.role).toBeUndefined(); 59 | }); 60 | 61 | test("deletedDiff should not pollute returned diffs prototype", () => { 62 | const l = { role: "user" }; 63 | const r = JSON.parse('{ "__proto__": { "role": "admin" } }'); 64 | const difference = deletedDiff(l, r); 65 | 66 | expect(l.role).toBe("user"); 67 | expect(r.role).toBeUndefined(); 68 | expect(difference.role).toBeUndefined(); 69 | }); 70 | }); 71 | -------------------------------------------------------------------------------- /test/preserveArray.test.js: -------------------------------------------------------------------------------- 1 | import preserveArray from '../src/preserveArray'; 2 | 3 | describe('.preserveArray', () => { 4 | test('returns diff with nested objects converted back to arrays when property is deleted', () => { 5 | const left = { a: [{ b: ['#', '#', '#', { hello: '' }] }, '#', { c: '', d: ['#', ''] }, '#'] }; 6 | const right = { a: [{ b: ['#', '#', '#', { hello: 'world' }] }, '#', { c: 'hello', d: ['#', 'bob'] }] }; 7 | const diff = { 8 | a: { 9 | 0: { 10 | b: { 11 | 3: { 12 | hello: 'world' 13 | } 14 | } 15 | }, 16 | 2: { 17 | c: 'hello', 18 | d: { 19 | 1: 'bob' 20 | } 21 | }, 22 | 3: undefined 23 | } 24 | }; 25 | const expected = { 26 | a: [ 27 | { 28 | b: [ 29 | 'empty', 30 | 'empty', 31 | 'empty', 32 | { 33 | hello: 'world' 34 | } 35 | ] 36 | }, 37 | 'empty', 38 | { 39 | c: 'hello', 40 | d: ['empty', 'bob'] 41 | }, 42 | undefined 43 | ] 44 | }; 45 | delete expected.a[0].b[0]; 46 | delete expected.a[0].b[1]; 47 | delete expected.a[0].b[2]; 48 | delete expected.a[1]; 49 | delete expected.a[2].d[0]; 50 | 51 | expect(preserveArray(diff, left, right)).toEqual(expected); 52 | }); 53 | 54 | test('returns diff with nested objects converted back to arrays when new property is added', () => { 55 | const left = { a: [{ b: ['#', '#', '#', { hello: '' }] }, '#', { c: '', d: ['#', ''] }] }; 56 | const right = { a: [{ b: ['#', '#', '#', { hello: 'world' }] }, '#', { c: 'hello', d: ['#', 'bob'] }, 'foobar'] }; 57 | const diff = { 58 | a: { 59 | 0: { 60 | b: { 61 | 3: { 62 | hello: 'world' 63 | } 64 | } 65 | }, 66 | 2: { 67 | c: 'hello', 68 | d: { 69 | 1: 'bob' 70 | } 71 | }, 72 | 3: 'foobar' 73 | } 74 | }; 75 | const expected = { 76 | a: [ 77 | { 78 | b: [ 79 | 'empty', 80 | 'empty', 81 | 'empty', 82 | { 83 | hello: 'world' 84 | } 85 | ] 86 | }, 87 | 'empty', 88 | { 89 | c: 'hello', 90 | d: ['empty', 'bob'] 91 | }, 92 | 'foobar' 93 | ] 94 | }; 95 | delete expected.a[0].b[0]; 96 | delete expected.a[0].b[1]; 97 | delete expected.a[0].b[2]; 98 | delete expected.a[1]; 99 | delete expected.a[2].d[0]; 100 | 101 | expect(preserveArray(diff, left, right)).toEqual(expected); 102 | }); 103 | }); 104 | -------------------------------------------------------------------------------- /test/updated.test.js: -------------------------------------------------------------------------------- 1 | import updatedDiff from '../src/updated'; 2 | 3 | describe('.updatedDiff', () => { 4 | 5 | describe('base case', () => { 6 | describe('equal', () => { 7 | test.each([ 8 | ['int', 1], 9 | ['string', 'a'], 10 | ['boolean', true], 11 | ['null', null], 12 | ['undefined', undefined], 13 | ['object', { a: 1 }], 14 | ['array', [1]], 15 | ['function', () => ({})], 16 | ['date', new Date()], 17 | ['date with milliseconds', new Date('2017-01-01T00:00:00.637Z')], 18 | ])('returns empty object when given values of type %s are equal', (type, value) => { 19 | expect(updatedDiff(value, value)).toEqual({}); 20 | }); 21 | }); 22 | 23 | describe('not equal and not object', () => { 24 | test.each([ 25 | [1, 2], 26 | ['a', 'b'], 27 | [true, false], 28 | ['hello', null], 29 | ['hello', undefined], 30 | [null, undefined], 31 | [undefined, null], 32 | [null, { a: 1 }], 33 | ['872983', { areaCode: '+44', number: '872983' }], 34 | [100, () => ({})], 35 | [() => ({}), 100], 36 | [new Date('2017-01-01'), new Date('2017-01-02')], 37 | [new Date('2017-01-01T00:00:00.636Z'), new Date('2017-01-01T00:00:00.637Z')], 38 | ])('returns right hand side value when different to left hand side value (%s, %s)', (lhs, rhs) => { 39 | expect(updatedDiff(lhs, rhs)).toEqual(rhs); 40 | }); 41 | }); 42 | }); 43 | 44 | describe('recursive case', () => { 45 | describe('object', () => { 46 | test("return right hand side empty object value when left hand side has been updated", () => { 47 | expect(updatedDiff({ a: 1 }, { a: {} })).toEqual({ a: {} }); 48 | }); 49 | 50 | test('returns right hand side value when given objects are different at root', () => { 51 | expect(updatedDiff({ a: 1 }, { a: 2 })).toEqual({ a: 2 }); 52 | }); 53 | 54 | test('returns right hand side value when right hand side value is null', () => { 55 | expect(updatedDiff({ a: 1 }, { a: null })).toEqual({ a: null }); 56 | }); 57 | 58 | test('returns subset of right hand side value when sibling objects differ', () => { 59 | expect(updatedDiff({ a: { b: 1 }, c: 2 }, { a: { b: 1 }, c: 3 })).toEqual({ c: 3 }); 60 | }); 61 | 62 | test('returns subset of right hand side value when nested values differ', () => { 63 | expect(updatedDiff({ a: { b: 1, c: 2} }, { a: { b: 1, c: 3 } })).toEqual({ a: { c: 3 } }); 64 | }); 65 | 66 | test('returns subset of right hand side value when nested values differ at multiple paths', () => { 67 | expect(updatedDiff({ a: { b: 1 }, c: 2, d: { e: 100 } }, { a: { b: 99 }, c: 3, d: { e: 100 } })).toEqual({ a: { b: 99 }, c: 3 }); 68 | }); 69 | 70 | test('returns empty object when deleted from right hand side', () => { 71 | expect(updatedDiff({ a: 1, b: { c: 2 }}, { a: 1 })).toEqual({}); 72 | }); 73 | 74 | test('returns empty object when a key value has been added', () => { 75 | expect(updatedDiff({ a: 1 }, { a: 1, b: 2 })).toEqual({}); 76 | }); 77 | 78 | test('returns subset of right hand side with updated date', () => { 79 | expect(updatedDiff({ date: new Date('2016') }, { date: new Date('2017') })).toEqual({ date: new Date('2017') }); 80 | }); 81 | }); 82 | 83 | describe('arrays', () => { 84 | test("return right hand side empty object value when left hand side has been updated", () => { 85 | expect(updatedDiff([{ a: 1 }], [{ a: {} }])).toEqual({ 0: { a: {} } }); 86 | }); 87 | 88 | test('returns right hand side value as object of indices to value when arrays are different', () => { 89 | expect(updatedDiff([1], [2])).toEqual({ 0: 2 }); 90 | }); 91 | 92 | test('returns subset of right hand side array as object of indices to value when arrays differs at multiple indicies', () => { 93 | expect(updatedDiff([1, 2, 3], [9, 8, 3])).toEqual({ 0: 9, 1: 8 }); 94 | }); 95 | 96 | test('returns subset of right hand side array as object of indices to value when right hand side array has deletions', () => { 97 | expect(updatedDiff([1, 2, 3], [1, 3])).toEqual({ 1: 3 }); 98 | }); 99 | 100 | test('returns empty object when right hand side array has additions', () => { 101 | expect(updatedDiff([1, 2, 3], [1, 2, 3, 9])).toEqual({}); 102 | }); 103 | 104 | test('returns subset of right hand side with updated date', () => { 105 | expect(updatedDiff([new Date('2016')], [new Date('2017')])).toEqual({ 0: new Date('2017') }); 106 | }); 107 | }); 108 | 109 | describe('date', () => { 110 | test('returns empty object when dates are equal', () => { 111 | expect(updatedDiff(new Date('2016'), new Date('2016'))).toEqual({}); 112 | }); 113 | }); 114 | 115 | describe('object create null', () => { 116 | test('returns right hand side value when given objects are different at root', () => { 117 | const lhs = Object.create(null); 118 | lhs.a = 1; 119 | const rhs = Object.create(null); 120 | rhs.a = 2; 121 | expect(updatedDiff(lhs, rhs)).toEqual({ a: 2 }); 122 | }); 123 | 124 | test('returns subset of right hand side value when sibling objects differ', () => { 125 | const lhs = Object.create(null); 126 | lhs.a = { b: 1 }; 127 | lhs.c = 2; 128 | const rhs = Object.create(null); 129 | rhs.a = { b: 1 }; 130 | rhs.c = 3; 131 | expect(updatedDiff(lhs, rhs)).toEqual({ c: 3 }); 132 | }); 133 | 134 | test('returns subset of right hand side value when nested values differ', () => { 135 | const lhs = Object.create(null); 136 | lhs.a = { b: 1, c: 2 }; 137 | const rhs = Object.create(null); 138 | rhs.a = { b: 1, c: 3 }; 139 | expect(updatedDiff(lhs, rhs)).toEqual({ a: { c: 3 } }); 140 | }); 141 | 142 | test('returns subset of right hand side with updated date', () => { 143 | const lhs = Object.create(null); 144 | lhs.date = new Date('2016'); 145 | const rhs = Object.create(null); 146 | rhs.date = new Date('2017'); 147 | expect(updatedDiff(lhs, rhs)).toEqual({ date: new Date('2017') }); 148 | }); 149 | }); 150 | 151 | describe('object with non-function hasOwnProperty property', () => { 152 | test('can represent the property in diff despite it being part of Object.prototype', () => { 153 | const lhs = { hasOwnProperty: false }; 154 | const rhs = { hasOwnProperty: true }; 155 | expect(updatedDiff(lhs, rhs)).toEqual({ hasOwnProperty: true }); 156 | }); 157 | }); 158 | }); 159 | }); 160 | -------------------------------------------------------------------------------- /test/utils.test.js: -------------------------------------------------------------------------------- 1 | import { isDate, isEmpty, isObject } from '../src/utils'; 2 | 3 | describe('utils', () => { 4 | 5 | describe('.isDate', () => { 6 | test.each([ 7 | [new Date()], 8 | [new Date('2016')], 9 | [new Date('2016-01')], 10 | [new Date('2016-01-01')], 11 | [new Date('2016-01-01:14:45:20')], 12 | [new Date('Tue Feb 14 2017 14:45:20 GMT+0000 (GMT)')], 13 | [new Date('nonsense')], 14 | ])('returns true when given a date object of %s', (date) => { 15 | expect(isDate(date)).toBe(true); 16 | }); 17 | 18 | test.each([ 19 | [100], 20 | ['100'], 21 | [false], 22 | [{ a: 100 }], 23 | [[100, 101, 102]], 24 | [Date.parse('2016')], 25 | [Date.now()], 26 | ])('returns false when not given a date object of %s', (x) => { 27 | expect(isDate(x)).toBe(false); 28 | }); 29 | }); 30 | 31 | describe('.isEmpty', () => { 32 | describe('returns true', () => { 33 | test('when given an empty object', () => { 34 | expect(isEmpty({})).toBe(true); 35 | }); 36 | 37 | test('when given an empty array', () => { 38 | expect(isEmpty([])).toBe(true); 39 | }); 40 | }); 41 | 42 | describe('returns false', () => { 43 | test('when given an empty object', () => { 44 | expect(isEmpty({ a: 1 })).toBe(false); 45 | }); 46 | 47 | test('when given an empty array', () => { 48 | expect(isEmpty([1])).toBe(false); 49 | }); 50 | }); 51 | }); 52 | 53 | describe('.isObject', () => { 54 | test('returns true when value is an object', () => { 55 | expect(isObject({})).toBe(true); 56 | }); 57 | 58 | test('returns true when value is an array', () => { 59 | expect(isObject([])).toBe(true); 60 | }); 61 | 62 | test.each([ 63 | ['int', 1], 64 | ['string', 'a'], 65 | ['boolean', true], 66 | ['null', null], 67 | ['undefined', undefined], 68 | ['function', () => ({})], 69 | ])('returns false when value is of type: %s', (type, value) => { 70 | expect(isObject(value)).toBe(false); 71 | }); 72 | }); 73 | }); 74 | --------------------------------------------------------------------------------