├── mocha.opts ├── .gitignore ├── .prettierrc ├── .travis.yml ├── scripts └── move-type-declarations.js ├── rollup.config.js ├── tsconfig.json ├── CHANGELOG.md ├── appveyor.yml ├── package.json ├── LICENSE ├── README.md ├── src └── index.ts └── test └── test.ts /mocha.opts: -------------------------------------------------------------------------------- 1 | --require sucrase/register 2 | test/test.ts -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | *.d.ts 4 | dist -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "useTabs": true, 3 | "singleQuote": true, 4 | "trailingComma": "none" 5 | } -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "8" 4 | 5 | env: 6 | global: 7 | - BUILD_TIMEOUT=10000 -------------------------------------------------------------------------------- /scripts/move-type-declarations.js: -------------------------------------------------------------------------------- 1 | const sander = require('sander'); 2 | const glob = require('tiny-glob/sync'); 3 | 4 | for (const file of glob('src/**/*.js')) { 5 | sander.unlinkSync(file); 6 | } 7 | 8 | sander.rimrafSync('types'); 9 | for (const file of glob('src/**/*.d.ts')) { 10 | sander.renameSync(file).to(file.replace(/^src/, 'types')); 11 | } -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import sucrase from 'rollup-plugin-sucrase'; 2 | import pkg from './package.json'; 3 | 4 | export default { 5 | input: 'src/index.ts', 6 | output: [ 7 | { file: pkg.main, format: 'umd', name: 'objectCull' }, 8 | { file: pkg.module, format: 'esm' } 9 | ], 10 | plugins: [ 11 | sucrase({ 12 | transforms: ['typescript'] 13 | }) 14 | ] 15 | }; 16 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "noImplicitAny": true, 4 | "diagnostics": true, 5 | "noImplicitThis": true, 6 | "noEmitOnError": true, 7 | "lib": ["es5", "es6", "dom"] 8 | }, 9 | "target": "ES5", 10 | "module": "ES6", 11 | "include": [ 12 | "src" 13 | ], 14 | "exclude": [ 15 | "node_modules" 16 | ] 17 | } -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # object-cull changelog 2 | 3 | ## 1.1.4 4 | 5 | * Handle nulls 6 | 7 | ## 1.1.3 8 | 9 | * Error on non-prepared object 10 | 11 | ## 1.1.2 12 | 13 | * Handle objects with unread properties 14 | 15 | ## 1.1.1 16 | 17 | * Handle array destructuring assignments ([#4](https://github.com/Rich-Harris/object-cull/issues/4)) 18 | 19 | ## 1.1.0 20 | 21 | * Only preserve array length if it is accessed 22 | * Switch from mocha to uvu 23 | 24 | ## 1.0.1 25 | 26 | * Bail on non-POJOs 27 | * Bind methods to their owners 28 | 29 | ## 1.0.0 30 | 31 | * First release -------------------------------------------------------------------------------- /appveyor.yml: -------------------------------------------------------------------------------- 1 | # http://www.appveyor.com/docs/appveyor-yml 2 | 3 | version: "{build}" 4 | 5 | clone_depth: 10 6 | 7 | init: 8 | - git config --global core.autocrlf false 9 | 10 | environment: 11 | matrix: 12 | # node.js 13 | - nodejs_version: 8 14 | 15 | install: 16 | - ps: Install-Product node $env:nodejs_version 17 | - npm install 18 | 19 | build: off 20 | 21 | test_script: 22 | - node --version && npm --version 23 | - npm test 24 | 25 | matrix: 26 | fast_finish: false 27 | 28 | # cache: 29 | # - C:\Users\appveyor\AppData\Roaming\npm-cache -> package.json # npm cache 30 | # - node_modules -> package.json # local npm modules 31 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "object-cull", 3 | "description": "object-cull", 4 | "version": "1.1.4", 5 | "repository": "Rich-Harris/object-cull", 6 | "main": "dist/object-cull.umd.js", 7 | "module": "dist/object-cull.esm.js", 8 | "types": "types/index.d.ts", 9 | "files": [ 10 | "dist", 11 | "types" 12 | ], 13 | "devDependencies": { 14 | "@types/node": "^10.9.4", 15 | "rollup": "^2.23.0", 16 | "rollup-plugin-sucrase": "^2.1.0", 17 | "sander": "^0.6.0", 18 | "sucrase": "^3.15.0", 19 | "tiny-glob": "^0.2.6", 20 | "typescript": "^3.9.7", 21 | "uvu": "^0.3.0" 22 | }, 23 | "scripts": { 24 | "build-declarations": "tsc -d && node scripts/move-type-declarations.js", 25 | "build": "npm run build-declarations && rollup -c", 26 | "dev": "rollup -cw", 27 | "test": "uvu -r sucrase/register", 28 | "prepublishOnly": "npm test && npm run build" 29 | }, 30 | "license": "MIT" 31 | } 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2019 Rich Harris 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # object-cull 2 | 3 | Create a copy of an object, based on which properties were accessed. 4 | 5 | ```js 6 | import { prepare, apply } from 'object-cull'; 7 | 8 | const proxy = prepare({ 9 | firstname: 'Terrell', 10 | lastname: 'Snider', 11 | friends: [ 12 | { firstname: 'Rachelle', lastname: 'Knight' }, 13 | { firstname: 'Ila', lastname: 'Farrell' }, 14 | { firstname: 'Vasquez', lastname: 'Flynn' } 15 | ], 16 | pets: [ 17 | { name: 'Bobo', species: 'Great Dane' } 18 | ] 19 | }); 20 | 21 | console.log(proxy.firstname); // Terrell 22 | console.log(proxy.friends[0].firstname); // Rachelle 23 | 24 | const { kept, culled } = apply(proxy); 25 | 26 | console.log(kept); 27 | /* 28 | { firstname: 'Terrell', friends: [ { firstname: 'Rachelle' } ] } 29 | */ 30 | 31 | console.log(culled); 32 | /* 33 | [ 34 | { path: 'lastname', value: 'Snider' }, 35 | { path: 'friends.0.lastname', value: 'Knight' }, 36 | { path: 'friends.1', value: { firstname: 'Ila', lastname: 'Farrell' } }, 37 | { path: 'friends.2', value: { firstname: 'Vasquez', lastname: 'Flynn' } }, 38 | { path: 'pets', value: [ { name: 'Bobo', species: 'Great Dane' } ] } 39 | ] 40 | */ 41 | 42 | const unused_bytes = culled.reduce((total, item) => { 43 | return total + JSON.stringify(item.value).length; 44 | }, 0); 45 | 46 | const percent_unused = 100 * unused_bytes / JSON.stringify(proxy).length; 47 | 48 | console.log(`${percent_unused}% of the data was unused`); 49 | ``` 50 | 51 | 52 | ## Why? 53 | 54 | Mostly for server-side rendering. You might not need to serialize *all* your data to send it to the client. 55 | 56 | 57 | ## Prior art 58 | 59 | * [js-off](https://github.com/reconbot/js-off) 60 | 61 | 62 | ## License 63 | 64 | MIT 65 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | let proxy_lookup: WeakMap; 2 | let object_lookup: WeakMap; 3 | let reads: WeakMap>; 4 | 5 | type Culled = { path: string, value: any }; 6 | 7 | function add_read(obj: any, prop: string) { 8 | if (!reads.has(obj)) reads.set(obj, new Set()); 9 | reads.get(obj).add(prop) 10 | } 11 | 12 | const handler: ProxyHandler = { 13 | get: (obj: any, prop: string | symbol) => { 14 | // handle array destructuring assignments (`const [a, b, c] = proxy`) 15 | if (typeof prop === 'symbol') return obj[prop]; 16 | 17 | add_read(obj, prop as string); 18 | return to_proxy(obj[prop], obj); 19 | }, 20 | set: (obj: any, prop: string, value: any) => { 21 | obj[prop] = value; 22 | return true; 23 | } 24 | } 25 | 26 | function to_proxy(value: any, parent: any) { 27 | if (typeof value === 'function' && parent) { 28 | return value.bind(parent); 29 | } 30 | 31 | if (!value || typeof value !== 'object') { 32 | return value; 33 | } 34 | 35 | if (!proxy_lookup.has(value)) { 36 | const proxy = new Proxy(value, handler); 37 | proxy_lookup.set(value, proxy); 38 | object_lookup.set(proxy, value); 39 | } 40 | 41 | return proxy_lookup.get(value); 42 | } 43 | 44 | export function prepare(input: any) { 45 | if (!proxy_lookup) { 46 | proxy_lookup = new WeakMap(); 47 | object_lookup = new WeakMap(); 48 | reads = new WeakMap(); 49 | } 50 | 51 | return to_proxy(input, null); 52 | } 53 | 54 | function get_type(thing: any) { 55 | return Object.prototype.toString.call(thing).slice(8, -1); 56 | } 57 | 58 | function apply_at_path(path: string, object: any, culled: Culled[]) { 59 | if (!proxy_lookup.has(object)) return object; 60 | 61 | const type = get_type(object); 62 | if (type !== 'Array' && type !== 'Object') return object; // bail. TODO Map/Set/etc? 63 | 64 | const kept: Array | Record = ( 65 | type === 'Array' ? [] : {} 66 | ); 67 | 68 | const was_read = reads.get(object); 69 | 70 | Object.keys(object).forEach(key => { 71 | const child_path = path ? `${path}.${key}` : key; 72 | 73 | if (was_read && was_read.has(key)) { 74 | (kept as Record)[key] = apply_at_path(child_path, object[key], culled); 75 | } else { 76 | culled.push({ 77 | path: child_path, 78 | value: object[key] 79 | }); 80 | } 81 | }); 82 | 83 | // treat length as a special case, since it's non-enumerable 84 | if (type === 'Array' && was_read && was_read.has('length')) { 85 | kept.length = object.length; 86 | } 87 | 88 | return kept; 89 | } 90 | 91 | export function apply(object: any) { 92 | if (object_lookup && object_lookup.has(object)) { 93 | // input was a proxy — we need the underlying object 94 | object = object_lookup.get(object); 95 | } 96 | 97 | if (!proxy_lookup.has(object)) { 98 | throw new Error('You must call `prepare` before calling `apply`'); 99 | } 100 | 101 | const culled: Culled[] = []; 102 | const kept = apply_at_path('', object, culled); 103 | 104 | return { kept, culled }; 105 | } -------------------------------------------------------------------------------- /test/test.ts: -------------------------------------------------------------------------------- 1 | import { test } from 'uvu'; 2 | import * as assert from 'uvu/assert'; 3 | import { prepare, apply } from '../src/index'; 4 | 5 | test('culls unread properties from an object', () => { 6 | const obj = { 7 | foo: 1, 8 | bar: 2 9 | }; 10 | 11 | const proxy = prepare(obj); 12 | assert.is(proxy.foo, 1); 13 | 14 | assert.equal(apply(obj), { 15 | kept: { 16 | foo: 1 17 | }, 18 | culled: [ 19 | { path: 'bar', value: 2 } 20 | ] 21 | }); 22 | }); 23 | 24 | test('culls unread properties from a proxy', () => { 25 | const proxy = prepare({ 26 | foo: 1, 27 | bar: 2 28 | }); 29 | assert.is(proxy.foo, 1); 30 | 31 | assert.equal(apply(proxy), { 32 | kept: { 33 | foo: 1 34 | }, 35 | culled: [ 36 | { path: 'bar', value: 2 } 37 | ] 38 | }); 39 | }); 40 | 41 | test('culls unread properties from an array', () => { 42 | const arr = ['a', 'b', 'c']; 43 | 44 | const proxy = prepare(arr); 45 | assert.is(proxy[1], 'b'); 46 | 47 | assert.equal(apply(arr), { 48 | kept: [, 'b'], 49 | culled: [ 50 | { path: '0', value: 'a' }, 51 | { path: '2', value: 'c' } 52 | ] 53 | }); 54 | }); 55 | 56 | test('preserves array length if length is accessed', () => { 57 | const arr = ['a', 'b', 'c']; 58 | 59 | const proxy = prepare(arr); 60 | assert.is(proxy[1], 'b'); 61 | assert.is(proxy.length, 3); 62 | 63 | assert.equal(apply(arr), { 64 | kept: [, 'b', ,], 65 | culled: [ 66 | { path: '0', value: 'a' }, 67 | { path: '2', value: 'c' } 68 | ] 69 | }); 70 | }); 71 | 72 | test('culls nested properties', () => { 73 | // https://www.json-generator.com/ 74 | const user = { 75 | firstname: 'Terrell', 76 | lastname: 'Snider', 77 | friends: [ 78 | { firstname: 'Rachelle', lastname: 'Knight' }, 79 | { firstname: 'Ila', lastname: 'Farrell' }, 80 | { firstname: 'Vasquez', lastname: 'Flynn' } 81 | ], 82 | pets: [ 83 | { name: 'Bobo', species: 'Great Dane' } 84 | ] 85 | }; 86 | 87 | const proxy = prepare(user); 88 | assert.is(proxy.firstname, 'Terrell'); 89 | assert.is(proxy.friends[0].firstname, 'Rachelle'); 90 | 91 | assert.equal(apply(user), { 92 | kept: { 93 | firstname: 'Terrell', 94 | friends: [ 95 | { firstname: 'Rachelle' } 96 | ] 97 | }, 98 | culled: [ 99 | { path: 'lastname', value: 'Snider' }, 100 | { path: 'friends.0.lastname', value: 'Knight' }, 101 | { path: 'friends.1', value: { firstname: 'Ila', lastname: 'Farrell' } }, 102 | { path: 'friends.2', value: { firstname: 'Vasquez', lastname: 'Flynn' } }, 103 | { path: 'pets', value: [ { name: 'Bobo', species: 'Great Dane' } ] } 104 | ] 105 | }); 106 | }); 107 | 108 | test('binds methods', () => { 109 | const obj = { 110 | map: new Map([ 111 | [1, 'a'], 112 | [2, 'b'] 113 | ]) 114 | }; 115 | 116 | const proxy = prepare(obj); 117 | 118 | assert.is(proxy.map.get(1), 'a'); 119 | 120 | assert.equal(apply(obj), { 121 | kept: { 122 | map: new Map([ 123 | [1, 'a'], 124 | [2, 'b'] 125 | ]) 126 | }, 127 | culled: [] 128 | }); 129 | }); 130 | 131 | test('destructuring objects works', () => { 132 | const obj = { a: 1, b: 2, c: 3 }; 133 | const proxy = prepare(obj); 134 | 135 | const { b } = proxy; 136 | 137 | assert.is(b, 2); 138 | 139 | assert.equal(apply(obj), { 140 | kept: { b: 2 }, 141 | culled: [ 142 | { path: 'a', value: 1 }, 143 | { path: 'c', value: 3 } 144 | ] 145 | }); 146 | }); 147 | 148 | test('destructuring arrays works', () => { 149 | const arr = ['a', 'b', 'c']; 150 | const proxy = prepare(arr); 151 | 152 | const [, x] = proxy; 153 | 154 | assert.is(x, 'b'); 155 | 156 | assert.equal(apply(arr), { 157 | kept: ['a', 'b', ,], 158 | culled: [ 159 | { path: '2', value: 'c' } 160 | ] 161 | }); 162 | }); 163 | 164 | test('culls unread objects with unread properties', () => { 165 | const obj = { arr: [] }; 166 | prepare(obj); 167 | 168 | assert.equal(apply(obj), { 169 | kept: {}, 170 | culled: [ 171 | { path: 'arr', value: [] } 172 | ] 173 | }) 174 | }); 175 | 176 | test('keeps read objects with unread properties', () => { 177 | const obj = { arr: [] }; 178 | const proxy = prepare(obj); 179 | 180 | assert.ok(proxy.arr); 181 | 182 | assert.equal(apply(obj), { 183 | kept: { arr: [] }, 184 | culled: [] 185 | }) 186 | }); 187 | 188 | test('errors if apply called on non-prepared object', () => { 189 | const obj = {}; 190 | 191 | assert.throws(() => apply(obj), 'You must call `prepare` before calling `apply`'); 192 | }); 193 | 194 | test('keeps everything if object is stringified', () => { 195 | const obj = { foo: ['a', 'b', 'c'], bar: null }; 196 | const proxy = prepare(obj); 197 | 198 | const json = JSON.stringify(proxy, null, ' '); 199 | 200 | assert.equal(apply(obj), { 201 | kept: { 202 | foo: ['a', 'b', 'c'], 203 | bar: null 204 | }, 205 | culled: [] 206 | }); 207 | 208 | assert.equal(json, `{\n "foo": [\n "a",\n "b",\n "c"\n ],\n "bar": null\n}`); 209 | }); 210 | 211 | test.run(); --------------------------------------------------------------------------------