├── .github └── FUNDING.yml ├── .gitignore ├── .npmignore ├── .nycrc ├── .prettierrc ├── .travis.yml ├── LICENSE ├── README.md ├── docs └── patch-examples.md ├── package-lock.json ├── package.json ├── src ├── define.md ├── define.tests.ts ├── define.ts ├── functions │ ├── patch-each.md │ ├── patch-each.tests.ts │ ├── patch-each.ts │ ├── patch.md │ ├── patch.tests.ts │ ├── patch.ts │ ├── set-each.md │ ├── set-each.tests.ts │ ├── set-each.ts │ ├── set.md │ ├── set.tests.ts │ ├── set.ts │ ├── unset-each.tests.ts │ ├── unset-each.ts │ ├── unset.tests.ts │ └── unset.ts ├── helpers.md ├── helpers.tests.ts ├── helpers.ts ├── index.ts ├── rules.md ├── rules.ts ├── types.ts ├── wide-weak-map.tests.ts └── wide-weak-map.ts ├── tsconfig.json └── tslint.json /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [skonves] 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # see: https://raw.githubusercontent.com/github/gitignore/master/Node.gitignore 2 | 3 | # Logs 4 | logs 5 | *.log 6 | npm-debug.log* 7 | yarn-debug.log* 8 | yarn-error.log* 9 | 10 | # Runtime data 11 | pids 12 | *.pid 13 | *.seed 14 | *.pid.lock 15 | 16 | # Directory for instrumented libs generated by jscoverage/JSCover 17 | lib-cov 18 | 19 | # Coverage directory used by tools like istanbul 20 | coverage 21 | 22 | # nyc test coverage 23 | .nyc_output 24 | 25 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 26 | .grunt 27 | 28 | # Bower dependency directory (https://bower.io/) 29 | bower_components 30 | 31 | # node-waf configuration 32 | .lock-wscript 33 | 34 | # Compiled binary addons (https://nodejs.org/api/addons.html) 35 | build/Release 36 | 37 | # Dependency directories 38 | node_modules/ 39 | jspm_packages/ 40 | 41 | # TypeScript v1 declaration files 42 | typings/ 43 | 44 | # Optional npm cache directory 45 | .npm 46 | 47 | # Optional eslint cache 48 | .eslintcache 49 | 50 | # Optional REPL history 51 | .node_repl_history 52 | 53 | # Output of 'npm pack' 54 | *.tgz 55 | 56 | # Yarn Integrity file 57 | .yarn-integrity 58 | 59 | # dotenv environment variables file 60 | .env 61 | 62 | # next.js build output 63 | .next 64 | 65 | # Build output 66 | lib/ 67 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | * 2 | !lib/**/* 3 | *.js.map 4 | *.tests.js 5 | *.tests.d.ts 6 | -------------------------------------------------------------------------------- /.nycrc: -------------------------------------------------------------------------------- 1 | { 2 | "extension": [".ts"], 3 | "reporter": ["lcov"], 4 | "include": "src", 5 | "exclude": "**/*tests.*" 6 | } 7 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "useTabs": false, 4 | "tabWidth": 2, 5 | "trailingComma": "all" 6 | } 7 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - 'lts/carbon' #8.x - Maintenance LTS 4 | - 'lts/*' #10.x - Active LTS 5 | - 'stable' #12.x - Current Release 6 | install: 7 | - npm ci 8 | script: 9 | - npm run lint 10 | - npm run build 11 | - npm run test 12 | after_success: 13 | - npm run coveralls 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Steve Konves 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 | [![travis](https://img.shields.io/travis/skonves/flux-standard-functions.svg)](https://travis-ci.org/skonves/flux-standard-functions) 2 | [![coveralls](https://img.shields.io/coveralls/skonves/flux-standard-functions.svg)](https://coveralls.io/github/skonves/flux-standard-functions) 3 | [![npm](https://img.shields.io/npm/v/flux-standard-functions.svg)](https://www.npmjs.com/package/flux-standard-functions) 4 | 5 | # Flux Standard Functions 6 | 7 | Build simple, predictable reducers in Redux 8 | 9 | ## Motivation 10 | 11 | One of the best features of Redux is its lack of features. Actions may be structured in any way imaginable so long as they contain a `type` property. Reducing functions must only return a new state from the original state and an action. With minimal opinions, Redux is very flexible. 12 | 13 | But ultimate flexibility can often be a barrier to productivity. Jason Kurian (@JaKXz) introduced a "human-friendly standard for Flux action objects" which he calls "[Flux Standard Actions](https://github.com/redux-utilities/flux-standard-action)" (or FSAs) to help mitigate this effect. He observed that "It's much easier to work with Flux actions if we can make certain assumptions about their shape." The opinions introduced by FSAs help the developer reason about the shape of actions, but there has still been a lack of similar opinions for reducers. 14 | 15 | This library introduces "Flux Standard Functions" (or FSFs) that aim provide a similar standard for designing reducing functions. By removing some of Redux’s ultimate flexibility, FSFs allow developers to build (and understand) simple, predictable reducers. 16 | 17 | ## Design goals 18 | 19 | * **Simple** - There are only three CRUD functions that mutate data. 20 | * **Comprehensive** - This package shouldn't limit what developers can build. 21 | * **Productive** - Maximize productivity by minimizing repeated code. 22 | * **Backwards compatible** - FSFs are just as awesome with "brown field" projects. 23 | 24 | ## Example 25 | 26 | Here is an example of a reducer built with Flux Standard Functions: 27 | 28 | ```js 29 | function patchEachUser(state, action) { 30 | const existingUsers = state.users; 31 | const userUpdates = action.payload; 32 | 33 | const updatedUsers = patchEach(existingUsers, userUpdates, userDefinition); 34 | 35 | return { 36 | ...state, 37 | users: updatedUsers, 38 | }; 39 | } 40 | ``` 41 | 42 | This reducer needs to update existing data, so we use the "Patch" function. Because we want to update multiple users at once, we use the batch varient which is `patchEach`. 43 | 44 | ## The Three Functions 45 | 46 | The Standard Functions typically work with a combination of three parameters: `target`, `payload`, and `definition`. The `target` is the data that is being "mutated". (Note: that if the `target` is changed, then a shallow clone is created.) The `payload` is the new data that is being added, updated, replaced, or removed. The `definition` is an object that describes the structure of the `target` object and is used for validation, indexing, and optimization. 47 | 48 | ### Set 49 | 50 | Set provides the ability to either add or overwrite data. This is analogous to the "Create" CRUD operation. If a value being set already exists, then it will be overwritten. If the value being set does not exist then it is added. Any operations that set a value not inclued in the `definition` or that are defined as immutable will be ignored. 51 | 52 | Use [`set`](https://github.com/skonves/flux-standard-functions/tree/master/src/functions/set.md) for single values and [`setEach`](https://github.com/skonves/flux-standard-functions/tree/master/src/functions/set-each.md) for batch set operations. 53 | 54 | ### Patch 55 | 56 | Patch provides the ability to update (or "upsert") data. This is similar the the "Update" CRUD operation. If a value being patched already exists, then it will be replaced. For complex properties, it will be partially updated with the properties in the `payload`. If the property did not already exist and is valid per the `definition` then it will be added. 57 | 58 | Use [`patch`](https://github.com/skonves/flux-standard-functions/tree/master/src/functions/patch.md) for single patches and [`patchEach`](https://github.com/skonves/flux-standard-functions/tree/master/src/functions/patch-each.md) for batch patch operations. 59 | 60 | ### Unset 61 | 62 | Unset provides the ability to remove data. This is analogous to the "Delete" CRUD operation. If the valued being unset exists, then it is removed. If the value being unset does not exist or is specified by the `definition` to be required or immutable, then nothing happens. 63 | 64 | Use [`unset`](https://github.com/skonves/flux-standard-functions/tree/master/src/functions/unset.md) to remove single values and [`unsetEach`](https://github.com/skonves/flux-standard-functions/tree/master/src/functions/unset-each.md) for batch unset operations. 65 | 66 | ## Definitions and Rules 67 | 68 | The Standard Functions use the `definition` parameter to validate changes. The [`define()`](https://github.com/skonves/flux-standard-functions/tree/master/src/define.md) function is used to create the defintion for types: 69 | 70 | Here is an example of defining a "User" object: 71 | 72 | ```js 73 | const userDefinition = define({ 74 | id: key(), 75 | name: required(), 76 | email: optional(), 77 | createdOn: immutable(), 78 | }); 79 | ``` 80 | 81 | See the documentation on [Rules](https://github.com/skonves/flux-standard-functions/tree/master/src/rules.md) for the various rules that can be used to create definitions. 82 | 83 | ## Indexes 84 | 85 | The Redux docs contain a fantastic article on [Normalizing State Shape](https://redux.js.org/recipes/structuringreducers/normalizingstateshape). The section on Designing a Normalized State gives a few basic concepts for data normalization which include: 86 | 87 | * Each type of data gets its own "table" in the state. 88 | * Each "data table" should store the individual items in an object, with the IDs of the items as keys and the items themselves as the values. 89 | * Any references to individual items should be done by storing the item's ID. 90 | 91 | This package heavily depends the concept of "tables" with the structure referred to as "Indexes". Indexes are just plain Javascript objects with the IDs of the items as keys and the items themselves as the values. The "key" properties speficied using the `define` function. 92 | 93 | An "Index" of users might look like this: 94 | 95 | ```js 96 | const userDefinition = define({ 97 | id: key(), 98 | name: requried(), 99 | email: optional(), 100 | }); 101 | 102 | const userIndex = { 103 | abc: { 104 | id: 'abd', 105 | name: 'John Doe', 106 | email: 'jd@example.com', 107 | }, 108 | def: { 109 | id: 'def', 110 | name: 'Jane Porter', 111 | }, 112 | jkl: { 113 | id: 'jkl', 114 | name: 'Eric Tile', 115 | }, 116 | }; 117 | ``` 118 | 119 | This package also provide a few helper functions to easily convert data between Indexes and Arrays. See the documentation on [Helpers](https://github.com/skonves/flux-standard-functions/tree/master/src/helpers.md) for more information. 120 | 121 | ## Prior Art 122 | 123 | ### Redux Data Normalization 124 | 125 | The principles of Data Normalization frequently cited within this project heavily influenced its development. Like is recommended, Flux Standard Functions work well with a "flat" or normalized state. 126 | 127 | ### Underscore/Lodash 128 | 129 | The `_.set` and `_.merge` functions map roughly to the `set` and `patch` Standard Functions. There are a number of examples to be found online of using Underscore or Lodash for building reducers. 130 | 131 | ### Normalizr 132 | 133 | "Normalizr is a small, but powerful utility for taking JSON with a schema definition and returning nested entities with their IDs, gathered in dictionaries." 134 | -------------------------------------------------------------------------------- /docs/patch-examples.md: -------------------------------------------------------------------------------- 1 | ### When patching with primitive properties 2 | 3 | #### Adding a new primitive value 4 | 5 | When a new property is supplied in the `payload` that is defined as `optional()`, it is added to the result: 6 | 7 | ```js 8 | const definition = define({ 9 | id: key(), 10 | name: required(), 11 | email: optional(), 12 | }); 13 | 14 | const target = { id: 7, name: 'John Doe' }; 15 | const payload = { email: 'john.doe@example.com' }; 16 | 17 | const result = patch(target, payload, definition); 18 | // => { id: 7, name: 'John Doe', email: 'john.doe@example.com' } 19 | ``` 20 | 21 | The result is created as a shallow clone of the `target` (unless no change has been made) so it can safely be used when returning a new state. 22 | 23 | Note that if a property is supplied by the `payload` but is not defined in the `definition`, then it is ignored by the `patch()` function. If no properties are added (or updated), then the original `target` is returned by reference. 24 | 25 | #### Overwriting an existing primitive value 26 | 27 | When a primitive property is supplied in the `payload` that already exists on the `target` (but is not defined as a key or immutable), then it is updated in the `result`: 28 | 29 | ```js 30 | const definition = define({ 31 | id: key(), 32 | name: required(), 33 | email: optional(), 34 | }); 35 | 36 | const target = { id: 7, name: 'John Doe' }; 37 | const payload = { name: 'Jane Doe' }; 38 | 39 | const result = patch(target, payload, definition); 40 | // => { id: 7, name: 'Jane Doe' } 41 | ``` 42 | 43 | The result is created as a shallow clone of the `target` (unless no change has been made) so it can safely be used when returning a new state. 44 | 45 | Note that if a property exists on the `target` and the `payload` but is defined as a `key()` or `immutable()`, then it is ignored by the `patch()` function. If no properties are updated (or added), then the original `target` is returned by reference. 46 | 47 | #### Deleting a primitive value 48 | 49 | If the `payload` contains a property with whose value is the `DELETE_VALUE` symbol, then the property will be removed on the `result`. 50 | 51 | ```js 52 | import { DELETE_VALUE } from 'flux-standard-functions'; 53 | 54 | const definition = define({ 55 | id: key(), 56 | name: required(), 57 | email: optional(), 58 | }); 59 | 60 | const target = { id: 7, name: 'John Doe', email: 'john.doe@example.com' }; 61 | const payload = { email: DELETE_VALUE }; 62 | 63 | const result = patch(target, payload, definition); 64 | // => { id: 7, name: 'John Doe' } 65 | ``` 66 | 67 | The result is created as a shallow clone of the `target` (unless no change has been made) so it can safely be used when returning a new state. 68 | 69 | Note that if the property did not already exist on the `target` or is not defined as `optional()`, then it is ignored by the `patch()` function. If no properties are deleted (or otherwise changed), then the original `target` is returned by reference. 70 | 71 | ### When patching with a complex value 72 | 73 | #### Adding a new complex value 74 | 75 | If the `payload` contains a complex property that does not yet exist on the `target`, then the value will be added (as long as it is valid). 76 | 77 | ```js 78 | const definition = define({ 79 | id: key(), 80 | name: required(), 81 | address: define({ 82 | zip: required(), 83 | line1: required(), 84 | line2: optional(), 85 | }); 86 | }); 87 | 88 | const target = { id: 7, name: 'John Doe' }; 89 | const payload = { address: { zip: 98765, line1: '123 Main St.' } }; 90 | 91 | const result = patch(target, payload, definition); 92 | // => { id: 7, name: 'John Doe', address: { zip: 98765, line1: '123 Main St.' } } 93 | ``` 94 | 95 | The result is created as a shallow clone of the `target` (unless no change has been made) so it can safely be used when returning a new state. 96 | 97 | Note that child properties of the supplied complex value will be ignored if they are not present in the definition. The entire complex property will also be ignored if it is not valid per the definition and not already present on the `target`. 98 | 99 | If a complex property is supplied by the `payload` but is not defined in the `definition`, then it is ignored by the `patch()` function. If no properties are added (or updated), then the original `target` is returned by reference. 100 | 101 | #### Patching an existing complex value 102 | 103 | If the `payload` contains a complex property that already exists on the `target`, then the existing property will be patched. 104 | 105 | ```js 106 | const definition = define({ 107 | id: key(), 108 | name: required(), 109 | address: define({ 110 | zip: required(), 111 | line1: required(), 112 | line2: optional(), 113 | }); 114 | }); 115 | 116 | const target = { id: 7, name: 'John Doe', address: { zip: 98765, line1: '123 Main St.' } }; 117 | const payload = { address: { line1: '456 Third St.' } }; 118 | 119 | const result = patch(target, payload, definition); 120 | // => { id: 7, name: 'John Doe', address: { zip: 98765, line1: '456 Third St.' } } 121 | ``` 122 | 123 | Patching complex child properties works just like patching the child property as the `target` of the `patch()` function. This means that you can deeply nest multiple layers of complex values and use `DELETE_VALUE` to remove deeply nested properties. (Please note that while this library does support deep, complex state structure, the Redux docs generally recommend flat state.) 124 | 125 | The result is created as a shallow clone of the `target` (unless no change has been made) so it can safely be used when returning a new state. 126 | 127 | If a complex property is supplied by the `payload` but is not defined in the `definition`, then it is ignored by the `patch()` function. If no properties are added (or updated), then the original `target` is returned by reference. 128 | 129 | #### Deleting a complex value 130 | 131 | Deleting a complex value works exactly like deleting a primitive value: 132 | 133 | ```js 134 | import { DELETE_VALUE } from 'flux-standard-functions'; 135 | 136 | const definition = define({ 137 | id: key(), 138 | name: required(), 139 | address: define({ 140 | zip: required(), 141 | line1: required(), 142 | line2: optional(), 143 | }); 144 | }); 145 | 146 | const target = { id: 7, name: 'John Doe', address: { zip: 98765, line1: '123 Main St.' } }; 147 | const payload = { address: DELETE_VALUE }; 148 | 149 | const result = patch(target, payload, definition); 150 | // => { id: 7, name: 'John Doe' } 151 | ``` 152 | 153 | The result is created as a shallow clone of the `target` (unless no change has been made) so it can safely be used when returning a new state. 154 | 155 | Note that if the property did not already exist on the `target` or is not defined as `optional()`, then it is ignored by the `patch()` function. If no properties are deleted (or otherwise changed), then the original `target` is returned by reference. 156 | 157 | ### When patching with Index values 158 | 159 | In this library, an "Index" is an object that meets two basic conditions. First, its values are objects whose definitions have a property defined as a `key()`. Secondly, the values of the keys on the "Index" object are equal to the `key()` properties on its child values. See the docs for more info on using "Index" objects. 160 | 161 | TODO 162 | 163 | ### When patching with primitive array values 164 | 165 | TODO 166 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "flux-standard-functions", 3 | "version": "0.2.0", 4 | "description": "Build simple, predictable reducers in Redux", 5 | "main": "./lib/index.js", 6 | "types": "./lib/index.d.ts", 7 | "scripts": { 8 | "prebuild": "npm run lint && rm -rf lib/*", 9 | "build": "tsc", 10 | "lint": "tslint -c tslint.json -e 'node_modules/**/*' '**/*.ts'", 11 | "start": "node ./lib/index.js", 12 | "test": "NODE_ENV=test nyc mocha --require source-map-support/register --require ts-node/register --recursive './src/**/*.tests.ts'", 13 | "coveralls": "cat ./coverage/lcov.info | coveralls", 14 | "prepack": "npm run build" 15 | }, 16 | "keywords": [], 17 | "author": "skonves", 18 | "license": "MIT", 19 | "repository": { 20 | "type": "git", 21 | "url": "git+https://github.com/skonves/flux-standard-functions.git" 22 | }, 23 | "devDependencies": { 24 | "@types/chai": "^4.1.4", 25 | "@types/mocha": "^5.2.4", 26 | "@types/node": "^10.5.2", 27 | "chai": "^4.1.2", 28 | "coveralls": "^3.0.2", 29 | "mocha": "^5.2.0", 30 | "nyc": "^14.1.1", 31 | "prettier": "^1.12.1", 32 | "source-map-support": "^0.5.6", 33 | "ts-node": "^6.0.3", 34 | "tslint": "^5.10.0", 35 | "typescript": "^2.8.3" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/define.md: -------------------------------------------------------------------------------- 1 | # Define 2 | 3 | The define function is used to create definitions for the objects that on contained in the Redux store. The Standard Functions use these definitions for validation, indexing, and optimization. 4 | 5 | To use these functions: 6 | 7 | ```js 8 | import { define } from 'flux-standard-functions'; 9 | ``` 10 | 11 | ## define(rules) 12 | 13 | Parameters: 14 | 15 | * `rules ` - An object that respresents the properties and property rules for a given type. 16 | 17 | Creates a Definition object that describes a type. These definitions are required arguments for most of the Standard Functions. 18 | 19 | Here is an example of defining a "User" object: 20 | 21 | ```js 22 | const userDefinition = define({ 23 | id: key(), 24 | name: required(), 25 | email: optional(), 26 | createdOn: immutable(), 27 | }); 28 | ``` 29 | 30 | Any operation that adds a property not included in the definition will be ignored without throwing an error. Properties are ignored independently. This means that, per the above definition, if the `email` and `address` properties were patched, then `address` would be ignored, but `email` would still be updated. 31 | -------------------------------------------------------------------------------- /src/define.tests.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import { define } from './define'; 3 | import { 4 | key, 5 | required, 6 | optional, 7 | objectOf, 8 | indexOf, 9 | array, 10 | immutable, 11 | } from './rules'; 12 | import { Patch, DELETE_VALUE, Index } from '.'; 13 | 14 | describe('define', () => { 15 | describe('getPayload', () => { 16 | it('removes extraneous values from input', () => { 17 | // ARRANGE 18 | type TestItem = { 19 | id: string; 20 | name: string; 21 | value?: number; 22 | }; 23 | 24 | const payload: any = { 25 | id: 'the ID', 26 | name: 'the NAME', 27 | value: 7, 28 | extra: 'prop', 29 | }; 30 | 31 | const expected: TestItem = { 32 | id: 'the ID', 33 | name: 'the NAME', 34 | value: 7, 35 | }; 36 | 37 | // ACT 38 | const definition = define({ 39 | id: key(), 40 | name: required(), 41 | value: optional(), 42 | }); 43 | const result = definition.getPayload(payload); 44 | 45 | // ASSERT 46 | expect(result).to.deep.equal(expected); 47 | }); 48 | 49 | it('returns falsy when required primitive property is missing', () => { 50 | // ARRANGE 51 | type TestItem = { 52 | id: string; 53 | name: string; 54 | value?: number; 55 | }; 56 | 57 | const payload: any = { 58 | id: 'the ID', 59 | value: 7, 60 | }; 61 | 62 | // ACT 63 | const definition = define({ 64 | id: key(), 65 | name: required(), 66 | value: optional(), 67 | }); 68 | const result = definition.getPayload(payload); 69 | 70 | // ASSERT 71 | expect(result).to.not.be.ok; 72 | }); 73 | 74 | it('returns value when optional primitive property is missing', () => { 75 | // ARRANGE 76 | type TestItem = { 77 | id: string; 78 | name: string; 79 | value?: number; 80 | }; 81 | 82 | const payload: any = { 83 | id: 'the ID', 84 | name: 'the NAME', 85 | }; 86 | 87 | const expected: TestItem = { 88 | id: 'the ID', 89 | name: 'the NAME', 90 | }; 91 | 92 | // ACT 93 | const definition = define({ 94 | id: key(), 95 | name: required(), 96 | value: optional(), 97 | }); 98 | const result = definition.getPayload(payload); 99 | 100 | // ASSERT 101 | expect(result).to.deep.equal(expected); 102 | }); 103 | 104 | it('returns falsy when required child object is invalid', () => { 105 | // ARRANGE 106 | type TestItem = { 107 | id: string; 108 | child: TestChild; 109 | }; 110 | 111 | type TestChild = { 112 | id: string; 113 | name: string; 114 | }; 115 | 116 | const payload: any = { 117 | id: 'the ID', 118 | child: { id: 'the CHILD ID' }, 119 | }; 120 | 121 | // ACT 122 | const childDefinition = define({ 123 | id: key(), 124 | name: required(), 125 | }); 126 | const definition = define({ 127 | id: key(), 128 | child: required(objectOf(childDefinition)), 129 | }); 130 | const result = definition.getPayload(payload); 131 | 132 | // ASSERT 133 | expect(result).to.not.be.ok; 134 | }); 135 | 136 | it('removes child object when it is invalid but optional', () => { 137 | // ARRANGE 138 | type TestItem = { 139 | id: string; 140 | child?: TestChild; 141 | }; 142 | 143 | type TestChild = { 144 | id: string; 145 | name: string; 146 | }; 147 | 148 | const payload: any = { 149 | id: 'the ID', 150 | child: { id: 'the CHILD ID' }, 151 | }; 152 | 153 | const expected: TestItem = { 154 | id: 'the ID', 155 | }; 156 | 157 | // ACT 158 | const childDefinition = define({ 159 | id: key(), 160 | name: required(), 161 | }); 162 | const definition = define({ 163 | id: key(), 164 | child: optional(objectOf(childDefinition)), 165 | }); 166 | const result = definition.getPayload(payload); 167 | 168 | // ASSERT 169 | expect(result).to.deep.equal(expected); 170 | }); 171 | 172 | it('removes extraneous values from a child object', () => { 173 | // ARRANGE 174 | type TestItem = { 175 | id: string; 176 | child: TestChild; 177 | }; 178 | 179 | type TestChild = { 180 | id: string; 181 | name: string; 182 | }; 183 | 184 | const payload: any = { 185 | id: 'the ID', 186 | child: { 187 | id: 'the CHILD ID', 188 | name: ' the CHILD NAME', 189 | extraneous: 'value', 190 | }, 191 | }; 192 | 193 | const expected: TestItem = { 194 | id: 'the ID', 195 | child: { 196 | id: 'the CHILD ID', 197 | name: ' the CHILD NAME', 198 | }, 199 | }; 200 | 201 | // ACT 202 | const childDefinition = define({ 203 | id: key(), 204 | name: required(), 205 | }); 206 | const definition = define({ 207 | id: key(), 208 | child: required(objectOf(childDefinition)), 209 | }); 210 | const result = definition.getPayload(payload); 211 | 212 | // ASSERT 213 | expect(result).to.deep.equal(expected); 214 | }); 215 | 216 | it('allows a falsy value for a missing but optional child index', () => { 217 | // ARRANGE 218 | type TestItem = { 219 | id: string; 220 | children?: Index; 221 | }; 222 | 223 | type TestChild = { 224 | id: string; 225 | name: string; 226 | }; 227 | 228 | const payload: any = { 229 | id: 'the ID', 230 | }; 231 | 232 | const expected: TestItem = { 233 | id: 'the ID', 234 | }; 235 | 236 | // ACT 237 | const childDefinition = define({ 238 | id: key(), 239 | name: required(), 240 | }); 241 | const definition = define({ 242 | id: key(), 243 | children: optional(indexOf(childDefinition)), 244 | }); 245 | const result = definition.getPayload(payload); 246 | 247 | // ASSERT 248 | expect(result).to.deep.equal(expected); 249 | }); 250 | 251 | it('adds an empty index for a required but missing child index', () => { 252 | // ARRANGE 253 | type TestItem = { 254 | id: string; 255 | children: Index; 256 | }; 257 | 258 | type TestChild = { 259 | id: string; 260 | name: string; 261 | }; 262 | 263 | const payload: any = { 264 | id: 'the ID', 265 | }; 266 | 267 | const expected: TestItem = { 268 | id: 'the ID', 269 | children: {}, 270 | }; 271 | 272 | // ACT 273 | const childDefinition = define({ 274 | id: key(), 275 | name: required(), 276 | }); 277 | const definition = define({ 278 | id: key(), 279 | children: required(indexOf(childDefinition)), 280 | }); 281 | const result = definition.getPayload(payload); 282 | 283 | // ASSERT 284 | expect(result).to.deep.equal(expected); 285 | }); 286 | 287 | it('removes invalid objects from a child index', () => { 288 | // ARRANGE 289 | type TestItem = { 290 | id: string; 291 | children: Index; 292 | }; 293 | 294 | type TestChild = { 295 | id: string; 296 | name: string; 297 | }; 298 | 299 | const payload: any = { 300 | id: 'the ID', 301 | children: { 302 | a: { id: 'a', name: 'the CHILD NAME' }, 303 | b: { id: 'b' }, 304 | }, 305 | }; 306 | 307 | const expected: TestItem = { 308 | id: 'the ID', 309 | children: { 310 | a: { id: 'a', name: 'the CHILD NAME' }, 311 | }, 312 | }; 313 | 314 | // ACT 315 | const childDefinition = define({ 316 | id: key(), 317 | name: required(), 318 | }); 319 | const definition = define({ 320 | id: key(), 321 | children: required(indexOf(childDefinition)), 322 | }); 323 | const result = definition.getPayload(payload); 324 | 325 | // ASSERT 326 | expect(result).to.deep.equal(expected); 327 | }); 328 | 329 | it('removes extraneous values from objects in a child index', () => { 330 | // ARRANGE 331 | type TestItem = { 332 | id: string; 333 | children: Index; 334 | }; 335 | 336 | type TestChild = { 337 | id: string; 338 | name: string; 339 | }; 340 | 341 | const payload: any = { 342 | id: 'the ID', 343 | children: { 344 | a: { id: 'a', name: 'the CHILD NAME', extraneous: 'value' }, 345 | }, 346 | }; 347 | 348 | const expected: TestItem = { 349 | id: 'the ID', 350 | children: { 351 | a: { id: 'a', name: 'the CHILD NAME' }, 352 | }, 353 | }; 354 | 355 | // ACT 356 | const childDefinition = define({ 357 | id: key(), 358 | name: required(), 359 | }); 360 | const definition = define({ 361 | id: key(), 362 | children: required(indexOf(childDefinition)), 363 | }); 364 | const result = definition.getPayload(payload); 365 | 366 | // ASSERT 367 | expect(result).to.deep.equal(expected); 368 | }); 369 | 370 | it('allows a falsy value for a missing but optional child array', () => { 371 | // ARRANGE 372 | type TestItem = { 373 | id: string; 374 | values?: number[]; 375 | }; 376 | 377 | const payload: any = { 378 | id: 'the ID', 379 | }; 380 | 381 | const expected: TestItem = { 382 | id: 'the ID', 383 | }; 384 | 385 | // ACT 386 | const definition = define({ 387 | id: key(), 388 | values: optional(array()), 389 | }); 390 | const result = definition.getPayload(payload); 391 | 392 | // ASSERT 393 | expect(result).to.deep.equal(expected); 394 | }); 395 | 396 | it('adds an empty array for a required but missing child array', () => { 397 | // ARRANGE 398 | type TestItem = { 399 | id: string; 400 | values: number[]; 401 | }; 402 | 403 | const payload: any = { 404 | id: 'the ID', 405 | }; 406 | 407 | const expected: TestItem = { 408 | id: 'the ID', 409 | values: [], 410 | }; 411 | 412 | // ACT 413 | const definition = define({ 414 | id: key(), 415 | values: required(array()), 416 | }); 417 | const result = definition.getPayload(payload); 418 | 419 | // ASSERT 420 | expect(result).to.deep.equal(expected); 421 | }); 422 | }); 423 | 424 | describe('getPatch', () => { 425 | it('removes extraneous primitive value', () => { 426 | // ARRANGE 427 | type TestItem = { 428 | id: string; 429 | name: string; 430 | value?: number; 431 | }; 432 | 433 | const payload: any = { 434 | name: 'the NAME', 435 | value: 7, 436 | extraneous: 'value', 437 | }; 438 | 439 | const expected: Patch = { 440 | name: 'the NAME', 441 | value: 7, 442 | }; 443 | 444 | // ACT 445 | const definition = define({ 446 | id: key(), 447 | name: required(), 448 | value: optional(), 449 | }); 450 | const result = definition.getPatch(payload); 451 | 452 | // ASSERT 453 | expect(result).to.deep.equal(expected); 454 | }); 455 | 456 | it('removes DELETE_VALUE for required property', () => { 457 | // ARRANGE 458 | type TestItem = { 459 | id: string; 460 | name: string; 461 | value: number; 462 | }; 463 | 464 | const payload: any = { 465 | name: DELETE_VALUE, 466 | value: 7, 467 | }; 468 | 469 | const expected: Patch = { 470 | value: 7, 471 | }; 472 | 473 | // ACT 474 | const definition = define({ 475 | id: key(), 476 | name: required(), 477 | value: required(), 478 | }); 479 | const result = definition.getPatch(payload); 480 | 481 | // ASSERT 482 | expect(result).to.deep.equal(expected); 483 | }); 484 | 485 | it('removes DELETE_VALUE for immutable property', () => { 486 | // ARRANGE 487 | type TestItem = { 488 | id: string; 489 | name: string; 490 | value: number; 491 | }; 492 | 493 | const payload: any = { 494 | name: DELETE_VALUE, 495 | value: 7, 496 | }; 497 | 498 | const expected: Patch = { 499 | value: 7, 500 | }; 501 | 502 | // ACT 503 | const definition = define({ 504 | id: key(), 505 | name: immutable(), 506 | value: required(), 507 | }); 508 | const result = definition.getPatch(payload); 509 | 510 | // ASSERT 511 | expect(result).to.deep.equal(expected); 512 | }); 513 | 514 | it('removes primitive value for immutable property', () => { 515 | // ARRANGE 516 | type TestItem = { 517 | id: string; 518 | name: string; 519 | }; 520 | 521 | const payload: any = { 522 | id: 'the ID', 523 | name: 'the NAME', 524 | }; 525 | 526 | const expected: Patch = { 527 | name: 'the NAME', 528 | }; 529 | 530 | // ACT 531 | const definition = define({ 532 | id: key(), 533 | name: required(), 534 | }); 535 | const result = definition.getPatch(payload); 536 | 537 | // ASSERT 538 | expect(result).to.deep.equal(expected); 539 | }); 540 | 541 | it('returns falsy when input has no valid properties', () => { 542 | // ARRANGE 543 | type TestItem = { 544 | id: string; 545 | name: string; 546 | }; 547 | 548 | const payload: any = { 549 | id: 'new ID', 550 | }; 551 | 552 | // ACT 553 | const definition = define({ 554 | id: key(), 555 | name: required(), 556 | }); 557 | const result = definition.getPatch(payload); 558 | 559 | // ASSERT 560 | expect(result).to.not.be.ok; 561 | }); 562 | 563 | it('removes a non-array value for an array property', () => { 564 | // ARRANGE 565 | type TestItem = { 566 | id: string; 567 | name: string; 568 | items: number[]; 569 | }; 570 | 571 | const payload: any = { 572 | name: 'the NAME', 573 | items: 'not an array', 574 | }; 575 | 576 | const expected: Patch = { 577 | name: 'the NAME', 578 | }; 579 | 580 | // ACT 581 | const definition = define({ 582 | id: key(), 583 | name: required(), 584 | items: array(), 585 | }); 586 | const result = definition.getPatch(payload); 587 | 588 | // ASSERT 589 | expect(result).to.deep.equal(expected); 590 | }); 591 | }); 592 | 593 | describe('getKey', () => { 594 | it('returns a key function for ???'); 595 | it('returns falsy for ???'); 596 | }); 597 | }); 598 | -------------------------------------------------------------------------------- /src/define.ts: -------------------------------------------------------------------------------- 1 | import { Definition, Rule, Patch } from '.'; 2 | import { DELETE_VALUE } from './types'; 3 | 4 | /** 5 | * Creates a definition for a type. These definitions are required arguments for most of 6 | * the Standard Functions. Any operation that adds a property not included in the definition 7 | * will be ignored without throwing an error. Properties are ignored independently. 8 | * @param rules An object that respresents the property and property rules for a given type. 9 | * @returns Returns a Definition object. 10 | */ 11 | export function define( 12 | rules: { [K in keyof Record]: Rule }, 13 | ): Definition { 14 | const keys = Object.keys(rules); 15 | 16 | const keyName = keys.find(key => rules[key].isKey); 17 | 18 | return { 19 | getPayload: createGetPayloadFunction(rules), 20 | getPatch: createGetPatchFunction(rules), 21 | getKey: item => item[keyName], 22 | getDefinitions: key => { 23 | const rule = rules[key]; 24 | 25 | if (!rule) return undefined; 26 | 27 | const { object, index, isArray } = rule; 28 | 29 | return { object, index, isArray }; 30 | }, 31 | }; 32 | } 33 | 34 | export function dynamic(getKey?: () => string): Definition { 35 | return { 36 | getKey: getKey || (() => null), 37 | getPatch: x => x, 38 | getPayload: x => x, 39 | getDefinitions: () => ({}), 40 | }; 41 | } 42 | 43 | function createGetPayloadFunction( 44 | rules: { [K in keyof T]: Rule }, 45 | ): (payload: T) => T { 46 | return payload => { 47 | const result = {}; 48 | 49 | let hasAny = false; 50 | let hasAnyRequred = false; 51 | 52 | for (const key of Object.keys(rules)) { 53 | const rule: Rule = rules[key]; 54 | if (!rule) continue; 55 | if (payload[key] === DELETE_VALUE) return undefined; 56 | 57 | if (rule.isReadonly) { 58 | hasAnyRequred = true; 59 | } 60 | 61 | if (typeof payload[key] === 'undefined' || payload[key] === null) { 62 | if (rule.isRequired) { 63 | if (rule.isArray) { 64 | hasAny = true; 65 | result[key] = []; 66 | } else if (rule.index) { 67 | hasAny = true; 68 | result[key] = {}; 69 | } else { 70 | return undefined; 71 | } 72 | } 73 | } else { 74 | if (rule.object) { 75 | const childPayload = rule.object.getPayload(payload[key]); 76 | 77 | if (childPayload) { 78 | hasAny = true; 79 | result[key] = childPayload; 80 | } else if (rule.isRequired) { 81 | return undefined; 82 | } 83 | } else if (rule.index) { 84 | hasAny = true; 85 | result[key] = {}; 86 | for (const childKey in payload[key]) { 87 | const childPayload = rule.index.getPayload(payload[key][childKey]); 88 | if (childPayload) result[key][childKey] = childPayload; 89 | } 90 | } else { 91 | hasAny = true; 92 | result[key] = payload[key]; 93 | } 94 | } 95 | } 96 | 97 | return hasAny || !hasAnyRequred ? (result as T) : undefined; 98 | }; 99 | } 100 | 101 | function createGetPatchFunction( 102 | rules: { [K in keyof T]: Rule }, 103 | ): (payload: T) => Patch { 104 | return payload => { 105 | const patch = {}; 106 | 107 | let hasAny = false; 108 | 109 | for (const key of Object.keys(rules)) { 110 | const rule: Rule = rules[key]; 111 | if (!rule) continue; 112 | 113 | if (payload[key] === DELETE_VALUE) { 114 | if (!rule.isReadonly && !rule.isRequired) { 115 | hasAny = true; 116 | patch[key] = DELETE_VALUE; 117 | } 118 | } else { 119 | if ( 120 | !rule.isReadonly && 121 | typeof payload[key] !== 'undefined' && 122 | payload[key] !== null && 123 | (!rule.isArray || Array.isArray(payload[key])) 124 | ) { 125 | hasAny = true; 126 | patch[key] = payload[key]; 127 | } 128 | } 129 | } 130 | 131 | return hasAny ? patch : undefined; 132 | }; 133 | } 134 | -------------------------------------------------------------------------------- /src/functions/patch-each.md: -------------------------------------------------------------------------------- 1 | # Patch Each 2 | 3 | Patch Each provides the ability to either partially update existing data or add new data to multiple items within an index. 4 | 5 | To use this function: 6 | 7 | ```js 8 | import { patchEach } from 'flux-standard-functions'; 9 | ``` 10 | 11 | ## patchEach(target, payload, definition) 12 | 13 | Parameters: 14 | 15 | * `target ` - The Index to be patched. 16 | * `payload ` - An array or Index of objects that contain the patch data. If the `payload` is an array, then the object in the `target` object that is patched is determined by the key of the object in the `payload` array. If the `payload` is an Index, then the objects in the `target` object that are patched are determined by the keys within in the `payload` Index. Properties and sub-properties must be included in the supplied `definition` and any of its sub-definitions. Patch properties not included in the `definition` will be ignored. Values may be removed from the objects in the `target` Index by using the `DELETE_VALUE` symbol. 17 | * `definition ` - Defines the properties of the object in the `target` index being patched so that immutable properties are not updated, required properties are not removed, and extraneous properties are not added. 18 | 19 | Patches the objects in the `target` Index with each of the objects from the `payload` array. Note that each of the objects in the supplied `payload` array will be applied to the `target` Index similar to the `patch(target, key, payload, definition)` function. If `target` is patched, then an updated shallow clone of `target` is returned; otherwise, `target` is returned by reference. 20 | 21 | Example of patching with an Array: 22 | 23 | ```js 24 | function patchEachUser(state, action) { 25 | const target = state.users; 26 | /* 27 | { 28 | { abc: { id: 'abc', name: 'John Doe' }}, 29 | { def: { id: 'def', name: 'Jane Porter' }}, 30 | { jkl: { id: 'jkl', name: 'Eric Tile' }}, 31 | } 32 | */ 33 | const payload = action.payload; 34 | /* 35 | [ 36 | { id: 'abc', email: 'john.doe@example.com' }, 37 | { id: 'def', email: 'jane.porter@example.com' }, 38 | ] 39 | */ 40 | 41 | const updatedUsers = patchEach(target, payload, userDefinition); 42 | /* 43 | { 44 | { abc: { id: 'abc', name: 'John Doe', email: 'john.doe@example.com' }}, 45 | { def: { id: 'def', name: 'Jane Porter', email: 'jane.porter@example.com' }}, 46 | { jkl: { id: 'jkl', name: 'Eric Tile' }}, 47 | } 48 | */ 49 | 50 | return { 51 | ...state, 52 | users: updatedUsers, 53 | }; 54 | } 55 | ``` 56 | 57 | Example of patching with an Index: 58 | 59 | ```js 60 | function patchEachUser(state, action) { 61 | const target = state.users; 62 | /* 63 | { 64 | { abc: { id: 'abc', name: 'John Doe' }}, 65 | { def: { id: 'def', name: 'Jane Porter' }}, 66 | { jkl: { id: 'jkl', name: 'Eric Tile' }}, 67 | } 68 | */ 69 | const payload = action.payload; 70 | /* 71 | { 72 | { abc: { email: 'john.doe@example.com' }}, 73 | { def: { email: 'jane.porter@example.com' }}, 74 | } 75 | */ 76 | 77 | const updatedUsers = patchEach(target, payload, userDefinition); 78 | /* 79 | { 80 | { abc: { id: 'abc', name: 'John Doe', email: 'john.doe@example.com' }}, 81 | { def: { id: 'def', name: 'Jane Porter', email: 'jane.porter@example.com' }}, 82 | { jkl: { id: 'jkl', name: 'Eric Tile' }}, 83 | } 84 | */ 85 | 86 | return { 87 | ...state, 88 | users: updatedUsers, 89 | }; 90 | } 91 | ``` 92 | -------------------------------------------------------------------------------- /src/functions/patch-each.tests.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | 3 | import { 4 | Patch, 5 | DELETE_VALUE, 6 | Index, 7 | define, 8 | key, 9 | optional, 10 | required, 11 | objectOf, 12 | indexOf, 13 | array, 14 | patchEach, 15 | } from '..'; 16 | 17 | type TestChildItem = { 18 | id: string; 19 | name: string; 20 | value?: number; 21 | }; 22 | 23 | const childDef = define({ 24 | id: key(), 25 | name: required(), 26 | value: optional(), 27 | }); 28 | 29 | type TestItem = { 30 | id: string; 31 | name: string; 32 | value?: number; 33 | child?: TestChildItem; 34 | children?: Index; 35 | items?: number[]; 36 | }; 37 | 38 | const parentDef = define({ 39 | id: key(), 40 | name: required(), 41 | value: optional(), 42 | child: optional(objectOf(childDef)), 43 | children: optional(indexOf(childDef)), 44 | items: optional(array()), 45 | }); 46 | 47 | describe('patch-each', () => { 48 | describe('from array', () => { 49 | it('Adds new values', () => { 50 | // ARRANGE 51 | const target: Index = { 52 | a: { 53 | id: 'a', 54 | name: 'name of a', 55 | }, 56 | b: { 57 | id: 'b', 58 | name: 'name of b', 59 | }, 60 | }; 61 | const payload: Patch[] = [ 62 | { 63 | id: 'a', 64 | value: 7, 65 | }, 66 | ]; 67 | 68 | const expected: Index = { 69 | a: { 70 | id: 'a', 71 | name: 'name of a', 72 | value: 7, 73 | }, 74 | b: { 75 | id: 'b', 76 | name: 'name of b', 77 | }, 78 | }; 79 | 80 | // ACT 81 | const result = patchEach(target, payload, parentDef); 82 | 83 | // ASSERT 84 | expect(result).to.deep.equal(expected); 85 | }); 86 | 87 | it('Overwrites existing values', () => { 88 | // ARRANGE 89 | const target: Index = { 90 | a: { 91 | id: 'a', 92 | name: 'name of a', 93 | value: 7, 94 | }, 95 | b: { 96 | id: 'b', 97 | name: 'name of b', 98 | }, 99 | }; 100 | const payload: Patch[] = [ 101 | { 102 | id: 'a', 103 | value: 18, 104 | }, 105 | ]; 106 | 107 | const expected: Index = { 108 | a: { 109 | id: 'a', 110 | name: 'name of a', 111 | value: 18, 112 | }, 113 | b: { 114 | id: 'b', 115 | name: 'name of b', 116 | }, 117 | }; 118 | 119 | // ACT 120 | const result = patchEach(target, payload, parentDef); 121 | 122 | // ASSERT 123 | expect(result).to.deep.equal(expected); 124 | }); 125 | 126 | it('Deletes existing values', () => { 127 | // ARRANGE 128 | const target: Index = { 129 | a: { 130 | id: 'a', 131 | name: 'name of a', 132 | value: 7, 133 | }, 134 | b: { 135 | id: 'b', 136 | name: 'name of b', 137 | }, 138 | }; 139 | const payload: Patch[] = [ 140 | { 141 | id: 'a', 142 | value: DELETE_VALUE, 143 | }, 144 | ]; 145 | 146 | const expected: Index = { 147 | a: { 148 | id: 'a', 149 | name: 'name of a', 150 | }, 151 | b: { 152 | id: 'b', 153 | name: 'name of b', 154 | }, 155 | }; 156 | 157 | // ACT 158 | const result = patchEach(target, payload, parentDef); 159 | 160 | // ASSERT 161 | expect(result).to.deep.equal(expected); 162 | }); 163 | 164 | it('No-ops when payload is undefined', () => { 165 | // ARRANGE 166 | const target: Index = { 167 | a: { 168 | id: 'a', 169 | name: 'name of a', 170 | value: 7, 171 | }, 172 | b: { 173 | id: 'b', 174 | name: 'name of b', 175 | }, 176 | }; 177 | const payload: Patch[] = undefined; 178 | 179 | const expected: Index = { ...target }; 180 | 181 | // ACT 182 | const result = patchEach(target, payload, parentDef); 183 | 184 | // ASSERT 185 | expect(result).to.deep.equal(expected); 186 | expect(result).to.equal(target); 187 | }); 188 | 189 | it('No-ops when payload is null', () => { 190 | // ARRANGE 191 | const target: Index = { 192 | a: { 193 | id: 'a', 194 | name: 'name of a', 195 | value: 7, 196 | }, 197 | b: { 198 | id: 'b', 199 | name: 'name of b', 200 | }, 201 | }; 202 | const payload: Patch[] = null; 203 | 204 | const expected: Index = { ...target }; 205 | 206 | // ACT 207 | const result = patchEach(target, payload, parentDef); 208 | 209 | // ASSERT 210 | expect(result).to.deep.equal(expected); 211 | expect(result).to.equal(target); 212 | }); 213 | 214 | it('No-ops when payload is an empty array', () => { 215 | // ARRANGE 216 | const target: Index = { 217 | a: { 218 | id: 'a', 219 | name: 'name of a', 220 | value: 7, 221 | }, 222 | b: { 223 | id: 'b', 224 | name: 'name of b', 225 | }, 226 | }; 227 | const payload: Patch[] = []; 228 | 229 | const expected: Index = { ...target }; 230 | 231 | // ACT 232 | const result = patchEach(target, payload, parentDef); 233 | 234 | // ASSERT 235 | expect(result).to.deep.equal(expected); 236 | expect(result).to.equal(target); 237 | }); 238 | 239 | it('No-ops when all payload items are invalid', () => { 240 | // ARRANGE 241 | const target: Index = { 242 | a: { 243 | id: 'a', 244 | name: 'name of a', 245 | value: 7, 246 | }, 247 | b: { 248 | id: 'b', 249 | name: 'name of b', 250 | }, 251 | }; 252 | const payload: Patch[] = [ 253 | { 254 | id: 'a', 255 | }, 256 | ]; 257 | 258 | const expected: Index = { ...target }; 259 | 260 | // ACT 261 | const result = patchEach(target, payload, parentDef); 262 | 263 | // ASSERT 264 | expect(result).to.deep.equal(expected); 265 | expect(result).to.equal(target); 266 | }); 267 | 268 | it('No-ops when no value is changed', () => { 269 | // ARRANGE 270 | const target: Index = { 271 | a: { 272 | id: 'a', 273 | name: 'name of a', 274 | value: 7, 275 | }, 276 | b: { 277 | id: 'b', 278 | name: 'name of b', 279 | }, 280 | }; 281 | const payload: Patch[] = [ 282 | { 283 | id: 'a', 284 | value: 7, 285 | }, 286 | ]; 287 | 288 | const expected: Index = { ...target }; 289 | 290 | // ACT 291 | const result = patchEach(target, payload, parentDef); 292 | 293 | // ASSERT 294 | expect(result).to.deep.equal(expected); 295 | expect(result).to.equal(target); 296 | }); 297 | 298 | it('No-ops when no value is deleted', () => { 299 | // ARRANGE 300 | const target: Index = { 301 | a: { 302 | id: 'a', 303 | name: 'name of a', 304 | }, 305 | b: { 306 | id: 'b', 307 | name: 'name of b', 308 | }, 309 | }; 310 | const payload: Patch[] = [ 311 | { 312 | id: 'a', 313 | value: DELETE_VALUE, 314 | }, 315 | ]; 316 | 317 | const expected: Index = { ...target }; 318 | 319 | // ACT 320 | const result = patchEach(target, payload, parentDef); 321 | 322 | // ASSERT 323 | expect(result).to.deep.equal(expected); 324 | expect(result).to.equal(target); 325 | }); 326 | }); 327 | 328 | describe('from index', () => { 329 | it('Adds new values', () => { 330 | // ARRANGE 331 | const target: Index = { 332 | a: { 333 | id: 'a', 334 | name: 'name of a', 335 | }, 336 | b: { 337 | id: 'b', 338 | name: 'name of b', 339 | }, 340 | }; 341 | const payload: { [key: string]: Patch } = { 342 | a: { value: 7 }, 343 | }; 344 | 345 | const expected: Index = { 346 | a: { 347 | id: 'a', 348 | name: 'name of a', 349 | value: 7, 350 | }, 351 | b: { 352 | id: 'b', 353 | name: 'name of b', 354 | }, 355 | }; 356 | 357 | // ACT 358 | const result = patchEach(target, payload, parentDef); 359 | 360 | // ASSERT 361 | expect(result).to.deep.equal(expected); 362 | }); 363 | 364 | it('Overwrites existing values', () => { 365 | // ARRANGE 366 | const target: Index = { 367 | a: { 368 | id: 'a', 369 | name: 'name of a', 370 | value: 7, 371 | }, 372 | b: { 373 | id: 'b', 374 | name: 'name of b', 375 | }, 376 | }; 377 | const payload: { [key: string]: Patch } = { 378 | a: { value: 18 }, 379 | }; 380 | 381 | const expected: Index = { 382 | a: { 383 | id: 'a', 384 | name: 'name of a', 385 | value: 18, 386 | }, 387 | b: { 388 | id: 'b', 389 | name: 'name of b', 390 | }, 391 | }; 392 | 393 | // ACT 394 | const result = patchEach(target, payload, parentDef); 395 | 396 | // ASSERT 397 | expect(result).to.deep.equal(expected); 398 | }); 399 | 400 | it('Deletes existing values', () => { 401 | // ARRANGE 402 | const target: Index = { 403 | a: { 404 | id: 'a', 405 | name: 'name of a', 406 | value: 7, 407 | }, 408 | b: { 409 | id: 'b', 410 | name: 'name of b', 411 | }, 412 | }; 413 | const payload: { [key: string]: Patch } = { 414 | a: { value: DELETE_VALUE }, 415 | }; 416 | 417 | const expected: Index = { 418 | a: { 419 | id: 'a', 420 | name: 'name of a', 421 | }, 422 | b: { 423 | id: 'b', 424 | name: 'name of b', 425 | }, 426 | }; 427 | 428 | // ACT 429 | const result = patchEach(target, payload, parentDef); 430 | 431 | // ASSERT 432 | expect(result).to.deep.equal(expected); 433 | }); 434 | 435 | it('No-ops when payload is empty', () => { 436 | // ARRANGE 437 | const target: Index = { 438 | a: { 439 | id: 'a', 440 | name: 'name of a', 441 | value: 7, 442 | }, 443 | b: { 444 | id: 'b', 445 | name: 'name of b', 446 | }, 447 | }; 448 | const payload = {}; 449 | 450 | const expected: Index = { ...target }; 451 | 452 | // ACT 453 | const result = patchEach(target, payload, parentDef); 454 | 455 | // ASSERT 456 | expect(result).to.deep.equal(expected); 457 | expect(result).to.equal(target); 458 | }); 459 | 460 | it('No-ops when payload is undefined', () => { 461 | // ARRANGE 462 | const target: Index = { 463 | a: { 464 | id: 'a', 465 | name: 'name of a', 466 | value: 7, 467 | }, 468 | b: { 469 | id: 'b', 470 | name: 'name of b', 471 | }, 472 | }; 473 | const payload = undefined; 474 | 475 | const expected: Index = { ...target }; 476 | 477 | // ACT 478 | const result = patchEach(target, payload, parentDef); 479 | 480 | // ASSERT 481 | expect(result).to.deep.equal(expected); 482 | expect(result).to.equal(target); 483 | }); 484 | 485 | it('No-ops when payload is null', () => { 486 | // ARRANGE 487 | const target: Index = { 488 | a: { 489 | id: 'a', 490 | name: 'name of a', 491 | value: 7, 492 | }, 493 | b: { 494 | id: 'b', 495 | name: 'name of b', 496 | }, 497 | }; 498 | const payload = null; 499 | 500 | const expected: Index = { ...target }; 501 | 502 | // ACT 503 | const result = patchEach(target, payload, parentDef); 504 | 505 | // ASSERT 506 | expect(result).to.deep.equal(expected); 507 | expect(result).to.equal(target); 508 | }); 509 | 510 | it('No-ops when all payload items are invalid', () => { 511 | // ARRANGE 512 | const target: Index = { 513 | a: { 514 | id: 'a', 515 | name: 'name of a', 516 | }, 517 | b: { 518 | id: 'b', 519 | name: 'name of b', 520 | }, 521 | }; 522 | const payload: { [key: string]: Patch } = { 523 | a: { id: 'THE NEW ID' }, 524 | }; 525 | 526 | const expected: Index = { ...target }; 527 | 528 | // ACT 529 | const result = patchEach(target, payload, parentDef); 530 | 531 | // ASSERT 532 | expect(result).to.deep.equal(expected); 533 | expect(result).to.equal(target); 534 | }); 535 | 536 | it('No-ops when no value is changed', () => { 537 | // ARRANGE 538 | const target: Index = { 539 | a: { 540 | id: 'a', 541 | name: 'name of a', 542 | value: 7, 543 | }, 544 | b: { 545 | id: 'b', 546 | name: 'name of b', 547 | }, 548 | }; 549 | const payload: { [key: string]: Patch } = { 550 | a: { value: 7 }, 551 | }; 552 | 553 | const expected: Index = { ...target }; 554 | 555 | // ACT 556 | const result = patchEach(target, payload, parentDef); 557 | 558 | // ASSERT 559 | expect(result).to.deep.equal(expected); 560 | expect(result).to.equal(target); 561 | }); 562 | 563 | it('No-ops when no value is deleted', () => { 564 | // ARRANGE 565 | const target: Index = { 566 | a: { 567 | id: 'a', 568 | name: 'name of a', 569 | }, 570 | b: { 571 | id: 'b', 572 | name: 'name of b', 573 | }, 574 | }; 575 | const payload: { [key: string]: Patch } = { 576 | a: { value: DELETE_VALUE }, 577 | }; 578 | 579 | const expected: Index = { ...target }; 580 | 581 | // ACT 582 | const result = patchEach(target, payload, parentDef); 583 | 584 | // ASSERT 585 | expect(result).to.deep.equal(expected); 586 | expect(result).to.equal(target); 587 | }); 588 | }); 589 | }); 590 | -------------------------------------------------------------------------------- /src/functions/patch-each.ts: -------------------------------------------------------------------------------- 1 | import { Definition, Index, Patch } from '..'; 2 | import { patch } from './patch'; 3 | 4 | /** 5 | * Patches the objects in the `target` Index with each of the objects from the 6 | * `payload` array. Note that each of the objects in the supplied `payload` array 7 | * will be applied to the `target` Index similar to the `patch(target, key, payload, 8 | * definition)` function. 9 | * @param target The Index to be patched. 10 | * @param payload An array of objects that contain the patch data. The object in 11 | * the `target` object that is patched is determined by the key of the object 12 | * in the `payload` array. Properties and sub-properties must be included in the 13 | * supplied `definition` and any of its sub-definitions. Patch properties not 14 | * included in the `definition` will be ignored. Values may be removed from the 15 | * objects in the `target` Index by using the `DELETE_VALUE` symbol. 16 | * @param definition Defines the properties of the object in the `target` index 17 | * being patched so that immutable properties are not updated, required properties 18 | * are not removed, and extraneous properties are not added. 19 | * @returns If `target` is patched, then an updated shallow clone of `target` 20 | * is returned; otherwise, `target` is returned by reference. 21 | */ 22 | export function patchEach( 23 | target: Index, 24 | payload: Patch[], 25 | definition: Definition, 26 | ): Index; 27 | 28 | /** 29 | * Patches the objects in the `target` Index with each of the objects from the 30 | * `payload` Index. Note that each of the objects in the supplied `payload` Index 31 | * will be applied to the `target` Index similar to the `patch(target, key, payload, 32 | * definition)` function. 33 | * @param target The Index to be patched. 34 | * @param payload An Index of objects that contain the patch data. The objects in 35 | * the `target` object that are patched are determined by the keys within 36 | * in the `payload` Index. Properties and sub-properties must be included in the 37 | * supplied `definition` and any of its sub-definitions. Patch properties not 38 | * included in the `definition` will be ignored. Values may be removed from the 39 | * objects in the `target` Index by using the `DELETE_VALUE` symbol. 40 | * @param definition Defines the properties of the object in the `target` index 41 | * being patched so that immutable properties are not updated, required properties 42 | * are not removed, and extraneous properties are not added. 43 | * @returns If `target` is patched, then an updated shallow clone of `target` 44 | * is returned; otherwise, `target` is returned by reference. 45 | */ 46 | export function patchEach( 47 | target: Index, 48 | payload: Index>, 49 | definition: Definition, 50 | ): Index; 51 | 52 | export function patchEach(a, b, c?): Index { 53 | if (Array.isArray(b)) { 54 | return patchEachFromArray(a, b, c); 55 | } 56 | 57 | return patchEachFromIndex(a, b, c); 58 | } 59 | 60 | function patchEachFromArray( 61 | target: Index, 62 | payload: Patch[], 63 | definition: Definition, 64 | ): Index { 65 | if (!payload.length) return target; 66 | 67 | const updates: Index = {}; 68 | 69 | for (const item of payload) { 70 | const key = definition.getKey(item); 71 | if (!key) continue; 72 | 73 | const existingItem = target[key]; 74 | if (!existingItem) continue; 75 | 76 | const patchedItem = patch(existingItem, item, definition); 77 | if (patchedItem === existingItem) continue; 78 | 79 | updates[key] = patchedItem; 80 | } 81 | 82 | return Object.keys(updates).length 83 | ? Object.assign({}, target, updates) 84 | : target; 85 | } 86 | 87 | function patchEachFromIndex( 88 | target: Index, 89 | payload: Index>, 90 | definition: Definition, 91 | ): Index { 92 | if (!payload) return target; 93 | 94 | const updates: Index = {}; 95 | 96 | for (const key in payload) { 97 | const existingItem = target[key]; 98 | if (!existingItem) { 99 | const newItem = definition.getPayload(payload[key]); 100 | if (!newItem) continue; 101 | 102 | updates[key] = newItem; 103 | continue; 104 | } 105 | 106 | const patchedItem = patch(existingItem, payload[key], definition); 107 | if (patchedItem === existingItem) continue; 108 | 109 | updates[key] = patchedItem; 110 | } 111 | 112 | return Object.keys(updates).length 113 | ? Object.assign({}, target, updates) 114 | : target; 115 | } 116 | -------------------------------------------------------------------------------- /src/functions/patch.md: -------------------------------------------------------------------------------- 1 | # Patch 2 | 3 | Patch provides the ability to either partially update existing data or add new data. 4 | 5 | To use this function: 6 | 7 | ```js 8 | import { patch } from 'flux-standard-functions'; 9 | ``` 10 | 11 | ## patch(target, payload, definition) 12 | 13 | Parameters: 14 | 15 | * `target ` - The object to be patched. 16 | * `payload ` - An object that contains the patch data. Properties and sub-properties must be included in the supplied `definition` and any of its sub-definitions. Patch properties not included in the `definition` will be ignored. Values may be removed from the object in the `target` index by using the `DELETE_VALUE` symbol. 17 | * `definition ` - Defines the properties of the `target` being patched so that immutable properties are not updated, required properties are not removed, and extraneous properties are not added. 18 | 19 | Adds, updates, or deletes properties on the `target` object using values from the `payload`. If `target` is patched, then an updated shallow clone of `target` is returned; otherwise, `target` is returned by reference. 20 | 21 | The following is an example of a reducer that uses the `patch` function to patch the a "user" object from state. 22 | 23 | ```js 24 | function patchUser(state, action) { 25 | const target = state.user; // { id: 'abc', name: 'John Doe' } 26 | const payload = action.payload; // { email: 'john.doe@example.com' } 27 | 28 | const updatedUser = patch(target, payload, userDefinition); 29 | // => { id: 'abc', name: 'John Doe', email: 'john.doe@example.com' } 30 | 31 | return { 32 | ...state, 33 | user: updatedUser, 34 | }; 35 | } 36 | ``` 37 | 38 | ## patch(target, key, payload, definition) 39 | 40 | Parameters: 41 | 42 | * `target ` - The Index to be patched. 43 | * `key string|number` - The key of the object within the Index. 44 | * `payload ` - An object that contains the patch data. Properties and sub-properties must be included in the supplied `definition` and any of its sub-definitions. Patch properties not included in the `definition` will be ignored. Values may be removed from the object in the `target` Index by using the `DELETE_VALUE` symbol. 45 | * `definition ` - Defines the properties of the object in the `target` index being patched so that immutable properties are not updated, required properties are not removed, and extraneous properties are not added. 46 | 47 | Adds, updates, or deletes properties on an object in the `target` Index using values from the `payload`. If `target` is patched, then an updated shallow clone of `target` is returned; otherwise, `target` is returned by reference. 48 | 49 | The following is an example of a reducer that uses the `patch` function to patch an Index containing user data. 50 | 51 | ```js 52 | function patchUsers(state, action) { 53 | const target = state.users; 54 | /* 55 | { abc: { id: 'abc', name: 'John Doe' }}, 56 | { def: { id: 'def', name: 'Jane Porter' }} 57 | */ 58 | const key = action.payload.key; // 'abc' 59 | const payload = action.payload.data; // { email: 'john.doe@example.com' } 60 | 61 | const updatedUsers = patch(target, key, payload, userDefinition); 62 | /* 63 | { abc: { id: 'abc', name: 'John Doe', email: 'john.doe@example.com' }}, 64 | { def: { id: 'def', name: 'Jane Porter' }} 65 | */ 66 | 67 | return { 68 | ...state, 69 | users: updatedUsers, 70 | }; 71 | } 72 | ``` 73 | -------------------------------------------------------------------------------- /src/functions/patch.tests.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | 3 | import { 4 | array, 5 | define, 6 | DELETE_VALUE, 7 | key as keyRule, 8 | Index, 9 | indexOf, 10 | objectOf, 11 | optional, 12 | patch, 13 | Patch, 14 | required, 15 | } from '..'; 16 | 17 | type TestChildItem = { 18 | id: string; 19 | name: string; 20 | value?: number; 21 | }; 22 | 23 | const childDef = define({ 24 | id: keyRule(), 25 | name: required(), 26 | value: optional(), 27 | }); 28 | 29 | type TestItem = { 30 | id: string; 31 | name: string; 32 | value?: number; 33 | child?: TestChildItem; 34 | children?: Index; 35 | items?: number[]; 36 | }; 37 | 38 | const parentDef = define({ 39 | id: keyRule(), 40 | name: required(), 41 | value: optional(), 42 | child: optional(objectOf(childDef)), 43 | children: optional(indexOf(childDef)), 44 | items: optional(array()), 45 | }); 46 | 47 | describe('patch', () => { 48 | describe('target object', () => { 49 | describe('with primitive property value', () => { 50 | it('Adds a new value', () => { 51 | // ARRANGE 52 | const target: TestItem = { 53 | id: 'QWERTY', 54 | name: 'asdf', 55 | }; 56 | const payload: Patch = { 57 | value: 7, 58 | }; 59 | 60 | const expected: TestItem = { 61 | id: 'QWERTY', 62 | name: 'asdf', 63 | value: 7, 64 | }; 65 | 66 | // ACT 67 | const result = patch(target, payload, parentDef); 68 | 69 | // ASSERT 70 | expect(result).to.deep.equal(expected); 71 | }); 72 | 73 | it('Overwrites an existing value', () => { 74 | // ARRANGE 75 | const target: TestItem = { 76 | id: 'QWERTY', 77 | name: 'asdf', 78 | value: 7, 79 | }; 80 | const payload: Patch = { 81 | value: 18, 82 | }; 83 | 84 | const expected: TestItem = { 85 | id: 'QWERTY', 86 | name: 'asdf', 87 | value: 18, 88 | }; 89 | 90 | // ACT 91 | const result = patch(target, payload, parentDef); 92 | 93 | // ASSERT 94 | expect(result).to.deep.equal(expected); 95 | }); 96 | 97 | it('No-ops when payload is undefined', () => { 98 | // ARRANGE 99 | const target: TestItem = { 100 | id: 'QWERTY', 101 | name: 'asdf', 102 | value: 7, 103 | }; 104 | const payload: Patch = undefined; 105 | 106 | const expected: TestItem = { ...target }; 107 | 108 | // ACT 109 | const result = patch(target, payload, parentDef); 110 | 111 | // ASSERT 112 | expect(result).to.deep.equal(expected); 113 | expect(result).to.equal(target); 114 | }); 115 | 116 | it('No-ops when payload is null', () => { 117 | // ARRANGE 118 | const target: TestItem = { 119 | id: 'QWERTY', 120 | name: 'asdf', 121 | value: 7, 122 | }; 123 | const payload: Patch = null; 124 | 125 | const expected: TestItem = { ...target }; 126 | 127 | // ACT 128 | const result = patch(target, payload, parentDef); 129 | 130 | // ASSERT 131 | expect(result).to.deep.equal(expected); 132 | expect(result).to.equal(target); 133 | }); 134 | 135 | it('No-ops when value cannot be deleted', () => { 136 | // ARRANGE 137 | const target: TestItem = { 138 | id: 'QWERTY', 139 | name: 'asdf', 140 | value: 7, 141 | }; 142 | const payload: Patch = { 143 | id: DELETE_VALUE, 144 | }; 145 | 146 | const expected: TestItem = { ...target }; 147 | 148 | // ACT 149 | const result = patch(target, payload, parentDef); 150 | 151 | // ASSERT 152 | expect(result).to.deep.equal(expected); 153 | expect(result).to.equal(target); 154 | }); 155 | 156 | it('No-ops when value is not updated', () => { 157 | // ARRANGE 158 | const target: TestItem = { 159 | id: 'QWERTY', 160 | name: 'asdf', 161 | value: 7, 162 | }; 163 | const payload: Patch = { 164 | value: 7, 165 | }; 166 | 167 | const expected: TestItem = { ...target }; 168 | 169 | // ACT 170 | const result = patch(target, payload, parentDef); 171 | 172 | // ASSERT 173 | expect(result).to.deep.equal(expected); 174 | expect(result).to.equal(target); 175 | }); 176 | 177 | it('No-ops when value is not deleted', () => { 178 | // ARRANGE 179 | const target: TestItem = { 180 | id: 'QWERTY', 181 | name: 'asdf', 182 | }; 183 | const payload: Patch = { 184 | value: DELETE_VALUE, 185 | }; 186 | 187 | const expected: TestItem = { ...target }; 188 | 189 | // ACT 190 | const result = patch(target, payload, parentDef); 191 | 192 | // ASSERT 193 | expect(result).to.deep.equal(expected); 194 | expect(result).to.equal(target); 195 | }); 196 | }); 197 | 198 | describe('with object property value', () => { 199 | it('Adds a new object', () => { 200 | // ARRANGE 201 | const target: TestItem = { 202 | id: 'QWERTY', 203 | name: 'asdf', 204 | }; 205 | const payload: Patch = { 206 | child: { id: 'child_id', name: 'child name' }, 207 | }; 208 | 209 | const expected: TestItem = { 210 | id: 'QWERTY', 211 | name: 'asdf', 212 | child: { id: 'child_id', name: 'child name' }, 213 | }; 214 | 215 | // ACT 216 | const result = patch(target, payload, parentDef); 217 | 218 | // ASSERT 219 | expect(result).to.deep.equal(expected); 220 | }); 221 | 222 | it('Patches an existing object', () => { 223 | // ARRANGE 224 | const target: TestItem = { 225 | id: 'QWERTY', 226 | name: 'asdf', 227 | child: { id: 'child_id', name: 'child name' }, 228 | }; 229 | const payload: Patch = { 230 | child: { id: 'new id', name: 'new child name' }, 231 | }; 232 | 233 | const expected: TestItem = { 234 | id: 'QWERTY', 235 | name: 'asdf', 236 | child: { id: 'child_id', name: 'new child name' }, 237 | }; 238 | 239 | // ACT 240 | const result = patch(target, payload, parentDef); 241 | 242 | // ASSERT 243 | expect(result).to.deep.equal(expected); 244 | }); 245 | 246 | it('No-ops when payload is undefined', () => { 247 | // ARRANGE 248 | const target: TestItem = { 249 | id: 'QWERTY', 250 | name: 'asdf', 251 | }; 252 | const payload: Patch = undefined; 253 | 254 | const expected: TestItem = { ...target }; 255 | 256 | // ACT 257 | const result = patch(target, payload, parentDef); 258 | 259 | // ASSERT 260 | expect(result).to.deep.equal(expected); 261 | expect(result).to.equal(target); 262 | }); 263 | 264 | it('No-ops when payload is null', () => { 265 | // ARRANGE 266 | const target: TestItem = { 267 | id: 'QWERTY', 268 | name: 'asdf', 269 | }; 270 | const payload: Patch = null; 271 | 272 | const expected: TestItem = { ...target }; 273 | 274 | // ACT 275 | const result = patch(target, payload, parentDef); 276 | 277 | // ASSERT 278 | expect(result).to.deep.equal(expected); 279 | expect(result).to.equal(target); 280 | }); 281 | 282 | it('No-ops when new object is invalied', () => { 283 | // ARRANGE 284 | const target: TestItem = { 285 | id: 'QWERTY', 286 | name: 'asdf', 287 | }; 288 | const payload: Patch = { 289 | child: { id: 'child_id' }, 290 | }; 291 | 292 | const expected: TestItem = { ...target }; 293 | 294 | // ACT 295 | const result = patch(target, payload, parentDef); 296 | 297 | // ASSERT 298 | expect(result).to.deep.equal(expected); 299 | expect(result).to.equal(target); 300 | }); 301 | 302 | it('No-ops when patch only updates immutable child properties', () => { 303 | // ARRANGE 304 | const target: TestItem = { 305 | id: 'QWERTY', 306 | name: 'asdf', 307 | child: { id: 'child_id', name: 'child name' }, 308 | }; 309 | const payload: Patch = { 310 | child: { id: 'new child ID' }, 311 | }; 312 | 313 | const expected: TestItem = { ...target }; 314 | 315 | // ACT 316 | const result = patch(target, payload, parentDef); 317 | 318 | // ASSERT 319 | expect(result).to.deep.equal(expected); 320 | expect(result).to.equal(target); 321 | }); 322 | 323 | it('No-ops when value is not updated', () => { 324 | // ARRANGE 325 | const target: TestItem = { 326 | id: 'QWERTY', 327 | name: 'asdf', 328 | child: { id: 'child_id', name: 'child name' }, 329 | }; 330 | const payload: Patch = { 331 | child: { name: 'child name' }, 332 | }; 333 | 334 | const expected: TestItem = { ...target }; 335 | 336 | // ACT 337 | const result = patch(target, payload, parentDef); 338 | 339 | // ASSERT 340 | expect(result).to.deep.equal(expected); 341 | expect(result).to.equal(target); 342 | }); 343 | 344 | it('No-ops when value is not deleted', () => { 345 | // ARRANGE 346 | const target: TestItem = { 347 | id: 'QWERTY', 348 | name: 'asdf', 349 | child: { id: 'child_id', name: 'child name' }, 350 | }; 351 | const payload: Patch = { 352 | child: { value: DELETE_VALUE }, 353 | }; 354 | 355 | const expected: TestItem = { ...target }; 356 | 357 | // ACT 358 | const result = patch(target, payload, parentDef); 359 | 360 | // ASSERT 361 | expect(result).to.deep.equal(expected); 362 | expect(result).to.equal(target); 363 | }); 364 | }); 365 | 366 | describe('with index property value', () => { 367 | it('Adds a new index', () => { 368 | // ARRANGE 369 | const target: TestItem = { 370 | id: 'QWERTY', 371 | name: 'asdf', 372 | }; 373 | const payload: Patch = { 374 | children: { 375 | a: { id: 'a', name: 'child a' }, 376 | b: { id: 'b', name: 'child b' }, 377 | }, 378 | }; 379 | 380 | const expected: TestItem = { 381 | id: 'QWERTY', 382 | name: 'asdf', 383 | children: { 384 | a: { id: 'a', name: 'child a' }, 385 | b: { id: 'b', name: 'child b' }, 386 | }, 387 | }; 388 | 389 | // ACT 390 | const result = patch(target, payload, parentDef); 391 | 392 | // ASSERT 393 | expect(result).to.deep.equal(expected); 394 | }); 395 | 396 | it('Patches an existing index', () => { 397 | // ARRANGE 398 | const target: TestItem = { 399 | id: 'QWERTY', 400 | name: 'asdf', 401 | children: { 402 | a: { id: 'a', name: 'child a' }, 403 | b: { id: 'b', name: 'child b' }, 404 | }, 405 | }; 406 | const payload: Patch = { 407 | children: { 408 | a: { name: 'new child a name' }, 409 | }, 410 | }; 411 | 412 | const expected: TestItem = { 413 | id: 'QWERTY', 414 | name: 'asdf', 415 | children: { 416 | a: { id: 'a', name: 'new child a name' }, 417 | b: { id: 'b', name: 'child b' }, 418 | }, 419 | }; 420 | 421 | // ACT 422 | const result = patch(target, payload, parentDef); 423 | 424 | // ASSERT 425 | expect(result).to.deep.equal(expected); 426 | }); 427 | 428 | it('No-ops when new index is invalid', () => { 429 | // ARRANGE 430 | const target: TestItem = { 431 | id: 'QWERTY', 432 | name: 'asdf', 433 | }; 434 | const payload: Patch = { 435 | children: { a: { id: 'child_id' } }, 436 | }; 437 | 438 | const expected: TestItem = { ...target }; 439 | 440 | // ACT 441 | const result = patch(target, payload, parentDef); 442 | 443 | // ASSERT 444 | expect(result).to.deep.equal(expected); 445 | expect(result).to.equal(target); 446 | }); 447 | 448 | it('No-ops when new index updates an immutable child value', () => { 449 | // ARRANGE 450 | const target: TestItem = { 451 | id: 'QWERTY', 452 | name: 'asdf', 453 | children: { id: 'child_id', name: 'child name' }, 454 | }; 455 | const payload: Patch = { 456 | children: { a: { id: 'new child ID' } }, 457 | }; 458 | 459 | const expected: TestItem = { ...target }; 460 | 461 | // ACT 462 | const result = patch(target, payload, parentDef); 463 | 464 | // ASSERT 465 | expect(result).to.deep.equal(expected); 466 | expect(result).to.equal(target); 467 | }); 468 | 469 | it('No-ops when value is not updated', () => { 470 | // ARRANGE 471 | const target: TestItem = { 472 | id: 'QWERTY', 473 | name: 'asdf', 474 | children: { a: { id: 'child_id', name: 'child name' } }, 475 | }; 476 | const payload: Patch = { 477 | children: { a: { name: 'child name' } }, 478 | }; 479 | 480 | const expected: TestItem = { ...target }; 481 | 482 | // ACT 483 | const result = patch(target, payload, parentDef); 484 | 485 | // ASSERT 486 | expect(result).to.deep.equal(expected); 487 | expect(result).to.equal(target); 488 | }); 489 | 490 | it('No-ops when value is not deleted', () => { 491 | // ARRANGE 492 | const target: TestItem = { 493 | id: 'QWERTY', 494 | name: 'asdf', 495 | children: { a: { id: 'child_id', name: 'child name' } }, 496 | }; 497 | const payload: Patch = { 498 | children: { a: { value: DELETE_VALUE } }, 499 | }; 500 | 501 | const expected: TestItem = { ...target }; 502 | 503 | // ACT 504 | const result = patch(target, payload, parentDef); 505 | 506 | // ASSERT 507 | expect(result).to.deep.equal(expected); 508 | expect(result).to.equal(target); 509 | }); 510 | }); 511 | 512 | describe('with primitive array property', () => { 513 | it('Adds a new array', () => { 514 | // ARRANGE 515 | const target: TestItem = { 516 | id: 'QWERTY', 517 | name: 'asdf', 518 | }; 519 | const payload: Patch = { 520 | items: [7, 18], 521 | }; 522 | 523 | const expected: TestItem = { 524 | id: 'QWERTY', 525 | name: 'asdf', 526 | items: [7, 18], 527 | }; 528 | 529 | // ACT 530 | const result = patch(target, payload, parentDef); 531 | 532 | // ASSERT 533 | expect(result).to.deep.equal(expected); 534 | }); 535 | 536 | it('Patches an existing array', () => { 537 | // ARRANGE 538 | const target: TestItem = { 539 | id: 'QWERTY', 540 | name: 'asdf', 541 | items: [7], 542 | }; 543 | const payload: Patch = { 544 | items: [18], 545 | }; 546 | 547 | const expected: TestItem = { 548 | id: 'QWERTY', 549 | name: 'asdf', 550 | items: [7, 18], 551 | }; 552 | 553 | // ACT 554 | const result = patch(target, payload, parentDef); 555 | 556 | // ASSERT 557 | expect(result).to.deep.equal(expected); 558 | }); 559 | 560 | it('No-ops when new array is falsy', () => { 561 | // ARRANGE 562 | const target: TestItem = { 563 | id: 'QWERTY', 564 | name: 'asdf', 565 | items: [7], 566 | }; 567 | const payload: Patch = { 568 | items: null, 569 | }; 570 | 571 | const expected: TestItem = { ...target }; 572 | 573 | // ACT 574 | const result = patch(target, payload, parentDef); 575 | 576 | // ASSERT 577 | expect(result).to.deep.equal(expected); 578 | expect(result).to.equal(target); 579 | }); 580 | 581 | it('No-ops when array is not updated', () => { 582 | // ARRANGE 583 | const target: TestItem = { 584 | id: 'QWERTY', 585 | name: 'asdf', 586 | items: [7, 18], 587 | }; 588 | const payload: Patch = { 589 | items: [18], 590 | }; 591 | 592 | const expected: TestItem = { ...target }; 593 | 594 | // ACT 595 | const result = patch(target, payload, parentDef); 596 | 597 | // ASSERT 598 | expect(result).to.deep.equal(expected); 599 | expect(result).to.equal(target); 600 | }); 601 | }); 602 | 603 | it('Deletes an existing value', () => { 604 | // ARRANGE 605 | const target: TestItem = { 606 | id: 'QWERTY', 607 | name: 'asdf', 608 | value: 7, 609 | }; 610 | const payload: Patch = { 611 | value: DELETE_VALUE, 612 | }; 613 | 614 | const expected: TestItem = { 615 | id: 'QWERTY', 616 | name: 'asdf', 617 | }; 618 | 619 | // ACT 620 | const result = patch(target, payload, parentDef); 621 | 622 | // ASSERT 623 | expect(result).to.deep.equal(expected); 624 | }); 625 | }); 626 | 627 | describe('object in target index', () => { 628 | it('Deletes an existing value', () => { 629 | // ARRANGE 630 | const key = 'QWERTY'; 631 | 632 | const target: Index = { 633 | [key]: { 634 | id: key, 635 | name: 'asdf', 636 | value: 7, 637 | }, 638 | 'not-the-key': { 639 | id: 'not-the-key', 640 | name: 'asdf', 641 | }, 642 | }; 643 | const payload: Patch = { 644 | value: DELETE_VALUE, 645 | }; 646 | 647 | const expected: Index = { 648 | [key]: { 649 | id: key, 650 | name: 'asdf', 651 | }, 652 | 'not-the-key': { 653 | id: 'not-the-key', 654 | name: 'asdf', 655 | }, 656 | }; 657 | 658 | // ACT 659 | const result = patch(target, key, payload, parentDef); 660 | 661 | // ASSERT 662 | expect(result).to.deep.equal(expected); 663 | }); 664 | 665 | describe('with primitive property value', () => { 666 | it('Adds a new value', () => { 667 | // ARRANGE 668 | 669 | const key = 'QWERTY'; 670 | 671 | const target: Index = { 672 | [key]: { 673 | id: key, 674 | name: 'asdf', 675 | }, 676 | 'not-the-key': { 677 | id: 'not-the-key', 678 | name: 'asdf', 679 | }, 680 | }; 681 | const payload: Patch = { 682 | value: 7, 683 | }; 684 | 685 | const expected: Index = { 686 | [key]: { 687 | id: key, 688 | name: 'asdf', 689 | value: 7, 690 | }, 691 | 'not-the-key': { 692 | id: 'not-the-key', 693 | name: 'asdf', 694 | }, 695 | }; 696 | 697 | // ACT 698 | const result = patch(target, key, payload, parentDef); 699 | 700 | // ASSERT 701 | expect(result).to.deep.equal(expected); 702 | }); 703 | 704 | it('Overwrites an existing value', () => { 705 | // ARRANGE 706 | const key = 'QWERTY'; 707 | 708 | const target: Index = { 709 | [key]: { 710 | id: key, 711 | name: 'asdf', 712 | value: 7, 713 | }, 714 | 'not-the-key': { 715 | id: 'not-the-key', 716 | name: 'asdf', 717 | }, 718 | }; 719 | const payload: Patch = { 720 | value: 18, 721 | }; 722 | 723 | const expected: Index = { 724 | [key]: { 725 | id: key, 726 | name: 'asdf', 727 | value: 18, 728 | }, 729 | 'not-the-key': { 730 | id: 'not-the-key', 731 | name: 'asdf', 732 | }, 733 | }; 734 | 735 | // ACT 736 | const result = patch(target, key, payload, parentDef); 737 | 738 | // ASSERT 739 | expect(result).to.deep.equal(expected); 740 | }); 741 | 742 | it('No-ops when patch is invalid', () => { 743 | // ARRANGE 744 | const key = 'QWERTY'; 745 | 746 | const target: Index = { 747 | [key]: { 748 | id: key, 749 | name: 'asdf', 750 | value: 7, 751 | }, 752 | 'not-the-key': { 753 | id: 'not-the-key', 754 | name: 'asdf', 755 | }, 756 | }; 757 | const payload: Patch = { 758 | id: 'THE NEW ID', 759 | }; 760 | 761 | const expected: Index = { ...target }; 762 | 763 | // ACT 764 | const result = patch(target, key, payload, parentDef); 765 | 766 | // ASSERT 767 | expect(result).to.deep.equal(expected); 768 | expect(result).to.equal(target); 769 | }); 770 | 771 | it('No-ops when value is not updated', () => { 772 | // ARRANGE 773 | const key = 'QWERTY'; 774 | 775 | const target: Index = { 776 | [key]: { 777 | id: key, 778 | name: 'asdf', 779 | }, 780 | 'not-the-key': { 781 | id: 'not-the-key', 782 | name: 'asdf', 783 | }, 784 | }; 785 | const payload: Patch = { 786 | name: 'asdf', 787 | }; 788 | 789 | const expected: Index = { ...target }; 790 | 791 | // ACT 792 | const result = patch(target, key, payload, parentDef); 793 | 794 | // ASSERT 795 | expect(result).to.deep.equal(expected); 796 | expect(result).to.equal(target); 797 | }); 798 | 799 | it('No-ops when value is not deleted', () => { 800 | // ARRANGE 801 | const key = 'QWERTY'; 802 | 803 | const target: Index = { 804 | [key]: { 805 | id: key, 806 | name: 'asdf', 807 | }, 808 | 'not-the-key': { 809 | id: 'not-the-key', 810 | name: 'asdf', 811 | }, 812 | }; 813 | 814 | const payload: Patch = { 815 | value: DELETE_VALUE, 816 | }; 817 | 818 | const expected: Index = { ...target }; 819 | 820 | // ACT 821 | const result = patch(target, key, payload, parentDef); 822 | 823 | // ASSERT 824 | expect(result).to.deep.equal(expected); 825 | expect(result).to.equal(target); 826 | }); 827 | }); 828 | 829 | describe('with object property value', () => { 830 | it('Adds a new object', () => { 831 | // ARRANGE 832 | const key = 'QWERTY'; 833 | 834 | const target: Index = { 835 | [key]: { 836 | id: key, 837 | name: 'asdf', 838 | }, 839 | 'not-the-key': { 840 | id: 'not-the-key', 841 | name: 'asdf', 842 | }, 843 | }; 844 | const payload: Patch = { 845 | child: { id: 'child-id', name: 'child-name' }, 846 | }; 847 | 848 | const expected: Index = { 849 | [key]: { 850 | id: key, 851 | name: 'asdf', 852 | child: { id: 'child-id', name: 'child-name' }, 853 | }, 854 | 'not-the-key': { 855 | id: 'not-the-key', 856 | name: 'asdf', 857 | }, 858 | }; 859 | 860 | // ACT 861 | const result = patch(target, key, payload, parentDef); 862 | 863 | // ASSERT 864 | expect(result).to.deep.equal(expected); 865 | }); 866 | 867 | it('Patches an existing object', () => { 868 | // ARRANGE 869 | const key = 'QWERTY'; 870 | 871 | const target: Index = { 872 | [key]: { 873 | id: key, 874 | name: 'asdf', 875 | child: { id: 'child-id', name: 'child-name' }, 876 | }, 877 | 'not-the-key': { 878 | id: 'not-the-key', 879 | name: 'asdf', 880 | }, 881 | }; 882 | const payload: Patch = { 883 | child: { name: 'new child name' }, 884 | }; 885 | 886 | const expected: Index = { 887 | [key]: { 888 | id: key, 889 | name: 'asdf', 890 | child: { id: 'child-id', name: 'new child name' }, 891 | }, 892 | 'not-the-key': { 893 | id: 'not-the-key', 894 | name: 'asdf', 895 | }, 896 | }; 897 | 898 | // ACT 899 | const result = patch(target, key, payload, parentDef); 900 | 901 | // ASSERT 902 | expect(result).to.deep.equal(expected); 903 | }); 904 | 905 | it('No-ops when value updates an immutable property', () => { 906 | // ARRANGE 907 | const key = 'QWERTY'; 908 | 909 | const target: Index = { 910 | [key]: { 911 | id: key, 912 | name: 'asdf', 913 | }, 914 | 'not-the-key': { 915 | id: 'not-the-key', 916 | name: 'asdf', 917 | }, 918 | }; 919 | const payload: Patch = { 920 | child: { id: 'child-id' }, 921 | }; 922 | 923 | const expected: Index = { ...target }; 924 | 925 | // ACT 926 | const result = patch(target, key, payload, parentDef); 927 | 928 | // ASSERT 929 | expect(result).to.deep.equal(expected); 930 | expect(result).to.equal(target); 931 | }); 932 | 933 | it('No-ops when value is not updated', () => { 934 | // ARRANGE 935 | const key = 'QWERTY'; 936 | 937 | const target: Index = { 938 | [key]: { 939 | id: key, 940 | name: 'asdf', 941 | child: { id: 'child-id', name: 'child-name', value: 7 }, 942 | }, 943 | 'not-the-key': { 944 | id: 'not-the-key', 945 | name: 'asdf', 946 | }, 947 | }; 948 | const payload: Patch = { 949 | child: { value: 7 }, 950 | }; 951 | 952 | const expected: Index = { ...target }; 953 | 954 | // ACT 955 | const result = patch(target, key, payload, parentDef); 956 | 957 | // ASSERT 958 | expect(result).to.deep.equal(expected); 959 | expect(result).to.equal(target); 960 | }); 961 | 962 | it('No-ops when value is not deleted', () => { 963 | // ARRANGE 964 | const key = 'QWERTY'; 965 | 966 | const target: Index = { 967 | [key]: { 968 | id: key, 969 | name: 'asdf', 970 | child: { id: 'child-id', name: 'child-name' }, 971 | }, 972 | 'not-the-key': { 973 | id: 'not-the-key', 974 | name: 'asdf', 975 | }, 976 | }; 977 | const payload: Patch = { 978 | child: { value: DELETE_VALUE }, 979 | }; 980 | const expected: Index = { ...target }; 981 | 982 | // ACT 983 | const result = patch(target, key, payload, parentDef); 984 | 985 | // ASSERT 986 | expect(result).to.deep.equal(expected); 987 | expect(result).to.equal(target); 988 | }); 989 | }); 990 | 991 | describe('with index property value', () => { 992 | it('Adds a new index', () => { 993 | // ARRANGE 994 | const key = 'QWERTY'; 995 | 996 | const target: Index = { 997 | [key]: { 998 | id: key, 999 | name: 'asdf', 1000 | }, 1001 | 'not-the-key': { 1002 | id: 'not-the-key', 1003 | name: 'asdf', 1004 | }, 1005 | }; 1006 | const payload: Patch = { 1007 | children: { 1008 | a: { id: 'a', name: 'child-value-a' }, 1009 | b: { id: 'b', name: 'child-value-b' }, 1010 | }, 1011 | }; 1012 | 1013 | const expected: Index = { 1014 | [key]: { 1015 | id: key, 1016 | name: 'asdf', 1017 | children: { 1018 | a: { id: 'a', name: 'child-value-a' }, 1019 | b: { id: 'b', name: 'child-value-b' }, 1020 | }, 1021 | }, 1022 | 'not-the-key': { 1023 | id: 'not-the-key', 1024 | name: 'asdf', 1025 | }, 1026 | }; 1027 | 1028 | // ACT 1029 | const result = patch(target, key, payload, parentDef); 1030 | 1031 | // ASSERT 1032 | expect(result).to.deep.equal(expected); 1033 | }); 1034 | 1035 | it('Patches an existing object', () => { 1036 | // ARRANGE 1037 | const key = 'QWERTY'; 1038 | 1039 | const target: Index = { 1040 | [key]: { 1041 | id: key, 1042 | name: 'asdf', 1043 | children: { 1044 | a: { id: 'a', name: 'child-value-a' }, 1045 | b: { id: 'b', name: 'child-value-b' }, 1046 | }, 1047 | }, 1048 | 'not-the-key': { 1049 | id: 'not-the-key', 1050 | name: 'asdf', 1051 | }, 1052 | }; 1053 | const payload: Patch = { 1054 | children: { 1055 | b: { name: 'new child-value-b' }, 1056 | }, 1057 | }; 1058 | 1059 | const expected: Index = { 1060 | [key]: { 1061 | id: key, 1062 | name: 'asdf', 1063 | children: { 1064 | a: { id: 'a', name: 'child-value-a' }, 1065 | b: { id: 'b', name: 'new child-value-b' }, 1066 | }, 1067 | }, 1068 | 'not-the-key': { 1069 | id: 'not-the-key', 1070 | name: 'asdf', 1071 | }, 1072 | }; 1073 | 1074 | // ACT 1075 | const result = patch(target, key, payload, parentDef); 1076 | 1077 | // ASSERT 1078 | expect(result).to.deep.equal(expected); 1079 | }); 1080 | 1081 | it('No-ops when index values are invalid', () => { 1082 | // ARRANGE 1083 | const key = 'QWERTY'; 1084 | 1085 | const target: Index = { 1086 | [key]: { 1087 | id: key, 1088 | name: 'asdf', 1089 | }, 1090 | 'not-the-key': { 1091 | id: 'not-the-key', 1092 | name: 'asdf', 1093 | }, 1094 | }; 1095 | const payload: Patch = { 1096 | children: { 1097 | a: { id: 'a' }, 1098 | b: { id: 'b' }, 1099 | }, 1100 | }; 1101 | 1102 | const expected: Index = { ...target }; 1103 | 1104 | // ACT 1105 | const result = patch(target, key, payload, parentDef); 1106 | 1107 | // ASSERT 1108 | expect(result).to.deep.equal(expected); 1109 | expect(result).to.equal(target); 1110 | }); 1111 | 1112 | it('No-ops when new index value updates an immutable value', () => { 1113 | // ARRANGE 1114 | const key = 'QWERTY'; 1115 | 1116 | const target: Index = { 1117 | [key]: { 1118 | id: key, 1119 | name: 'asdf', 1120 | children: { 1121 | a: { id: 'a', name: 'child-value-a' }, 1122 | b: { id: 'b', name: 'child-value-b' }, 1123 | }, 1124 | }, 1125 | 'not-the-key': { 1126 | id: 'not-the-key', 1127 | name: 'asdf', 1128 | }, 1129 | }; 1130 | const payload: Patch = { 1131 | children: { 1132 | b: { id: 'new id' }, 1133 | }, 1134 | }; 1135 | 1136 | const expected: Index = { ...target }; 1137 | 1138 | // ACT 1139 | const result = patch(target, key, payload, parentDef); 1140 | 1141 | // ASSERT 1142 | expect(result).to.deep.equal(expected); 1143 | expect(result).to.equal(target); 1144 | }); 1145 | 1146 | it('No-ops when value is not updated', () => { 1147 | // ARRANGE 1148 | const key = 'QWERTY'; 1149 | 1150 | const target: Index = { 1151 | [key]: { 1152 | id: key, 1153 | name: 'asdf', 1154 | children: { 1155 | a: { id: 'a', name: 'child-value-a' }, 1156 | b: { id: 'b', name: 'child-value-b' }, 1157 | }, 1158 | }, 1159 | 'not-the-key': { 1160 | id: 'not-the-key', 1161 | name: 'asdf', 1162 | }, 1163 | }; 1164 | const payload: Patch = { 1165 | children: { 1166 | b: { name: 'child-value-b' }, 1167 | }, 1168 | }; 1169 | 1170 | const expected: Index = { ...target }; 1171 | 1172 | // ACT 1173 | const result = patch(target, key, payload, parentDef); 1174 | 1175 | // ASSERT 1176 | expect(result).to.deep.equal(expected); 1177 | expect(result).to.equal(target); 1178 | }); 1179 | 1180 | it('No-ops when value is not deleted', () => { 1181 | // ARRANGE 1182 | const key = 'QWERTY'; 1183 | 1184 | const target: Index = { 1185 | [key]: { 1186 | id: key, 1187 | name: 'asdf', 1188 | children: { 1189 | a: { id: 'a', name: 'child-value-a' }, 1190 | b: { id: 'b', name: 'child-value-b' }, 1191 | }, 1192 | }, 1193 | 'not-the-key': { 1194 | id: 'not-the-key', 1195 | name: 'asdf', 1196 | }, 1197 | }; 1198 | const payload: Patch = { 1199 | children: { 1200 | b: { value: DELETE_VALUE }, 1201 | }, 1202 | }; 1203 | 1204 | const expected: Index = { ...target }; 1205 | 1206 | // ACT 1207 | const result = patch(target, key, payload, parentDef); 1208 | 1209 | // ASSERT 1210 | expect(result).to.deep.equal(expected); 1211 | expect(result).to.equal(target); 1212 | }); 1213 | }); 1214 | 1215 | describe('with primitive array property', () => { 1216 | it('Adds a new array', () => { 1217 | // ARRANGE 1218 | const key = 'QWERTY'; 1219 | 1220 | const target: Index = { 1221 | [key]: { 1222 | id: key, 1223 | name: 'asdf', 1224 | }, 1225 | 'not-the-key': { 1226 | id: 'not-the-key', 1227 | name: 'asdf', 1228 | }, 1229 | }; 1230 | const payload: Patch = { 1231 | items: [1, 2, 3], 1232 | }; 1233 | 1234 | const expected: Index = { 1235 | [key]: { 1236 | id: key, 1237 | name: 'asdf', 1238 | items: [1, 2, 3], 1239 | }, 1240 | 'not-the-key': { 1241 | id: 'not-the-key', 1242 | name: 'asdf', 1243 | }, 1244 | }; 1245 | 1246 | // ACT 1247 | const result = patch(target, key, payload, parentDef); 1248 | 1249 | // ASSERT 1250 | expect(result).to.deep.equal(expected); 1251 | }); 1252 | 1253 | it('Patches an existing array', () => { 1254 | // ARRANGE 1255 | const key = 'QWERTY'; 1256 | 1257 | const target: Index = { 1258 | [key]: { 1259 | id: key, 1260 | name: 'asdf', 1261 | items: [1, 2, 3], 1262 | }, 1263 | 'not-the-key': { 1264 | id: 'not-the-key', 1265 | name: 'asdf', 1266 | }, 1267 | }; 1268 | const payload: Patch = { 1269 | items: [4, 5], 1270 | }; 1271 | 1272 | const expected: Index = { 1273 | [key]: { 1274 | id: key, 1275 | name: 'asdf', 1276 | items: [1, 2, 3, 4, 5], 1277 | }, 1278 | 'not-the-key': { 1279 | id: 'not-the-key', 1280 | name: 'asdf', 1281 | }, 1282 | }; 1283 | 1284 | // ACT 1285 | const result = patch(target, key, payload, parentDef); 1286 | 1287 | // ASSERT 1288 | expect(result).to.deep.equal(expected); 1289 | }); 1290 | 1291 | it('No-ops when payload is not an array', () => { 1292 | // ARRANGE 1293 | const key = 'QWERTY'; 1294 | 1295 | const target: Index = { 1296 | [key]: { 1297 | id: key, 1298 | name: 'asdf', 1299 | items: [1, 2, 3], 1300 | }, 1301 | 'not-the-key': { 1302 | id: 'not-the-key', 1303 | name: 'asdf', 1304 | }, 1305 | }; 1306 | const payload: Patch = { 1307 | items: '4,5', 1308 | }; 1309 | 1310 | const expected: Index = { ...target }; 1311 | 1312 | // ACT 1313 | const result = patch(target, key, payload, parentDef); 1314 | 1315 | // ASSERT 1316 | expect(result).to.deep.equal(expected); 1317 | expect(result).to.equal(target); 1318 | }); 1319 | 1320 | it('No-ops when array is not updated', () => { 1321 | // ARRANGE 1322 | const key = 'QWERTY'; 1323 | 1324 | const target: Index = { 1325 | [key]: { 1326 | id: key, 1327 | name: 'asdf', 1328 | items: [1, 2, 3], 1329 | }, 1330 | 'not-the-key': { 1331 | id: 'not-the-key', 1332 | name: 'asdf', 1333 | }, 1334 | }; 1335 | const payload: Patch = { 1336 | items: [2, 3], 1337 | }; 1338 | 1339 | const expected: Index = { ...target }; 1340 | 1341 | // ACT 1342 | const result = patch(target, key, payload, parentDef); 1343 | 1344 | // ASSERT 1345 | expect(result).to.deep.equal(expected); 1346 | expect(result).to.equal(target); 1347 | }); 1348 | }); 1349 | }); 1350 | }); 1351 | -------------------------------------------------------------------------------- /src/functions/patch.ts: -------------------------------------------------------------------------------- 1 | import { Definition, Index, Patch } from '..'; 2 | import { DELETE_VALUE, Primitive } from '../types'; 3 | import { patchEach } from './patch-each'; 4 | import { setEach } from './set-each'; 5 | 6 | /** 7 | * Adds, updates, or deletes properties on the `target` object using values 8 | * from the `payload`. 9 | * @param target The object to be patched. 10 | * @param payload An object that contains the patch data. Properties and 11 | * sub-properties must be included in the supplied `definition` and any of its 12 | * sub-definitions. Patch properties not included in the `definition` will 13 | * be ignored. Values may be removed from the `target` object by using the 14 | * `DELETE_VALUE` symbol. 15 | * @param definition Defines the properties of the `target` being patched so 16 | * that immutable properties are not updated, required properties are not 17 | * removed, and extraneous properties are not added. 18 | * @returns If `target` is patched, then an updated shallow clone of `target` 19 | * is returned; otherwise, `target` is returned by reference. 20 | */ 21 | export function patch( 22 | target: T, 23 | payload: Patch, 24 | definition: Definition, 25 | ): T; 26 | 27 | /** 28 | * Adds, updates, or deletes properties on an object in the `target` Index using values 29 | * from the `payload`. 30 | * @param target The Index to be patched. 31 | * @param key The key of the object within the Index. 32 | * @param payload An object that contains the patch data. Properties and 33 | * sub-properties must be included in the supplied `definition` and any of its 34 | * sub-definitions. Patch properties not included in the `definition` will 35 | * be ignored. Values may be removed from the object in the `target` index by 36 | * using the `DELETE_VALUE` symbol. 37 | * @param definition Defines the properties of the object in the `target` index 38 | * being patched so that immutable properties are not updated, required properties 39 | * are not removed, and extraneous properties are not added. 40 | * @returns If `target` is patched, then an updated shallow clone of `target` 41 | * is returned; otherwise, `target` is returned by reference. 42 | */ 43 | export function patch( 44 | target: Index, 45 | key: string | number, 46 | payload: Patch, 47 | definition: Definition, 48 | ): Index; 49 | export function patch(a, b, c?, d?): T | Index { 50 | if (d) { 51 | return patchIndex(a, b, c, d); 52 | } 53 | 54 | return patchObject(a, b, c); 55 | } 56 | 57 | function patchObject( 58 | target: T, 59 | payload: Patch, 60 | definition: Definition, 61 | ): T { 62 | if (typeof payload === 'undefined' || payload === null) return target; 63 | 64 | const patchValue = definition.getPatch(payload); 65 | 66 | if (!patchValue) return target; 67 | 68 | let patched = false; 69 | const result: T = Object.assign({}, target); 70 | 71 | for (const key in patchValue) { 72 | const hasExistingValue = typeof result[key] !== 'undefined'; 73 | if ( 74 | target[key] === patchValue[key] || 75 | (patchValue[key] === DELETE_VALUE && !hasExistingValue) 76 | ) { 77 | continue; 78 | } 79 | if (patchValue[key] === DELETE_VALUE && hasExistingValue) { 80 | delete result[key]; 81 | patched = true; 82 | } else { 83 | const childDefinitions = definition.getDefinitions(key); 84 | 85 | if (childDefinitions && childDefinitions.index) { 86 | if (hasExistingValue) { 87 | const originalValue = result[key] as any; 88 | const childPatch = patchValue[key]; 89 | 90 | const newValue = patchEach( 91 | originalValue, 92 | childPatch, 93 | childDefinitions.index, 94 | ); 95 | if (newValue !== originalValue) { 96 | result[key] = newValue as any; 97 | patched = true; 98 | } 99 | } else { 100 | const originalValue = {}; 101 | const childPatch = patchValue[key]; 102 | 103 | const newValue = patchEach( 104 | originalValue, 105 | childPatch, 106 | childDefinitions.index, 107 | ); 108 | 109 | if (newValue !== originalValue) { 110 | result[key] = newValue as any; 111 | patched = true; 112 | } 113 | } 114 | } else if (childDefinitions && childDefinitions.object) { 115 | if (hasExistingValue) { 116 | const originalValue = result[key]; 117 | const childPatch = patchValue[key]; 118 | 119 | const newValue = patch( 120 | originalValue, 121 | childPatch, 122 | childDefinitions.object, 123 | ); 124 | 125 | if (newValue !== originalValue) { 126 | result[key] = newValue; 127 | patched = true; 128 | } 129 | } else { 130 | const childObject = childDefinitions.object.getPayload( 131 | patchValue[key], 132 | ); 133 | 134 | if (childObject) { 135 | result[key] = childObject; 136 | patched = true; 137 | } 138 | } 139 | } else if (childDefinitions && childDefinitions.isArray) { 140 | if (hasExistingValue) { 141 | const originalValue = result[key] as any; 142 | const childPatch = patchValue[key]; 143 | 144 | const newValue = setEach(originalValue, childPatch) as any; 145 | 146 | if (newValue !== originalValue) { 147 | result[key] = newValue; 148 | patched = true; 149 | } 150 | } else { 151 | result[key] = patchValue[key]; 152 | patched = true; 153 | } 154 | } else { 155 | result[key] = patchValue[key]; 156 | patched = true; 157 | } 158 | } 159 | } 160 | 161 | return patched ? result : target; 162 | } 163 | 164 | function patchIndex( 165 | target: Index, 166 | key: string | number, 167 | payload: Patch, 168 | definition: Definition, 169 | ): Index { 170 | if (typeof payload === 'undefined' || payload === null) return target; 171 | 172 | const item = target[key]; 173 | 174 | if (!item) return target; 175 | 176 | const patchedItem = patch(item, payload, definition); 177 | 178 | if (item === patchedItem) return target; 179 | 180 | return Object.assign({}, target, { [key]: patchedItem }); 181 | } 182 | -------------------------------------------------------------------------------- /src/functions/set-each.md: -------------------------------------------------------------------------------- 1 | # Set Each 2 | 3 | Set Each provides the ability to either add or overwrite data to multiple items at once. 4 | 5 | To use this function: 6 | 7 | ```js 8 | import { setEach } from 'flux-standard-functions'; 9 | ``` 10 | 11 | ## setEach(target, payload) 12 | 13 | Parameters: 14 | 15 | * `target ` - Array of primitive values. 16 | * `payload ` - The Array of new primitive values to add. 17 | 18 | Adds new values to a primitive array (strings, numbers, booleans, or Symbols). If any of the `payload` values did not already exist in the `target` the a new Array is returned; otherwise, the original `target` is returned by reference. The resulting array will not contain any duplicate values. 19 | 20 | Example: 21 | 22 | ```js 23 | function setFavoriteNumber(state, action) { 24 | const target = state.favoriteNumbers; // [ 4, 8, 15, 16 ] 25 | const payload = action.payload; // [ 16, 23, 42 ] 26 | 27 | const updatedFavoriteNumbers = setEach(target, payload); // => [ 4, 8, 15, 16, 23, 42 ] 28 | 29 | return { 30 | ...state, 31 | favoriteNumbers: updatedFavoriteNumbers, 32 | }; 33 | } 34 | ``` 35 | 36 | ## setEach(target, payload, definition) 37 | 38 | Parameters: 39 | 40 | * `target ` - The Index to update. 41 | * `payload ` - An Array or Index of objects to add to the `target` Index. If the `payload` is an Array, then the property of the object that is defined as the `key()` is used to determine the Index key. If the `payload` is an Index, then the keys of the `payload` object are matched to the keys of the `target` object. 42 | * `definition ` - Defines the properties of the `target` object. The definition must contain a `key()` property. 43 | 44 | Adds or replaces multiple objects within the `target` Index. If the object in the `target` Index was added or replaced, then an updated shallow clone of the `target` Index is returned. If nothing changed (eg. all of the objects in the `payload` array were invalid per the `definition`) then the original `target` is returned by reference. 45 | 46 | Example of a reducer that adds or overwrite from an Array: 47 | 48 | ```js 49 | function setEachUser(state, action) { 50 | const target = state.users; 51 | /* 52 | { 53 | { def: { id: 'def', name: 'Jane Porter', email: 'jane.porter@example.com' }}, 54 | { jkl: { id: 'jkl', name: 'Eric Tile' }}, 55 | } 56 | */ 57 | const payload = action.payload; 58 | /* 59 | [ 60 | { id: 'abc', name: 'John Doe' }, 61 | { id: 'def', name: 'Jane Clayton' }, 62 | ] 63 | */ 64 | 65 | const updatedUsers = setEach(target, payload, userDefinition); 66 | /* 67 | { 68 | { abc: { id: 'abc', name: 'John Doe' }}, 69 | { def: { id: 'def', name: 'Jane Clayton' }}, 70 | { jkl: { id: 'jkl', name: 'Eric Tile' }}, 71 | } 72 | */ 73 | 74 | return { 75 | ...state, 76 | users: updatedUsers, 77 | }; 78 | } 79 | ``` 80 | 81 | Example of a reducer that adds or overwrite from an Index: 82 | 83 | ```js 84 | function setEachUser(state, action) { 85 | const target = state.users; 86 | /* 87 | { 88 | { def: { id: 'def', name: 'Jane Porter', email: 'jane.porter@example.com' }}, 89 | { jkl: { id: 'jkl', name: 'Eric Tile' }}, 90 | } 91 | */ 92 | const payload = action.payload; 93 | /* 94 | { 95 | { abc: { id: 'abc', name: 'John Doe' }}, 96 | { def: { id: 'def', name: 'Jane Clayton' }}, 97 | } 98 | */ 99 | 100 | const updatedUsers = setEach(target, payload, userDefinition); 101 | /* 102 | { 103 | { abc: { id: 'abc', name: 'John Doe' }}, 104 | { def: { id: 'def', name: 'Jane Clayton' }}, 105 | { jkl: { id: 'jkl', name: 'Eric Tile' }}, 106 | } 107 | */ 108 | 109 | return { 110 | ...state, 111 | users: updatedUsers, 112 | }; 113 | } 114 | ``` 115 | -------------------------------------------------------------------------------- /src/functions/set-each.tests.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | 3 | import { 4 | Index, 5 | define, 6 | key as keyRule, 7 | optional, 8 | required, 9 | objectOf, 10 | indexOf, 11 | array, 12 | setEach, 13 | } from '..'; 14 | 15 | type TestChildItem = { 16 | id: string; 17 | name: string; 18 | value?: number; 19 | }; 20 | 21 | const childDef = define({ 22 | id: keyRule(), 23 | name: required(), 24 | value: optional(), 25 | }); 26 | 27 | type TestItem = { 28 | id: string; 29 | name: string; 30 | value?: number; 31 | child?: TestChildItem; 32 | children?: Index; 33 | items?: number[]; 34 | }; 35 | 36 | const parentDef = define({ 37 | id: keyRule(), 38 | name: required(), 39 | value: optional(), 40 | child: optional(objectOf(childDef)), 41 | children: optional(indexOf(childDef)), 42 | items: optional(array()), 43 | }); 44 | 45 | describe('setEach', () => { 46 | describe('primitive array', () => { 47 | it('Adds new values', () => { 48 | // ARRANGE 49 | const target = [1, 2, 3]; 50 | const payload = [4, 5]; 51 | 52 | // ACT 53 | const result = setEach(target, payload); 54 | 55 | // ASSERT 56 | expect(result).to.have.members([1, 2, 3, 4, 5]); 57 | }); 58 | 59 | it('No-ops when adding existing values', () => { 60 | // ARRANGE 61 | const target = [1, 2, 3]; 62 | const payload = [2, 3]; 63 | 64 | // ACT 65 | const result = setEach(target, payload); 66 | 67 | // ASSERT 68 | expect(result).to.have.members([1, 2, 3]); 69 | expect(result).to.equal(target); 70 | }); 71 | 72 | it('No-ops when payload is undefined', () => { 73 | // ARRANGE 74 | const target = [1, 2, 3]; 75 | const payload = undefined; 76 | 77 | // ACT 78 | const result = setEach(target, payload); 79 | 80 | // ASSERT 81 | expect(result).to.have.members([1, 2, 3]); 82 | expect(result).to.equal(target); 83 | }); 84 | 85 | it('No-ops when payload is null', () => { 86 | // ARRANGE 87 | const target = [1, 2, 3]; 88 | const payload = null; 89 | 90 | // ACT 91 | const result = setEach(target, payload); 92 | 93 | // ASSERT 94 | expect(result).to.have.members([1, 2, 3]); 95 | expect(result).to.equal(target); 96 | }); 97 | }); 98 | 99 | describe('from array', () => { 100 | it('Adds new items', () => { 101 | // ARRANGE 102 | const target: Index = { 103 | a: { 104 | id: 'a', 105 | name: 'original name of a', 106 | value: 7, 107 | }, 108 | }; 109 | const payload: TestItem[] = [ 110 | { 111 | id: 'b', 112 | name: 'name of b', 113 | }, 114 | ]; 115 | 116 | const expected: Index = { 117 | a: { 118 | id: 'a', 119 | name: 'original name of a', 120 | value: 7, 121 | }, 122 | b: { 123 | id: 'b', 124 | name: 'name of b', 125 | }, 126 | }; 127 | 128 | // ACT 129 | const result = setEach(target, payload, parentDef); 130 | 131 | // ASSERT 132 | expect(result).to.deep.equal(expected); 133 | }); 134 | 135 | it('Overwrites existing items', () => { 136 | // ARRANGE 137 | const target: Index = { 138 | a: { 139 | id: 'a', 140 | name: 'original name of a', 141 | value: 7, 142 | }, 143 | b: { 144 | id: 'b', 145 | name: 'name of b', 146 | }, 147 | }; 148 | const payload: TestItem[] = [ 149 | { 150 | id: 'a', 151 | name: 'new name of a', 152 | }, 153 | ]; 154 | 155 | const expected: Index = { 156 | a: { 157 | id: 'a', 158 | name: 'new name of a', 159 | }, 160 | b: { 161 | id: 'b', 162 | name: 'name of b', 163 | }, 164 | }; 165 | 166 | // ACT 167 | const result = setEach(target, payload, parentDef); 168 | 169 | // ASSERT 170 | expect(result).to.deep.equal(expected); 171 | }); 172 | 173 | it('No-ops when payload is undefined', () => { 174 | // ARRANGE 175 | const target: Index = { 176 | a: { 177 | id: 'a', 178 | name: 'original name of a', 179 | value: 7, 180 | }, 181 | b: { 182 | id: 'b', 183 | name: 'name of b', 184 | }, 185 | }; 186 | const payload: any[] = undefined; 187 | 188 | const expected: Index = { ...target }; 189 | 190 | // ACT 191 | const result = setEach(target, payload, parentDef); 192 | 193 | // ASSERT 194 | expect(result).to.deep.equal(expected); 195 | expect(result).to.equal(target); 196 | }); 197 | 198 | it('No-ops when payload is null', () => { 199 | // ARRANGE 200 | const target: Index = { 201 | a: { 202 | id: 'a', 203 | name: 'original name of a', 204 | value: 7, 205 | }, 206 | b: { 207 | id: 'b', 208 | name: 'name of b', 209 | }, 210 | }; 211 | const payload: any[] = null; 212 | 213 | const expected: Index = { ...target }; 214 | 215 | // ACT 216 | const result = setEach(target, payload, parentDef); 217 | 218 | // ASSERT 219 | expect(result).to.deep.equal(expected); 220 | expect(result).to.equal(target); 221 | }); 222 | 223 | it('No-ops when key is not included', () => { 224 | // ARRANGE 225 | const target: Index = { 226 | a: { 227 | id: 'a', 228 | name: 'original name of a', 229 | value: 7, 230 | }, 231 | b: { 232 | id: 'b', 233 | name: 'name of b', 234 | }, 235 | }; 236 | const payload: any[] = [ 237 | { 238 | name: 'new name of a', 239 | value: 18, 240 | }, 241 | ]; 242 | 243 | const expected: Index = { ...target }; 244 | 245 | // ACT 246 | const result = setEach(target, payload, parentDef); 247 | 248 | // ASSERT 249 | expect(result).to.deep.equal(expected); 250 | expect(result).to.equal(target); 251 | }); 252 | 253 | it('No-ops when payload item is invalid', () => { 254 | // ARRANGE 255 | const target: Index = { 256 | a: { 257 | id: 'a', 258 | name: 'original name of a', 259 | value: 7, 260 | }, 261 | b: { 262 | id: 'b', 263 | name: 'name of b', 264 | }, 265 | }; 266 | const payload: any[] = [ 267 | { 268 | id: 'a', 269 | value: 18, 270 | }, 271 | ]; 272 | 273 | const expected: Index = { ...target }; 274 | 275 | // ACT 276 | const result = setEach(target, payload, parentDef); 277 | 278 | // ASSERT 279 | expect(result).to.deep.equal(expected); 280 | expect(result).to.equal(target); 281 | }); 282 | }); 283 | 284 | describe('from object', () => { 285 | it('Adds new items', () => { 286 | // ARRANGE 287 | const target: Index = { 288 | a: { 289 | id: 'a', 290 | name: 'name of a', 291 | value: 7, 292 | }, 293 | }; 294 | const payload: Index = { 295 | b: { 296 | id: 'b', 297 | name: 'name of b', 298 | }, 299 | }; 300 | 301 | const expected: Index = { 302 | a: { 303 | id: 'a', 304 | name: 'name of a', 305 | value: 7, 306 | }, 307 | b: { 308 | id: 'b', 309 | name: 'name of b', 310 | }, 311 | }; 312 | 313 | // ACT 314 | const result = setEach(target, payload, parentDef); 315 | 316 | // ASSERT 317 | expect(result).to.deep.equal(expected); 318 | }); 319 | 320 | it('Overwrites existing items', () => { 321 | // ARRANGE 322 | const target: Index = { 323 | a: { 324 | id: 'a', 325 | name: 'original name of a', 326 | value: 7, 327 | }, 328 | b: { 329 | id: 'b', 330 | name: 'name of b', 331 | }, 332 | }; 333 | const payload: Index = { 334 | a: { 335 | id: 'a', 336 | name: 'new name of a', 337 | }, 338 | }; 339 | 340 | const expected: Index = { 341 | a: { 342 | id: 'a', 343 | name: 'new name of a', 344 | }, 345 | b: { 346 | id: 'b', 347 | name: 'name of b', 348 | }, 349 | }; 350 | 351 | // ACT 352 | const result = setEach(target, payload, parentDef); 353 | 354 | // ASSERT 355 | expect(result).to.deep.equal(expected); 356 | }); 357 | 358 | it('No-ops when payload is undefined', () => { 359 | // ARRANGE 360 | const target: Index = { 361 | a: { 362 | id: 'a', 363 | name: 'original name of a', 364 | value: 7, 365 | }, 366 | b: { 367 | id: 'b', 368 | name: 'name of b', 369 | }, 370 | }; 371 | const payload: Index = undefined; 372 | 373 | const expected: Index = { ...target }; 374 | 375 | // ACT 376 | const result = setEach(target, payload, parentDef); 377 | 378 | // ASSERT 379 | expect(result).to.deep.equal(expected); 380 | expect(result).to.equal(target); 381 | }); 382 | 383 | it('No-ops when payload is null', () => { 384 | // ARRANGE 385 | const target: Index = { 386 | a: { 387 | id: 'a', 388 | name: 'original name of a', 389 | value: 7, 390 | }, 391 | b: { 392 | id: 'b', 393 | name: 'name of b', 394 | }, 395 | }; 396 | const payload: Index = null; 397 | 398 | const expected: Index = { ...target }; 399 | 400 | // ACT 401 | const result = setEach(target, payload, parentDef); 402 | 403 | // ASSERT 404 | expect(result).to.deep.equal(expected); 405 | expect(result).to.equal(target); 406 | }); 407 | 408 | it('No-ops when key is not included', () => { 409 | // ARRANGE 410 | const target: Index = { 411 | a: { 412 | id: 'a', 413 | name: 'original name of a', 414 | value: 7, 415 | }, 416 | b: { 417 | id: 'b', 418 | name: 'name of b', 419 | }, 420 | }; 421 | const payload: Index = { 422 | a: { 423 | name: 'new name of a', 424 | value: 18, 425 | }, 426 | }; 427 | 428 | const expected: Index = { ...target }; 429 | 430 | // ACT 431 | const result = setEach(target, payload, parentDef); 432 | 433 | // ASSERT 434 | expect(result).to.deep.equal(expected); 435 | expect(result).to.equal(target); 436 | }); 437 | 438 | it('No-ops when item key does not match payload key', () => { 439 | // ARRANGE 440 | const target: Index = { 441 | a: { 442 | id: 'a', 443 | name: 'original name of a', 444 | value: 7, 445 | }, 446 | b: { 447 | id: 'b', 448 | name: 'name of b', 449 | }, 450 | }; 451 | const payload: Index = { 452 | a: { 453 | id: 'not the same as the payload key', 454 | name: 'new name of a', 455 | value: 18, 456 | }, 457 | }; 458 | 459 | const expected: Index = { ...target }; 460 | 461 | // ACT 462 | const result = setEach(target, payload, parentDef); 463 | 464 | // ASSERT 465 | expect(result).to.deep.equal(expected); 466 | expect(result).to.equal(target); 467 | }); 468 | 469 | it('No-ops when payload item is invalid', () => { 470 | // ARRANGE 471 | const target: Index = { 472 | a: { 473 | id: 'a', 474 | name: 'original name of a', 475 | value: 7, 476 | }, 477 | b: { 478 | id: 'b', 479 | name: 'name of b', 480 | }, 481 | }; 482 | const payload: Index = { 483 | a: { 484 | id: 'a', 485 | value: 18, 486 | }, 487 | }; 488 | 489 | const expected: Index = { ...target }; 490 | 491 | // ACT 492 | const result = setEach(target, payload, parentDef); 493 | 494 | // ASSERT 495 | expect(result).to.deep.equal(expected); 496 | expect(result).to.equal(target); 497 | }); 498 | }); 499 | }); 500 | -------------------------------------------------------------------------------- /src/functions/set-each.ts: -------------------------------------------------------------------------------- 1 | import { Primitive, Index, Definition } from '..'; 2 | 3 | /** 4 | * Adds new values to a primitive array (strings, numbers, booleans, or Symbols). 5 | * @param target An array of primitive values. 6 | * @param payload An array of new primitive value to add. 7 | * @returns If any of the `payload` did not alreay exist in the `target` 8 | * the a new Array is returned; otherwise, the original `target` is returned by reference. 9 | * The resulting array will not contain any duplicate values. 10 | */ 11 | export function setEach(target: T[], payload: T[]): T[]; 12 | 13 | /** 14 | * Adds or replaces multiple objects within the `target` Index. 15 | * @param target The Index to update. 16 | * @param payload An Array of objects to add to the `target` Index. The property 17 | * of the object that is defined as the `key()` is used to determine the Index key. 18 | * @param definition Defines the properties of the `payload` object. The 19 | * definition must contain a `key()` property. 20 | * @returns If the object in the `target` Index was added or replaced, then an 21 | * updated shallow clone of the `target` Index is returned. If nothing changed 22 | * (eg. all of the objects in the `payload` array were invalid per the `definition`) 23 | * then the original `target` is returned by reference. 24 | */ 25 | export function setEach( 26 | target: Index, 27 | payload: T[], 28 | definition: Definition, 29 | ): Index; 30 | 31 | /** 32 | * Adds or replaces multiple objects within the `target` Index. 33 | * @param target The Index to update. 34 | * @param payload An Index of objects to add to the `target` Index. The keys of 35 | * the `payload` object are matched to the keys of the `target` object. 36 | * @param definition Defines the properties of the `payload` object. The 37 | * definition must contain a `key()` property. 38 | * @returns If the object in the `target` Index was added or replaced, then an 39 | * updated shallow clone of the `target` Index is returned. If nothing changed 40 | * (eg. all of the objects in the `payload` array were invalid per the `definition`) 41 | * then the original `target` is returned by reference. 42 | */ 43 | export function setEach( 44 | target: Index, 45 | payload: Index, 46 | definition: Definition, 47 | ): Index; 48 | 49 | export function setEach(a, b, c?): T[] | Index { 50 | if (Array.isArray(a)) { 51 | return setInPrimitiveArray(a, b); 52 | } 53 | if (Array.isArray(b)) { 54 | return setFromArray(a, b, c); 55 | } 56 | return setFromIndex(a, b, c); 57 | } 58 | 59 | function setInPrimitiveArray( 60 | target: T[], 61 | payload: T[], 62 | ): T[] { 63 | if (typeof payload === 'undefined' || payload === null) return target; 64 | 65 | const set = new Set(target); 66 | 67 | let added = false; 68 | 69 | for (const value of payload) { 70 | if (!set.has(value)) { 71 | set.add(value); 72 | added = true; 73 | } 74 | } 75 | 76 | return added ? Array.from(set) : target; 77 | } 78 | 79 | function setFromArray( 80 | target: Index, 81 | payload: T[], 82 | definition: Definition, 83 | ): Index { 84 | const updates: Index = {}; 85 | 86 | for (const item of payload) { 87 | const key = definition.getKey(item); 88 | if (!key) continue; 89 | 90 | const validItem = definition.getPayload(item); 91 | if (!validItem) continue; 92 | 93 | updates[key] = validItem; 94 | } 95 | 96 | return Object.keys(updates).length 97 | ? Object.assign({}, target, updates) 98 | : target; 99 | } 100 | 101 | function setFromIndex( 102 | target: Index, 103 | payload: Index, 104 | definition: Definition, 105 | ): Index { 106 | if (!payload) return target; 107 | const updates: Index = {}; 108 | 109 | for (const key in payload) { 110 | const validItem = definition.getPayload(payload[key]); 111 | if (!validItem) continue; 112 | 113 | const keyFromDefinition = definition.getKey(validItem); 114 | if (key !== keyFromDefinition) continue; 115 | 116 | updates[key] = validItem; 117 | } 118 | 119 | return Object.keys(updates).length 120 | ? Object.assign({}, target, updates) 121 | : target; 122 | } 123 | -------------------------------------------------------------------------------- /src/functions/set.md: -------------------------------------------------------------------------------- 1 | # Set 2 | 3 | Set provides the ability to either add or overwrite data. 4 | 5 | To use this function: 6 | 7 | ```js 8 | import { set } from 'flux-standard-functions'; 9 | ``` 10 | 11 | ## set(target, payload) 12 | 13 | Parameters: 14 | 15 | * `target ` - Array of primitive values (strings, numbers, booleans, or Symbols) 16 | * `payload ` - The primitive value to add. 17 | 18 | Adds a new value to a primitive array (strings, numbers, booleans, or Symbols). If the `payload` did not already exist in the `target` the a new Array is returned; otherwise, the original `target` is returned by reference. 19 | 20 | Example: 21 | 22 | ```js 23 | function setFavoriteNumber(state, action) { 24 | const target = state.favoriteNumbers; // [ 4, 8, 15, 16, 23 ] 25 | const payload = action.payload; // 42 26 | 27 | const updatedFavoriteNumbers = set(target, payload); // => [ 4, 8, 15, 16, 23, 42 ] 28 | 29 | return { 30 | ...state, 31 | favoriteNumbers: updatedFavoriteNumbers, 32 | }; 33 | } 34 | ``` 35 | 36 | ## set(target, payload, definition) 37 | 38 | Parameters: 39 | 40 | * `target ` - The Index to update 41 | * `payload ` - The object to add or replace. The property of the object that is defined as the `key()` is used to determine the Index key. 42 | * `definition ` - Defines the properties of the `payload` object. The definition must contain a `key()` property. 43 | 44 | Adds or replaces an object within the `target` Index. If the object in the `target` Index was added or replaced, then an updated shallow clone of the `target` object is returned. If nothing changed (eg. the `payload` was invalid per the `definition`) then the original `target` is returned by reference. 45 | 46 | Example of a reducer that uses `set` to overwrite a user: 47 | 48 | ```js 49 | function setUser(state, action) { 50 | const target = state.users; 51 | /* 52 | { 53 | { abc: { id: 'abc', name: 'John Doe', email: 'john.doe@example.com' }}, 54 | { def: { id: 'def', name: 'Jane Porter', email: 'jane.porter@example.com' }}, 55 | { def: { id: 'jkl', name: 'Eric Tile' }}, 56 | } 57 | */ 58 | const payload = action.payload; // { id: 'def', name: 'Jane Clayton` } 59 | 60 | const updatedUsers = set(target, payload, userDefinition); 61 | /* 62 | { 63 | { abc: { id: 'abc', name: 'John Doe', email: 'john.doe@example.com' }}, 64 | { def: { id: 'def', name: 'Jane Clayton' }}, 65 | { jkl: { id: 'jkl', name: 'Eric Tile' }}, 66 | } 67 | */ 68 | 69 | return { 70 | ...state, 71 | users: updatedUsers, 72 | }; 73 | } 74 | ``` 75 | 76 | ## set(target, key, payload, definition) 77 | 78 | Parameters: 79 | 80 | * `target ` - The object to update. 81 | * `key ` - The property on the `target` object that will be set. 82 | * `payload ` - The new value of the property. 83 | * `definition ` - Defines the properties of the `target` object so that immutable and extraneous properties are not set. 84 | 85 | Adds or replaces a property on the `target` object. If the value of the updated property changed, then an updated shallow clone of the `target` object is returned. If the value did not change (eg. the property was not included in the `definition` or is defined as `immutable()`), then the original `target` is returned by reference. 86 | 87 | Example of a reducer that uses `set` to add or overwrite a user's email: 88 | 89 | ```js 90 | function setUserEmail(state, action) { 91 | const target = state.user; // { id: 'abc', name: 'John Doe' } 92 | const key = 'email'; 93 | const payload = action.payload; // 'john.doe@example.com' 94 | 95 | const updatedUser = set(target, key, payload, userDefinition); 96 | // => { id: 'abc', name: 'John Doe', email: 'john.doe@example.com' } 97 | 98 | return { 99 | ...state, 100 | user: updatedUser, 101 | }; 102 | } 103 | ``` 104 | -------------------------------------------------------------------------------- /src/functions/set.tests.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | 3 | import { Definition, set, Index, define } from '..'; 4 | import { 5 | key as keyRule, 6 | required, 7 | optional, 8 | objectOf, 9 | indexOf, 10 | array, 11 | } from '../rules'; 12 | 13 | type TestChildItem = { 14 | id: string; 15 | name: string; 16 | value?: number; 17 | }; 18 | 19 | const childDef = define({ 20 | id: keyRule(), 21 | name: required(), 22 | value: optional(), 23 | }); 24 | 25 | type TestItem = { 26 | id: string; 27 | name: string; 28 | value?: number; 29 | child?: TestChildItem; 30 | children?: Index; 31 | items?: number[]; 32 | }; 33 | 34 | const parentDef = define({ 35 | id: keyRule(), 36 | name: required(), 37 | value: optional(), 38 | child: optional(objectOf(childDef)), 39 | children: optional(indexOf(childDef)), 40 | items: optional(array()), 41 | }); 42 | 43 | describe('set', () => { 44 | describe('target primitive array', () => { 45 | it('Adds a new value', () => { 46 | // ARRANGE 47 | const target = [1, 2, 3]; 48 | const payload = 4; 49 | 50 | // ACT 51 | const result = set(target, payload); 52 | 53 | // ASSERT 54 | expect(result).to.have.members([1, 2, 3, 4]); 55 | }); 56 | 57 | it('No-ops when payload is undefined', () => { 58 | // ARRANGE 59 | const target = [1, 2, 3]; 60 | const payload = undefined; 61 | 62 | // ACT 63 | const result = set(target, payload); 64 | 65 | // ASSERT 66 | expect(result).to.have.members([1, 2, 3]); 67 | expect(result).to.equal(target); 68 | }); 69 | 70 | it('No-ops when payload is null', () => { 71 | // ARRANGE 72 | const target = [1, 2, 3]; 73 | const payload = null; 74 | 75 | // ACT 76 | const result = set(target, payload); 77 | 78 | // ASSERT 79 | expect(result).to.have.members([1, 2, 3]); 80 | expect(result).to.equal(target); 81 | }); 82 | 83 | it('No-ops when value already exists', () => { 84 | // ARRANGE 85 | const target = [1, 2, 3]; 86 | const payload = 3; 87 | 88 | // ACT 89 | const result = set(target, payload); 90 | 91 | // ASSERT 92 | expect(result).to.have.members([1, 2, 3]); 93 | expect(result).to.equal(target); 94 | }); 95 | }); 96 | 97 | describe('target object', () => { 98 | describe('with a primitive property value', () => { 99 | it('Adds a new primitive value', () => { 100 | // ARRANGE 101 | const target: TestItem = { 102 | id: 'QWERTY', 103 | name: 'asdf', 104 | }; 105 | const key = 'value'; 106 | const payload = 7; 107 | // const definition: Definition = { 108 | // getPayload: x => null, 109 | // getPatch: x => ({ [key]: payload }), 110 | // getKey: x => x.id, 111 | // getDefinitions: key => null, 112 | // }; 113 | 114 | const definition = parentDef; 115 | 116 | const expected: TestItem = { 117 | id: 'QWERTY', 118 | name: 'asdf', 119 | value: 7, 120 | }; 121 | 122 | // ACT 123 | const result = set(target, key, payload, definition); 124 | 125 | // ASSERT 126 | expect(result).to.deep.equal(expected); 127 | }); 128 | 129 | it('Overwrites an existing primitive value', () => { 130 | // ARRANGE 131 | const target: TestItem = { 132 | id: 'QWERTY', 133 | name: 'asdf', 134 | value: 7, 135 | }; 136 | const key = 'value'; 137 | const payload = 18; 138 | 139 | const expected: TestItem = { 140 | id: 'QWERTY', 141 | name: 'asdf', 142 | value: 18, 143 | }; 144 | 145 | // ACT 146 | const result = set(target, key, payload, parentDef); 147 | 148 | // ASSERT 149 | expect(result).to.deep.equal(expected); 150 | }); 151 | 152 | it('No-ops when payload is undefined', () => { 153 | // ARRANGE 154 | const target: TestItem = { 155 | id: 'QWERTY', 156 | name: 'asdf', 157 | }; 158 | const key = 'id'; 159 | const payload = undefined; 160 | 161 | const expected: TestItem = { ...target }; 162 | 163 | // ACT 164 | const result = set(target, key, payload, parentDef); 165 | 166 | // ASSERT 167 | expect(result).to.deep.equal(expected); 168 | expect(result).to.equal(target); 169 | }); 170 | 171 | it('No-ops when payload is null', () => { 172 | // ARRANGE 173 | const target: TestItem = { 174 | id: 'QWERTY', 175 | name: 'asdf', 176 | }; 177 | const key = 'id'; 178 | const payload = null; 179 | 180 | const expected: TestItem = { ...target }; 181 | 182 | // ACT 183 | const result = set(target, key, payload, parentDef); 184 | 185 | // ASSERT 186 | expect(result).to.deep.equal(expected); 187 | expect(result).to.equal(target); 188 | }); 189 | 190 | it('No-ops when setting an immutable value', () => { 191 | // ARRANGE 192 | const target: TestItem = { 193 | id: 'QWERTY', 194 | name: 'asdf', 195 | }; 196 | const key = 'id'; 197 | const payload = 'THE NEW ID'; 198 | 199 | const expected: TestItem = { ...target }; 200 | 201 | // ACT 202 | const result = set(target, key, payload, parentDef); 203 | 204 | // ASSERT 205 | expect(result).to.deep.equal(expected); 206 | expect(result).to.equal(target); 207 | }); 208 | 209 | it('No-ops when property value is not updated', () => { 210 | // ARRANGE 211 | const target: TestItem = { 212 | id: 'QWERTY', 213 | name: 'asdf', 214 | value: 7, 215 | }; 216 | const key = 'value'; 217 | const payload = 7; 218 | 219 | const expected: TestItem = { ...target }; 220 | 221 | // ACT 222 | const result = set(target, key, payload, parentDef); 223 | 224 | // ASSERT 225 | expect(result).to.deep.equal(expected); 226 | expect(result).to.equal(target); 227 | }); 228 | }); 229 | 230 | describe('with an object property value', () => { 231 | it('Adds a new object value', () => { 232 | // ARRANGE 233 | const target: TestItem = { 234 | id: 'QWERTY', 235 | name: 'asdf', 236 | }; 237 | const key = 'child'; 238 | const payload = { id: 'child id', name: 'child name' }; 239 | 240 | const expected: TestItem = { 241 | id: 'QWERTY', 242 | name: 'asdf', 243 | child: { id: 'child id', name: 'child name' }, 244 | }; 245 | 246 | // ACT 247 | const result = set(target, key, payload, parentDef); 248 | 249 | // ASSERT 250 | expect(result).to.deep.equal(expected); 251 | }); 252 | 253 | it('Overwrites an existing object', () => { 254 | // ARRANGE 255 | const target: TestItem = { 256 | id: 'QWERTY', 257 | name: 'asdf', 258 | child: { id: 'child id', name: 'child name', value: 7 }, 259 | }; 260 | const key = 'child'; 261 | const payload = { id: 'new child id', name: 'new child name' }; 262 | 263 | const expected: TestItem = { 264 | id: 'QWERTY', 265 | name: 'asdf', 266 | child: { id: 'new child id', name: 'new child name' }, 267 | }; 268 | 269 | // ACT 270 | const result = set(target, key, payload, parentDef); 271 | 272 | // ASSERT 273 | expect(result).to.deep.equal(expected); 274 | }); 275 | 276 | it('No-ops when payload is undefined', () => { 277 | // ARRANGE 278 | const target: TestItem = { 279 | id: 'QWERTY', 280 | name: 'asdf', 281 | }; 282 | const key = 'child'; 283 | const payload = undefined; 284 | 285 | const expected: TestItem = { ...target }; 286 | 287 | // ACT 288 | const result = set(target, key, payload, parentDef); 289 | 290 | // ASSERT 291 | expect(result).to.deep.equal(expected); 292 | expect(result).to.equal(target); 293 | }); 294 | 295 | it('No-ops when payload is null', () => { 296 | // ARRANGE 297 | const target: TestItem = { 298 | id: 'QWERTY', 299 | name: 'asdf', 300 | }; 301 | const key = 'child'; 302 | const payload = null; 303 | 304 | const expected: TestItem = { ...target }; 305 | 306 | // ACT 307 | const result = set(target, key, payload, parentDef); 308 | 309 | // ASSERT 310 | expect(result).to.deep.equal(expected); 311 | expect(result).to.equal(target); 312 | }); 313 | 314 | it('No-ops when new object is invalid', () => { 315 | // ARRANGE 316 | const target: TestItem = { 317 | id: 'QWERTY', 318 | name: 'asdf', 319 | }; 320 | const key = 'child'; 321 | const payload = { id: 'child id' }; 322 | 323 | const expected: TestItem = { ...target }; 324 | 325 | // ACT 326 | const result = set(target, key, payload, parentDef); 327 | 328 | // ASSERT 329 | expect(result).to.deep.equal(expected); 330 | expect(result).to.equal(target); 331 | }); 332 | }); 333 | 334 | describe('with an index property value', () => { 335 | it('Adds a new index value', () => { 336 | // ARRANGE 337 | const target: TestItem = { 338 | id: 'QWERTY', 339 | name: 'asdf', 340 | }; 341 | const key = 'children'; 342 | const payload = { a: { id: 'a', name: 'child name' } }; 343 | 344 | const expected: TestItem = { 345 | id: 'QWERTY', 346 | name: 'asdf', 347 | children: { a: { id: 'a', name: 'child name' } }, 348 | }; 349 | 350 | // ACT 351 | const result = set(target, key, payload, parentDef); 352 | 353 | // ASSERT 354 | expect(result).to.deep.equal(expected); 355 | }); 356 | 357 | it('Adds a new empty value', () => { 358 | // ARRANGE 359 | const target: TestItem = { 360 | id: 'QWERTY', 361 | name: 'asdf', 362 | }; 363 | const key = 'children'; 364 | const payload = {}; 365 | 366 | const expected: TestItem = { 367 | id: 'QWERTY', 368 | name: 'asdf', 369 | children: {}, 370 | }; 371 | 372 | // ACT 373 | const result = set(target, key, payload, parentDef); 374 | 375 | // ASSERT 376 | expect(result).to.deep.equal(expected); 377 | }); 378 | 379 | it('Overwrites an existing index', () => { 380 | // ARRANGE 381 | const target: TestItem = { 382 | id: 'QWERTY', 383 | name: 'asdf', 384 | children: { a: { id: 'a', name: 'child name' } }, 385 | }; 386 | const key = 'children'; 387 | const payload = { b: { id: 'b', name: 'new child name' } }; 388 | 389 | const expected: TestItem = { 390 | id: 'QWERTY', 391 | name: 'asdf', 392 | children: { b: { id: 'b', name: 'new child name' } }, 393 | }; 394 | 395 | // ACT 396 | const result = set(target, key, payload, parentDef); 397 | 398 | // ASSERT 399 | expect(result).to.deep.equal(expected); 400 | }); 401 | 402 | it('Overwrites an existing index with an empty value', () => { 403 | // ARRANGE 404 | const target: TestItem = { 405 | id: 'QWERTY', 406 | name: 'asdf', 407 | children: { a: { id: 'a', name: 'child name' } }, 408 | }; 409 | const key = 'children'; 410 | const payload = {}; 411 | 412 | const expected: TestItem = { 413 | id: 'QWERTY', 414 | name: 'asdf', 415 | children: {}, 416 | }; 417 | 418 | // ACT 419 | const result = set(target, key, payload, parentDef); 420 | 421 | // ASSERT 422 | expect(result).to.deep.equal(expected); 423 | }); 424 | 425 | it('No-ops when payload is undefined', () => { 426 | // ARRANGE 427 | const target: TestItem = { 428 | id: 'QWERTY', 429 | name: 'asdf', 430 | children: { a: { id: 'a', name: 'child name' } }, 431 | }; 432 | const key = 'children'; 433 | const payload = undefined; 434 | 435 | const expected: TestItem = { ...target }; 436 | 437 | // ACT 438 | const result = set(target, key, payload, parentDef); 439 | 440 | // ASSERT 441 | expect(result).to.deep.equal(expected); 442 | expect(result).to.equal(target); 443 | }); 444 | 445 | it('No-ops when payload is null', () => { 446 | // ARRANGE 447 | const target: TestItem = { 448 | id: 'QWERTY', 449 | name: 'asdf', 450 | children: { a: { id: 'a', name: 'child name' } }, 451 | }; 452 | const key = 'children'; 453 | const payload = null; 454 | 455 | const expected: TestItem = { ...target }; 456 | 457 | // ACT 458 | const result = set(target, key, payload, parentDef); 459 | 460 | // ASSERT 461 | expect(result).to.deep.equal(expected); 462 | expect(result).to.equal(target); 463 | }); 464 | 465 | it('No-ops when new index is invalid', () => { 466 | // ARRANGE 467 | const target: TestItem = { 468 | id: 'QWERTY', 469 | name: 'asdf', 470 | children: { a: { id: 'a', name: 'child name' } }, 471 | }; 472 | const key = 'children'; 473 | const payload = { b: { id: 'b' } }; 474 | 475 | const expected: TestItem = { ...target }; 476 | 477 | // ACT 478 | const result = set(target, key, payload, parentDef); 479 | 480 | // ASSERT 481 | expect(result).to.deep.equal(expected); 482 | expect(result).to.equal(target); 483 | }); 484 | }); 485 | 486 | describe('with a primitive array property value', () => { 487 | it('Adds a new primitive array', () => { 488 | // ARRANGE 489 | const target: TestItem = { 490 | id: 'QWERTY', 491 | name: 'asdf', 492 | }; 493 | const key = 'items'; 494 | const payload = [1, 2, 3]; 495 | 496 | const expected: TestItem = { 497 | id: 'QWERTY', 498 | name: 'asdf', 499 | items: [1, 2, 3], 500 | }; 501 | 502 | // ACT 503 | const result = set(target, key, payload, parentDef); 504 | 505 | // ASSERT 506 | expect(result).to.deep.equal(expected); 507 | }); 508 | 509 | it('Overwrites an existing primitive array', () => { 510 | // ARRANGE 511 | const target: TestItem = { 512 | id: 'QWERTY', 513 | name: 'asdf', 514 | items: [1, 2, 3], 515 | }; 516 | const key = 'items'; 517 | const payload = [3, 4, 5]; 518 | 519 | const expected: TestItem = { 520 | id: 'QWERTY', 521 | name: 'asdf', 522 | items: [3, 4, 5], 523 | }; 524 | 525 | // ACT 526 | const result = set(target, key, payload, parentDef); 527 | 528 | // ASSERT 529 | expect(result).to.deep.equal(expected); 530 | }); 531 | 532 | it('No-ops when payload is undefined', () => { 533 | // ARRANGE 534 | const target: TestItem = { 535 | id: 'QWERTY', 536 | name: 'asdf', 537 | items: [1, 2, 3], 538 | }; 539 | const key = 'items'; 540 | const payload = undefined; 541 | 542 | const expected: TestItem = { ...target }; 543 | 544 | // ACT 545 | const result = set(target, key, payload, parentDef); 546 | 547 | // ASSERT 548 | expect(result).to.deep.equal(expected); 549 | expect(result).to.equal(target); 550 | }); 551 | 552 | it('No-ops when new array is null', () => { 553 | // ARRANGE 554 | const target: TestItem = { 555 | id: 'QWERTY', 556 | name: 'asdf', 557 | items: [1, 2, 3], 558 | }; 559 | const key = 'items'; 560 | const payload = null; 561 | 562 | const expected: TestItem = { ...target }; 563 | 564 | // ACT 565 | const result = set(target, key, payload, parentDef); 566 | 567 | // ASSERT 568 | expect(result).to.deep.equal(expected); 569 | expect(result).to.equal(target); 570 | }); 571 | }); 572 | }); 573 | 574 | describe('index', () => { 575 | it('Adds a new item', () => { 576 | // ARRANGE 577 | const target: Index = { 578 | a: { 579 | id: 'a', 580 | name: 'name of a', 581 | }, 582 | }; 583 | const payload = { 584 | id: 'b', 585 | name: 'name of b', 586 | }; 587 | const definition: Definition = { 588 | getPayload: x => x as TestItem, 589 | getPatch: x => null, 590 | getKey: x => x.id, 591 | getDefinitions: key => null, 592 | }; 593 | 594 | const expected: Index = { 595 | a: { 596 | id: 'a', 597 | name: 'name of a', 598 | }, 599 | b: { 600 | id: 'b', 601 | name: 'name of b', 602 | }, 603 | }; 604 | 605 | // ACT 606 | const result = set(target, payload, definition); 607 | 608 | // ASSERT 609 | expect(result).to.deep.equal(expected); 610 | }); 611 | 612 | it('Overwrites an existing item', () => { 613 | // ARRANGE 614 | const target: Index = { 615 | a: { 616 | id: 'a', 617 | name: 'name of a', 618 | }, 619 | b: { 620 | id: 'b', 621 | name: 'original name of b', 622 | value: 7, 623 | }, 624 | }; 625 | const payload = { 626 | id: 'b', 627 | name: 'new name of b', 628 | }; 629 | const definition: Definition = { 630 | getPayload: x => x as TestItem, 631 | getPatch: x => null, 632 | getKey: x => x.id, 633 | getDefinitions: key => null, 634 | }; 635 | 636 | const expected: Index = { 637 | a: { 638 | id: 'a', 639 | name: 'name of a', 640 | }, 641 | b: { 642 | id: 'b', 643 | name: 'new name of b', 644 | }, 645 | }; 646 | 647 | // ACT 648 | const result = set(target, payload, definition); 649 | 650 | // ASSERT 651 | expect(result).to.deep.equal(expected); 652 | }); 653 | 654 | it('No-ops when payload is undefined', () => { 655 | // ARRANGE 656 | const target: Index = { 657 | a: { 658 | id: 'a', 659 | name: 'name of a', 660 | }, 661 | b: { 662 | id: 'b', 663 | name: 'original name of b', 664 | value: 7, 665 | }, 666 | }; 667 | const payload = undefined; 668 | const definition: Definition = { 669 | getPayload: x => null, 670 | getPatch: x => null, 671 | getKey: x => x.id, 672 | getDefinitions: key => null, 673 | }; 674 | 675 | const expected: Index = { ...target }; 676 | 677 | // ACT 678 | const result = set(target, payload, definition); 679 | 680 | // ASSERT 681 | expect(result).to.deep.equal(expected); 682 | expect(result).to.equal(target); 683 | }); 684 | 685 | it('No-ops when payload is null', () => { 686 | // ARRANGE 687 | const target: Index = { 688 | a: { 689 | id: 'a', 690 | name: 'name of a', 691 | }, 692 | b: { 693 | id: 'b', 694 | name: 'original name of b', 695 | value: 7, 696 | }, 697 | }; 698 | const payload = null; 699 | const definition: Definition = { 700 | getPayload: x => null, 701 | getPatch: x => null, 702 | getKey: x => x.id, 703 | getDefinitions: key => null, 704 | }; 705 | 706 | const expected: Index = { ...target }; 707 | 708 | // ACT 709 | const result = set(target, payload, definition); 710 | 711 | // ASSERT 712 | expect(result).to.deep.equal(expected); 713 | expect(result).to.equal(target); 714 | }); 715 | 716 | it('No-ops when getPayload returns falsy', () => { 717 | // ARRANGE 718 | const target: Index = { 719 | a: { 720 | id: 'a', 721 | name: 'name of a', 722 | }, 723 | b: { 724 | id: 'b', 725 | name: 'original name of b', 726 | value: 7, 727 | }, 728 | }; 729 | const payload = { 730 | id: 'b', 731 | name: 'asdfsadf', 732 | value: 18, 733 | }; 734 | const definition: Definition = { 735 | getPayload: x => null, 736 | getPatch: x => null, 737 | getKey: x => x.id, 738 | getDefinitions: key => null, 739 | }; 740 | 741 | const expected: Index = { ...target }; 742 | 743 | // ACT 744 | const result = set(target, payload, definition); 745 | 746 | // ASSERT 747 | expect(result).to.deep.equal(expected); 748 | expect(result).to.equal(target); 749 | }); 750 | 751 | it('No-ops when getId returns falsy', () => { 752 | // ARRANGE 753 | const target: Index = { 754 | a: { 755 | id: 'a', 756 | name: 'name of a', 757 | }, 758 | b: { 759 | id: 'b', 760 | name: 'original name of b', 761 | value: 7, 762 | }, 763 | }; 764 | const payload = { 765 | id: 'b', 766 | name: 'asdfsadf', 767 | value: 18, 768 | }; 769 | const definition: Definition = { 770 | getPayload: x => x as TestItem, 771 | getPatch: x => null, 772 | getKey: x => null, 773 | getDefinitions: key => null, 774 | }; 775 | 776 | const expected: Index = { ...target }; 777 | 778 | // ACT 779 | const result = set(target, payload, definition); 780 | 781 | // ASSERT 782 | expect(result).to.deep.equal(expected); 783 | expect(result).to.equal(target); 784 | }); 785 | }); 786 | }); 787 | -------------------------------------------------------------------------------- /src/functions/set.ts: -------------------------------------------------------------------------------- 1 | import { Primitive, Definition, Index, Patch } from '..'; 2 | import { patch } from './patch'; 3 | 4 | /** 5 | * Adds a new value to a primitive array (strings, numbers, booleans, or Symbols). 6 | * @param target An array of primitive values. 7 | * @param payload A primitive value to add to the array. 8 | * @returns If the `payload` did not already exist in the `target` the a new 9 | * Array is returned; otherwise, the original `target` is returned by reference. 10 | */ 11 | export function set(target: T[], payload: T): T[]; 12 | 13 | /** 14 | * Adds or replaces an object within the `target` Index. 15 | * @param target The Index to update. 16 | * @param payload The object to add or replace. The property of the object that 17 | * is defined as the `key()` is used to determine the Index key. 18 | * @param definition Defines the properties of the `payload` object. The 19 | * definition must contain a `key()` property. 20 | * @returns If the object in the `target` Index was added or replaced, then an 21 | * updated shallow clone of the `target` object is returned. If nothing changed 22 | * (eg. the `payload` was invalid per the `definition`) then the original `target` 23 | * is returned by reference. 24 | */ 25 | export function set( 26 | target: Index, 27 | payload: T, 28 | definition: Definition, 29 | ): Index; 30 | 31 | /** 32 | * Adds or replaces a property on the `target` object. 33 | * @param target The object to update. 34 | * @param key The property on the `target` object that will be set. 35 | * @param payload The new value of the property. 36 | * @param definition Defines the properties of the `target` object so that immutable 37 | * and extraneous properties are not set. 38 | * @returns If the value of the updated property changed, then an updated shallow 39 | * clone of the `target` object is returned. If the value did not change (eg. the 40 | * property is not included in the `definition` or is defined as `immutable()`), 41 | * then the original `target` is returned by reference. 42 | */ 43 | export function set( 44 | target: T, 45 | key: keyof T, 46 | payload: any, 47 | definition: Definition, 48 | ): T; 49 | 50 | export function set(a, b, c?, d?): T | T[] | Index { 51 | if (Array.isArray(a)) { 52 | return setInPrimitiveArray(a, b); 53 | } 54 | if (d) { 55 | return setOnObject(a, b, c, d); 56 | } 57 | return setInIndex(a, b, c); 58 | } 59 | 60 | function setInPrimitiveArray( 61 | target: T[], 62 | payload: T, 63 | ): T[] { 64 | if (typeof payload === 'undefined' || payload === null) return target; 65 | 66 | const originalSet = new Set(target); 67 | 68 | return originalSet.has(payload) 69 | ? target 70 | : Array.from(originalSet.add(payload)); 71 | } 72 | 73 | function setOnObject( 74 | target: T, 75 | key: keyof T, 76 | payload: any, 77 | definition: Definition, 78 | ): T { 79 | if (typeof payload === 'undefined' || payload === null) return target; 80 | 81 | const childDefinition = definition.getDefinitions(key); 82 | 83 | if (childDefinition && childDefinition.object) { 84 | const childPayload = childDefinition.object.getPayload(payload); 85 | return typeof childPayload === 'undefined' || childPayload === null 86 | ? target 87 | : Object.assign({}, target, { [key]: childPayload }); // { ...(target as any), [key]: childPayload }; 88 | } else if (childDefinition && childDefinition.index) { 89 | const childKeys = Object.keys(payload); 90 | 91 | let shouldClone = 92 | !childKeys.length && 93 | (!target[key] || Object.keys(target[key] as any).length); 94 | 95 | const childResult = {}; 96 | 97 | for (const childKey of childKeys) { 98 | const childPayload = childDefinition.index.getPayload(payload[childKey]); 99 | 100 | if (typeof childPayload === 'undefined' || childPayload === null) { 101 | continue; 102 | } 103 | 104 | shouldClone = true; 105 | childResult[childKey] = childPayload; 106 | } 107 | 108 | return shouldClone 109 | ? Object.assign({}, target, { [key]: childResult }) 110 | : target; 111 | } else if (childDefinition && childDefinition.isArray) { 112 | const x = definition.getPatch({ [key]: payload } as Patch); 113 | return x && x[key] ? Object.assign({}, target, { [key]: x[key] }) : target; 114 | } else { 115 | return patch(target, { [key]: payload } as Patch, definition); 116 | } 117 | } 118 | 119 | function setInIndex( 120 | target: Index, 121 | payload: T, 122 | definition: Definition, 123 | ): Index { 124 | if (!payload) return target; 125 | 126 | const validItem = definition.getPayload(payload); 127 | if (!validItem) return target; 128 | 129 | const key = definition.getKey(payload); 130 | if (!key) return target; 131 | 132 | return Object.assign({}, target, { [key]: validItem }); 133 | } 134 | -------------------------------------------------------------------------------- /src/functions/unset-each.tests.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | 3 | import { unsetEach, Index } from '..'; 4 | 5 | type TestItem = { 6 | id: string; 7 | name: string; 8 | value?: number; 9 | }; 10 | 11 | describe('unsetEach', () => { 12 | describe('primitive array', () => { 13 | it('Removes existing values', () => { 14 | // ARRANGE 15 | const target = [1, 2, 3, 4]; 16 | const payload = [3, 4]; 17 | 18 | // ACT 19 | const result = unsetEach(target, payload); 20 | 21 | // ASSERT 22 | expect(result).to.have.members([1, 2]); 23 | }); 24 | 25 | it('No-ops when payload is undefined', () => { 26 | // ARRANGE 27 | const target = [1, 2, 3]; 28 | const payload = undefined; 29 | 30 | // ACT 31 | const result = unsetEach(target, payload); 32 | 33 | // ASSERT 34 | expect(result).to.have.members([1, 2, 3]); 35 | expect(result).to.equal(target); 36 | }); 37 | 38 | it('No-ops when payload is null', () => { 39 | // ARRANGE 40 | const target = [1, 2, 3]; 41 | const payload = null; 42 | 43 | // ACT 44 | const result = unsetEach(target, payload); 45 | 46 | // ASSERT 47 | expect(result).to.have.members([1, 2, 3]); 48 | expect(result).to.equal(target); 49 | }); 50 | 51 | it('No-ops when values do not exist', () => { 52 | // ARRANGE 53 | const target = [1, 2, 3]; 54 | const payload = [4, 5]; 55 | 56 | // ACT 57 | const result = unsetEach(target, payload); 58 | 59 | // ASSERT 60 | expect(result).to.have.members([1, 2, 3]); 61 | expect(result).to.equal(target); 62 | }); 63 | }); 64 | 65 | describe('index', () => { 66 | it('Removes existing items', () => { 67 | // ARRANGE 68 | const target: Index = { 69 | a: { 70 | id: 'a', 71 | name: 'name of a', 72 | value: 7, 73 | }, 74 | b: { 75 | id: 'b', 76 | name: 'name of b', 77 | }, 78 | }; 79 | const payload = ['a']; 80 | 81 | const expected = { 82 | b: { 83 | id: 'b', 84 | name: 'name of b', 85 | }, 86 | }; 87 | 88 | // ACT 89 | const result = unsetEach(target, payload); 90 | 91 | // ASSERT 92 | expect(result).to.deep.equal(expected); 93 | }); 94 | 95 | it('No-ops when payload is undefined', () => { 96 | // ARRANGE 97 | const target: Index = { 98 | a: { 99 | id: 'a', 100 | name: 'name of a', 101 | value: 7, 102 | }, 103 | b: { 104 | id: 'b', 105 | name: 'name of b', 106 | }, 107 | }; 108 | const payload = undefined; 109 | 110 | const expected = { ...target }; 111 | 112 | // ACT 113 | const result = unsetEach(target, payload); 114 | 115 | // ASSERT 116 | expect(result).to.deep.equal(expected); 117 | expect(result).to.equal(target); 118 | }); 119 | 120 | it('No-ops when payload is null', () => { 121 | // ARRANGE 122 | const target: Index = { 123 | a: { 124 | id: 'a', 125 | name: 'name of a', 126 | value: 7, 127 | }, 128 | b: { 129 | id: 'b', 130 | name: 'name of b', 131 | }, 132 | }; 133 | const payload = null; 134 | 135 | const expected = { ...target }; 136 | 137 | // ACT 138 | const result = unsetEach(target, payload); 139 | 140 | // ASSERT 141 | expect(result).to.deep.equal(expected); 142 | expect(result).to.equal(target); 143 | }); 144 | 145 | it('No-ops when values do not exist', () => { 146 | // ARRANGE 147 | const target: Index = { 148 | a: { 149 | id: 'a', 150 | name: 'name of a', 151 | value: 7, 152 | }, 153 | b: { 154 | id: 'b', 155 | name: 'name of b', 156 | }, 157 | }; 158 | const payload = ['not a key']; 159 | 160 | const expected = { ...target }; 161 | 162 | // ACT 163 | const result = unsetEach(target, payload); 164 | 165 | // ASSERT 166 | expect(result).to.deep.equal(expected); 167 | expect(result).to.equal(target); 168 | }); 169 | }); 170 | }); 171 | -------------------------------------------------------------------------------- /src/functions/unset-each.ts: -------------------------------------------------------------------------------- 1 | import { Index, Primitive } from '..'; 2 | 3 | export function unsetEach(target: T[], payload: T[]): T[]; 4 | export function unsetEach( 5 | target: Index, 6 | keys: (string | number)[], 7 | ): Index; 8 | export function unsetEach(a, b): T[] | Index { 9 | if (Array.isArray(a)) { 10 | return unsetFromPrimitiveArray(a, b); 11 | } 12 | return unsetFromIndex(a, b); 13 | } 14 | 15 | function unsetFromPrimitiveArray( 16 | target: T[], 17 | payload: T[], 18 | ): T[] { 19 | if (typeof payload === 'undefined' || payload === null) return target; 20 | 21 | const set = new Set(target); 22 | 23 | let removed = false; 24 | 25 | for (const item of payload) { 26 | removed = set.delete(item) || removed; 27 | } 28 | 29 | return removed ? Array.from(set) : target; 30 | } 31 | 32 | function unsetFromIndex( 33 | target: Index, 34 | keys: (string | number)[], 35 | ): Index { 36 | const originalKeys = Object.keys(target); 37 | 38 | const set = new Set(keys); 39 | 40 | const finalKeys = originalKeys.filter(key => !set.has(key)); 41 | 42 | if (finalKeys.length === originalKeys.length) return target; 43 | 44 | const result: Index = {}; 45 | 46 | for (const key of finalKeys) { 47 | result[key] = target[key]; 48 | } 49 | 50 | return result; 51 | } 52 | -------------------------------------------------------------------------------- /src/functions/unset.tests.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | 3 | import { 4 | DELETE_VALUE, 5 | Index, 6 | define, 7 | key as keyRule, 8 | optional, 9 | required, 10 | objectOf, 11 | indexOf, 12 | array, 13 | unset, 14 | } from '..'; 15 | 16 | type TestChildItem = { 17 | id: string; 18 | name: string; 19 | value?: number; 20 | }; 21 | 22 | const childDef = define({ 23 | id: keyRule(), 24 | name: required(), 25 | value: optional(), 26 | }); 27 | 28 | type TestItem = { 29 | id: string; 30 | name: string; 31 | value?: number; 32 | child?: TestChildItem; 33 | children?: Index; 34 | items?: number[]; 35 | }; 36 | 37 | const parentDef = define({ 38 | id: keyRule(), 39 | name: required(), 40 | value: optional(), 41 | child: optional(objectOf(childDef)), 42 | children: optional(indexOf(childDef)), 43 | items: optional(array()), 44 | }); 45 | 46 | describe('unset', () => { 47 | describe('primitive array', () => { 48 | it('Removes an existing value', () => { 49 | // ARRANGE 50 | const target = [1, 2, 3]; 51 | const payload = 3; 52 | 53 | // ACT 54 | const result = unset(target, payload); 55 | 56 | // ASSERT 57 | expect(result).to.have.members([1, 2]); 58 | }); 59 | 60 | it('No-ops when payload is undefined', () => { 61 | // ARRANGE 62 | const target = [1, 2, 3]; 63 | const payload = undefined; 64 | 65 | // ACT 66 | const result = unset(target, payload); 67 | 68 | // ASSERT 69 | expect(result).to.have.members([1, 2, 3]); 70 | expect(result).to.equal(target); 71 | }); 72 | 73 | it('No-ops when payload is null', () => { 74 | // ARRANGE 75 | const target = [1, 2, 3]; 76 | const payload = null; 77 | 78 | // ACT 79 | const result = unset(target, payload); 80 | 81 | // ASSERT 82 | expect(result).to.have.members([1, 2, 3]); 83 | expect(result).to.equal(target); 84 | }); 85 | 86 | it('No-ops when value did not exist', () => { 87 | // ARRANGE 88 | const target = [1, 2, 3]; 89 | const payload = 4; 90 | 91 | // ACT 92 | const result = unset(target, payload); 93 | 94 | // ASSERT 95 | expect(result).to.have.members([1, 2, 3]); 96 | expect(result).to.equal(target); 97 | }); 98 | }); 99 | 100 | describe('object', () => { 101 | it('Removes an existing value', () => { 102 | // ARRANGE 103 | const target: TestItem = { 104 | id: 'QWERTY', 105 | name: 'asdf', 106 | value: 7, 107 | }; 108 | const key = 'value'; 109 | 110 | const expected: TestItem = { 111 | id: 'QWERTY', 112 | name: 'asdf', 113 | }; 114 | 115 | // ACT 116 | const result = unset(target, key, parentDef); 117 | 118 | // ASSERT 119 | expect(result).to.deep.equal(expected); 120 | }); 121 | 122 | it('No-ops when key is undefined', () => { 123 | // ARRANGE 124 | const target: TestItem = { 125 | id: 'QWERTY', 126 | name: 'asdf', 127 | }; 128 | const key = undefined; 129 | 130 | const expected: TestItem = { ...target }; 131 | 132 | // ACT 133 | const result = unset(target, key, parentDef); 134 | 135 | // ASSERT 136 | expect(result).to.deep.equal(expected); 137 | expect(result).to.equal(target); 138 | }); 139 | 140 | it('No-ops when key is null', () => { 141 | // ARRANGE 142 | const target: TestItem = { 143 | id: 'QWERTY', 144 | name: 'asdf', 145 | }; 146 | const key = null; 147 | 148 | const expected: TestItem = { ...target }; 149 | 150 | // ACT 151 | const result = unset(target, key, parentDef); 152 | 153 | // ASSERT 154 | expect(result).to.deep.equal(expected); 155 | expect(result).to.equal(target); 156 | }); 157 | 158 | it('No-ops if value is not found', () => { 159 | // ARRANGE 160 | const target: TestItem = { 161 | id: 'QWERTY', 162 | name: 'asdf', 163 | }; 164 | const key = 'value'; 165 | 166 | const expected: TestItem = { ...target }; 167 | 168 | // ACT 169 | const result = unset(target, key, parentDef); 170 | 171 | // ASSERT 172 | expect(result).to.deep.equal(expected); 173 | expect(result).to.equal(target); 174 | }); 175 | 176 | it('No-ops if getPatch returns falsy', () => { 177 | // ARRANGE 178 | const target: TestItem = { 179 | id: 'QWERTY', 180 | name: 'asdf', 181 | }; 182 | const key = 'id'; 183 | 184 | const expected: TestItem = { ...target }; 185 | 186 | // ACT 187 | const result = unset(target, key, parentDef); 188 | 189 | // ASSERT 190 | expect(result).to.deep.equal(expected); 191 | expect(result).to.equal(target); 192 | }); 193 | }); 194 | 195 | describe('index', () => { 196 | it('Removes an existing item', () => { 197 | // ARRANGE 198 | const key = 'QWERTY'; 199 | 200 | const target: Index = { 201 | [key]: { 202 | id: key, 203 | name: 'asdf', 204 | }, 205 | 'not-the-key': { 206 | id: 'not-the-key', 207 | name: 'asdf', 208 | }, 209 | }; 210 | 211 | const expected: Index = { 212 | 'not-the-key': { 213 | id: 'not-the-key', 214 | name: 'asdf', 215 | }, 216 | }; 217 | 218 | // ACT 219 | const result = unset(target, key); 220 | 221 | // ASSERT 222 | expect(result).to.deep.equal(expected); 223 | }); 224 | 225 | it('No-ops when key is undefined', () => { 226 | // ARRANGE 227 | const target: Index = { 228 | a: { 229 | id: 'a', 230 | name: 'asdf', 231 | }, 232 | b: { 233 | id: 'b', 234 | name: 'asdf', 235 | }, 236 | }; 237 | const key = undefined; 238 | 239 | const expected: Index = { ...target }; 240 | 241 | // ACT 242 | const result = unset(target, key); 243 | 244 | // ASSERT 245 | expect(result).to.deep.equal(expected); 246 | expect(result).to.equal(target); 247 | }); 248 | 249 | it('No-ops when key is null', () => { 250 | // ARRANGE 251 | const target: Index = { 252 | a: { 253 | id: 'a', 254 | name: 'asdf', 255 | }, 256 | b: { 257 | id: 'b', 258 | name: 'asdf', 259 | }, 260 | }; 261 | const key = null; 262 | 263 | const expected: Index = { ...target }; 264 | 265 | // ACT 266 | const result = unset(target, key); 267 | 268 | // ASSERT 269 | expect(result).to.deep.equal(expected); 270 | expect(result).to.equal(target); 271 | }); 272 | 273 | it('No-ops when the key does not match an item', () => { 274 | // ARRANGE 275 | const key = 'NOT FOUND'; 276 | 277 | const target: Index = { 278 | a: { 279 | id: 'a', 280 | name: 'asdf', 281 | }, 282 | b: { 283 | id: 'b', 284 | name: 'asdf', 285 | }, 286 | }; 287 | 288 | const expected: Index = { ...target }; 289 | 290 | // ACT 291 | const result = unset(target, key); 292 | 293 | // ASSERT 294 | expect(result).to.deep.equal(expected); 295 | expect(result).to.equal(target); 296 | }); 297 | }); 298 | }); 299 | -------------------------------------------------------------------------------- /src/functions/unset.ts: -------------------------------------------------------------------------------- 1 | import { Primitive, Definition, Index, patch } from '..'; 2 | import { DELETE_VALUE, Patch } from '../types'; 3 | 4 | export function unset(target: T[], payload: T): T[]; 5 | export function unset(target: T, key: keyof T, definition: Definition): T; 6 | export function unset(target: Index, key: string | number): Index; 7 | export function unset(a, b, c?): T | T[] | Index { 8 | if (Array.isArray(a)) { 9 | return unsetFromPrimitiveArray(a, b); 10 | } 11 | if (c) { 12 | return unsetFromObject(a, b, c); 13 | } 14 | 15 | return unsetFromIndex(a, b); 16 | } 17 | 18 | function unsetFromPrimitiveArray( 19 | target: T[], 20 | payload: T, 21 | ): T[] { 22 | const set = new Set(target); 23 | return set.delete(payload) ? Array.from(set) : target; 24 | } 25 | 26 | function unsetFromObject( 27 | target: T, 28 | key: keyof T, 29 | definition: Definition, 30 | ): T { 31 | if (typeof target[key] === 'undefined') return target; 32 | 33 | return patch(target, { [key]: DELETE_VALUE } as Patch, definition); 34 | } 35 | 36 | function unsetFromIndex(target: Index, key: string): Index { 37 | if (!target[key]) return target; 38 | 39 | const result = Object.assign({}, target); 40 | delete result[key]; 41 | 42 | return result; 43 | } 44 | -------------------------------------------------------------------------------- /src/helpers.md: -------------------------------------------------------------------------------- 1 | # Helpers 2 | 3 | This package provides a few convenience function for converting data between Indexes and Arrays. 4 | 5 | To use these functions: 6 | 7 | ```js 8 | import { index, deindex } from 'flux-standard-functions'; 9 | ``` 10 | 11 | ## index(values, definition) 12 | 13 | Pameters: 14 | 15 | * `values ` - An array of objects. 16 | * `definition ` - An object that defines the objects in the array. The Defintion must specifies which property should be used as the key of the index. 17 | 18 | Converts an array of objects to an Index whose key is defined by the supplied `definiton` object. 19 | 20 | Example: 21 | 22 | ```js 23 | const definition = define({ 24 | id: key(), // <= Specifies that the "id" property should be used as the Index key 25 | name: required(), 26 | }); 27 | 28 | const values = [ 29 | { id: 'abc', name: 'John Doe' }, 30 | { id: 'def', name: 'Jane Porter' }, 31 | { id: 'jkl', name: 'Eric Tile' }, 32 | ]; 33 | 34 | const result = index(values, definition); 35 | /* output => 36 | { 37 | { abc: { id: 'abc', name: 'John Doe' }}, 38 | { def: { id: 'def', name: 'Jane Porter' }}, 39 | { jkl: { id: 'jkl', name: 'Eric Tile' }}, 40 | } 41 | */ 42 | ``` 43 | 44 | ## deindex(values) 45 | 46 | Pameters: 47 | 48 | * `values ` - An Index of objects. 49 | 50 | Converts an Index to an Array of objects. 51 | 52 | Example: 53 | 54 | ```js 55 | const values = { 56 | { abc: { id: 'abc', name: 'John Doe' }}, 57 | { def: { id: 'def', name: 'Jane Porter' }}, 58 | { jkl: { id: 'jkl', name: 'Eric Tile' }}, 59 | }; 60 | 61 | const result = index(values, definition); 62 | /* output => 63 | [ 64 | { id: 'abc', name: 'John Doe' }, 65 | { id: 'def', name: 'Jane Porter' }, 66 | { id: 'jkl', name: 'Eric Tile' }, 67 | ] 68 | */ 69 | ``` 70 | -------------------------------------------------------------------------------- /src/helpers.tests.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import { Index, index, key, required, define, deindex } from '.'; 3 | 4 | describe('index', () => { 5 | it('indexes a collection', () => { 6 | // ARRANGE 7 | type Item = { id: string; name: string }; 8 | const itemDef = define({ id: key(), name: required() }); 9 | 10 | const items: Item[] = [ 11 | { id: '123', name: 'item 1' }, 12 | { id: 'abc', name: 'item 2' }, 13 | ]; 14 | 15 | const expected: Index = { 16 | '123': { id: '123', name: 'item 1' }, 17 | abc: { id: 'abc', name: 'item 2' }, 18 | }; 19 | 20 | // ACT 21 | const result = index(items, itemDef); 22 | 23 | // ASSERT 24 | expect(result).to.deep.equal(expected); 25 | }); 26 | }); 27 | 28 | describe('deindex', () => { 29 | it('deindexes an index', () => { 30 | // ARRANGE 31 | type Item = { id: string; name: string }; 32 | const itemDef = define({ id: key(), name: required() }); 33 | 34 | const itemIndex: Index = { 35 | '123': { id: '123', name: 'item 1' }, 36 | abc: { id: 'abc', name: 'item 2' }, 37 | }; 38 | 39 | const expected: Item[] = [ 40 | { id: '123', name: 'item 1' }, 41 | { id: 'abc', name: 'item 2' }, 42 | ]; 43 | 44 | // ACT 45 | const result = deindex(itemIndex); 46 | 47 | // ASSERT 48 | expect(result).to.deep.equal(expected); 49 | }); 50 | }); 51 | -------------------------------------------------------------------------------- /src/helpers.ts: -------------------------------------------------------------------------------- 1 | import { Definition, Index } from '.'; 2 | import { WideWeakMap } from './wide-weak-map'; 3 | 4 | /** 5 | * Converts an array of objects to an Index whose key is defined by the 6 | * supplied `definiton` object. 7 | * @param values An array of objects. 8 | * @param definition An object that defines the objects in the array. 9 | * The Defintion must specifies which property should be used as the key 10 | * of the index. 11 | * @returns An Index object. 12 | */ 13 | export function index(values: T[], definition: Definition): Index { 14 | if (!indexMap.has(values, definition)) { 15 | const result = {}; 16 | const length = values.length; 17 | for (let i = 0; i < length; i++) { 18 | const v = values[i]; 19 | result[definition.getKey(v)] = v; 20 | } 21 | 22 | indexMap.set([values, definition], result); 23 | } 24 | 25 | return indexMap.get(values, definition) as any; 26 | } 27 | const indexMap = new WideWeakMap(); 28 | 29 | /** 30 | * Returns all of the values of an Index as an array objects. 31 | * @param values An index of objects 32 | */ 33 | export function deindex(values: Index): T[] { 34 | if (!deindexMap.has(values)) { 35 | const keys = Object.keys(values); 36 | const length = keys.length; 37 | const result = new Array(length); 38 | for (let i = 0; i < length; i++) { 39 | result[i] = values[keys[i]]; 40 | } 41 | 42 | deindexMap.set(values, result); 43 | } 44 | 45 | return deindexMap.get(values); 46 | } 47 | const deindexMap = new WeakMap(); 48 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { 2 | Definition, 3 | DELETE_VALUE, 4 | Index, 5 | Patch, 6 | Primitive, 7 | Rule, 8 | } from './types'; 9 | 10 | export { set } from './functions/set'; 11 | export { setEach } from './functions/set-each'; 12 | export { unset } from './functions/unset'; 13 | export { unsetEach } from './functions/unset-each'; 14 | export { patch } from './functions/patch'; 15 | export { patchEach } from './functions/patch-each'; 16 | 17 | export { 18 | array, 19 | immutable, 20 | indexOf, 21 | key, 22 | objectOf, 23 | optional, 24 | required, 25 | } from './rules'; 26 | 27 | export { define } from './define'; 28 | export { index, deindex } from './helpers'; 29 | -------------------------------------------------------------------------------- /src/rules.md: -------------------------------------------------------------------------------- 1 | # Rules 2 | 3 | These rules define how object properties behave while being Patched, Set, and Unset. 4 | 5 | To use these rules: 6 | 7 | ```js 8 | import { 9 | key, 10 | immutable, 11 | required, 12 | optional, 13 | indexOf, 14 | objectOf, 15 | array, 16 | } from 'flux-standard-functions'; 17 | ``` 18 | 19 | ## key() 20 | 21 | This rule indicates that the property is to be used as the "key" within an Index. A definition is not required to include a key, but any definition with a key may only include one. A property defined as a key is also required and immutable. The `key()` rule may not be combined with `optional()`. 22 | 23 | ## immutable() 24 | 25 | This rule indicates that a property value may not be changed once it is added. Immutable properties are required by default, but can be explicitly specified to be optional. Any operations to update an immutable property will be ignored without throwing an error. To include a property that may be initially `undefined` but then immutable once added, use `optional(immutable())`. 26 | 27 | ## required() 28 | 29 | This rule indicates that a property value must be included. Required properties may be updated, but may not be removed. Any operations to remove an required property will be ignored without throwing an error. The `required()` rule may not be combined with `optional()`. 30 | 31 | ## optional() 32 | 33 | This rule indicates that a property value may must be included. Required properties may be updated, but may not be removed. Any operations to remove an required property will be ignored without throwing an error. 34 | 35 | ## objectOf(definition) 36 | 37 | Parameters: 38 | 39 | * `definition ` - A definiton object that describes a complex property. 40 | 41 | This rule is used to define complex properties. 42 | 43 | ```js 44 | const userDefinition = define({ 45 | id: key(), 46 | name: required(), 47 | address: objectOf( 48 | define({ 49 | line1: required(), 50 | line2: optional(), 51 | city: required(), 52 | state: required(), 53 | postalCode: required(), 54 | }), 55 | ), 56 | }); 57 | ``` 58 | 59 | Properties defined with this rule are optional by default. This rule can be combined with `immutable()` and `required()`. (Combining with `option()` is technically allowed, but has no effect.) 60 | 61 | Note: An immutable complex property may not be removed or replaced, but its own properties are not automatically immutable. This is similar to the behavior of a `const` in ES6: constants may not be changed, but their properties may be added, edited, or removed. 62 | 63 | Warning: While this package does support complex properties, it is generally a good practice to keep your Redux store fairely flat. The Redux docs contain a great article about [normalizing state shape](https://redux.js.org/recipes/structuringreducers/normalizingstateshape). This rule may make it easy to work with a deeply nested state; however, it doesn't make doing so correct. Consider only using complex properties sparingly. 64 | 65 | ## indexOf(definition) 66 | 67 | Parameters: 68 | 69 | * `definition ` - A definiton object that describes the objects within the Index property. 70 | 71 | This rule is similar to `objectOf` but instead declares an Index of the defined property. 72 | 73 | ```js 74 | const userDefinition = define({ 75 | id: key(), 76 | name: required(), 77 | widgets: indexOf( 78 | define({ 79 | id: key(), 80 | color: required(), 81 | }), 82 | ), 83 | }); 84 | ``` 85 | 86 | Properties defined with this rule are optional by default. This rule can be combined with `immutable()` and `required()`. (Combining with `option()` is technically allowed, but has no effect.) 87 | 88 | Note: An immutable Index property may not be removed or replaced, but the objects it contains may be added, updated, or removed. This is similar to the behavior of a `const` in ES6: constants may not be changed, but their properties may be added, edited, or removed. 89 | 90 | Warning: While this package does support Index properties, it is generally a good practice to keep your Redux store fairely flat. The Redux docs contain a great article about [normalizing state shape](https://redux.js.org/recipes/structuringreducers/normalizingstateshape). This rule may make it easy to work with a deeply nested state; however, it doesn't make doing so correct. Consider only using Index properties sparingly. Instead, consider using an `array()` property to store references to objects in another "table" within the Redux store. 91 | 92 | ## array() 93 | 94 | The rule is used to defined primitive array properties. Note that this package does not support Arrays that contain objects. A primitive array may only contain `string`, `number`, `boolean`, or `Symbol` values. 95 | -------------------------------------------------------------------------------- /src/rules.ts: -------------------------------------------------------------------------------- 1 | import { Rule } from '.'; 2 | import { Definition, Primitive } from './types'; 3 | 4 | /** 5 | * Indicates that the property is to be used as the "key" within an Index. 6 | * A definition is not required to include a key, but any definition with 7 | * a key may only include one. A property defined as a key is also required 8 | * and immutable. The `key()` rule may not be combined with `optional()`. 9 | */ 10 | export function key(): Rule { 11 | return { isKey: true, isReadonly: true, isRequired: true }; 12 | } 13 | 14 | /** 15 | * Indicates that a property value may not be changed once it is added. 16 | * Immutable properties are required by default, but can be explicitly 17 | * specified to be optional. Any operations to update an immutable property 18 | * will be ignored without throwing an error. To include a property that 19 | * may be initially `undefined` but then immutable once added, use 20 | * `optional(immutable())`. 21 | */ 22 | export function immutable(rule?: Rule): Rule { 23 | return { 24 | isKey: rule ? rule.isKey : false, 25 | isReadonly: true, 26 | isRequired: rule ? rule.isRequired : true, 27 | isArray: rule ? rule.isArray : undefined, 28 | index: rule ? rule.index : undefined, 29 | object: rule ? rule.object : undefined, 30 | }; 31 | } 32 | 33 | /** 34 | * Indicates that a property value must be included. Required properties may 35 | * be updated, but may not be removed. Any operations to remove an required 36 | * property will be ignored without throwing an error. The `required()` rule 37 | * may not be combined with `optional()`. 38 | */ 39 | export function required(rule?: Rule): Rule { 40 | return { 41 | isKey: rule ? rule.isKey : false, 42 | isReadonly: rule ? rule.isReadonly : undefined, 43 | isRequired: true, 44 | isArray: rule ? rule.isArray : undefined, 45 | index: rule ? rule.index : undefined, 46 | object: rule ? rule.object : undefined, 47 | }; 48 | } 49 | 50 | /** 51 | * Indicates that a property value may must be included. Required properties may 52 | * be updated, but may not be removed. Any operations to remove an required 53 | * property will be ignored without throwing an error. An immutable complex property 54 | * may not be removed or replaced, but its own properties are not automatically 55 | * immutable. This is similar to the behavior of a `const` in ES6: constants may 56 | * not be changed, but their properties may be added, edited, or removed. 57 | */ 58 | export function optional(rule?: Rule): Rule { 59 | return { 60 | isKey: rule ? rule.isKey : false, 61 | isReadonly: rule ? rule.isReadonly : undefined, 62 | isRequired: false, 63 | isArray: rule ? rule.isArray : undefined, 64 | index: rule ? rule.index : undefined, 65 | object: rule ? rule.object : undefined, 66 | }; 67 | } 68 | /** 69 | * Defines an Index property. Properties defined with this rule are optional by 70 | * default. This rule can be combined with `immutable()` and `required()`. 71 | * (Combining with `option()` is technically allowed, but has no effect.) An 72 | * immutable Index property may not be removed or replaced, but the objects 73 | * it contains may be added, updated, or removed. This is similar to the behavior 74 | * of a `const` in ES6: constants may not be changed, but their properties may be 75 | * added, edited, or removed. 76 | */ 77 | export function indexOf(definition: Definition): Rule { 78 | return { isKey: false, index: definition }; 79 | } 80 | 81 | /** 82 | * Defines a complex property. Properties defined with this rule are optional by 83 | * default. This rule can be combined with `immutable()` and `required()`. 84 | * (Combining with `option()` is technically allowed, but has no effect.) 85 | */ 86 | export function objectOf(definition: Definition): Rule { 87 | return { isKey: false, object: definition }; 88 | } 89 | 90 | /** 91 | * Defines a primitive array property. Note that this package does not support 92 | * defining Arrays that contain objects. A primitive array may only contain `string`, 93 | * `number`, `boolean`, or `Symbol` values. 94 | */ 95 | export function array(): Rule { 96 | return { isKey: false, isArray: true }; 97 | } 98 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | export type Primitive = Symbol | string | number | boolean; 2 | export interface Definition { 3 | getPayload(payload: Patch): T; 4 | getPatch(payload: Patch): Patch; 5 | getKey(payload: Patch): string; 6 | getDefinitions( 7 | key: keyof T, 8 | ): { 9 | index?: Definition; 10 | object?: Definition; 11 | isArray?: boolean; 12 | }; 13 | } 14 | export type Index = { [key: string]: T } | { [key: number]: T }; 15 | export type Patch = { [K in keyof T]?: any }; 16 | export type Rule = { 17 | isKey: boolean; 18 | isRequired?: boolean; 19 | isReadonly?: boolean; 20 | index?: Definition; 21 | object?: Definition; 22 | isArray?: boolean; 23 | }; 24 | 25 | export const DELETE_VALUE = Symbol('DELETE_VALUE'); 26 | -------------------------------------------------------------------------------- /src/wide-weak-map.tests.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import { WideWeakMap } from './wide-weak-map'; 3 | 4 | describe('WideWeakMap', () => { 5 | describe('delete', () => { 6 | it('deletes a value with a single key', () => { 7 | // ARRANGE 8 | const key = {}; 9 | const value = {}; 10 | 11 | const sut = new WideWeakMap(); 12 | sut.set([key], value); 13 | 14 | // ACT 15 | const result = sut.delete(key); 16 | 17 | // ASSERT 18 | const x = sut.get(key); 19 | expect(result).to.be.true; 20 | expect(x).to.be.undefined; 21 | }); 22 | 23 | it('deletes a value with multiple keys', () => { 24 | // ARRANGE 25 | const key1 = {}; 26 | const key2 = {}; 27 | const value = {}; 28 | 29 | const sut = new WideWeakMap(); 30 | sut.set([key1, key2], value); 31 | 32 | // ACT 33 | const result = sut.delete(key1, key2); 34 | 35 | // ASSERT 36 | const x = sut.get(key1, key2); 37 | expect(result).to.be.true; 38 | expect(x).to.be.undefined; 39 | }); 40 | 41 | it('no-ops when a single key is not matched', () => { 42 | // ARRANGE 43 | const key = {}; 44 | const value = {}; 45 | 46 | const sut = new WideWeakMap(); 47 | sut.set([{}], value); 48 | 49 | // ACT 50 | const result = sut.delete(key); 51 | 52 | // ASSERT 53 | expect(result).to.be.false; 54 | }); 55 | 56 | it('no-ops when multiple keys are not matched', () => { 57 | // ARRANGE 58 | const key1 = {}; 59 | const key2 = {}; 60 | const value = {}; 61 | 62 | const sut = new WideWeakMap(); 63 | sut.set([{}, {}], value); 64 | 65 | // ACT 66 | const result = sut.delete(key1, key2); 67 | 68 | // ASSERT 69 | expect(result).to.be.false; 70 | }); 71 | 72 | it('leaves other values with deeper key paths', () => { 73 | // ARRANGE 74 | const key1 = {}; 75 | const key2 = {}; 76 | const key3 = {}; 77 | const value = {}; 78 | 79 | const sut = new WideWeakMap(); 80 | 81 | sut.set([key1, key2], value); 82 | sut.set([key1, key2, key3], value); 83 | 84 | // ACT 85 | sut.delete([key1, key2]); 86 | 87 | // ASSERT 88 | const x = sut.get(key1, key2, key3); 89 | expect(x).to.equal(value); 90 | }); 91 | 92 | it('leaves other values with shallower key paths', () => { 93 | // ARRANGE 94 | const key1 = {}; 95 | const key2 = {}; 96 | const key3 = {}; 97 | const value = {}; 98 | 99 | const sut = new WideWeakMap(); 100 | 101 | sut.set([key1, key2], value); 102 | sut.set([key1, key2, key3], value); 103 | 104 | // ACT 105 | sut.delete([key1, key2, key3]); 106 | 107 | // ASSERT 108 | const x = sut.get(key1, key2); 109 | expect(x).to.equal(value); 110 | }); 111 | }); 112 | 113 | describe('get', () => { 114 | it('gets a value with a single key', () => { 115 | // ARRANGE 116 | const key = {}; 117 | const value = {}; 118 | 119 | const sut = new WideWeakMap(); 120 | sut.set([key], value); 121 | 122 | // ACT 123 | const result = sut.get(key); 124 | 125 | // ASSERT 126 | expect(result).to.equal(value); 127 | }); 128 | 129 | it('gets a value with multiple keys', () => { 130 | // ARRANGE 131 | const key1 = {}; 132 | const key2 = {}; 133 | const value = {}; 134 | 135 | const sut = new WideWeakMap(); 136 | sut.set([key1, key2], value); 137 | 138 | // ACT 139 | const result = sut.get(key1, key2); 140 | 141 | // ASSERT 142 | expect(result).to.equal(value); 143 | }); 144 | 145 | it('returns undefined when a single key is not matched', () => { 146 | // ARRANGE 147 | const key = {}; 148 | const value = {}; 149 | 150 | const sut = new WideWeakMap(); 151 | sut.set([key], value); 152 | 153 | // ACT 154 | const result = sut.get({}); 155 | 156 | // ASSERT 157 | expect(result).to.be.undefined; 158 | }); 159 | 160 | it('returns undefined when multiple keys are not matched', () => { 161 | // ARRANGE 162 | const key1 = {}; 163 | const key2 = {}; 164 | const value = {}; 165 | 166 | const sut = new WideWeakMap(); 167 | sut.set([key1, key2], value); 168 | 169 | // ACT 170 | const result = sut.get({}, {}); 171 | 172 | // ASSERT 173 | expect(result).to.be.undefined; 174 | }); 175 | 176 | it('returns undefined when multiple keys are in the wrong order', () => { 177 | // ARRANGE 178 | const key1 = {}; 179 | const key2 = {}; 180 | const value = {}; 181 | 182 | const sut = new WideWeakMap(); 183 | sut.set([key1, key2], value); 184 | 185 | // ACT 186 | const result = sut.get(key2, key1); 187 | 188 | // ASSERT 189 | expect(result).to.be.undefined; 190 | }); 191 | }); 192 | 193 | describe('has', () => { 194 | it('returns true when a single key is matched', () => { 195 | // ARRANGE 196 | const key = {}; 197 | const value = {}; 198 | 199 | const sut = new WideWeakMap(); 200 | sut.set([key], value); 201 | 202 | // ACT 203 | const result = sut.has(key); 204 | 205 | // ASSERT 206 | expect(result).to.be.true; 207 | }); 208 | 209 | it('returns true when multiple keys are matched', () => { 210 | // ARRANGE 211 | const key1 = {}; 212 | const key2 = {}; 213 | const value = {}; 214 | 215 | const sut = new WideWeakMap(); 216 | sut.set([key1, key2], value); 217 | 218 | // ACT 219 | const result = sut.has(key1, key2); 220 | 221 | // ASSERT 222 | expect(result).to.be.true; 223 | }); 224 | 225 | it('returns false when a single key is not matched', () => { 226 | // ARRANGE 227 | const key = {}; 228 | const value = {}; 229 | 230 | const sut = new WideWeakMap(); 231 | sut.set([key], value); 232 | 233 | // ACT 234 | const result = sut.has({}); 235 | 236 | // ASSERT 237 | expect(result).to.be.false; 238 | }); 239 | 240 | it('returns false when multiple keys are not matched', () => { 241 | // ARRANGE 242 | const key1 = {}; 243 | const key2 = {}; 244 | const value = {}; 245 | 246 | const sut = new WideWeakMap(); 247 | sut.set([key1, key2], value); 248 | 249 | // ACT 250 | const result = sut.has({}, {}); 251 | 252 | // ASSERT 253 | expect(result).to.be.false; 254 | }); 255 | 256 | it('returns false when multiple keys are in the wrong order', () => { 257 | // ARRANGE 258 | const key1 = {}; 259 | const key2 = {}; 260 | const value = {}; 261 | 262 | const sut = new WideWeakMap(); 263 | sut.set([key1, key2], value); 264 | 265 | // ACT 266 | const result = sut.has(key2, key1); 267 | 268 | // ASSERT 269 | expect(result).to.be.false; 270 | }); 271 | }); 272 | 273 | describe('set', () => { 274 | it('adds a value with a single key', () => { 275 | // ARRANGE 276 | const key = {}; 277 | const value = {}; 278 | 279 | const sut = new WideWeakMap(); 280 | 281 | // ACT 282 | const x = sut.set([key], value); 283 | 284 | // ASSERT 285 | const result = sut.get(key); 286 | expect(result).to.equal(value); 287 | expect(x).to.equal(sut); 288 | }); 289 | 290 | it('adds a value with multiple keys', () => { 291 | // ARRANGE 292 | const key1 = {}; 293 | const key2 = {}; 294 | const value = {}; 295 | 296 | const sut = new WideWeakMap(); 297 | 298 | // ACT 299 | const x = sut.set([key1, key2], value); 300 | 301 | // ASSERT 302 | const result = sut.get(key1, key2); 303 | expect(result).to.equal(value); 304 | expect(x).to.equal(sut); 305 | }); 306 | }); 307 | }); 308 | -------------------------------------------------------------------------------- /src/wide-weak-map.ts: -------------------------------------------------------------------------------- 1 | export class WideWeakMap { 2 | delete(...keys: K[]): boolean { 3 | if (keys.length === 1) { 4 | return this.values.delete(keys[0]); 5 | } else if (this.children.has(keys[0])) { 6 | return this.children.get(keys[0]).delete(...keys.slice(1)); 7 | } else { 8 | return false; 9 | } 10 | } 11 | 12 | get(...keys: K[]): V | undefined { 13 | if (keys.length === 1) { 14 | return this.values.get(keys[0]); 15 | } else if (this.children.has(keys[0])) { 16 | return this.children.get(keys[0]).get(...keys.slice(1)); 17 | } else { 18 | return undefined; 19 | } 20 | } 21 | 22 | has(...keys: K[]): boolean { 23 | if (keys.length === 1) { 24 | return this.values.has(keys[0]); 25 | } else if (this.children.has(keys[0])) { 26 | return this.children.get(keys[0]).has(...keys.slice(1)); 27 | } else { 28 | return false; 29 | } 30 | } 31 | 32 | set(keys: K[], value: V): this { 33 | if (keys.length === 1) { 34 | this.values.set(keys[0], value); 35 | return this; 36 | } 37 | 38 | if (!this.children.has(keys[0])) { 39 | this.children.set(keys[0], new WideWeakMap()); 40 | } 41 | this.children.get(keys[0]).set(keys.slice(1), value); 42 | 43 | return this; 44 | } 45 | 46 | private readonly children = new WeakMap>(); 47 | private readonly values = new WeakMap(); 48 | } 49 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["es6"], 4 | "module": "commonjs", 5 | "noImplicitReturns": true, 6 | "outDir": "lib", 7 | "sourceMap": true, 8 | "target": "es3", 9 | "declaration": true, 10 | "removeComments": false 11 | }, 12 | "include": ["src"] 13 | } 14 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "adjacent-overload-signatures": true, 4 | "ban-comma-operator": true, 5 | "no-namespace": true, 6 | "no-parameter-reassignment": true, 7 | "no-reference": true, 8 | "label-position": true, 9 | "no-conditional-assignment": true, 10 | "no-construct": true, 11 | "no-duplicate-super": true, 12 | "no-duplicate-switch-case": true, 13 | "no-duplicate-variable": [true, "check-parameters"], 14 | "no-shadowed-variable": true, 15 | "no-empty": [true, "allow-empty-catch"], 16 | "no-invalid-this": true, 17 | "no-string-throw": true, 18 | "no-unsafe-finally": true, 19 | "no-duplicate-imports": true, 20 | "no-empty-interface": { 21 | "severity": "warning" 22 | }, 23 | "no-import-side-effect": { 24 | "severity": "warning" 25 | }, 26 | "no-var-keyword": { 27 | "severity": "warning" 28 | }, 29 | "triple-equals": { 30 | "severity": "warning" 31 | }, 32 | "prefer-for-of": { 33 | "severity": "warning" 34 | }, 35 | "unified-signatures": { 36 | "severity": "warning" 37 | }, 38 | "prefer-const": { 39 | "severity": "warning" 40 | }, 41 | "trailing-comma": { 42 | "severity": "warning" 43 | } 44 | }, 45 | "defaultSeverity": "error" 46 | } 47 | --------------------------------------------------------------------------------