├── .eslintrc ├── .github └── workflows │ └── main.yaml ├── .gitignore ├── .prettierignore ├── README.md ├── package-lock.json ├── package.json └── src ├── basic-types.js ├── deep-set.js ├── index.js ├── main.js ├── main.spec.js ├── utils.js └── utils.spec.js /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "ignorePatterns": ["dist"], 3 | "env": { "browser": true, "es6": true }, 4 | "extends": ["eslint:recommended"], 5 | "parserOptions": { 6 | "ecmaVersion": 2018, 7 | "sourceType": "module", 8 | "allowImportExportEverywhere": true 9 | }, 10 | "rules": { 11 | "no-console": [2, { "allow": ["warn", "error", "info"] }], 12 | "no-prototype-builtins": [0] 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /.github/workflows/main.yaml: -------------------------------------------------------------------------------- 1 | name: CI Checks 2 | 3 | on: [pull_request, workflow_dispatch] 4 | 5 | jobs: 6 | has-increased-version: 7 | runs-on: ubuntu-22.04 8 | steps: 9 | - uses: actions/checkout@v4 10 | - uses: henrikjoreteg/version-compare@main 11 | tests: 12 | runs-on: ubuntu-22.04 13 | steps: 14 | - uses: actions/checkout@v4 15 | - name: Install modules 16 | run: npm ci 17 | - name: Run unit tests and build 18 | run: npm test && npm run build 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | .DS_Store 4 | large.js -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # sinks 2 | 3 | ![](https://img.shields.io/npm/dm/sinks.svg)![](https://img.shields.io/npm/v/sinks.svg)![](https://img.shields.io/npm/l/sinks.svg) 4 | 5 | A set of simple utilities for defining, validating, updating, diffing, and ultimately syncing (get it?!) state stored as simple objects and arrays. 6 | 7 | Small enough for use in client-side code < 2kb. In fact, it's very handy if you're looking for a way to enforce more structure in redux reducers. 8 | 9 | Enables validation (optional), deep immutable setting of values, and efficient diffing. 10 | 11 | ## What does it do? 12 | 13 | It was designed to be part of a system where data stored as simple objects needs to be synchronized between servers and clients yet allow for disconnected changes and caching state locally then synchronizing it again when reconnected. 14 | 15 | ## Basic use cases 16 | 17 | 1. You have a large object and a modified version of that large object, you want them to be the same, but you don't want to send the entire new object. 18 | 19 | 2. Defining/validating/updating objects stored in redux reducers. Updates are always immutable! 20 | 21 | 3. You want to define, at a high-level, a simple object shape where all values are optional but any values have to match defined shape and types. 22 | 23 | 4. You want to try merging two object not knowing if they have conflicting changes or not. Giving you the information about what properties are in conflict. 24 | 25 | ## Main exports 26 | 27 | ### `getChanges(originalObject, finalState, {includeDeletions: true, ignoredKeys: []})` 28 | 29 | This will return an object containing changes that can be applied to another object using `updateObject()`. If there are no changes, returns `null`. 30 | 31 | You it takes an optional options object where you can opt out of including deletions and you can specify a list of top-level object keys to ignore. 32 | 33 | ### `updateObject(currentObject, changes)` 34 | 35 | This returns an updated object with changes applied. These changes need to be structured as an object of paths to update. 36 | 37 | Example of updating a nested item: 38 | 39 | ```js 40 | const obj1 = { 41 | name: 'Henrik', 42 | } 43 | 44 | const updatedObject = updateObject(obj1, { 45 | 'favoriteColors.foo.name': 'yellow', 46 | }) 47 | 48 | console.log(updatedObject) 49 | // { 50 | // name: 'Henrik', 51 | // favoriteColors: { 52 | // foo: { 53 | // name: 'yellow', 54 | // }, 55 | // }, 56 | // } 57 | ``` 58 | 59 | Example of deleting a value: 60 | 61 | ```js 62 | const obj1 = { 63 | name: 'Henrik', 64 | something: { 65 | foo: 'cool', 66 | }, 67 | } 68 | 69 | // setting a value to `null` deletes it 70 | const updatedObject = updateObject(obj1, { 71 | 'something.foo': null, 72 | }) 73 | 74 | // Note: empty objects are removed 75 | console.log(updatedObject) 76 | // { 77 | // name: 'Henrik', 78 | // } 79 | ``` 80 | 81 | Example of updating item in an array 82 | 83 | ```js 84 | const obj1 = { 85 | name: 'Henrik', 86 | myStuff: [{ id: 'thing', description: 'pizza' }], 87 | } 88 | 89 | // if an array already exists in the first object 90 | // you can just provide an update that uses the 91 | // index as a number in your update path: 92 | const updated = updateObject(obj1, { 93 | 'myStuff.0.description': 'skis', 94 | }) 95 | 96 | console.log(updated) 97 | // { 98 | // name: 'Henrik', 99 | // myStuff: [{ id: 'thing', description: 'skis' }], 100 | // } 101 | ``` 102 | 103 | Updating by array index doesn't work if the array doesn't exist in the object. But we can explicitly specify that an item in an array is an index by putting square brackets around it: 104 | 105 | ```js 106 | // note that missing things are created 107 | // but we can't know whether to create an 108 | // array or an object unless you tell it with `[]` 109 | const obj1 = { 110 | name: 'Henrik', 111 | } 112 | 113 | // The square brackets tells the updater to create an array 114 | // instead of an object with a key named '0' 115 | const updated = updateObject(obj1, { 116 | 'myStuff.[0].description': 'skis', 117 | }) 118 | 119 | console.log(updated) 120 | // { 121 | // name: 'Henrik', 122 | // myStuff: [{ id: 'thing', description: 'skis' }], 123 | // } 124 | 125 | // if you *DON'T* supply the brackets this would happen 126 | console.log( 127 | updateObject(obj1, { 128 | 'myStuff.0.description': 'skis', 129 | }) 130 | ) 131 | // { 132 | // name: 'Henrik', 133 | // myStuff: { 134 | // 0: { 135 | // description: 'skis', 136 | // }, 137 | // }, 138 | // } 139 | ``` 140 | 141 | You don't have to supply a whole path if you want to set an object: 142 | 143 | ```js 144 | const obj1 = { 145 | name: 'Henrik', 146 | } 147 | 148 | // can just supply an object 149 | console.log( 150 | updateObject(obj1, { 151 | other: { 152 | nested: 'thing', 153 | }, 154 | }) 155 | ) 156 | // { 157 | // name: 'Henrik', 158 | // other: { 159 | // nested: 'thing' 160 | // } 161 | // } 162 | ``` 163 | 164 | Empty objects and arrays and `null` values are automatically removed. 165 | 166 | ```js 167 | const obj1 = { 168 | name: 'Henrik', 169 | } 170 | 171 | // even if you set a deeply nested set of 172 | // objects and the very last value is empty 173 | // the whole chain of empty stuff is removed 174 | console.log( 175 | updateObject(obj1, { 176 | other: { 177 | nested: { 178 | // note final value is an empty array 179 | // once this is removed, the parent ones 180 | // will be empty. So the whole thing is 181 | // removed. 182 | hi: [], 183 | }, 184 | }, 185 | }) 186 | ) 187 | // { 188 | // name: 'Henrik', 189 | // } 190 | ``` 191 | 192 | ### `setValue(obj1, keyPath, updatedValue)` 193 | 194 | This is the single-key update version of `updateObject` in fact, this what `updateObject` calls for each key you provide in the update object. 195 | 196 | ### `mergeObjects(obj1, obj2)` 197 | 198 | This will get additive changes (not deletions) from each object compared to the other, and try to build a shared object of merged changes. 199 | 200 | It returns an object with two properties: 201 | 202 | 1. `updated`: the new merged object 203 | 2. `conflicts`: this property only exists if there are conflicts. These conflicts are an object keyed by conflicting key name and containing an array of original and new values for that key. 204 | 205 | ```js 206 | const obj1 = { 207 | name: 'bob', 208 | favoriteColor: 'blue', 209 | } 210 | 211 | const obj2 = { 212 | name: 'sue', 213 | age: 28, 214 | } 215 | 216 | const { updated, conflicts } = mergeObjects(obj1, obj2) 217 | 218 | console.log(updated) 219 | // { 220 | // name: 'bob', original name (no update was made) 221 | // age: 28, no conflict here, so age was applied from obj2 222 | // favoriteColor: 'blue', (no conflict so favoriteColor was 223 | // kept from first, notice it was *NOT* deleted. 224 | // } 225 | console.log(conflicts) 226 | // { 227 | // name: ['bob', 'sue'] // value from first listed first 228 | // } 229 | ``` 230 | 231 | ## `buildDefinition(definitionObject, fnsObject[optional])` Using validation and object definitions 232 | 233 | You can optionally choose to create a definition that describes valid shape of the object. Doing this can give you some comfort at runtime that you're not getting unexpected values. 234 | 235 | `buildDefinition` returns an object with the following methods: 236 | 237 | - `definition.validate(object)` takes object to validate, 238 | - `definition.setValue(startingObject, keyPath, newValue, shouldValidate [defaults to true])`: , 239 | - `definition.update(startingObject, updatesObject, shouldValidate [defaults to true])` 240 | - `definition.merge(startingObject, otherObject, shouldValidate [defaults to true])` 241 | 242 | You define an object as follows. Please note that the "types" are just strings. These get mapped to functions you can supply as a second argument to `buildDefinition`. 243 | 244 | If you don't supply one, we have a simple default set of very basic type checks out of the box. Please see `src/basic-types.js`. These are also exported as `import { basicTypes } from 'sinks'` so they can easily be extended. 245 | 246 | ```js 247 | import { buildDefinition } from 'sinks' 248 | 249 | // example definition 250 | const bareDefinition = { 251 | // meta 252 | lastChanged: 'timestamp', 253 | lastSaved: 'timestamp', 254 | created: 'timestamp', 255 | 256 | // provider 257 | sedationProviderName: 'str', 258 | surgeonName: 'str', 259 | recorderName: 'str', 260 | office: 'str', 261 | 262 | // vitals types 263 | // the "{}" here allows for any keyed name. 264 | // This is very important when building state objects with unknown keys 265 | 'trackedVitalTypes.{}.id': 'str', 266 | 'trackedVitalTypes.{}.selected': 'bool', 267 | 'trackedVitalTypes.{}.hasReceivedAutoValue': 'bool', 268 | 269 | // You can also define arrays 270 | medications: 'arr', 271 | 'medications.[].id': 'str', 272 | 'medications.[].name': 'str', 273 | } 274 | 275 | const definition = buildDefinition(bareDefinition) 276 | 277 | // note you still have to supply the object each time 278 | // it does not maintain state internally! 279 | const startingObject = {} 280 | 281 | // this will throw because it's not defined 282 | try { 283 | const newObject = definition.setValue( 284 | startingObject, 285 | 'somethingSilly', 286 | 'blah' 287 | ) 288 | } catch (e) { 289 | // this will throw!! 290 | } 291 | 292 | // The same thing will not throw if we tell it not to validate 293 | const finalObject = definition.setValue( 294 | startingObject, 295 | 'somethingSilly', 296 | 'blah', 297 | false // here we turn *off* validation 298 | ) 299 | 300 | // NOTE: these are immutable sets! 301 | // any object in the chain that has been edited 302 | // has been copied and replaced. 303 | console.log(startingObject === finalObject) // false 304 | ``` 305 | 306 | ## Running tests 307 | 308 | ``` 309 | npm test 310 | ``` 311 | 312 | ## install 313 | 314 | ``` 315 | npm install sinks 316 | ``` 317 | 318 | ## Change log 319 | 320 | - `3.1.4`: Fixed build errors caused by recent dependency changes 321 | - `3.1.3`: Some more, minor performance improvements for validate() functions by doing a bit more pre-sorting. 322 | - `3.1.2`: Bugfixes and 10x performance improvement of validate() function. 323 | - `3.1.1`: Now exporting `simpleObjectDeepEqual` as part of main export. 324 | - `3.1.0`: Added `simpleObjectDeepEqual` utility for lightweight object comparisons of simple objects. 325 | - `3.0.4`: Added other test for `getChanges` to ensure it handle nested objects with integer keys correctly. 326 | - `3.0.3`: Fixed bug where `getChanges` not handle objects with integer keys correctly. 327 | - `3.0.2`: Fix bad prepublish script. 328 | - `3.0.1`: Removing unnecessary conditional check. 329 | - `3.0.0`: `getChanges` now returns individual key path updates, this is important for simultaneous changes of nested objects. Technically, this should not be a breaking change unless you manually modify or somehow deal with the changes object. But since it changes the shape of something returned by public API I decided to bump the major version. 330 | - `2.0.0`: Now recursively removes all keys with values `{}`, `[]`, or `null` at the end of all set/update operations. 331 | - `1.0.0`: `getChanges` now takes an options object instead of just a boolean and that option option now can take a `ignoredKeys: []` option to ignore changes to specified top-level keys. 332 | - `0.0.1`: First public release. 333 | 334 | ## credits 335 | 336 | If you like this follow [@HenrikJoreteg](http://twitter.com/henrikjoreteg) on twitter. 337 | 338 | Props to [Jason Miller](https://github.com/developit) for [dlv](https://github.com/developit/dlv) (a dependency) and [Frank Wilkerson](https://github.com/fwilkerson) for [clean-set](https://github.com/fwilkerson/clean-set) which I modified and included here (along with his MIT license). 339 | 340 | ## license 341 | 342 | [MIT](http://mit.joreteg.com/) 343 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sinks", 3 | "version": "3.1.4", 4 | "description": "Tools for object sync (get it?!), validation, diffing, and immutable deep setting", 5 | "source": "src/index.js", 6 | "main": "./dist/index.js", 7 | "module": "./dist/index.module.js", 8 | "unpkg": "./dist/index.umd.js", 9 | "scripts": { 10 | "prepublishOnly": "npm run format && npm run lint && npm test && npm run build", 11 | "build": "rm -rf ./dist && microbundle --no-compress", 12 | "test-inspect": "node --inspect-brk definition.spec.js", 13 | "test": "tape -r esm ./**/*.spec.js", 14 | "lint": "eslint .", 15 | "format": "prettier --write ." 16 | }, 17 | "keywords": [ 18 | "sync", 19 | "diff", 20 | "object", 21 | "immutable" 22 | ], 23 | "author": "", 24 | "license": "MIT", 25 | "files": [ 26 | "dist/*" 27 | ], 28 | "devDependencies": { 29 | "eslint": "7.18.0", 30 | "esm": "3.2.25", 31 | "microbundle": "0.13.0", 32 | "prettier": "2.2.1", 33 | "prettier-plugin-jsdoc": "0.3.30", 34 | "tape": "5.0.1" 35 | }, 36 | "prettier": { 37 | "semi": false, 38 | "arrowParens": "avoid", 39 | "singleQuote": true, 40 | "jsdocParser": true 41 | }, 42 | "dependencies": { 43 | "dlv": "1.1.3" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/basic-types.js: -------------------------------------------------------------------------------- 1 | const str = val => typeof val === 'string' 2 | const bool = val => typeof val === 'boolean' 3 | const num = val => typeof val === 'number' 4 | const int = Number.isInteger 5 | const positiveInt = val => int(val) && val >= 0 6 | const matches = values => val => values.includes(val) 7 | const timestamp = val => positiveInt(val) && val > 0 && val < 7258147200000 // this is year 2200 8 | const arr = Array.isArray 9 | const obj = val => typeof val === 'object' 10 | const func = val => typeof val === 'function' 11 | 12 | export const basicTypes = { 13 | str, 14 | bool, 15 | num, 16 | int, 17 | positiveInt, 18 | matches, 19 | timestamp, 20 | arr, 21 | obj, 22 | func, 23 | } 24 | -------------------------------------------------------------------------------- /src/deep-set.js: -------------------------------------------------------------------------------- 1 | // Modified version of clean-set (https://github.com/fwilkerson/clean-set) 2 | // License included below 3 | /* 4 | MIT License 5 | 6 | Copyright (c) 2018 Frank Wilkerson 7 | 8 | Permission is hereby granted, free of charge, to any person obtaining a copy 9 | of this software and associated documentation files (the "Software"), to deal 10 | in the Software without restriction, including without limitation the rights 11 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 12 | copies of the Software, and to permit persons to whom the Software is 13 | furnished to do so, subject to the following conditions: 14 | 15 | The above copyright notice and this permission notice shall be included in all 16 | copies or substantial portions of the Software. 17 | 18 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 19 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 20 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 21 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 22 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 23 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 24 | SOFTWARE. 25 | */ 26 | 27 | import { isEmpty } from './utils' 28 | const digitRe = /^\[\d+\]/ 29 | 30 | export const removeNullAndEmpty = original => { 31 | const removeEmpty = obj => { 32 | const isObj = typeof obj === 'object' 33 | const isArray = Array.isArray(obj) 34 | 35 | if (!isObj) { 36 | return obj 37 | } 38 | 39 | if (isArray) { 40 | for (let i = 0, l = obj.length; i < l; i++) { 41 | const value = obj[i] 42 | if (value === null || isEmpty(value)) { 43 | obj.splice(i, 1) 44 | removeEmpty(original) 45 | } else { 46 | removeEmpty(value) 47 | } 48 | } 49 | } else { 50 | for (const key in obj) { 51 | const value = obj[key] 52 | if (value === null || isEmpty(value)) { 53 | delete obj[key] 54 | removeEmpty(original) 55 | } else { 56 | removeEmpty(value) 57 | } 58 | } 59 | } 60 | 61 | return obj 62 | } 63 | 64 | return removeEmpty(original) 65 | } 66 | 67 | const copy = (source, isArr) => { 68 | let to = (source && !!source.pop) || isArr ? [] : {} 69 | for (let i in source) to[i] = source[i] 70 | return to 71 | } 72 | 73 | export default (source, keys, update, merge = true) => { 74 | keys.split && (keys = keys.split('.')) 75 | keys = keys.map(key => (digitRe.test(key) ? Number(key.slice(1, -1)) : key)) 76 | 77 | let next = copy(source), 78 | last = next, 79 | i = 0, 80 | l = keys.length 81 | 82 | const shouldDelete = update === null 83 | 84 | for (; i < l; i++) { 85 | const isLast = i === l - 1 86 | const currentKey = keys[i] 87 | if (shouldDelete && isLast) { 88 | if (Array.isArray(last)) { 89 | last.splice(currentKey, 1) 90 | } else { 91 | delete last[currentKey] 92 | } 93 | } else { 94 | if (isLast) { 95 | last[currentKey] = 96 | typeof update === 'object' && !Array.isArray(update) && merge 97 | ? Object.assign({}, last[currentKey], update) 98 | : update 99 | } else { 100 | last = last[currentKey] = copy( 101 | last[currentKey], 102 | typeof keys[i + 1] === 'number' 103 | ) 104 | } 105 | } 106 | } 107 | 108 | return removeNullAndEmpty(next) 109 | } 110 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | export * from './main.js' 2 | export * from './basic-types.js' 3 | export { simpleObjectDeepEqual } from './utils.js' 4 | -------------------------------------------------------------------------------- /src/main.js: -------------------------------------------------------------------------------- 1 | //@ts-check 2 | import deepSet from './deep-set' 3 | import { basicTypes } from './basic-types' 4 | import { injectBrackets, isEmpty, stripBrackets } from './utils' 5 | import dlv from 'dlv' 6 | 7 | const toRegexp = str => 8 | new RegExp( 9 | '^' + 10 | str 11 | .replace(/\./g, '\\.') 12 | .replace(/\{\}/g, '[^.]+') 13 | .replace(/\[\]/g, '\\d+') + 14 | '$' 15 | ) 16 | 17 | const buildMatcherFunction = definition => { 18 | /** 19 | * We first categorize them by length then by what the key starts with to be 20 | * able to limit the number of items it has to test for each key. 21 | */ 22 | const processedByLength = {} 23 | for (const key in definition) { 24 | const value = definition[key] 25 | if (key.includes('{}') || key.includes('[]')) { 26 | const split = key.split('.') 27 | const keysBeforeVariable = [] 28 | const length = split.length 29 | if (!processedByLength[length]) { 30 | processedByLength[length] = {} 31 | } 32 | const processed = processedByLength[length] 33 | for (const part of split) { 34 | if (part === '{}' || part === '[]') { 35 | break 36 | } 37 | keysBeforeVariable.push(part) 38 | } 39 | const startsWith = keysBeforeVariable.join('.') + '.' 40 | if (!processed[startsWith]) { 41 | processed[startsWith] = [] 42 | } 43 | const regex = toRegexp(key) 44 | processed[startsWith].push({ 45 | value, 46 | test: regex.test.bind(regex), 47 | }) 48 | } 49 | } 50 | 51 | return path => { 52 | if (definition[path]) { 53 | return definition[path] 54 | } 55 | const length = path.split('.').length 56 | const processed = processedByLength[length] 57 | for (const key in processed) { 58 | if (path.startsWith(key)) { 59 | const limitedItemsToTest = processed[key] 60 | for (const { test, value } of limitedItemsToTest) { 61 | if (test(path)) { 62 | return value 63 | } 64 | } 65 | } 66 | } 67 | } 68 | } 69 | 70 | /** 71 | * @param {any} original The original object 72 | * @param {any} modified The modified one 73 | * @param {{ 74 | * includeDeletions?: boolean 75 | * ignoredKeys?: string[] 76 | * }} [options] 77 | * @returns {any} Changes object or null 78 | */ 79 | export const getChanges = ( 80 | original, 81 | modified, 82 | { includeDeletions = true, ignoredKeys } = {} 83 | ) => { 84 | const combined = { ...original, ...modified } 85 | const changes = {} 86 | for (const key in combined) { 87 | if (ignoredKeys && ignoredKeys.includes(key)) { 88 | continue 89 | } 90 | const inOriginal = original && original.hasOwnProperty(key) 91 | const inModified = modified && modified.hasOwnProperty(key) 92 | // removed in new 93 | if (inOriginal && !inModified) { 94 | if (includeDeletions) { 95 | changes[key] = null 96 | } 97 | continue 98 | } 99 | 100 | // what type of value are we dealing with? 101 | const modifiedValue = modified[key] 102 | const modifiedValueIsObject = typeof modifiedValue === 'object' 103 | 104 | // checks if modified value is different in any way 105 | if (!inOriginal || modifiedValue !== original[key]) { 106 | if (modifiedValueIsObject && modifiedValue !== null) { 107 | // we pass through "ignored" for nested stuff, but not the ignored keys 108 | // those only apply at the top level 109 | const otherChanges = getChanges( 110 | inOriginal ? original[key] : {}, 111 | modifiedValue, 112 | { 113 | includeDeletions, 114 | } 115 | ) 116 | for (const otherKey in otherChanges) { 117 | const value = otherChanges[otherKey] 118 | const isArray = Array.isArray(modified[key]) 119 | changes[ 120 | key + '.' + (isArray ? injectBrackets(otherKey) : otherKey) 121 | ] = value 122 | } 123 | } else { 124 | changes[key] = modifiedValue 125 | } 126 | } 127 | } 128 | return Object.keys(changes).length ? changes : null 129 | } 130 | 131 | export const setValue = deepSet 132 | 133 | export const updateObject = (obj, updateObj) => { 134 | let updated = obj 135 | for (const key in updateObj) { 136 | updated = setValue(updated, key, updateObj[key], false) 137 | } 138 | return updated 139 | } 140 | 141 | export const mergeObjects = (obj1, obj2) => { 142 | const addedByObj2 = getChanges(obj1, obj2, { includeDeletions: false }) 143 | const addedByObj1 = getChanges(obj2, obj1, { includeDeletions: false }) 144 | 145 | if (!addedByObj1 && !addedByObj2) { 146 | return { updated: obj1 } 147 | } 148 | if (!addedByObj1) { 149 | return { updated: updateObject(obj1, addedByObj2) } 150 | } 151 | if (!addedByObj2) { 152 | return { updated: updateObject(obj2, addedByObj1) } 153 | } 154 | 155 | // both made additions, there may be conflicts 156 | // look for keys that are the same 157 | const conflicts = {} 158 | const notConflicted = {} 159 | for (const key in addedByObj2) { 160 | if (addedByObj1.hasOwnProperty(key)) { 161 | const cleanedKey = stripBrackets(key) 162 | conflicts[key] = [dlv(obj1, cleanedKey), dlv(obj2, cleanedKey)] 163 | } else { 164 | notConflicted[key] = addedByObj2[key] 165 | } 166 | } 167 | for (const key in addedByObj1) { 168 | if (!addedByObj2.hasOwnProperty(key)) { 169 | notConflicted[key] = addedByObj1[key] 170 | } 171 | } 172 | const toReturn = { updated: updateObject(obj1, notConflicted) } 173 | if (!isEmpty(conflicts)) { 174 | toReturn.conflicts = conflicts 175 | } 176 | return toReturn 177 | } 178 | 179 | export const buildDefinition = (definition, fnsObject = basicTypes) => { 180 | const matcher = buildMatcherFunction(definition) 181 | 182 | const validateObject = (obj, path = '') => { 183 | for (const key in obj) { 184 | const value = obj[key] 185 | if (value === null) { 186 | continue 187 | } 188 | const extendedPath = path ? path + '.' + key : key 189 | const type = matcher(extendedPath) 190 | 191 | if (typeof value === 'object' && type !== 'obj') { 192 | if (isEmpty(value) && !type) { 193 | throw Error('INVALID path: ' + extendedPath) 194 | } 195 | validateObject(value, extendedPath) 196 | } else { 197 | if (!type) { 198 | throw Error('INVALID path: ' + extendedPath) 199 | } 200 | let testFn 201 | if (Array.isArray(type)) { 202 | let found = false 203 | let passed = false 204 | for (const typeEntry of type) { 205 | if (fnsObject[typeEntry]) { 206 | found = true 207 | if (fnsObject[typeEntry](value)) { 208 | passed = true 209 | break 210 | } 211 | } 212 | } 213 | if (!passed || !found) { 214 | throw Error(`INVALID ${extendedPath}: ${value}`) 215 | } 216 | return 217 | } else { 218 | testFn = fnsObject[type] 219 | } 220 | if (!testFn) { 221 | throw Error('INVALID type: ' + type) 222 | } 223 | if (!testFn(value)) { 224 | throw Error(`INVALID ${extendedPath}: ${value}`) 225 | } 226 | } 227 | } 228 | } 229 | 230 | const setValue = (obj, path, value, validate = true) => { 231 | const updated = deepSet(obj, path, value) 232 | if (validate) { 233 | validateObject(updated) 234 | } 235 | return updated 236 | } 237 | 238 | const update = (obj, updateObj, validate = true) => { 239 | let updated = obj 240 | for (const key in updateObj) { 241 | updated = setValue(updated, key, updateObj[key], false) 242 | } 243 | if (validate) { 244 | validateObject(updated) 245 | } 246 | return updated 247 | } 248 | 249 | const merge = (obj1, obj2, validate = true) => { 250 | const result = mergeObjects(obj1, obj2) 251 | if (validate) { 252 | validateObject(result.updated) 253 | } 254 | return result 255 | } 256 | 257 | return { 258 | validate: validateObject, 259 | setValue, 260 | update, 261 | merge, 262 | } 263 | } 264 | -------------------------------------------------------------------------------- /src/main.spec.js: -------------------------------------------------------------------------------- 1 | import test from 'tape' 2 | import { removeNullAndEmpty } from './deep-set' 3 | import { buildDefinition, getChanges, updateObject } from './main' 4 | import { simpleObjectDeepEqual } from './utils' 5 | 6 | test('basic deep set value works', t => { 7 | const built = buildDefinition({ 8 | 'items.[]': 'arr', 9 | 'items.[].something.{}.else.[].crazy': 'str', 10 | }) 11 | t.deepEqual( 12 | built.setValue({}, 'items.[0].something.hi.else.[0].crazy', 'hi'), 13 | { 14 | items: [{ something: { hi: { else: [{ crazy: 'hi' }] } } }], 15 | } 16 | ) 17 | t.throws(() => { 18 | built.setValue({}, 'items.[0].something.hi.else.[0].crazys', 'hi') 19 | }) 20 | // validation is optional 21 | t.doesNotThrow(() => { 22 | built.setValue({}, 'items.[0].something.hi.else.[0].crazys', 'hi', false) 23 | }) 24 | // throws if setting object to invalid path 25 | t.throws(() => { 26 | built.setValue({}, 'silliness', [{ ok: 'you' }]) 27 | }) 28 | // does not throw if setting objects as long as 29 | // child values are valid 30 | t.doesNotThrow(() => { 31 | built.setValue({}, 'items', [ 32 | { 33 | something: { 34 | foo: { 35 | else: [{ crazy: 'yeah' }], 36 | }, 37 | }, 38 | }, 39 | ]) 40 | }) 41 | // does not throw if setting empty objects because validation happens at the end 42 | // and empty objects are removed 43 | t.deepEqual(built.setValue({}, 'ridiculous', []), {}) 44 | 45 | // does throw if setting objects with invalid children 46 | t.throws(() => { 47 | built.setValue({}, 'items', [ 48 | { 49 | something: { 50 | foo: { 51 | else: [{ crazy: 5 }], 52 | }, 53 | }, 54 | }, 55 | ]) 56 | }) 57 | t.end() 58 | }) 59 | 60 | test('update with simple objects does merge by default', t => { 61 | const definition = { 62 | 'stuff.{}.id': 'str', 63 | 'stuff.{}.name': 'str', 64 | 'stuff.{}.value': 'positiveInt', 65 | } 66 | const built = buildDefinition(definition) 67 | const res = built.update({}, { 'stuff.d_0': { id: 'd_0', name: 'Joe' } }) 68 | 69 | t.deepEqual(res, { 70 | stuff: { 71 | d_0: { 72 | id: 'd_0', 73 | name: 'Joe', 74 | }, 75 | }, 76 | }) 77 | 78 | // now set partial object 79 | const res2 = built.update(res, { 'stuff.d_0': { value: 45 } }) 80 | t.deepEqual(res2, { 81 | stuff: { 82 | d_0: { 83 | id: 'd_0', 84 | name: 'Joe', 85 | value: 45, 86 | }, 87 | }, 88 | }) 89 | 90 | t.end() 91 | }) 92 | 93 | test('handles removing objects from array by index', t => { 94 | const definition = { 95 | stuff: 'arr', 96 | 'stuff.[].id': 'str', 97 | } 98 | const built = buildDefinition(definition) 99 | const res = built.update( 100 | {}, 101 | { stuff: [{ id: 'one' }, { id: 'two' }, { id: 'three' }] } 102 | ) 103 | t.deepEqual(res, { stuff: [{ id: 'one' }, { id: 'two' }, { id: 'three' }] }) 104 | 105 | // now remove by index 106 | const res2 = built.setValue(res, 'stuff.1', null) 107 | t.deepEqual(res2, { stuff: [{ id: 'one' }, { id: 'three' }] }) 108 | 109 | const res3 = built.setValue(res2, 'stuff.1', null) 110 | t.deepEqual(res3, { stuff: [{ id: 'one' }] }) 111 | 112 | const res4 = built.setValue(res3, 'stuff.0', null) 113 | t.deepEqual(res4, {}) 114 | 115 | t.end() 116 | }) 117 | 118 | test('handles removing objects if all keys deleted', t => { 119 | const definition = { 120 | 'stuff.{}.id': 'str', 121 | } 122 | const built = buildDefinition(definition) 123 | const res = built.update( 124 | { 125 | stuff: { 126 | foo: { 127 | id: 'one', 128 | }, 129 | }, 130 | }, 131 | { 'stuff.foo': null } 132 | ) 133 | t.deepEqual(res, {}) 134 | 135 | const built2 = buildDefinition({ stuff: 'str' }) 136 | const res2 = built2.update( 137 | {}, 138 | { 139 | stuff: 'hi', 140 | } 141 | ) 142 | t.deepEqual(res2, { stuff: 'hi' }) 143 | 144 | const res3 = built2.update( 145 | {}, 146 | { 147 | stuff: null, 148 | } 149 | ) 150 | t.deepEqual(res3, {}) 151 | 152 | t.end() 153 | }) 154 | 155 | test('object sync works', t => { 156 | const definition = { 157 | thing: 'str', 158 | 'items.[].name': 'str', 159 | 'items.[].value': 'positiveInt', 160 | } 161 | 162 | const entries = [ 163 | { 164 | definition, 165 | before: { thing: 'ok', items: [{ name: 'hi', value: 45 }] }, 166 | after: { thing: 'ok' }, 167 | expectedDiff: { items: null }, 168 | }, 169 | { 170 | definition, 171 | before: { thing: 'ok' }, 172 | after: { thing: 'ok', items: [{ name: 'hi', value: 45 }] }, 173 | expectedDiff: { 'items.[0].name': 'hi', 'items.[0].value': 45 }, 174 | }, 175 | { 176 | definition, 177 | before: { items: [{ name: 'hi' }] }, 178 | after: {}, 179 | expectedDiff: { items: null }, 180 | }, 181 | { 182 | definition: { 183 | 'big.[].hairy.{}.audacious': 'bool', 184 | 'big.[].hairy.{}.items.[].other': 'str', 185 | }, 186 | before: { 187 | big: [], 188 | }, 189 | after: { 190 | big: [ 191 | { 192 | hairy: { 193 | anything: { 194 | audacious: true, 195 | items: [{ other: 'something' }], 196 | }, 197 | }, 198 | }, 199 | ], 200 | }, 201 | expectedDiff: { 202 | 'big.[0].hairy.anything.audacious': true, 203 | 'big.[0].hairy.anything.items.[0].other': 'something', 204 | }, 205 | }, 206 | { 207 | definition: { 208 | 'crazy.{}.{}.{}.{}.{}.stuff': 'bool', 209 | 'crazy.{}.{}.{}.{}.items.[].thing': 'str', 210 | }, 211 | before: { 212 | crazy: { 213 | foo: { 214 | foo: { 215 | foo: { 216 | foo: { 217 | foo: { 218 | stuff: true, 219 | }, 220 | }, 221 | }, 222 | }, 223 | }, 224 | }, 225 | }, 226 | after: { 227 | crazy: { 228 | foo: { 229 | foo: { 230 | foo: { 231 | foo: { 232 | foo: { 233 | stuff: false, 234 | }, 235 | items: [ 236 | { 237 | thing: 'yep', 238 | }, 239 | ], 240 | }, 241 | }, 242 | }, 243 | }, 244 | }, 245 | }, 246 | expectedDiff: { 247 | 'crazy.foo.foo.foo.foo.foo.stuff': false, 248 | 'crazy.foo.foo.foo.foo.items.[0].thing': 'yep', 249 | }, 250 | }, 251 | { 252 | definition: { 253 | name: 'str', 254 | }, 255 | before: { 256 | name: 'henrik', 257 | }, 258 | after: { 259 | name: 'henrik', 260 | }, 261 | expectedDiff: null, 262 | }, 263 | { 264 | definition: { 265 | 'items.[].name': 'str', 266 | 'items.[].value': 'positiveInt', 267 | }, 268 | before: {}, 269 | after: { 270 | items: [ 271 | { name: 'something', value: 12 }, 272 | { name: 'somethingElse', value: 32 }, 273 | ], 274 | }, 275 | expectedDiff: { 276 | 'items.[0].name': 'something', 277 | 'items.[0].value': 12, 278 | 'items.[1].name': 'somethingElse', 279 | 'items.[1].value': 32, 280 | }, 281 | }, 282 | { 283 | definition: { 284 | 'items.[].name': 'str', 285 | 'items.[].value': 'positiveInt', 286 | }, 287 | before: { items: [{ name: 'hi' }] }, 288 | after: { 289 | items: [ 290 | { name: 'something', value: 12 }, 291 | { name: 'somethingElse', value: 32 }, 292 | ], 293 | }, 294 | expectedDiff: { 295 | 'items.[0].name': 'something', 296 | 'items.[0].value': 12, 297 | 'items.[1].name': 'somethingElse', 298 | 'items.[1].value': 32, 299 | }, 300 | }, 301 | { 302 | definition: { 303 | 'items.[].name': 'str', 304 | 'items.[].value': 'positiveInt', 305 | }, 306 | before: { items: [{ name: 'hi' }] }, 307 | after: {}, 308 | expectedDiff: { items: null }, 309 | }, 310 | { 311 | definition: { 312 | 'objWithIntKeys.{}.name': 'str', 313 | 'objWithIntKeys.{}.other': 'str', 314 | }, 315 | before: { 316 | objWithIntKeys: { 317 | 3: { 318 | name: 'first', 319 | other: 'prop', 320 | }, 321 | }, 322 | }, 323 | after: { 324 | objWithIntKeys: { 325 | 2: { 326 | name: 'second', 327 | }, 328 | }, 329 | }, 330 | expectedDiff: { 331 | 'objWithIntKeys.3': null, 332 | 'objWithIntKeys.2.name': 'second', 333 | }, 334 | }, 335 | { 336 | definition: { 337 | 'objWithIntKeys.{}.nested.{}.something': 'str', 338 | 'objWithIntKeys.{}.other': 'str', 339 | }, 340 | before: { 341 | objWithIntKeys: { 342 | 2: { 343 | other: 'thing', 344 | nested: { 345 | 8: { 346 | something: 'also nested', 347 | }, 348 | }, 349 | }, 350 | }, 351 | }, 352 | after: { 353 | objWithIntKeys: { 354 | 3: { 355 | nested: { 356 | 0: { 357 | something: 'hello', 358 | }, 359 | }, 360 | other: 'prop', 361 | }, 362 | }, 363 | }, 364 | expectedDiff: { 365 | 'objWithIntKeys.2': null, 366 | 'objWithIntKeys.3.nested.0.something': 'hello', 367 | 'objWithIntKeys.3.other': 'prop', 368 | }, 369 | }, 370 | ] 371 | 372 | entries.forEach(({ definition, before, after, expectedDiff }) => { 373 | const builtDefinition = buildDefinition(definition) 374 | const diff = getChanges(before, after) 375 | t.deepEqual(diff, expectedDiff) 376 | const updated = builtDefinition.update(before, diff) 377 | t.deepEqual(updated, after) 378 | }) 379 | 380 | t.end() 381 | }) 382 | 383 | test('updating with empty nested values removes them', t => { 384 | const obj1 = { 385 | name: 'Henrik', 386 | } 387 | 388 | t.deepEqual( 389 | updateObject(obj1, { 390 | other: { 391 | nested: { 392 | hi: [], 393 | }, 394 | }, 395 | }), 396 | obj1, 397 | 'Setting deeply nested set of set of objects / arrays the whole chain of empty stuff is removed' 398 | ) 399 | 400 | t.deepEqual( 401 | updateObject(obj1, { 402 | other: { 403 | ok: 'you', 404 | nested: { 405 | hi: [], 406 | }, 407 | }, 408 | }), 409 | { name: 'Henrik', other: { ok: 'you' } }, 410 | 'Setting deeply nested with partial real values works' 411 | ) 412 | 413 | t.end() 414 | }) 415 | 416 | test('merge works', t => { 417 | const entries = [ 418 | { 419 | description: 'handles discovering deeply nested conflicts', 420 | definition: { 421 | 'big.[].hairy.{}.audacious': 'bool', 422 | }, 423 | obj1: { 424 | big: [ 425 | { 426 | hairy: { 427 | thing: { 428 | audacious: true, 429 | }, 430 | }, 431 | }, 432 | { 433 | hairy: { 434 | thing: { 435 | audacious: false, 436 | }, 437 | }, 438 | }, 439 | ], 440 | }, 441 | obj2: { 442 | big: [ 443 | { 444 | hairy: { 445 | thing: { 446 | audacious: true, 447 | }, 448 | }, 449 | }, 450 | { 451 | hairy: { 452 | thing: { 453 | audacious: true, 454 | }, 455 | }, 456 | }, 457 | ], 458 | }, 459 | expectedOutcome: { 460 | updated: { 461 | big: [ 462 | { 463 | hairy: { 464 | thing: { 465 | audacious: true, 466 | }, 467 | }, 468 | }, 469 | { 470 | hairy: { 471 | thing: { 472 | audacious: false, 473 | }, 474 | }, 475 | }, 476 | ], 477 | }, 478 | conflicts: { 479 | 'big.[1].hairy.thing.audacious': [false, true], 480 | }, 481 | }, 482 | }, 483 | { 484 | description: 'both have new fields, no conflicts', 485 | definition: { 486 | name: 'str', 487 | age: 'positiveInt', 488 | other: 'str', 489 | }, 490 | obj1: { 491 | name: 'henrik', 492 | }, 493 | obj2: { 494 | age: 37, 495 | }, 496 | expectedOutcome: { 497 | updated: { 498 | name: 'henrik', 499 | age: 37, 500 | }, 501 | }, 502 | }, 503 | { 504 | description: 'both have new fields and a conflict', 505 | definition: { 506 | name: 'str', 507 | age: 'positiveInt', 508 | other: 'str', 509 | }, 510 | obj1: { 511 | name: 'henrik', 512 | other: 'same', 513 | }, 514 | obj2: { 515 | age: 37, 516 | other: 'different', 517 | }, 518 | expectedOutcome: { 519 | updated: { 520 | name: 'henrik', 521 | age: 37, 522 | other: 'same', 523 | }, 524 | conflicts: { 525 | other: ['same', 'different'], 526 | }, 527 | }, 528 | }, 529 | { 530 | description: 'throws when broken final product', 531 | definition: { 532 | name: 'str', 533 | age: 'positiveInt', 534 | other: 'str', 535 | }, 536 | obj1: { 537 | name: 'henrik', 538 | other: 'same', 539 | }, 540 | obj2: { 541 | age: 37, 542 | other: 'different', 543 | blah: 'not known path', 544 | }, 545 | throws: 'INVALID path: blah', 546 | }, 547 | { 548 | description: 'handles fields that are missing in second object', 549 | definition: { 550 | something: 'obj', 551 | 'something.{}.nested': 'str', 552 | }, 553 | obj2: { 554 | something: { 555 | foo: { 556 | nested: 'hi', 557 | }, 558 | }, 559 | }, 560 | obj1: undefined, 561 | expectedOutcome: { 562 | updated: { 563 | something: { 564 | foo: { 565 | nested: 'hi', 566 | }, 567 | }, 568 | }, 569 | }, 570 | }, 571 | { 572 | description: 'handles fields that are missing in first object', 573 | definition: { 574 | something: 'obj', 575 | 'something.{}.nested': 'str', 576 | }, 577 | obj2: {}, 578 | obj1: { 579 | something: { 580 | foo: { 581 | nested: 'hi', 582 | }, 583 | }, 584 | }, 585 | expectedOutcome: { 586 | updated: { 587 | something: { 588 | foo: { 589 | nested: 'hi', 590 | }, 591 | }, 592 | }, 593 | }, 594 | }, 595 | ] 596 | 597 | entries.forEach( 598 | ({ description, throws, definition, obj1, obj2, expectedOutcome }) => { 599 | const builtDefinition = buildDefinition(definition) 600 | if (throws) { 601 | t.throws( 602 | () => { 603 | builtDefinition.merge(obj1, obj2) 604 | }, 605 | { 606 | message: throws, 607 | }, 608 | description 609 | ) 610 | } else { 611 | const outcome = builtDefinition.merge(obj1, obj2) 612 | t.deepEqual(outcome, expectedOutcome, description) 613 | // also use our simple object deep equal to make sure it gets the same result 614 | t.equal(simpleObjectDeepEqual(outcome, expectedOutcome), true) 615 | } 616 | } 617 | ) 618 | 619 | t.end() 620 | }) 621 | 622 | test('handles numbers as object keys', t => { 623 | const res = updateObject({}, { 'something.1.else': 'hi' }) 624 | t.deepEqual( 625 | res, 626 | { something: { 1: { else: 'hi' } } }, 627 | 'assumes object by default' 628 | ) 629 | 630 | // can specifically set as array if need be 631 | const res2 = updateObject({}, { 'something.[1].else': 'hi' }) 632 | const sparseArr = [] 633 | sparseArr[1] = { else: 'hi' } 634 | t.deepEqual(res2, { something: sparseArr }) 635 | 636 | t.end() 637 | }) 638 | 639 | test('can handle setting objects without exploding', t => { 640 | const obj = { something: 'cool' } 641 | 642 | const definition = buildDefinition({ 643 | things: 'obj', 644 | }) 645 | 646 | const res = definition.update({}, { things: obj }, true) 647 | t.deepEqual(res, { things: { something: 'cool' } }) 648 | 649 | t.end() 650 | }) 651 | 652 | test('getChanges with ignoredKeys', t => { 653 | t.deepEqual( 654 | getChanges( 655 | {}, 656 | { something: 'hi', somethingElse: 'bye' }, 657 | { ignoredKeys: ['something'] } 658 | ), 659 | { somethingElse: 'bye' } 660 | ) 661 | 662 | t.deepEqual( 663 | getChanges( 664 | {}, 665 | { 666 | something: 'hi', 667 | somethingElse: { 668 | something: 'will still be here', 669 | this: 'will be here', 670 | }, 671 | doNotInclude: 'me', 672 | }, 673 | { ignoredKeys: ['something', 'doNotInclude'] } 674 | ), 675 | { 676 | 'somethingElse.something': 'will still be here', 677 | 'somethingElse.this': 'will be here', 678 | }, 679 | 'only ignores key on top level object' 680 | ) 681 | t.end() 682 | }) 683 | 684 | test('getChanges with includeDeletion option', t => { 685 | t.deepEqual( 686 | getChanges( 687 | { ok: 'hi' }, 688 | { something: 'hi', somethingElse: 'bye' }, 689 | { includeDeletions: false } 690 | ), 691 | { something: 'hi', somethingElse: 'bye' } 692 | ) 693 | 694 | t.deepEqual( 695 | getChanges( 696 | { ok: 'hi' }, 697 | { something: 'hi', somethingElse: 'bye', ok: null }, 698 | { includeDeletions: true } 699 | ), 700 | { something: 'hi', somethingElse: 'bye', ok: null } 701 | ) 702 | 703 | t.end() 704 | }) 705 | 706 | test('can handle functions as values', t => { 707 | const definition = buildDefinition({ 708 | things: 'func', 709 | }) 710 | 711 | const func = () => {} 712 | 713 | const res = definition.update({}, { things: func }, true) 714 | t.deepEqual(res, { things: func }) 715 | 716 | t.end() 717 | }) 718 | 719 | test('setting deeply nested value should not fail if lower level objects not defined', t => { 720 | const definition = buildDefinition({ 721 | other: 'str', 722 | 'systemsReview.{}': 'str', 723 | 'systemsReview.{}.id': 'str', 724 | 'systemsReview.{}.entries': 'arr', 725 | 'systemsReview.{}.entries.[].editing': 'bool', 726 | 'systemsReview.{}.entries.[].details.[]': 'arr', 727 | 'systemsReview.{}.entries.[].details.[].name': 'str', 728 | }) 729 | 730 | const res = definition.update( 731 | { other: 'thing' }, 732 | { 733 | 'systemsReview.cns.entries.[0]': { 734 | details: [{ name: 'hi' }], 735 | editing: true, 736 | }, 737 | } 738 | ) 739 | 740 | t.deepEqual(res, { 741 | other: 'thing', 742 | systemsReview: { 743 | cns: { 744 | entries: [ 745 | { 746 | details: [{ name: 'hi' }], 747 | editing: true, 748 | }, 749 | ], 750 | }, 751 | }, 752 | }) 753 | 754 | const expected = { other: 'thing' } 755 | 756 | t.deepEqual( 757 | definition.update(res, { 'systemsReview.cns.entries': null }, true), 758 | expected, 759 | 'should remove all keys' 760 | ) 761 | 762 | t.deepEqual( 763 | definition.update( 764 | res, 765 | { 766 | 'systemsReview.cns.entries.[0]': null, 767 | }, 768 | true 769 | ), 770 | expected, 771 | 'should remove all keys' 772 | ) 773 | 774 | t.deepEqual( 775 | definition.update(res, { 'systemsReview.cns.entries': null }, true), 776 | expected, 777 | 'should remove all keys' 778 | ) 779 | 780 | t.deepEqual( 781 | definition.update( 782 | res, 783 | { 784 | 'systemsReview.cns.entries.[0]': { 785 | details: [{ name: null }], 786 | editing: null, 787 | }, 788 | }, 789 | true 790 | ), 791 | expected, 792 | 'should remove all keys' 793 | ) 794 | 795 | t.end() 796 | }) 797 | 798 | test('remove empty', t => { 799 | t.deepEqual( 800 | removeNullAndEmpty({ 801 | hello: null, 802 | hi: [{ there: [{ ok: null }] }], 803 | }), 804 | {} 805 | ) 806 | 807 | t.deepEqual( 808 | removeNullAndEmpty({ 809 | hello: {}, 810 | hi: [{ there: [{ ok: [] }] }], 811 | }), 812 | {} 813 | ) 814 | 815 | t.deepEqual( 816 | removeNullAndEmpty({ 817 | items: [ 818 | { 819 | something: { 820 | hi: { 821 | else: [ 822 | { 823 | crazy: 'hi', 824 | }, 825 | ], 826 | }, 827 | there: null, 828 | }, 829 | ok: [{ things: [{ item: [{ silly: null }] }] }], 830 | }, 831 | ], 832 | }), 833 | { 834 | items: [ 835 | { 836 | something: { 837 | hi: { 838 | else: [ 839 | { 840 | crazy: 'hi', 841 | }, 842 | ], 843 | }, 844 | }, 845 | }, 846 | ], 847 | } 848 | ) 849 | t.end() 850 | }) 851 | 852 | test('validate function behavior', t => { 853 | const confirmError = (def, update, expectedMessage) => { 854 | try { 855 | def.update({}, update) 856 | t.fail('should have thrown') 857 | } catch (e) { 858 | t.equal(e.message, expectedMessage) 859 | } 860 | } 861 | const confirmOk = (def, update) => { 862 | t.doesNotThrow(() => { 863 | def.update({}, update) 864 | }) 865 | } 866 | 867 | t.test('function handles single type validations', t => { 868 | const def = buildDefinition({ 869 | 'items.{}.name': 'str', 870 | }) 871 | 872 | confirmError( 873 | def, 874 | { 875 | 'items.0.name': 5, 876 | }, 877 | 'INVALID items.0.name: 5' 878 | ) 879 | 880 | confirmError( 881 | def, 882 | { 883 | name: 'hi', 884 | }, 885 | 'INVALID path: name' 886 | ) 887 | 888 | confirmError( 889 | def, 890 | { 891 | 'the.name.of': 'hi', 892 | }, 893 | 'INVALID path: the.name.of' 894 | ) 895 | confirmError( 896 | def, 897 | { 898 | 'items.0.name': () => {}, 899 | }, 900 | 'INVALID items.0.name: () => {}' 901 | ) 902 | confirmOk(def, { 903 | 'items.0.name': 'hi', 904 | }) 905 | 906 | t.end() 907 | }) 908 | 909 | t.test('function handles array of types correctly', t => { 910 | const def = buildDefinition({ 911 | 'items.{}.name': ['str', 'bool'], 912 | }) 913 | 914 | confirmError( 915 | def, 916 | { 917 | 'items.0.name': 5, 918 | }, 919 | 'INVALID items.0.name: 5' 920 | ) 921 | 922 | confirmError( 923 | def, 924 | { 925 | name: 'hi', 926 | }, 927 | 'INVALID path: name' 928 | ) 929 | 930 | confirmError( 931 | def, 932 | { 933 | 'the.name.of': 'hi', 934 | }, 935 | 'INVALID path: the.name.of' 936 | ) 937 | confirmError( 938 | def, 939 | { 940 | 'items.0.name': () => {}, 941 | }, 942 | 'INVALID items.0.name: () => {}' 943 | ) 944 | confirmOk(def, { 945 | 'items.0.name': 'hi', 946 | }) 947 | confirmOk(def, { 948 | 'items.0.name': true, 949 | }) 950 | confirmOk(def, { 951 | 'items.0.name': false, 952 | }) 953 | 954 | t.end() 955 | }) 956 | 957 | t.test('function handles array of types correctly', t => { 958 | const def = buildDefinition({ 959 | 'items.{}.name': 'blah', 960 | }) 961 | confirmError( 962 | def, 963 | { 964 | 'items.0.name': 5, 965 | }, 966 | 'INVALID type: blah' 967 | ) 968 | t.end() 969 | }) 970 | 971 | t.test("confirm partial match is doesn't cause issues", t => { 972 | const def = buildDefinition({ 973 | 'items.{}.name': 'str', 974 | 'itemsmore.{}.name': 'bool', 975 | }) 976 | confirmError( 977 | def, 978 | { 979 | 'items.0.name': true, 980 | }, 981 | 'INVALID items.0.name: true' 982 | ) 983 | t.end() 984 | }) 985 | }) 986 | 987 | test.skip('validation performance test (using large local file not checked in)', t => { 988 | // eslint-disable-next-line no-undef 989 | const { large, definition } = require('../large') 990 | const time = Date.now() 991 | const amountToTest = 1000 992 | const target = 16 993 | for (let i = 0; i < amountToTest; i++) { 994 | definition.validate(large) 995 | } 996 | const diff = Date.now() - time 997 | const avg = diff / amountToTest 998 | // eslint-disable-next-line no-console 999 | console.log('diff', diff, 'avg', avg) 1000 | 1001 | t.ok( 1002 | avg < target, 1003 | true, 1004 | `took less than ${target}ms to do ${amountToTest} times` 1005 | ) 1006 | 1007 | // make sure it errors as expected 1008 | t.throws( 1009 | () => { 1010 | const copy = JSON.parse(JSON.stringify(large)) 1011 | copy.blah = 'thing' 1012 | definition.validate(copy) 1013 | }, 1014 | err => { 1015 | return err.message === 'INVALID path: blah' 1016 | }, 1017 | 'throws correct errors' 1018 | ) 1019 | 1020 | t.doesNotThrow(() => { 1021 | const copy = JSON.parse(JSON.stringify(large)) 1022 | copy.vitalRecords.rec_thing = { vitals: { hr: { value: 'thing' } } } 1023 | definition.validate(copy) 1024 | }) 1025 | 1026 | t.throws( 1027 | () => { 1028 | const copy = JSON.parse(JSON.stringify(large)) 1029 | copy.vitalRecords.rec_thing = { vitals: { hr: { value: true } } } 1030 | definition.validate(copy) 1031 | }, 1032 | err => { 1033 | return ( 1034 | err.message === 'INVALID vitalRecords.rec_thing.vitals.hr.value: true' 1035 | ) 1036 | }, 1037 | 'throws correct error for nested value with multiple types' 1038 | ) 1039 | 1040 | t.end() 1041 | }) 1042 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | export const isEmpty = unknown => { 2 | if (typeof unknown === 'object') { 3 | // handle null which is "object" 4 | if (!unknown) { 5 | return true 6 | } 7 | if (Array.isArray(unknown) && !unknown.length) { 8 | return true 9 | } 10 | return !Object.keys(unknown).length 11 | } 12 | return false 13 | } 14 | 15 | export const stripBrackets = str => str.replace(/[[\]]/g, '') 16 | 17 | export const injectBrackets = str => { 18 | if (!str) return str 19 | const split = str.split('.') 20 | return [`[${split[0]}]`, ...split.slice(1)].join('.') 21 | } 22 | 23 | /** 24 | * As simple object deep equal that makes the following assumptions: 25 | * 26 | * - No circular references 27 | * - No functions 28 | * - No complex objects like Date, Map, Set, etc. keysToIgnore is an optional 29 | * array of keys top level keys to ignore for the comparison. This allows 30 | * comparisons to ignore large objects without having to copy and remove keys 31 | * 32 | * @param {any} a First object 33 | * @param {any} b Second object 34 | * @param {string[]} [keysToIgnore] Optional array of top-level keys to ignore 35 | * @returns 36 | */ 37 | export const simpleObjectDeepEqual = (a, b, keysToIgnore = []) => { 38 | if (a === b) return true 39 | if (a == null || b == null) return false 40 | if (typeof a !== 'object' || typeof b !== 'object') return false 41 | const keysA = Object.keys(a).filter(key => !keysToIgnore.includes(key)) 42 | const keysB = Object.keys(b).filter(key => !keysToIgnore.includes(key)) 43 | if (keysA.length !== keysB.length) return false 44 | for (const key of keysA) { 45 | if (!keysB.includes(key)) return false 46 | if (!simpleObjectDeepEqual(a[key], b[key])) return false 47 | } 48 | return true 49 | } 50 | -------------------------------------------------------------------------------- /src/utils.spec.js: -------------------------------------------------------------------------------- 1 | import test from 'tape' 2 | import { injectBrackets, simpleObjectDeepEqual } from './utils' 3 | 4 | test('injectBrackets', t => { 5 | // we will never do this, but we don't want this function 6 | // to care if the thing it is wrapping is a number or not 7 | t.equal(injectBrackets('foo'), '[foo]') 8 | t.equal(injectBrackets('234'), '[234]') 9 | t.equal(injectBrackets('5.23'), '[5].23') 10 | t.equal(injectBrackets('5.23.asdf.asdf'), '[5].23.asdf.asdf') 11 | t.equal(injectBrackets(''), '') 12 | t.end() 13 | }) 14 | 15 | test('simpleObjectDeepEqual', t => { 16 | t.equal( 17 | simpleObjectDeepEqual( 18 | { 19 | e: '5', 20 | a: { 21 | c: [ 22 | 2, 23 | 3, 24 | { 25 | something: 'ok', 26 | d: 4, 27 | }, 28 | ], 29 | b: 1, 30 | }, 31 | }, 32 | { 33 | a: { 34 | b: 1, 35 | c: [ 36 | 2, 37 | 3, 38 | { 39 | d: 4, 40 | something: 'ok', 41 | }, 42 | ], 43 | }, 44 | e: '5', 45 | } 46 | ), 47 | true, 48 | 'nesting is fine, nested order of keys is ignored' 49 | ) 50 | t.equal( 51 | simpleObjectDeepEqual( 52 | { 53 | a: '5', 54 | }, 55 | { 56 | a: 5, 57 | } 58 | ), 59 | false, 60 | 'not equal because of type (we are doing === equal)' 61 | ) 62 | t.equal( 63 | simpleObjectDeepEqual( 64 | { 65 | a: 5, 66 | b: 6, 67 | }, 68 | { 69 | b: 6, 70 | a: 5, 71 | } 72 | ), 73 | true, 74 | 'order ignored' 75 | ) 76 | t.equal( 77 | simpleObjectDeepEqual( 78 | { 79 | arr: ['a', 'b', 'c'], 80 | }, 81 | { 82 | arr: ['a', 'b', 'c'], 83 | } 84 | ), 85 | true, 86 | 'arrays are fine' 87 | ) 88 | t.equal( 89 | simpleObjectDeepEqual( 90 | { 91 | arr: ['a', 'b', 'c'], 92 | }, 93 | { 94 | arr: ['b', 'a', 'c'], 95 | } 96 | ), 97 | false, 98 | 'array order matters' 99 | ) 100 | t.equal( 101 | simpleObjectDeepEqual( 102 | { 103 | missing: 'one', 104 | arr: ['a', 'b', 'c'], 105 | }, 106 | { 107 | arr: ['b', 'a', 'c'], 108 | } 109 | ), 110 | false, 111 | 'missing keys in second' 112 | ) 113 | t.equal( 114 | simpleObjectDeepEqual( 115 | { 116 | arr: ['a', 'b', 'c'], 117 | }, 118 | { 119 | missing: 'one', 120 | arr: ['a', 'b', 'c'], 121 | } 122 | ), 123 | false, 124 | 'missing keys in first' 125 | ) 126 | t.equal( 127 | simpleObjectDeepEqual( 128 | { 129 | arr: ['a', 'b', 'c'], 130 | }, 131 | { 132 | missing: 'one', 133 | arr: ['a', 'b', 'c'], 134 | }, 135 | ['missing'] 136 | ), 137 | true, 138 | 'is equal if we ignore key missing in first' 139 | ) 140 | t.equal( 141 | simpleObjectDeepEqual( 142 | { 143 | missing: 'one', 144 | arr: ['a', 'b', 'c'], 145 | }, 146 | { 147 | arr: ['a', 'b', 'c'], 148 | }, 149 | ['missing'] 150 | ), 151 | true, 152 | 'is equal if we ignore key missing in first' 153 | ) 154 | t.equal( 155 | simpleObjectDeepEqual( 156 | { 157 | missing: 'one', 158 | hi: 'there', 159 | }, 160 | { 161 | missing: { missing: 'boom' }, 162 | hi: 'there', 163 | }, 164 | ['missing'] 165 | ), 166 | true, 167 | 'handles nested missing keys' 168 | ) 169 | t.equal( 170 | simpleObjectDeepEqual( 171 | { 172 | other: { 173 | hi: 'there', 174 | missing: 'key', 175 | }, 176 | hi: 'there', 177 | }, 178 | { 179 | other: { 180 | hi: 'there', 181 | }, 182 | hi: 'there', 183 | }, 184 | ['missing'] 185 | ), 186 | false, 187 | 'only ignores missing top-level key' 188 | ) 189 | t.equal( 190 | simpleObjectDeepEqual( 191 | { 192 | arr: ['a', 'b'], 193 | }, 194 | { 195 | arr: ['b', 'a', 'c'], 196 | } 197 | ), 198 | false, 199 | 'array length matters' 200 | ) 201 | t.equal( 202 | simpleObjectDeepEqual( 203 | { 204 | arr: ['a', 'b'], 205 | }, 206 | { 207 | arr: { 0: 'b', 1: 'b' }, 208 | } 209 | ), 210 | false, 211 | 'handles keys that are integers' 212 | ) 213 | 214 | t.end() 215 | }) 216 | --------------------------------------------------------------------------------