├── .gitignore ├── LICENSE ├── README.md ├── benchmark └── benchmark.ts ├── docs └── immerutable.gif ├── fuzz ├── map.fuzz.test.ts ├── sortedmap.fuzz.test.ts └── util.ts ├── package.json ├── src ├── hash.ts ├── index.ts ├── lrucache.test.ts ├── lrucache.ts ├── map.test.ts ├── map.ts ├── sortedcollection.test.ts ├── sortedcollection.ts ├── sortedmap.test.ts ├── sortedmap.ts └── util.ts ├── tsconfig.json └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | coverage 3 | dist 4 | node_modules -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 scriby 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 | # immerutable 2 | 3 | The aim of this library is to provide scalable and performant data structures for use with Immer. Immer makes 4 | modifying immutable data simple & straightforward, but has lackluster performance when making many small modifications 5 | to arrays and large objects (maps). In order for Immer to retain immutability of the data, it must create copies of 6 | the arrays & objects that are modified (or contain something which is modified). Creating these copies becomes 7 | expensive as they grow in size. 8 | 9 | This library is inspired by ImmutableJS and provides data structures which use structural sharing that allow Immer 10 | to make copies of subsets of large objects when data changes. For large arrays & objects using Immerutable will provide 11 | a 30-40x+ speedup over basic arrays and objects with Immer (the larger the object, the greater the speedup). 12 | 13 | ## How it works 14 | 15 | ![](docs/immerutable.gif) 16 | 17 | ## Benchmarks 18 | 19 | All [benchmarks](benchmark/benchmark.ts) are the time to perform 4,000 operations. For instance, 4,000 individual insertions into a map or array. 20 | 21 | |benchmark|time| 22 | |---------|----| 23 | |immer map (set)|1900ms| 24 | |immerutable map (set)|48ms| 25 | ||| 26 | |immer array (insert in increasing order)|168ms| 27 | |immer array (insert in random order)|4018ms| 28 | |immer array (insert in decreasing order)|7791ms| 29 | |immerutable sorted collection (insert in increasing order)|108ms| 30 | |immerutable sorted collection (insert in random order)|268ms| 31 | |immerutable sorted collection (insert in decreasing order)|236ms| 32 | ||| 33 | |immerutable sorted map (insert in increasing order)|166ms| 34 | |immerutable sorted map (insert in random order)|345ms| 35 | |immerutable sorted map (insert in decreasing order)|305ms| 36 | ||| 37 | |immerutable lru cache set (max items = 2000)|252ms| 38 | |immerutable lru cache set (max items = 400)|369ms| 39 | 40 | ### When should I use this? 41 | 42 | * Dealing with large data structures, especially ones containing more than 10,000 items 43 | * If you have a use case dealing with performing many small modifications on a list or map with thousands of items 44 | * The data structures such as SortedMap or LruCache are a good fit for a use case you have 45 | 46 | ## Data structures 47 | 48 | All data structures are implemented as plain javascript objects such that they are fully serializable if stored in 49 | a redux or ngrx store. Because the data structures are plain objects, the methods for dealing with the data structure 50 | are not on the objects themselves. Rather, an "adapter" class is used which accepts the data structure as an argument 51 | to each method. 52 | 53 | ### Usage with Immer 54 | 55 | Example reducer using an Immerutable Sorted Map: 56 | 57 | ```typescript 58 | import {produce} from 'immer'; 59 | import {ISortedMap, SortedMapAdapter} from 'immerutable'; 60 | import {createFeatureSelector, createSelector} from 'ngrx'; 61 | 62 | export interface Book { 63 | id: string; 64 | title: string; 65 | author: string; 66 | } 67 | 68 | enum BookActionTypes { 69 | ADD_BOOK = 'ADD_BOOK', 70 | UPDATE_BOOK = 'UPDATE_BOOK', 71 | REMOVE_BOOK = 'REMOVE_BOOK' 72 | } 73 | 74 | export class AddBook { 75 | readonly type = BookActionTypes.ADD_BOOK; 76 | constructor(readonly payload: { book: Book }) {} 77 | } 78 | 79 | export class UpdateBook { 80 | readonly type = BookActionTypes.UPDATE_BOOK; 81 | constructor(readonly payload: { book: Book }) {} 82 | } 83 | 84 | export class RemoveBook { 85 | readonly type = BookActionTypes.REMOVE_BOOK; 86 | constructor(readonly payload: { bookId: string }) {} 87 | } 88 | 89 | const BookActions = AddBook | UpdateBook | RemoveBook; 90 | 91 | const bookAdapter = new SortedMapAdapter({ 92 | getOrderingKey: (book) => book.title 93 | }); 94 | 95 | export interface BooksState { 96 | books: ISortedMap; 97 | } 98 | 99 | const initialState: BooksState = { 100 | books: bookAdapter.create() 101 | }; 102 | 103 | export function bookReducer = produce((draft: BooksState, action: BookActions) => { 104 | switch (action.type) { 105 | case BookActionTypes.ADD_BOOK: 106 | bookAdapter.set(draft.books, action.payload.book.id, action.payload.book); 107 | break; 108 | case BookActionTypes.UPDATE_BOOK: 109 | bookAdapter.update(draft.books, action.payload.book.id, (book: Book) => { 110 | return action.payload.book; // Or, mutate the book object directly. 111 | }); 112 | break; 113 | case BookActionTypes.REMOVE_BOOK: 114 | bookAdapter.remove(draft.books, action.payload.bookId); 115 | break; 116 | default: 117 | return initialState; 118 | } 119 | }); 120 | 121 | 122 | // Example selectors 123 | export const booksFeature = createFeatureSelector('books'); 124 | 125 | export const getBooksIterable = createSelector(booksFeature, (booksState: BooksState) => { 126 | return booksAdapter.getValuesIterable(booksState.books); 127 | }); 128 | ``` 129 | 130 | ### Map 131 | 132 | Similar to using an object as a Map in javascript, this data structure allows an object to be indexed by a key. 133 | As of now, the key must either be a number or string. Get/has/set/remove operations are all constant time, and iteration 134 | is linear time. The underlying implementation for this data structure is a trie. 135 | 136 | ```typescript 137 | import {MapAdapter} from 'immerutable'; 138 | 139 | interface TestObject { 140 | data: string; 141 | } 142 | 143 | // Create an adapter to work with the map. 144 | const adapter = new MapAdapter(); 145 | 146 | // Create an empty map. Store this result of this in the redux or ngrx store. 147 | const map = adapter.create(); 148 | 149 | // Set an item in the map. 150 | adapter.set(map, 1, { data: 'test' }); 151 | 152 | // Get an item out of the map by key. 153 | const item = adapter.get(map, 1); 154 | 155 | // Check if the map has an item. 156 | const hasItem = adapter.has(map, 1); 157 | 158 | // Update an item in the map if it already exists. 159 | adapter.update(map, 1, (item) => { 160 | item.data = 'updated'; // The item may be mutated directly, or a new item may be returned. 161 | }); 162 | 163 | // Get the number of items in the map. 164 | const size = adapter.getSize(map); 165 | 166 | // Iterate through the map items (order is not guaranteed). 167 | // With iterator downleveling (setting in tsconfig) or ES6: 168 | for (const {key, value} of adapter.getIterable(map)) { 169 | console.log(key, value); 170 | } 171 | 172 | // Without iterator downleveling: 173 | const iterable = adapter.getIterable(map); 174 | const iterator = iterable[Symbol.iterator](); // May need Symbol.iterator polyfill 175 | let next: TestObject; 176 | 177 | while (!(next = iterator.next()).done) { 178 | const {key, value} = next.value; 179 | console.log(key, value); 180 | } 181 | 182 | // Convert to an array. May require polyfill. 183 | Array.from(adapter.getIterable(map)); 184 | 185 | // Remove an item from the map by key. 186 | adapter.remove(map, 1); 187 | ``` 188 | 189 | ### Sorted Collection 190 | 191 | This collection is similar to an array where all the items are kept in sorted order. However, it differs from an array 192 | in that items cannot be looked up by index, but they can be iterated in order. Insertion and removal are log(n) 193 | operations, and iteration is linear. The underlying implementation of this data structure is a B-tree. 194 | 195 | ```typescript 196 | import {SortedCollectionAdapter} from 'immerutable'; 197 | 198 | interface TestObject { 199 | id: string; 200 | data: string; 201 | order: number; 202 | } 203 | 204 | // Create an adapter to work with the sorted collection. 205 | const adapter = new SortedCollectionAdapter({ 206 | orderComparer: (a, b) => a.order - b.order, 207 | equalityComparer: (a, b) => a.id === b.id, 208 | }); 209 | 210 | // Create an empty sorted collection. Store the result of this in the redux or ngrx store. 211 | const sortedCollection = adapter.create(); 212 | 213 | const item = { id: 'a', data: 'test', order: 1 }; 214 | 215 | // Add an item to the sorted collection. Duplicates are allowed. 216 | adapter.insert(sortedCollection, item); 217 | 218 | // Update an item in the sorted collection. Updates to ordering properties MUST take 219 | // place from within the update method for the collection to stay in sorted order. 220 | const updated = adapter.update(sortedCollection, item, (existing) => { 221 | existing.order = 2; // The item may be mutated, or a new item may be returned. 222 | }); 223 | 224 | // Get the number of items in the collection. 225 | const size = adapter.getSize(sortedCollection); 226 | 227 | // Get the first item in sorted order in the collection. 228 | const first = adapter.getFirst(sortedCollection); 229 | 230 | // Get the last item in sorted order in the collection. 231 | const last = adapter.getLast(sortedCollection); 232 | 233 | // Iterate through the items in the collection (with iterator downleveling or ES6). 234 | // See map example for ES5 iterator. 235 | for (const item of adapter.getIterable(sortedCollection)) { 236 | console.log(item); 237 | } 238 | 239 | // Convert to an array (May require polyfill). 240 | Array.from(updater.getIterable(sortedCollection)); 241 | 242 | // Remove an item from the sorted collection. Properties which are used as part 243 | // of the orderComparer and equalityComparer must be included (other properties are optional). 244 | adapter.remove(sortedCollection, updated); 245 | ``` 246 | 247 | ### Sorted Map 248 | 249 | A sorted map combines the map and sorted collection data structures to provide a map which can be iterated in sorted 250 | order. This data structure is useful for efficiently keeping a list of items in order as items are added and removed 251 | for the purpose of rendering the list in a UI. 252 | 253 | ```typescript 254 | import {SortedMapAdapter} from 'immerutable'; 255 | 256 | interface TestObject { 257 | id: string; 258 | data: string; 259 | order: number; 260 | } 261 | 262 | const adapter = new SortedMapAdapter({ 263 | getOrderingKey: (item) => item.order 264 | }); 265 | 266 | const sortedMap = adapter.create(); 267 | 268 | // Set an item in the sorted map. 269 | adapter.set(sortedMap, 1, { data: 'test' }); 270 | 271 | // Get an item out of the sorted map by key. 272 | const item = adapter.get(sortedMap, 1); 273 | 274 | // Check if the sorted map has an item. 275 | const hasItem = adapter.has(sortedMap, 1); 276 | 277 | // Update an item in the sorted map if it already exists. 278 | adapter.update(sortedMap, 1, (item) => { 279 | item.data = 'updated'; // The item may be mutated directly, or a new item may be returned. 280 | }); 281 | 282 | // Get the number of items in the sorted map. 283 | const size = adapter.getSize(sortedMap); 284 | 285 | // Get the first item in sorted order in the sorted map. 286 | const first = adapter.getFirst(sortedMap); 287 | 288 | // Get the last item in sorted order in the sorted map. 289 | const last = adapter.getLast(sortedMap); 290 | 291 | // Iterate through the map items in sorted order. 292 | // With iterator downleveling (setting in tsconfig) or ES6: 293 | for (const {key, value} of adapter.getIterable(sortedMap)) { 294 | console.log(key, value); 295 | } 296 | 297 | // Iterate just through the values in sorted order. 298 | for (const value of adapter.getValuesIterable(sortedMap)) { 299 | console.log(value); 300 | } 301 | 302 | // Convert values to an array. May require polyfill. 303 | Array.from(adapter.getValuesIterable(sortedMap)); 304 | 305 | // Remove an item from the sorted map by key. 306 | adapter.remove(sortedMap, 1); 307 | ``` 308 | -------------------------------------------------------------------------------- /benchmark/benchmark.ts: -------------------------------------------------------------------------------- 1 | import {SortedCollectionAdapter} from '../src/sortedcollection'; 2 | 3 | require('source-map-support').install(); 4 | import produce, {setAutoFreeze, setUseProxies} from 'immer'; 5 | import {MapAdapter} from '../src/map'; 6 | import {SortedMapAdapter} from '../src/sortedmap'; 7 | import {LruCacheAdapter} from '../src/lrucache'; 8 | 9 | setUseProxies(true); 10 | setAutoFreeze(false); 11 | declare const global: any; 12 | 13 | const WARMUP_ITERATIONS = 1000; 14 | const ITERATIONS = 4000; 15 | type Obj = { order?: number, id?: number|string, data?: string }; 16 | 17 | function benchmark(label: string, cb: (iterations: number) => void) { 18 | cb(WARMUP_ITERATIONS); // Warm up V8 to let it optimize the code 19 | global.gc(); 20 | 21 | console.time(label); 22 | cb(ITERATIONS); 23 | console.timeEnd(label); 24 | } 25 | 26 | function immerutableMap() { 27 | benchmark('immerutable map (set)', (iterations) => { 28 | const adapter = new MapAdapter(); 29 | let state = { map: adapter.create() }; 30 | 31 | for (let i = 0; i < iterations; i++) { 32 | state = produce(state, (draft: typeof state) => { 33 | adapter.set(draft.map, i, { id: i, data: i.toString() }); 34 | }); 35 | } 36 | }); 37 | } 38 | 39 | function immerutableBtree() { 40 | const sortedCollectionAdapter = new SortedCollectionAdapter({ 41 | orderComparer: (a: Obj, b: Obj) => a.order! - b.order!, 42 | }); 43 | 44 | benchmark('immerutable sorted collection (insert in increasing order)', (iterations) => { 45 | let state = { btree: sortedCollectionAdapter.create() }; 46 | 47 | for (let i = 0; i < iterations; i++) { 48 | state = produce(state, (draft: typeof state) => { 49 | sortedCollectionAdapter.insert(draft.btree, { data: i.toString(), order: i }); 50 | }); 51 | } 52 | }); 53 | 54 | benchmark('immerutable sorted collection (insert in random order)', (iterations) => { 55 | let state = { btree: sortedCollectionAdapter.create() }; 56 | 57 | for (let i = 0; i < iterations; i++) { 58 | state = produce(state, (draft: typeof state) => { 59 | sortedCollectionAdapter.insert(draft.btree, { data: i.toString(), order: Math.random() }); 60 | }); 61 | } 62 | }); 63 | 64 | benchmark('immerutable sorted collection (insert in decreasing order)', (iterations) => { 65 | let state = { btree: sortedCollectionAdapter.create() }; 66 | 67 | for (let i = iterations - 1; i >= 0; i--) { 68 | state = produce(state, (draft: typeof state) => { 69 | sortedCollectionAdapter.insert(draft.btree, { data: i.toString(), order: i }); 70 | }); 71 | } 72 | }); 73 | 74 | benchmark(`immerutable sorted collection iteration (inside immer) (size: ${ITERATIONS})`, (() => { 75 | let state = { btree: sortedCollectionAdapter.create() }; 76 | for (let i = 0; i < ITERATIONS; i++) { 77 | sortedCollectionAdapter.insert(state.btree, {data: i.toString(), order: i }); 78 | } 79 | 80 | return () => { 81 | state = produce(state, (draft: typeof state) => { 82 | for (const item of sortedCollectionAdapter.getIterable(draft.btree)) { 83 | 84 | } 85 | }); 86 | }; 87 | })()); 88 | 89 | benchmark(`immerutable sorted collection iteration (outside immer) (size: ${ITERATIONS})`, (() => { 90 | let state = { btree: sortedCollectionAdapter.create() }; 91 | for (let i = 0; i < ITERATIONS; i++) { 92 | sortedCollectionAdapter.insert(state.btree, {data: i.toString(), order: i }); 93 | } 94 | 95 | return () => { 96 | for (const item of sortedCollectionAdapter.getIterable(state.btree)) { 97 | 98 | } 99 | }; 100 | })()); 101 | } 102 | 103 | function immerutableSortedMap() { 104 | const sortedMapAdapter = new SortedMapAdapter({ 105 | getOrderingKey: (obj: Obj) => obj.order!, 106 | }); 107 | 108 | benchmark('immerutable sorted map (insert in increasing order)', (iterations) => { 109 | let state = { sortedMap: sortedMapAdapter.create() }; 110 | 111 | for (let i = 0; i < iterations; i++) { 112 | state = produce(state, (draft: typeof state) => { 113 | sortedMapAdapter.set(draft.sortedMap, i.toString(), { data: i.toString(), order: i }); 114 | }); 115 | } 116 | }); 117 | 118 | benchmark('immerutable sorted map (insert in random order)', (iterations) => { 119 | let state = { sortedMap: sortedMapAdapter.create() }; 120 | 121 | for (let i = 0; i < iterations; i++) { 122 | state = produce(state, (draft: typeof state) => { 123 | sortedMapAdapter.set(draft.sortedMap, i.toString(), { data: i.toString(), order: Math.random() }); 124 | }); 125 | } 126 | }); 127 | 128 | benchmark('immerutable sorted map (insert in decreasing order)', (iterations) => { 129 | let state = { sortedMap: sortedMapAdapter.create() }; 130 | 131 | for (let i = iterations - 1; i >= 0; i--) { 132 | state = produce(state, (draft: typeof state) => { 133 | sortedMapAdapter.set(draft.sortedMap, i.toString(), { data: i.toString(), order: i }); 134 | }); 135 | } 136 | }); 137 | 138 | benchmark(`immerutable sorted map iteration (inside immer) (size: ${ITERATIONS})`, (() => { 139 | let state = { sortedMap: sortedMapAdapter.create() }; 140 | for (let i = 0; i < ITERATIONS; i++) { 141 | sortedMapAdapter.set(state.sortedMap, i.toString(), {data: i.toString(), order: i }); 142 | } 143 | 144 | return () => { 145 | state = produce(state, (draft: typeof state) => { 146 | for (const item of sortedMapAdapter.getIterable(draft.sortedMap)) { 147 | 148 | } 149 | }); 150 | }; 151 | })()); 152 | 153 | benchmark(`immerutable sorted map iteration (outside immer) (size: ${ITERATIONS})`, (() => { 154 | let state = { sortedMap: sortedMapAdapter.create() }; 155 | for (let i = 0; i < ITERATIONS; i++) { 156 | sortedMapAdapter.set(state.sortedMap, i.toString(), {data: i.toString(), order: i }); 157 | } 158 | 159 | return () => { 160 | for (const item of sortedMapAdapter.getIterable(state.sortedMap)) { 161 | 162 | } 163 | }; 164 | })()); 165 | } 166 | 167 | function immerArray() { 168 | benchmark('immer array (insert in increasing order)', (iterations) => { 169 | let state = { array: [] as Obj[] }; 170 | 171 | for (let i = 0; i < iterations; i++) { 172 | state = produce(state, (draft: typeof state) => { 173 | draft.array.push({ id: i, data: i.toString() }); 174 | }); 175 | } 176 | }); 177 | 178 | benchmark('immer array (insert in random order)', (iterations) => { 179 | let state = { array: [] as Obj[] }; 180 | 181 | for (let i = 0; i < iterations; i++) { 182 | state = produce(state, (draft: typeof state) => { 183 | draft.array.splice(Math.floor(Math.random() * i), 0, { id: i, data: i.toString() }); 184 | }); 185 | } 186 | }); 187 | 188 | benchmark('immer array (insert in decreasing order)', (iterations) => { 189 | let state = { array: [] as Obj[] }; 190 | 191 | for (let i = iterations - 1; i >= 0 ; i--) { 192 | state = produce(state, (draft: typeof state) => { 193 | draft.array.unshift({ id: i, data: i.toString() }); 194 | }); 195 | } 196 | }); 197 | 198 | benchmark('immer array iteration (inside immer) (size = 4000)', () => { 199 | let state = { array: [] as Obj[] }; 200 | for (let i = 0; i < ITERATIONS; i++) { 201 | state.array.push({ id: i, data: i.toString() }); 202 | } 203 | 204 | return () => { 205 | state = produce(state, (draft: typeof state) => { 206 | for (const item of draft.array) { 207 | 208 | } 209 | }); 210 | }; 211 | }); 212 | } 213 | 214 | function immerMap() { 215 | benchmark('immer map (set)', (iterations) => { 216 | let state = { map: Object.create(null) }; 217 | 218 | for (let i = 0; i < iterations; i++) { 219 | state = produce(state, (draft: typeof state) => { 220 | draft.map[i] = { id: i, data: i.toString() }; 221 | }); 222 | } 223 | }); 224 | } 225 | 226 | function lruCache() { 227 | benchmark(`lru cache (50% capacity)`, (iterations) => { 228 | const lruCache = new LruCacheAdapter(iterations / 2); 229 | 230 | let state = { lru: lruCache.create() }; 231 | 232 | for (let i = 0; i < iterations; i++) { 233 | state = produce(state, (draft: typeof state) => { 234 | lruCache.set(draft.lru, i, { data: i }); 235 | }); 236 | } 237 | }); 238 | 239 | benchmark(`lru cache (10% capacity)`, (iterations) => { 240 | const lruCache = new LruCacheAdapter(iterations / 10); 241 | 242 | let state = { lru: lruCache.create() }; 243 | 244 | for (let i = 0; i < iterations; i++) { 245 | state = produce(state, (draft: typeof state) => { 246 | lruCache.set(draft.lru, i, { data: i }); 247 | }); 248 | } 249 | }); 250 | } 251 | 252 | function divider() { 253 | console.log('-------------------------------------------'); 254 | } 255 | 256 | immerMap(); 257 | immerutableMap(); 258 | 259 | divider(); 260 | 261 | immerArray(); 262 | immerutableBtree(); 263 | 264 | divider(); 265 | 266 | immerutableSortedMap(); 267 | 268 | divider(); 269 | 270 | lruCache(); -------------------------------------------------------------------------------- /docs/immerutable.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/scriby/immerutable/0859158b4a71de3f0e0ab6e28a5a010eebe0b0c7/docs/immerutable.gif -------------------------------------------------------------------------------- /fuzz/map.fuzz.test.ts: -------------------------------------------------------------------------------- 1 | import * as rng from 'number-generator'; 2 | import {MapAdapter} from '../src/map'; 3 | import {getSeed} from './util'; 4 | 5 | const seed = getSeed(); 6 | 7 | describe(`Map (fuzz) (Seed: ${seed})`, () => { 8 | it('Retains consistency over many sets', () => { 9 | const random = rng.aleaRNGFactory(seed); 10 | const adapter = new MapAdapter(); 11 | const map = adapter.create(); 12 | const expected = Object.create(null); 13 | 14 | for (let i = 0; i < 200000; i++) { 15 | const value = random.uInt32(); 16 | const data = { data: value }; 17 | adapter.set(map, value, data); 18 | expected[value] = data; 19 | } 20 | 21 | expect(adapter.getSize(map)).toEqual(Object.keys(expected).length); 22 | 23 | for (const key in expected) { 24 | expect(adapter.get(map, Number(key))).toEqual(expected[key]); 25 | } 26 | 27 | for (const [key, value] of adapter.getIterable(map)) { 28 | expect(value).toEqual(expected[key]); 29 | } 30 | }); 31 | 32 | it('Adds and removes randomly', () => { 33 | const random = rng.aleaRNGFactory(seed); 34 | const adapter = new MapAdapter(); 35 | const map = adapter.create(); 36 | const expected = Object.create(null); 37 | const keys = []; 38 | 39 | for (let i = 0; i < 500000; i++) { 40 | // 2/3 of the time, add a random value 41 | if (random.uFloat32() < .67) { 42 | const value = random.uInt32(); 43 | const data = { data: value }; 44 | adapter.set(map, value, data); 45 | expected[value] = data; 46 | keys.push(value); 47 | } else { 48 | // 1/3 of the time, remove a value 49 | if (keys.length === 0) continue; 50 | 51 | const lastKey = keys.pop()!; 52 | adapter.remove(map, Number(lastKey)); 53 | delete expected[lastKey]; 54 | } 55 | } 56 | 57 | expect(adapter.getSize(map)).toEqual(Object.keys(expected).length); 58 | 59 | for (const key in expected) { 60 | expect(adapter.get(map, Number(key))).toEqual(expected[key]); 61 | } 62 | 63 | for (const [key, value] of adapter.getIterable(map)) { 64 | expect(value).toEqual(expected[key]); 65 | } 66 | }); 67 | }); -------------------------------------------------------------------------------- /fuzz/sortedmap.fuzz.test.ts: -------------------------------------------------------------------------------- 1 | import * as rng from 'number-generator'; 2 | import {SortedMapAdapter} from '../src/sortedmap'; 3 | import {getSeed} from './util'; 4 | 5 | const seed = getSeed(); 6 | 7 | describe(`SortedSet (fuzz) (Seed: ${seed})`, () => { 8 | it('Retains consistency over many sets', () => { 9 | const random = rng.aleaRNGFactory(seed); 10 | const adapter = new SortedMapAdapter({ 11 | getOrderingKey: item => item.order, 12 | }); 13 | const map = adapter.create(); 14 | const expected = Object.create(null); 15 | 16 | for (let i = 0; i < 200000; i++) { 17 | const value = random.uInt32(); 18 | const data = { data: value, order: value }; 19 | adapter.set(map, value, data); 20 | expected[value] = data; 21 | } 22 | 23 | expect(adapter.getSize(map)).toEqual(Object.keys(expected).length); 24 | 25 | for (const key in expected) { 26 | expect(adapter.get(map, Number(key))).toEqual(expected[key]); 27 | } 28 | 29 | let lastOrder = -Infinity; 30 | for (const [key, value] of adapter.getIterable(map)) { 31 | expect(value.order).toBeGreaterThanOrEqual(lastOrder); 32 | lastOrder = value.order; 33 | expect(value).toEqual(expected[key]); 34 | } 35 | }); 36 | 37 | it('Retains consistency for a large map with in-order inserts', () => { 38 | const LENGTH = 300000; 39 | const adapter = new SortedMapAdapter({ 40 | getOrderingKey: item => item.order, 41 | }); 42 | const map = adapter.create(); 43 | 44 | for (let i = 1; i <= LENGTH; i++) { 45 | adapter.set(map, i, { data: i, order: i }); 46 | } 47 | 48 | expect(adapter.getSize(map)).toEqual(LENGTH); 49 | 50 | let curr = 0; 51 | for (const [key, value] of adapter.getIterable(map)) { 52 | curr++; 53 | 54 | expect(key).toEqual(curr); 55 | expect(value.data).toEqual(curr); 56 | expect(value.order).toEqual(curr); 57 | } 58 | 59 | expect(curr).toEqual(LENGTH); 60 | }); 61 | 62 | it('Retains consistency for a large map with reverse-order inserts', () => { 63 | const LENGTH = 300000; 64 | const adapter = new SortedMapAdapter({ 65 | getOrderingKey: item => -item.order, 66 | }); 67 | const map = adapter.create(); 68 | 69 | for (let i = 1; i <= LENGTH; i++) { 70 | adapter.set(map, i, { data: i, order: i }); 71 | } 72 | 73 | expect(adapter.getSize(map)).toEqual(LENGTH); 74 | 75 | let curr = LENGTH; 76 | for (const [key, value] of adapter.getIterable(map)) { 77 | expect(key).toEqual(curr); 78 | expect(value.data).toEqual(curr); 79 | expect(value.order).toEqual(curr); 80 | 81 | curr--; 82 | } 83 | 84 | expect(curr).toEqual(0); 85 | }); 86 | 87 | it('Retains consistency for medium list sizes', () => { 88 | const random = rng.aleaRNGFactory(seed); 89 | 90 | for (let i = 0; i < 200; i++) { 91 | const adapter = new SortedMapAdapter({ 92 | getOrderingKey: item => -item.order, 93 | }); 94 | 95 | const map = adapter.create(); 96 | 97 | const length = 3000 + (random.uInt32() % 2000); 98 | const variance = (random.uInt32() % 100) + 1; 99 | 100 | for (let k = 1; k <= length; k++) { 101 | const key = k + (random.uInt32() % variance); 102 | const order = k + (random.uInt32() % variance); 103 | 104 | adapter.set(map, key, { data: key, order }); 105 | } 106 | 107 | let lastOrder = Infinity; 108 | for (const [key, value] of adapter.getIterable(map)) { 109 | expect(value.order).toBeLessThanOrEqual(lastOrder); 110 | lastOrder = value.order; 111 | 112 | expect(value.data).toEqual(key); 113 | } 114 | } 115 | }); 116 | 117 | //3662728131053617 118 | it('Adds and removes randomly', () => { 119 | const random = rng.aleaRNGFactory(seed); 120 | const adapter = new SortedMapAdapter({ 121 | getOrderingKey: item => item.order, 122 | }); 123 | const map = adapter.create(); 124 | const expected = Object.create(null); 125 | const keys = []; 126 | 127 | for (let i = 0; i < 500000; i++) { 128 | // 2/3 of the time, add a random value 129 | if (random.uFloat32() < .67) { 130 | const value = random.uInt32(); 131 | const data = { data: value, order: value % 5 }; // Mod value by 5 to increase collisions 132 | 133 | adapter.set(map, value, data); 134 | expected[value] = data; 135 | keys.push(value); 136 | } else { 137 | // 1/3 of the time, remove a value 138 | if (keys.length === 0) continue; 139 | 140 | const lastKey = keys.pop()!; 141 | adapter.remove(map, Number(lastKey)); 142 | delete expected[lastKey]; 143 | } 144 | } 145 | 146 | expect(adapter.getSize(map)).toEqual(Object.keys(expected).length); 147 | 148 | for (const key in expected) { 149 | expect(adapter.get(map, Number(key))).toEqual(expected[key]); 150 | } 151 | 152 | let lastOrder = -Infinity; 153 | for (const [key, value] of adapter.getIterable(map)) { 154 | expect(value.order).toBeGreaterThanOrEqual(lastOrder); 155 | lastOrder = value.order; 156 | expect(value).toEqual(expected[key]); 157 | } 158 | }); 159 | 160 | it('Retains consistency over many updates', () => { 161 | const random = rng.aleaRNGFactory(seed); 162 | const adapter = new SortedMapAdapter({ 163 | getOrderingKey: item => item.order, 164 | }); 165 | const map = adapter.create(); 166 | const expected = Object.create(null); 167 | 168 | for (let i = 0; i < 100000; i++) { 169 | const value = random.uInt32(); 170 | const data = { data: value, order: value }; 171 | adapter.set(map, value, data); 172 | expected[value] = data; 173 | } 174 | 175 | const expectedKeys = Object.keys(expected); 176 | 177 | for (let i = 0; i < 100000; i++) { 178 | const key = expectedKeys[Math.floor(random.uFloat32() * expectedKeys.length)]; 179 | 180 | adapter.update(map, Number(key), (item) => { 181 | item.order = random.uInt32(); 182 | }); 183 | } 184 | 185 | expect(adapter.getSize(map)).toEqual(expectedKeys.length); 186 | 187 | for (const key in expected) { 188 | expect(adapter.get(map, Number(key))).toEqual(expected[key]); 189 | } 190 | 191 | let lastOrder = -Infinity; 192 | for (const [key, value] of adapter.getIterable(map)) { 193 | expect(value.order).toBeGreaterThanOrEqual(lastOrder); 194 | lastOrder = value.order; 195 | expect(value).toEqual(expected[key]); 196 | } 197 | }); 198 | }); -------------------------------------------------------------------------------- /fuzz/util.ts: -------------------------------------------------------------------------------- 1 | export function getSeed() { 2 | return Math.floor(Math.random() * Number.MAX_SAFE_INTEGER); 3 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "immerutable", 3 | "description": "Scalable collections for Immer, inspired by ImmutableJS.", 4 | "version": "2.0.4", 5 | "devDependencies": { 6 | "@types/jest": "29.5.13", 7 | "@types/node": "22.5.5", 8 | "immer": "1.2.1", 9 | "jest": "29.7.0", 10 | "number-generator": "2.1.5", 11 | "source-map-support": "0.5.5", 12 | "ts-jest": "29.2.5", 13 | "typescript": "5.6.2" 14 | }, 15 | "files": ["dist/src/**", "src/**", "tsconfig.json"], 16 | "license": "MIT", 17 | "main": "dist/src/index.js", 18 | "author": "Chris Scribner", 19 | "repository": { 20 | "type" : "git", 21 | "url" : "https://github.com/scriby/immerutable" 22 | }, 23 | "scripts": { 24 | "benchmark": "tsc && node --expose-gc dist/benchmark/benchmark.js", 25 | "benchmark-debug": "tsc && node --inspect-brk --expose-gc dist/benchmark/benchmark.js", 26 | "coverage": "jest src --coverage", 27 | "build": "rm -rf dist/* && tsc", 28 | "fuzz": "jest fuzz", 29 | "fuzz-coverage": "jest fuzz --coverage", 30 | "fuzz-debug": "node --inspect-brk ./node_modules/.bin/jest fuzz --runInBand", 31 | "test": "jest src --watch", 32 | "test-debug": "node --inspect-brk ./node_modules/.bin/jest src --runInBand --watch" 33 | }, 34 | "jest": { 35 | "transform": { 36 | "^.+\\.ts$": "ts-jest" 37 | }, 38 | "testRegex": "((\\.|/)test)\\.ts$", 39 | "moduleFileExtensions": [ 40 | "ts", 41 | "js", 42 | "json", 43 | "node" 44 | ] 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/hash.ts: -------------------------------------------------------------------------------- 1 | /* 2 | MIT License 3 | 4 | Copyright (c) 2014-present, Facebook, Inc. 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | */ 24 | 25 | // This code is adapted from Facebook's ImmutableJS. 26 | 27 | export function hash(key: number|string): number { 28 | if (key == null) { 29 | return 0; 30 | } 31 | 32 | if (typeof key === 'number') { 33 | return hashNumber(key); 34 | } else if (typeof key === 'string') { 35 | return hashString(key); 36 | } else { 37 | throw new Error('Can only get hash code of numbers or strings'); 38 | } 39 | } 40 | 41 | function hashNumber(num: number): number { 42 | if (num !== num || num === Infinity) { 43 | return 0; 44 | } 45 | 46 | let h = num | 0; 47 | if (h !== num) { 48 | h ^= num * 0xffffffff; 49 | } 50 | while (num > 0xffffffff) { 51 | num /= 0xffffffff; 52 | h ^= num; 53 | } 54 | 55 | return h; 56 | } 57 | 58 | // http://jsperf.com/hashing-strings 59 | function hashString(str: string): number { 60 | // This is the hash from JVM 61 | // The hash code for a string is computed as 62 | // s[0] * 31 ^ (n - 1) + s[1] * 31 ^ (n - 2) + ... + s[n - 1], 63 | // where s[i] is the ith character of the string and n is the length of 64 | // the string. We "mod" the result to make it between 0 (inclusive) and 2^31 65 | // (exclusive) by dropping high bits. 66 | let hashed = 0; 67 | for (let ii = 0; ii < str.length; ii++) { 68 | hashed = (31 * hashed + str.charCodeAt(ii)) | 0; 69 | } 70 | return hashed; 71 | } 72 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export {IMap, ISingleValueNode, Key, MapAdapter} from './map'; 2 | export {ISortedCollection, LookupNodeInfo, ParentPath, SortedCollectionAdapter} from './sortedcollection'; 3 | export {GetOrderingKey, IKeyWithOrder, ISortedMap, SortedMapAdapter} from './sortedmap'; 4 | export {LruCacheAdapter, ILruCache} from './lrucache'; -------------------------------------------------------------------------------- /src/lrucache.test.ts: -------------------------------------------------------------------------------- 1 | import {LruCacheAdapter} from './lrucache'; 2 | 3 | const range = (start: number, end: number) => new Array(end - start + 1).join().split(',').map((empty, i) => i + start); 4 | 5 | describe('LRU Cache', () => { 6 | it('expires old items', () => { 7 | const adapter = new LruCacheAdapter(4); 8 | const lru = adapter.create(); 9 | 10 | adapter.set(lru, 'a', 'a'); 11 | adapter.set(lru, 'b', 'b'); 12 | adapter.set(lru, 'c', 'c'); 13 | adapter.set(lru, 'd', 'd'); 14 | adapter.set(lru, 'e', 'e'); 15 | 16 | expect(adapter.getSize(lru)).toBe(4); 17 | expect(Array.from(adapter.getIterable(lru))).toEqual([ 18 | ['b', 'b'], 19 | ['c', 'c'], 20 | ['d', 'd'], 21 | ['e', 'e'], 22 | ]); 23 | }); 24 | 25 | it('treats gotten items as more recent', () => { 26 | const adapter = new LruCacheAdapter(4); 27 | const lru = adapter.create(); 28 | 29 | adapter.set(lru, 'a', 'a'); 30 | adapter.set(lru, 'b', 'b'); 31 | adapter.set(lru, 'c', 'c'); 32 | adapter.set(lru, 'd', 'd'); 33 | adapter.get(lru, 'a'); 34 | adapter.set(lru, 'e', 'e'); 35 | 36 | expect(adapter.getSize(lru)).toBe(4); 37 | expect(Array.from(adapter.getValuesIterable(lru))).toEqual(['c', 'd', 'a', 'e']); 38 | }); 39 | 40 | it('treats updated items as more recent', () => { 41 | const adapter = new LruCacheAdapter(4); 42 | const lru = adapter.create(); 43 | 44 | adapter.set(lru, 'a', 'a'); 45 | adapter.set(lru, 'b', 'b'); 46 | adapter.set(lru, 'c', 'c'); 47 | adapter.update(lru, 'a', () => 'f'); 48 | adapter.set(lru, 'd', 'd'); 49 | adapter.set(lru, 'e', 'e'); 50 | 51 | expect(adapter.getSize(lru)).toBe(4); 52 | expect(Array.from(adapter.getValuesIterable(lru))).toEqual(['c', 'f', 'd', 'e']); 53 | }); 54 | 55 | it('removes items', () => { 56 | const adapter = new LruCacheAdapter(4); 57 | const lru = adapter.create(); 58 | 59 | adapter.set(lru, 'a', 'a'); 60 | adapter.set(lru, 'b', 'b'); 61 | adapter.set(lru, 'c', 'c'); 62 | expect(adapter.getSize(lru)).toBe(3); 63 | 64 | adapter.remove(lru, 'c'); 65 | expect(adapter.getSize(lru)).toBe(2); 66 | expect(Array.from(adapter.getValuesIterable(lru))).toEqual(['a', 'b']); 67 | }); 68 | 69 | it('gets keys', () => { 70 | const adapter = new LruCacheAdapter(4); 71 | const lru = adapter.create(); 72 | 73 | adapter.set(lru, 'a', 'a'); 74 | adapter.set(lru, 'b', 'b'); 75 | adapter.set(lru, 'c', 'c'); 76 | 77 | expect(Array.from(adapter.getKeysIterable(lru))).toEqual(['a', 'b', 'c']); 78 | }); 79 | 80 | it('iterables can be iterated multiple times', () => { 81 | const adapter = new LruCacheAdapter(4); 82 | const lru = adapter.create(); 83 | 84 | adapter.set(lru, 'a', 'a'); 85 | adapter.set(lru, 'b', 'b'); 86 | adapter.set(lru, 'c', 'c'); 87 | 88 | const iterable = adapter.getIterable(lru); 89 | const keysIterable = adapter.getKeysIterable(lru); 90 | const valuesIterable = adapter.getValuesIterable(lru); 91 | 92 | expect(Array.from(iterable)).toEqual(Array.from(iterable)); 93 | expect(Array.from(keysIterable)).toEqual(Array.from(keysIterable)); 94 | expect(Array.from(valuesIterable)).toEqual(Array.from(valuesIterable)); 95 | 96 | expect(Array.from(iterable).length).toBeGreaterThan(0); 97 | }); 98 | 99 | describe('asReadonlyMap', () => { 100 | const adapter = new LruCacheAdapter(10); 101 | const map = adapter.create(); 102 | 103 | for (let i = 1; i <= 9; i++) { 104 | adapter.set(map, `data ${i}`, i); 105 | } 106 | 107 | const readonlyMap = adapter.asReadonlyMap(map); 108 | 109 | it('iterates', () => { 110 | expect(Array.from(readonlyMap).sort((a, b) => a[1] - b[1])).toEqual(range(1, 9).map((n) => [`data ${n}`, n])); 111 | }); 112 | 113 | it('gets entries', () => { 114 | expect(Array.from(readonlyMap.entries()).sort((a, b) => a[1] - b[1])).toEqual(range(1, 9).map((n) => [`data ${n}`, n])); 115 | }); 116 | 117 | it('gets keys', () => { 118 | expect(Array.from(readonlyMap.keys()).sort()).toEqual(range(1, 9).map((n) => `data ${n}`)); 119 | }); 120 | 121 | it('gets values', () => { 122 | expect(Array.from(readonlyMap.values()).sort()).toEqual(range(1, 9)); 123 | }); 124 | 125 | it('can be iterated multiple times', () => { 126 | expect(Array.from(readonlyMap)).toEqual(Array.from(readonlyMap)); 127 | expect(Array.from(readonlyMap.entries())).toEqual(Array.from(readonlyMap.entries())); 128 | expect(Array.from(readonlyMap.keys())).toEqual(Array.from(readonlyMap.keys())); 129 | expect(Array.from(readonlyMap.values())).toEqual(Array.from(readonlyMap.values())); 130 | }); 131 | 132 | it('foreaches', () => { 133 | const foreached: Array<{key: string, value: number}> = []; 134 | 135 | readonlyMap.forEach((value, key) => { 136 | foreached.push({key, value}); 137 | }); 138 | 139 | expect(foreached.sort((a, b) => a.value - b.value)).toEqual(range(1, 9).map((n) => { 140 | return { key: `data ${n}`, value: n }; 141 | })); 142 | }); 143 | 144 | it('gets a value', () => { 145 | expect(readonlyMap.get('data 5')).toEqual(5); 146 | }); 147 | 148 | it('indicates when it has an item', () => { 149 | expect(readonlyMap.has('data 5')).toBe(true); 150 | }); 151 | 152 | it('indicates when it does not have an item', () => { 153 | expect(readonlyMap.has('data 99')).toBe(false); 154 | }); 155 | 156 | it('has the right size', () => { 157 | expect(readonlyMap.size).toBe(9); 158 | }); 159 | }); 160 | 161 | describe('keysAsReadonlySet', () => { 162 | const adapter = new LruCacheAdapter(10); 163 | const sortedMap = adapter.create(); 164 | 165 | for (let i = 1; i <= 9; i++) { 166 | adapter.set(sortedMap, i, `data ${i}`); 167 | } 168 | 169 | const readonlySet = adapter.keysAsReadonlySet(sortedMap); 170 | 171 | it('iterates', () => { 172 | expect(Array.from(readonlySet).sort()).toEqual(range(1, 9)); 173 | }); 174 | 175 | it('gets entries', () => { 176 | expect(Array.from(readonlySet.entries()).sort((a, b) => a[0] - b[0])).toEqual(range(1, 9).map((n) => [n, n])); 177 | }); 178 | 179 | it('gets keys', () => { 180 | expect(Array.from(readonlySet.keys()).sort()).toEqual(range(1, 9)); 181 | }); 182 | 183 | it('gets values', () => { 184 | expect(Array.from(readonlySet.values()).sort()).toEqual(range(1, 9)); 185 | }); 186 | 187 | it('can be iterated multiple times', () => { 188 | expect(Array.from(readonlySet)).toEqual(Array.from(readonlySet)); 189 | expect(Array.from(readonlySet.entries())).toEqual(Array.from(readonlySet.entries())); 190 | expect(Array.from(readonlySet.keys())).toEqual(Array.from(readonlySet.keys())); 191 | expect(Array.from(readonlySet.values())).toEqual(Array.from(readonlySet.values())); 192 | }); 193 | 194 | it('foreaches', () => { 195 | const foreached: number[] = []; 196 | 197 | readonlySet.forEach((key) => { 198 | foreached.push(key); 199 | }); 200 | 201 | expect(foreached.sort()).toEqual(range(1, 9)); 202 | }); 203 | 204 | it('indicates when it has an item', () => { 205 | expect(readonlySet.has(5)).toBe(true); 206 | }); 207 | 208 | it('indicates when it does not have an item', () => { 209 | expect(readonlySet.has(99)).toBe(false); 210 | }); 211 | 212 | it('has the right size', () => { 213 | expect(readonlySet.size).toBe(9); 214 | }); 215 | }); 216 | }); -------------------------------------------------------------------------------- /src/lrucache.ts: -------------------------------------------------------------------------------- 1 | import {ISortedMap, Key, SortedMapAdapter} from './sortedmap'; 2 | import {iterableToIterableIterator, mapIterable} from './util'; 3 | 4 | export interface ILruCache extends ISortedMap> { 5 | nextOrder: number; 6 | } 7 | 8 | interface LruWrapper { 9 | value: V; 10 | order: number; 11 | } 12 | 13 | export class LruCacheAdapter { 14 | private sortedMapAdapter = new SortedMapAdapter, number>({ 15 | getOrderingKey: (item) => item.order 16 | }); 17 | 18 | /** 19 | * Create a new LRU Cache adapter. 20 | * @param suggestedSize The max suggested # of entries for the LRU Cache to store. Up to 10% more than the provided 21 | * number may be stored. 22 | */ 23 | constructor(private suggestedSize: number) {} 24 | 25 | create(): ILruCache { 26 | return { 27 | ...this.sortedMapAdapter.create(), 28 | nextOrder: 0, 29 | }; 30 | } 31 | 32 | set(lru: ILruCache, key: K, value: V): void { 33 | this.sortedMapAdapter.set(lru, key, { value, order: this.getNextOrder(lru) }); 34 | 35 | // With Immer, it's more efficient to do multiple removals at once, so we store up to 10% extra entries 36 | // and then remove them all at once. 37 | if (this.getSize(lru) > this.suggestedSize * 1.1) { 38 | const iterable = this.sortedMapAdapter.getIterable(lru)[ Symbol.iterator ](); 39 | 40 | do { 41 | const oldestKey = iterable.next().value[0]; 42 | 43 | this.sortedMapAdapter.remove(lru, oldestKey); 44 | } while (this.getSize(lru) > this.suggestedSize); 45 | } 46 | } 47 | 48 | get(lru: ILruCache, key: K): V|undefined { 49 | const existing = this.sortedMapAdapter.update(lru, key, (item) => { 50 | item.order = this.getNextOrder(lru); 51 | }) as LruWrapper|undefined; 52 | 53 | return existing && existing.value; 54 | } 55 | 56 | peek(lru: ILruCache, key: K): V|undefined { 57 | const existing = this.sortedMapAdapter.get(lru, key); 58 | 59 | return existing && existing.value; 60 | } 61 | 62 | has(lru: ILruCache, key: K): boolean { 63 | return this.sortedMapAdapter.has(lru, key); 64 | } 65 | 66 | getIterable(lru: ILruCache): Iterable<[K, V]> { 67 | return { 68 | [Symbol.iterator]: () => { 69 | const sortedIterable = this.sortedMapAdapter.getIterable(lru)[Symbol.iterator](); 70 | 71 | return { 72 | next: () => { 73 | const next = sortedIterable.next(); 74 | 75 | if (next.done) { 76 | return { value: undefined as any, done: true }; 77 | } else { 78 | return { value: [next.value[0], next.value[1].value], done: false }; 79 | } 80 | } 81 | }; 82 | } 83 | }; 84 | } 85 | 86 | getValuesIterable(lru: ILruCache): Iterable { 87 | return mapIterable(this.getIterable(lru), (entry) => entry[1]); 88 | } 89 | 90 | getKeysIterable(lru: ILruCache): Iterable { 91 | return mapIterable(this.getIterable(lru), (entry) => entry[0]); 92 | } 93 | 94 | update(lru: ILruCache, key: K, updater: (item: V) => V|void): V|undefined { 95 | const updated = this.sortedMapAdapter.update(lru, key, (item) => { 96 | const updated = updater(item.value); 97 | 98 | if (updated) { 99 | item.value = updated; 100 | } 101 | 102 | item.order = this.getNextOrder(lru); 103 | }) as LruWrapper|undefined; 104 | 105 | return updated && updated.value; 106 | } 107 | 108 | getSize(lru: ILruCache): number { 109 | return this.sortedMapAdapter.getSize(lru); 110 | } 111 | 112 | remove(lru: ILruCache, key: K): void { 113 | return this.sortedMapAdapter.remove(lru, key); 114 | } 115 | 116 | asReadonlyMap(lru: ILruCache): ReadonlyMap { 117 | const readonlyMap: ReadonlyMap = { 118 | [Symbol.iterator]: () => iterableToIterableIterator(this.getIterable(lru))[Symbol.iterator](), 119 | entries: () => iterableToIterableIterator(this.getIterable(lru)), 120 | keys: () => iterableToIterableIterator(this.getKeysIterable(lru)), 121 | values: () => iterableToIterableIterator(this.getValuesIterable(lru)), 122 | forEach: (callbackfn: (value: V, key: K, map: ReadonlyMap) => void, thisArg?: any) => { 123 | const iterator = readonlyMap.entries(); 124 | while (true) { 125 | const next = iterator.next(); 126 | if (next.done) break; 127 | callbackfn.call(thisArg, next.value[1], next.value[0], readonlyMap); 128 | } 129 | }, 130 | get: (key: K) => this.peek(lru, key), 131 | has: (key: K) => this.has(lru, key), 132 | size: this.getSize(lru), 133 | }; 134 | 135 | return readonlyMap; 136 | } 137 | 138 | keysAsReadonlySet(lru: ILruCache): ReadonlySet { 139 | const readonlySet: ReadonlySet = { 140 | [Symbol.iterator]: () => iterableToIterableIterator(this.getKeysIterable(lru))[Symbol.iterator](), 141 | entries: () => iterableToIterableIterator(mapIterable(this.getKeysIterable(lru), (key) => [key, key] as [K, K])), 142 | keys: () => iterableToIterableIterator(this.getKeysIterable(lru)), 143 | values: () => iterableToIterableIterator(this.getKeysIterable(lru)), 144 | forEach: (callbackfn: (value: K, key: K, set: ReadonlySet) => void, thisArg?: any) => { 145 | const iterator = readonlySet.keys(); 146 | while (true) { 147 | const next = iterator.next(); 148 | if (next.done) break; 149 | callbackfn.call(thisArg, next.value, next.value, readonlySet); 150 | } 151 | }, 152 | has: (key: K) => this.has(lru, key), 153 | size: this.getSize(lru), 154 | }; 155 | 156 | return readonlySet; 157 | } 158 | 159 | private getNextOrder(lru: ILruCache): number { 160 | return lru.nextOrder++; 161 | } 162 | } 163 | -------------------------------------------------------------------------------- /src/map.test.ts: -------------------------------------------------------------------------------- 1 | import * as hash from './hash'; 2 | import {Key, MapAdapter} from './map'; 3 | 4 | //TODO: size doesn't decrease when removing non-existent item 5 | 6 | describe('map', () => { 7 | const range = (start: number, end: number) => new Array(end - start + 1).join().split(',').map((empty, i) => i + start); 8 | 9 | beforeEach(() => { 10 | jest.restoreAllMocks(); 11 | }); 12 | 13 | it('creates a map', () => { 14 | const adapter = new MapAdapter(); 15 | const map = adapter.create(); 16 | 17 | expect(map.root || undefined).not.toBeUndefined(); 18 | expect(map.size).toBe(0); 19 | }); 20 | 21 | it('gets undefined for a non-existent map key', () => { 22 | const adapter = new MapAdapter(); 23 | const map = adapter.create(); 24 | 25 | const value = adapter.get(map, 'test'); 26 | 27 | expect(value).toBeUndefined(); 28 | }); 29 | 30 | it('sets a map value', () => { 31 | const adapter = new MapAdapter(); 32 | const map = adapter.create(); 33 | const testValue = { testKey: 'test value' }; 34 | 35 | adapter.set(map, 'test', testValue); 36 | 37 | const fromMap = adapter.get(map, 'test'); 38 | expect(fromMap).toBe(testValue); 39 | expect(map.size).toBe(1); 40 | }); 41 | 42 | it('correctly handles different keys with the same hash code', () => { 43 | const adapter = new MapAdapter(); 44 | const map = adapter.create(); 45 | jest.spyOn(hash, 'hash').mockImplementation(() => { 46 | return 987654321; 47 | }); 48 | 49 | const testValue1 = { testKey: 'test value' }; 50 | const testValue2 = { testKey: 'test value 2' }; 51 | 52 | adapter.set(map, 0, testValue1); 53 | adapter.set(map, 1, testValue2); 54 | 55 | const fromMap1 = adapter.get(map, 0); 56 | expect(fromMap1).toBe(testValue1); 57 | 58 | const fromMap2 = adapter.get(map, 1); 59 | expect(fromMap2).toBe(testValue2); 60 | 61 | expect(map.size).toBe(2); 62 | }); 63 | 64 | it('sets values at the max depth of the tree', () => { 65 | class TestAdapter extends MapAdapter { 66 | protected maxDepth = 3; 67 | } 68 | 69 | const adapter = new TestAdapter(); 70 | const map = adapter.create(); 71 | 72 | for (let i = 0; i < 20; i++) { 73 | adapter.set(map, i.toString(), i); 74 | expect(adapter.get(map, i.toString())).toEqual(i); 75 | } 76 | }); 77 | 78 | it('replaces a value in all node types', () => { 79 | class TestAdapter extends MapAdapter { 80 | protected maxDepth = 3; 81 | } 82 | 83 | const adapter = new TestAdapter(); 84 | const map = adapter.create(); 85 | 86 | for (let i = 0; i < 20; i++) { 87 | adapter.set(map, i.toString(), i.toString()); 88 | expect(adapter.get(map, i.toString())).toEqual(i.toString()); 89 | } 90 | 91 | for (let i = 0; i < 20; i++) { 92 | adapter.set(map, i.toString(), i + '_updated'); 93 | expect(adapter.get(map, i.toString())).toEqual(i + '_updated'); 94 | } 95 | }); 96 | 97 | it('removes by key', () => { 98 | const adapter = new MapAdapter(); 99 | const map = adapter.create(); 100 | const testValue = { testKey: 'test value' }; 101 | 102 | adapter.set(map, 1, testValue); 103 | expect(adapter.get(map, 1)).toBe(testValue); 104 | expect(map.size).toBe(1); 105 | 106 | adapter.remove(map, 1); 107 | expect(adapter.get(map, 1)).toBeUndefined(); 108 | expect(map.size).toBe(0); 109 | }); 110 | 111 | it('removes by key with multiple items sharing the same hash code', () => { 112 | const adapter = new MapAdapter(); 113 | const map = adapter.create(); 114 | jest.spyOn(hash, 'hash').mockImplementation(() => { 115 | return 987654321; 116 | }); 117 | 118 | const testValue1 = { testKey: 'test value' }; 119 | const testValue2 = { testKey: 'test value 2' }; 120 | 121 | adapter.set(map, 0, testValue1); 122 | adapter.set(map, 1, testValue2); 123 | 124 | const fromMap1 = adapter.get(map, 0); 125 | expect(fromMap1).toBe(testValue1); 126 | 127 | const fromMap2 = adapter.get(map, 1); 128 | expect(fromMap2).toBe(testValue2); 129 | expect(map.size).toBe(2); 130 | 131 | adapter.remove(map, 0); 132 | expect(adapter.get(map, 0)).toBeUndefined(); 133 | expect(map.size).toBe(1); 134 | 135 | adapter.remove(map, 1); 136 | expect(adapter.get(map, 1)).toBeUndefined(); 137 | expect(map.size).toBe(0); 138 | }); 139 | 140 | it('removes a non-existent item', () => { 141 | const adapter = new MapAdapter(); 142 | const map = adapter.create(); 143 | 144 | adapter.remove(map, 1); 145 | 146 | expect(adapter.has(map, 1)).toBe(false); 147 | }); 148 | 149 | it('updates an item', () => { 150 | const adapter = new MapAdapter(); 151 | const map = adapter.create(); 152 | const testValue = { testKey: 'test value' }; 153 | 154 | adapter.set(map, 1, testValue); 155 | adapter.update(map, 1, item => { item.testKey = 'asdf'; }); 156 | 157 | expect(adapter.get(map, 1)).toEqual({ testKey: 'asdf' }); 158 | }); 159 | 160 | it('updates by key with multiple items sharing the same hash code', () => { 161 | const adapter = new MapAdapter(); 162 | const map = adapter.create(); 163 | jest.spyOn(hash, 'hash').mockImplementation(() => { 164 | return 987654321; 165 | }); 166 | 167 | const testValue1 = { testKey: 'test value' }; 168 | const testValue2 = { testKey: 'test value 2' }; 169 | 170 | adapter.set(map, 0, testValue1); 171 | adapter.set(map, 1, testValue2); 172 | 173 | adapter.update(map, 0, item => { item.testKey += ' a'; }); 174 | adapter.update(map, 1, item => { item.testKey += ' b'; }); 175 | 176 | expect(adapter.get(map, 0)).toEqual({ testKey: 'test value a' }); 177 | expect(adapter.get(map, 1)).toEqual({ testKey: 'test value 2 b' }); 178 | }); 179 | 180 | it('updates an item by returning a different value', () => { 181 | const adapter = new MapAdapter(); 182 | const map = adapter.create(); 183 | const testValue = { testKey: 'test value' }; 184 | 185 | adapter.set(map, 1, testValue); 186 | adapter.update(map, 1, () => ({ testKey: 'asdf' })); 187 | 188 | expect(adapter.get(map, 1)).toEqual({ testKey: 'asdf' }); 189 | }); 190 | 191 | it('does not update a non-existent item', () => { 192 | const adapter = new MapAdapter(); 193 | const map = adapter.create(); 194 | const testValue = { testKey: 'test value' }; 195 | 196 | adapter.update(map, 1, () => ({ testKey: 'asdf' })); 197 | 198 | expect(adapter.get(map, 1)).toBeUndefined(); 199 | }); 200 | 201 | it('updates an item at maxDepth', () => { 202 | class TestAdapter extends MapAdapter { 203 | protected maxDepth = 3; 204 | } 205 | 206 | const adapter = new TestAdapter(); 207 | const map = adapter.create(); 208 | 209 | for (let i = 0; i < 20; i++) { 210 | adapter.set(map, i.toString(), i.toString()); 211 | expect(adapter.get(map, i.toString())).toEqual(i.toString()); 212 | } 213 | 214 | adapter.update(map, '0', (item) => item + '_updated'); 215 | 216 | expect(adapter.get(map, '0')).toEqual('0_updated'); 217 | }); 218 | 219 | it('iterates through map entries', () => { 220 | const adapter = new MapAdapter(); 221 | const map = adapter.create(); 222 | const testValue = { x: 0, value: 'test value' }; 223 | 224 | for (let i = 1; i <= 20; i++) { 225 | adapter.set(map, i, { ...testValue, x: i }); 226 | } 227 | 228 | // Get coverage on "if (child === undefined) {" in map.ts. 229 | adapter.remove(map, 1); 230 | 231 | expect(Array.from(adapter.getIterable(map)).sort((a, b) => a[1].x - b[1].x)).toEqual(range(2, 20).map(i => ([ i, { x: i, value: 'test value' }]))); 232 | }); 233 | 234 | it('iterates through maxDepth map entries', () => { 235 | class TestAdapter extends MapAdapter { 236 | protected maxDepth = 3; 237 | } 238 | 239 | const adapter = new TestAdapter(); 240 | const map = adapter.create(); 241 | const testValue = { x: 0, value: 'test value' }; 242 | 243 | for (let i = 1; i <= 20; i++) { 244 | adapter.set(map, i, { ...testValue, x: i }); 245 | } 246 | 247 | expect(Array.from(adapter.getIterable(map)).sort((a, b) => a[1].x - b[1].x)).toEqual(range(1, 20).map(i => ([ i, { x: i, value: 'test value' }]))); 248 | }); 249 | 250 | it('iterates through map values', () => { 251 | const adapter = new MapAdapter(); 252 | const map = adapter.create(); 253 | const testValue = { x: 0, value: 'test value' }; 254 | 255 | for (let i = 1; i <= 20; i++) { 256 | adapter.set(map, i, { ...testValue, x: i }); 257 | } 258 | 259 | expect(Array.from(adapter.getValuesIterable(map)).sort((a, b) => a.x - b.x)).toEqual(range(1, 20).map(i => ({ x: i, value: 'test value' }))); 260 | }); 261 | 262 | it('iterates through map keys', () => { 263 | const adapter = new MapAdapter(); 264 | const map = adapter.create(); 265 | const testValue = { x: 0, value: 'test value' }; 266 | 267 | for (let i = 1; i <= 20; i++) { 268 | adapter.set(map, i, { ...testValue, x: i }); 269 | } 270 | 271 | expect(Array.from(adapter.getKeysIterable(map)).sort((a, b) => a - b)).toEqual(range(1, 20)); 272 | }); 273 | 274 | it('determines a key exists using has', () => { 275 | const adapter = new MapAdapter(); 276 | const map = adapter.create(); 277 | 278 | adapter.set(map, 'test', 10); 279 | 280 | expect(adapter.has(map, 'test')).toBe(true); 281 | }); 282 | 283 | it('determines a key does not exist using has', () => { 284 | const adapter = new MapAdapter(); 285 | const map = adapter.create(); 286 | 287 | expect(adapter.has(map, 'test')).toBe(false); 288 | }); 289 | 290 | it('determines an existing key with hash conflict exists', () => { 291 | const adapter = new MapAdapter(); 292 | const map = adapter.create(); 293 | jest.spyOn(hash, 'hash').mockImplementation(() => { 294 | return 987654321; 295 | }); 296 | 297 | adapter.set(map, 'test', 10); 298 | adapter.set(map, 'test1', 11); 299 | 300 | expect(adapter.has(map, 'test')).toBe(true); 301 | }); 302 | 303 | it('determines a non-existent key with hash conflict does not exist', () => { 304 | const adapter = new MapAdapter(); 305 | const map = adapter.create(); 306 | jest.spyOn(hash, 'hash').mockImplementation(() => { 307 | return 987654321; 308 | }); 309 | 310 | adapter.set(map, 'test', 10); 311 | 312 | expect(adapter.has(map, 'does not exist')).toBe(false); 313 | }); 314 | 315 | it('iterables can be iterated multiple times', () => { 316 | const adapter = new MapAdapter(); 317 | const map = adapter.create(); 318 | const testValue = { x: 0, value: 'test value' }; 319 | 320 | for (let i = 1; i <= 20; i++) { 321 | adapter.set(map, i, { ...testValue, x: i }); 322 | } 323 | 324 | const iterable = adapter.getIterable(map); 325 | const keysIterable = adapter.getKeysIterable(map); 326 | const valuesIterable = adapter.getValuesIterable(map); 327 | 328 | expect(Array.from(iterable)).toEqual(Array.from(iterable)); 329 | expect(Array.from(keysIterable)).toEqual(Array.from(keysIterable)); 330 | expect(Array.from(valuesIterable)).toEqual(Array.from(valuesIterable)); 331 | 332 | expect(Array.from(iterable).length).toBeGreaterThan(0); 333 | }); 334 | 335 | describe('asReadonlyMap', () => { 336 | const adapter = new MapAdapter(); 337 | const map = adapter.create(); 338 | 339 | for (let i = 1; i <= 9; i++) { 340 | adapter.set(map, `data ${i}`, i); 341 | } 342 | 343 | const readonlyMap = adapter.asReadonlyMap(map); 344 | 345 | it('iterates', () => { 346 | expect(Array.from(readonlyMap).sort((a, b) => a[1] - b[1])).toEqual(range(1, 9).map((n) => [`data ${n}`, n])); 347 | }); 348 | 349 | it('gets entries', () => { 350 | expect(Array.from(readonlyMap.entries()).sort((a, b) => a[1] - b[1])).toEqual(range(1, 9).map((n) => [`data ${n}`, n])); 351 | }); 352 | 353 | it('gets keys', () => { 354 | expect(Array.from(readonlyMap.keys()).sort()).toEqual(range(1, 9).map((n) => `data ${n}`)); 355 | }); 356 | 357 | it('gets values', () => { 358 | expect(Array.from(readonlyMap.values()).sort()).toEqual(range(1, 9)); 359 | }); 360 | 361 | it('can be iterated multiple times', () => { 362 | expect(Array.from(readonlyMap)).toEqual(Array.from(readonlyMap)); 363 | expect(Array.from(readonlyMap.entries())).toEqual(Array.from(readonlyMap.entries())); 364 | expect(Array.from(readonlyMap.keys())).toEqual(Array.from(readonlyMap.keys())); 365 | expect(Array.from(readonlyMap.values())).toEqual(Array.from(readonlyMap.values())); 366 | }); 367 | 368 | it('foreaches', () => { 369 | const foreached: Array<{key: string, value: number}> = []; 370 | 371 | readonlyMap.forEach((value, key) => { 372 | foreached.push({key, value}); 373 | }); 374 | 375 | expect(foreached.sort((a, b) => a.value - b.value)).toEqual(range(1, 9).map((n) => { 376 | return { key: `data ${n}`, value: n }; 377 | })); 378 | }); 379 | 380 | it('gets a value', () => { 381 | expect(readonlyMap.get('data 5')).toEqual(5); 382 | }); 383 | 384 | it('indicates when it has an item', () => { 385 | expect(readonlyMap.has('data 5')).toBe(true); 386 | }); 387 | 388 | it('indicates when it does not have an item', () => { 389 | expect(readonlyMap.has('data 99')).toBe(false); 390 | }); 391 | 392 | it('has the right size', () => { 393 | expect(readonlyMap.size).toBe(9); 394 | }); 395 | }); 396 | 397 | describe('keysAsReadonlySet', () => { 398 | const adapter = new MapAdapter(); 399 | const sortedMap = adapter.create(); 400 | 401 | for (let i = 1; i <= 9; i++) { 402 | adapter.set(sortedMap, i, `data ${i}`); 403 | } 404 | 405 | const readonlySet = adapter.keysAsReadonlySet(sortedMap); 406 | 407 | it('iterates', () => { 408 | expect(Array.from(readonlySet).sort()).toEqual(range(1, 9)); 409 | }); 410 | 411 | it('gets entries', () => { 412 | expect(Array.from(readonlySet.entries()).sort((a, b) => a[0] - b[0])).toEqual(range(1, 9).map((n) => [n, n])); 413 | }); 414 | 415 | it('gets keys', () => { 416 | expect(Array.from(readonlySet.keys()).sort()).toEqual(range(1, 9)); 417 | }); 418 | 419 | it('gets values', () => { 420 | expect(Array.from(readonlySet.values()).sort()).toEqual(range(1, 9)); 421 | }); 422 | 423 | it('can be iterated multiple times', () => { 424 | expect(Array.from(readonlySet)).toEqual(Array.from(readonlySet)); 425 | expect(Array.from(readonlySet.entries())).toEqual(Array.from(readonlySet.entries())); 426 | expect(Array.from(readonlySet.keys())).toEqual(Array.from(readonlySet.keys())); 427 | expect(Array.from(readonlySet.values())).toEqual(Array.from(readonlySet.values())); 428 | }); 429 | 430 | it('foreaches', () => { 431 | const foreached: number[] = []; 432 | 433 | readonlySet.forEach((key) => { 434 | foreached.push(key); 435 | }); 436 | 437 | expect(foreached.sort()).toEqual(range(1, 9)); 438 | }); 439 | 440 | it('indicates when it has an item', () => { 441 | expect(readonlySet.has(5)).toBe(true); 442 | }); 443 | 444 | it('indicates when it does not have an item', () => { 445 | expect(readonlySet.has(99)).toBe(false); 446 | }); 447 | 448 | it('has the right size', () => { 449 | expect(readonlySet.size).toBe(9); 450 | }); 451 | }); 452 | }); -------------------------------------------------------------------------------- /src/map.ts: -------------------------------------------------------------------------------- 1 | import {hash} from './hash'; 2 | import {iterableToIterableIterator, mapIterable} from './util'; 3 | 4 | export interface IMap { 5 | root: ITrieNode, 6 | size: number; 7 | } 8 | 9 | export type Key = number | string; 10 | 11 | export interface INumberIndexable { 12 | [key: number]: ISingleValueNode; 13 | } 14 | 15 | export interface IStringIndexable { 16 | [key: string]: ISingleValueNode; 17 | } 18 | 19 | export interface ITrieNode { 20 | [index: number]: ITrieNode | IMultiValueNode | ISingleValueNode; 21 | length: number; 22 | } 23 | 24 | export interface IMultiValueNode { 25 | map: INumberIndexable & IStringIndexable; 26 | } 27 | 28 | export interface ISingleValueNode { 29 | key: K; 30 | value: V; 31 | } 32 | 33 | /** 34 | * This adapter class can be used to interact with an Immerutable Map stored in the ngrx store. 35 | * Immerutable maps are very similar to maps provided by ImmutableJS. They use a trie structure 36 | * for structural sharing which allows for items to be inserted and removed without the need 37 | * to shallow copy the entire map. The keys used by this map may be strings or numbers only. 38 | * 39 | * Runtimes: 40 | * Get/Has: O(1) 41 | * Set: O(1) 42 | * Remove: O(1) 43 | * Iterate: O(n) 44 | * Note: The constant factor for these operations will be considerably higher than for standard maps. 45 | */ 46 | export class MapAdapter { 47 | /** The number of bits to use per level of the trie. */ 48 | protected shift = 4; 49 | /** The maximum length of an internal node in the map (containing value and child pointers). */ 50 | protected trieNodeSize = 1 << this.shift; 51 | /** The mask used to grab the last "shift" bits from the key. */ 52 | protected mask = this.trieNodeSize - 1; 53 | /** The maximum number of levels in the tree (when we've used up all the bits in the key). */ 54 | protected maxDepth = Math.ceil(32 / this.shift); 55 | 56 | /** 57 | * Creates a new Immerutable map. This map should be stored in the ngrx store. 58 | * It should not be modified or read from directly. All interaction with this 59 | * map should happen through this adapter class. 60 | */ 61 | create(): IMap { 62 | return { 63 | root: this.createTrieNode(), 64 | size: 0, 65 | }; 66 | } 67 | 68 | /** Returns true if the map contains the key, false otherwise. */ 69 | has(map: IMap, key: K): boolean { 70 | const {valueNode, depth} = this.lookupValueNode(map, key); 71 | 72 | if (valueNode === undefined) return false; 73 | 74 | if (depth < this.maxDepth) { 75 | return (valueNode as ISingleValueNode).key === key; 76 | } else { 77 | return key in (valueNode as IMultiValueNode).map; 78 | } 79 | } 80 | 81 | /** Gets the value for the specified key. If the key does not exist, undefined is returned. */ 82 | get(map: IMap, key: K): V|undefined { 83 | const {valueNode, depth} = this.lookupValueNode(map, key); 84 | 85 | if (valueNode === undefined) return; 86 | 87 | if (depth < this.maxDepth) { 88 | return (valueNode as ISingleValueNode).key === key ? (valueNode as ISingleValueNode).value : undefined; 89 | } else { 90 | const existing = (valueNode as IMultiValueNode).map[key as any]; 91 | 92 | return existing && existing.value; 93 | } 94 | } 95 | 96 | /** 97 | * Stores the specified value in the map using the specified key. 98 | * If the key already exists, it will be replaced with the new value. 99 | */ 100 | set(map: IMap, key: K, value: V): void { 101 | const {containingTrieNode, depth, index, valueNode} = this.lookupValueNode(map, key); 102 | 103 | if (valueNode === undefined) { 104 | map.size++; 105 | 106 | if (depth < this.maxDepth) { 107 | containingTrieNode[index] = this.createSingleValueNode(key, value); 108 | } else if (depth === this.maxDepth) { 109 | const newValueNode = containingTrieNode[index] = this.createValueNode(); 110 | newValueNode.map[key as Key] = this.createSingleValueNode(key, value); 111 | } 112 | 113 | return; 114 | } 115 | 116 | if (depth < this.maxDepth) { 117 | // item already exists in single value node. 118 | if ((valueNode as ISingleValueNode).key === key) { 119 | (valueNode as ISingleValueNode).value = value; 120 | } else { 121 | this.pushSingleValueNodeDown(containingTrieNode, index, depth); 122 | return this.set(map, key, value); 123 | } 124 | } else { 125 | if (!(key in (valueNode as IMultiValueNode).map)) { 126 | map.size++; 127 | } 128 | 129 | (valueNode as IMultiValueNode).map[key as Key] = this.createSingleValueNode(key, value); 130 | } 131 | } 132 | 133 | /** 134 | * Removes the specified key from the map. If the key does not exist, this is a no-op. 135 | */ 136 | remove(map: IMap, key: K): void { 137 | const {containingTrieNode, depth, index, valueNode} = this.lookupValueNode(map, key); 138 | 139 | if (valueNode) { 140 | if (depth < this.maxDepth) { 141 | delete containingTrieNode[index]; 142 | map.size--; 143 | } else if (key in (valueNode as IMultiValueNode).map) { 144 | delete (valueNode as IMultiValueNode).map[key as Key]; 145 | map.size--; 146 | } 147 | } 148 | } 149 | 150 | /** 151 | * Updates the value of the specified key in the map using an updater function. 152 | * The updater function will receive the existing value, and may either mutate it directly 153 | * or return a new value, which will take its place. 154 | * If the key is not found, the updater function is not called and no update is made. 155 | * 156 | * Example: 157 | * ``` 158 | * const adapter = new MapAdapter(); 159 | * const map = adapter.create(); 160 | * 161 | * adapter.set(map, 1, { data: 'one' }); 162 | * adapter.update(map, 1, (value) => { value.data = 'ichi'; }); 163 | * ``` 164 | */ 165 | update(map: IMap, key: K, updater: (item: V) => V|void|undefined): V|undefined { 166 | const {depth, valueNode} = this.lookupValueNode(map, key); 167 | if (valueNode === undefined) return; 168 | 169 | if (depth < this.maxDepth) { 170 | const value = (valueNode as ISingleValueNode).value; 171 | const retVal = updater(value) as V|undefined; 172 | if (retVal !== undefined) { 173 | (valueNode as ISingleValueNode).value = retVal; 174 | return retVal; 175 | } else { 176 | return value; 177 | } 178 | } else { 179 | const existing = (valueNode as IMultiValueNode).map[key as Key]; 180 | const value = existing && existing.value; 181 | const retVal = updater(value) as V|undefined; 182 | if (retVal !== undefined) { 183 | (valueNode as IMultiValueNode).map[key as Key] = this.createSingleValueNode(key, retVal); 184 | return retVal; 185 | } else { 186 | return value; 187 | } 188 | } 189 | } 190 | 191 | /** Gets the number of keys in the map. */ 192 | getSize(map: IMap): number { 193 | return map.size; 194 | } 195 | 196 | /** 197 | * Returns an Iterable which can be used to iterate through all the keys & values in the map. 198 | * Order of iteration is not guaranteed, however it will be consistent across multiple iterations. 199 | * DO NOT add or remove items from the map while iterating! 200 | * 201 | * This method requires Symbol.iterator or a polyfill. If using Typescript and compiling to ES5, the 202 | * downlevelIteration setting is recommended. Note that the iterable can be passed to Array.from to convert 203 | * to an array (note that this incurs a larger performance and memory cost compared to just iterating). 204 | * 205 | * Example (ES6 or ES5 w/ downlevelIteration): 206 | * ``` 207 | * const adapter = new MapAdapter(); 208 | * ... 209 | * 210 | * for ({key, value} of adapter.getIterable(map)) { 211 | * console.log(key, value); 212 | * } 213 | * ``` 214 | * 215 | * Example (old school): 216 | * ``` 217 | * const adapter = new MapAdapter(); 218 | * ... 219 | * 220 | * const iterable = adapter.getIterable(map); 221 | * const iterator = iterable[Symbol.iterator](); // May need Symbol.iterator polyfill 222 | * let next: T; 223 | * while (!(next = iterator.next()).done) { 224 | * const {key, value} = next.value; 225 | * console.log(key, value); 226 | * } 227 | * ``` 228 | */ 229 | getIterable(map: IMap): Iterable<[K, V]> { 230 | type Frame = { 231 | index: number, 232 | content: ITrieNode | ISingleValueNode | IMultiValueNode, 233 | }; 234 | 235 | return { 236 | [Symbol.iterator]: () => { 237 | const stack: Frame[] = [{ index: 0, content: map.root }]; 238 | 239 | const traverseToFurthestLeft = (frame: Frame): ISingleValueNode|undefined => { 240 | if (frame === undefined) return undefined; 241 | 242 | if (Array.isArray(frame.content)) { 243 | if (frame.index < frame.content.length) { 244 | const child = frame.content[frame.index++]; 245 | if (child === undefined) { 246 | return traverseToFurthestLeft(frame); 247 | } 248 | 249 | const nextFrame = { content: child, index: 0 }; 250 | stack.push(nextFrame); 251 | return traverseToFurthestLeft(nextFrame); 252 | } else { 253 | stack.pop(); 254 | 255 | return traverseToFurthestLeft(stack[stack.length - 1]); 256 | } 257 | } else if ('value' in frame.content) { 258 | stack.pop(); 259 | 260 | return frame.content as ISingleValueNode; 261 | } else { 262 | stack.pop(); 263 | const multiValueNode = frame.content as IMultiValueNode; 264 | const nextContent = Object.keys(multiValueNode.map).map(key => multiValueNode.map[key]); 265 | const nextFrame = { content: nextContent, index: 0 }; 266 | stack.push(nextFrame); 267 | 268 | return traverseToFurthestLeft(nextFrame); 269 | } 270 | }; 271 | 272 | return { 273 | next: () => { 274 | const value = traverseToFurthestLeft(stack[stack.length - 1]); 275 | 276 | if (value !== undefined) { 277 | return { 278 | value: [value.key, value.value], 279 | done: false, 280 | }; 281 | } else { 282 | return { 283 | value: undefined as any, 284 | done: true, 285 | }; 286 | } 287 | } 288 | }; 289 | } 290 | }; 291 | } 292 | 293 | getValuesIterable(map: IMap): Iterable { 294 | return mapIterable(this.getIterable(map), (entry) => entry[1]); 295 | } 296 | 297 | getKeysIterable(map: IMap): Iterable { 298 | return mapIterable(this.getIterable(map), (entry) => entry[0]); 299 | } 300 | 301 | asReadonlyMap(map: IMap): ReadonlyMap { 302 | const readonlyMap: ReadonlyMap = { 303 | [Symbol.iterator]: () => iterableToIterableIterator(this.getIterable(map))[Symbol.iterator](), 304 | entries: () => iterableToIterableIterator(this.getIterable(map)), 305 | keys: () => iterableToIterableIterator(this.getKeysIterable(map)), 306 | values: () => iterableToIterableIterator(this.getValuesIterable(map)), 307 | forEach: (callbackfn: (value: V, key: K, map: ReadonlyMap) => void, thisArg?: any) => { 308 | const iterator = readonlyMap.entries(); 309 | while (true) { 310 | const next = iterator.next(); 311 | if (next.done) break; 312 | callbackfn.call(thisArg, next.value[1], next.value[0], readonlyMap); 313 | } 314 | }, 315 | get: (key: K) => this.get(map, key), 316 | has: (key: K) => this.has(map, key), 317 | size: this.getSize(map), 318 | }; 319 | 320 | return readonlyMap; 321 | } 322 | 323 | keysAsReadonlySet(map: IMap): ReadonlySet { 324 | const readonlySet: ReadonlySet = { 325 | [Symbol.iterator]: () => iterableToIterableIterator(this.getKeysIterable(map))[Symbol.iterator](), 326 | entries: () => iterableToIterableIterator(mapIterable(this.getKeysIterable(map), (key) => [key, key] as [K, K])), 327 | keys: () => iterableToIterableIterator(this.getKeysIterable(map)), 328 | values: () => iterableToIterableIterator(this.getKeysIterable(map)), 329 | forEach: (callbackfn: (value: K, key: K, set: ReadonlySet) => void, thisArg?: any) => { 330 | const iterator = readonlySet.keys(); 331 | while (true) { 332 | const next = iterator.next(); 333 | if (next.done) break; 334 | callbackfn.call(thisArg, next.value, next.value, readonlySet); 335 | } 336 | }, 337 | has: (key: K) => this.has(map, key), 338 | size: this.getSize(map), 339 | }; 340 | 341 | return readonlySet; 342 | } 343 | 344 | private createTrieNode(): ITrieNode { 345 | return []; 346 | } 347 | 348 | private createSingleValueNode(key: K, value: V) { 349 | return { key: key, value: value }; 350 | } 351 | 352 | private createValueNode(): IMultiValueNode { 353 | return { 354 | map: Object.create(null), 355 | }; 356 | } 357 | 358 | private lookupValueNode(map: IMap, key: K) { 359 | let hashCode = hash(key); 360 | let node: ITrieNode = map.root; 361 | let index = 0; 362 | let depth = 1; 363 | let valueNode: IMultiValueNode | ISingleValueNode | undefined; 364 | 365 | while (depth <= this.maxDepth) { 366 | index = this.computePartialHashCode(hashCode, depth); 367 | depth++; 368 | 369 | let nextNode: ITrieNode | IMultiValueNode | ISingleValueNode = node[index]; 370 | if (nextNode === undefined) { 371 | valueNode = undefined; 372 | break; 373 | } else if (Array.isArray(nextNode)) { 374 | node = nextNode; 375 | } else { 376 | valueNode = nextNode as IMultiValueNode | ISingleValueNode; 377 | break; 378 | } 379 | } 380 | 381 | return { containingTrieNode: node, depth, index, valueNode }; 382 | } 383 | 384 | private pushSingleValueNodeDown(trieNode: ITrieNode, index: number, depth: number) { 385 | const singleValueNode = trieNode[index] as ISingleValueNode; 386 | const newTrieNode = trieNode[index] = this.createTrieNode(); 387 | const partialHash = this.computePartialHashCode(hash(singleValueNode.key), depth); 388 | 389 | if (depth === this.maxDepth - 1) { 390 | const newValueNode = newTrieNode[partialHash] = this.createValueNode(); 391 | newValueNode.map[singleValueNode.key as Key] = singleValueNode; 392 | } else { 393 | newTrieNode[partialHash] = singleValueNode; 394 | } 395 | } 396 | 397 | private computePartialHashCode(hashCode: number, depth: number) { 398 | return (hashCode >> ((depth - 1) * this.shift)) & this.mask; 399 | } 400 | } -------------------------------------------------------------------------------- /src/sortedcollection.test.ts: -------------------------------------------------------------------------------- 1 | import {SortedCollectionAdapter} from './sortedcollection'; 2 | 3 | interface TestObject { 4 | key: string; 5 | order: number; 6 | } 7 | 8 | describe('B-tree', () => { 9 | const orderComparer = (a: number, b: number) => a - b; 10 | const objOrderComparer = (a: TestObject, b: TestObject) => a.order - b.order; 11 | const range = (start: number, end: number) => new Array(end - start + 1).join().split(',').map((empty, i) => i + start); 12 | 13 | it('creates a sorted list', () => { 14 | const adapter = new SortedCollectionAdapter({ orderComparer, maxItemsPerLevel: 4 }); 15 | const btree = adapter.create(); 16 | 17 | expect(btree.root.items.length).toBe(0); 18 | }); 19 | 20 | it('inserts 1 through 10 in order', () => { 21 | const adapter = new SortedCollectionAdapter({ orderComparer, maxItemsPerLevel: 4 }); 22 | const btree = adapter.create(); 23 | 24 | for (let i = 1; i <= 10; i++) { 25 | adapter.insert(btree, i); 26 | } 27 | 28 | expect(Array.from(adapter.getIterable(btree))).toEqual(range(1, 10)); 29 | }); 30 | 31 | it('inserts 1 through 10 in reverse order', () => { 32 | const adapter = new SortedCollectionAdapter({ orderComparer, maxItemsPerLevel: 4 }); 33 | const btree = adapter.create(); 34 | 35 | for (let i = 10; i > 0; i--) { 36 | adapter.insert(btree, i); 37 | } 38 | 39 | expect(Array.from(adapter.getIterable(btree))).toEqual(range(1, 10)); 40 | }); 41 | 42 | it('re-orders input', () => { 43 | const adapter = new SortedCollectionAdapter({ orderComparer, maxItemsPerLevel: 4 }); 44 | const btree = adapter.create(); 45 | 46 | adapter.insert(btree, 7); 47 | adapter.insert(btree, 3); 48 | adapter.insert(btree, 1); 49 | adapter.insert(btree, 2); 50 | adapter.insert(btree, 4); 51 | adapter.insert(btree, 11); 52 | adapter.insert(btree, 8); 53 | adapter.insert(btree, 5); 54 | adapter.insert(btree, 6); 55 | adapter.insert(btree, 9); 56 | adapter.insert(btree, 10); 57 | adapter.insert(btree, 12); 58 | adapter.insert(btree, 13); 59 | adapter.insert(btree, 0); 60 | 61 | expect(Array.from(adapter.getIterable(btree))).toEqual(range(0, 13)); 62 | }); 63 | 64 | it('gets iterators (in-order insertion)', () => { 65 | for (let maxItemsPerLevel = 4; maxItemsPerLevel <= 16; maxItemsPerLevel += 2) { 66 | const adapter = new SortedCollectionAdapter({ orderComparer, maxItemsPerLevel }); 67 | 68 | for (let i = 1; i <= 100; i++) { 69 | const btree = adapter.create(); 70 | 71 | for (let j = 1; j <= i; j++) { 72 | adapter.insert(btree, j); 73 | } 74 | 75 | expect({ maxItemsPerLevel, array: Array.from(adapter.getIterable(btree)) }) 76 | .toEqual({ maxItemsPerLevel, array: range(1, i) }); 77 | } 78 | } 79 | }); 80 | 81 | it('removes items (right rotation)', () => { 82 | const adapter = new SortedCollectionAdapter({ orderComparer, maxItemsPerLevel: 4 }); 83 | const btree = adapter.create(); 84 | 85 | for (let i = 1; i <= 10; i++) { 86 | adapter.insert(btree, i); 87 | } 88 | 89 | adapter.remove(btree, 3); 90 | adapter.remove(btree, 2); 91 | 92 | expect(Array.from(adapter.getIterable(btree))).toEqual([1].concat(range(4, 10))); 93 | }); 94 | 95 | it('removes items (left rotation)', () => { 96 | const adapter = new SortedCollectionAdapter({ orderComparer, maxItemsPerLevel: 4 }); 97 | const btree = adapter.create(); 98 | 99 | for (let i = 1; i <= 10; i++) { 100 | adapter.insert(btree, i); 101 | } 102 | 103 | adapter.remove(btree, 9); 104 | 105 | expect(Array.from(adapter.getIterable(btree))).toEqual(range(1, 8).concat(10)); 106 | }); 107 | 108 | it('removes items (combine leaves, middle -> left, parent is root)', () => { 109 | const adapter = new SortedCollectionAdapter({ orderComparer, maxItemsPerLevel: 4 }); 110 | const btree = adapter.create(); 111 | 112 | for (let i = 1; i <= 10; i++) { 113 | adapter.insert(btree, i); 114 | } 115 | 116 | adapter.remove(btree, 7); 117 | adapter.remove(btree, 6); 118 | adapter.remove(btree, 5); 119 | 120 | expect(Array.from(adapter.getIterable(btree))).toEqual(range(1, 4).concat(range(8, 10))); 121 | }); 122 | 123 | it('removes items (combine leaves, left -> middle, parent is root)', () => { 124 | const adapter = new SortedCollectionAdapter({ orderComparer, maxItemsPerLevel: 4 }); 125 | const btree = adapter.create(); 126 | 127 | for (let i = 1; i <= 10; i++) { 128 | adapter.insert(btree, i); 129 | } 130 | 131 | adapter.remove(btree, 1); 132 | adapter.remove(btree, 2); 133 | adapter.remove(btree, 3); 134 | 135 | expect(Array.from(adapter.getIterable(btree))).toEqual(range(4, 10)); 136 | }); 137 | 138 | it('removes items (replace root)', () => { 139 | const adapter = new SortedCollectionAdapter({ orderComparer, maxItemsPerLevel: 4 }); 140 | const btree = adapter.create(); 141 | 142 | for (let i = 1; i <= 6; i++) { 143 | adapter.insert(btree, i); 144 | } 145 | 146 | adapter.remove(btree, 1); 147 | adapter.remove(btree, 2); 148 | 149 | expect(Array.from(adapter.getIterable(btree))).toEqual(range(3, 6)); 150 | }); 151 | 152 | it('removes items (internal node - take from left subtree)', () => { 153 | const adapter = new SortedCollectionAdapter({ orderComparer, maxItemsPerLevel: 4 }); 154 | const btree = adapter.create(); 155 | 156 | for (let i = 1; i <= 20; i++) { 157 | adapter.insert(btree, i); 158 | } 159 | 160 | adapter.remove(btree, 8); 161 | 162 | expect(Array.from(adapter.getIterable(btree))).toEqual(range(1, 7).concat(range(9, 20))); 163 | }); 164 | 165 | it('removes items (internal node - take from left subtree with rebalance)', () => { 166 | const adapter = new SortedCollectionAdapter({ orderComparer, maxItemsPerLevel: 4 }); 167 | const btree = adapter.create(); 168 | 169 | for (let i = 1; i <= 20; i++) { 170 | adapter.insert(btree, i); 171 | } 172 | 173 | adapter.remove(btree, 8); 174 | adapter.remove(btree, 7); 175 | 176 | expect(Array.from(adapter.getIterable(btree))).toEqual(range(1, 6).concat(range(9, 20))); 177 | }); 178 | 179 | it('removes items descending from 20', () => { 180 | const adapter = new SortedCollectionAdapter({ orderComparer, maxItemsPerLevel: 4 }); 181 | const btree = adapter.create(); 182 | 183 | for (let i = 1; i <= 20; i++) { 184 | adapter.insert(btree, i); 185 | } 186 | 187 | for (let i = 20; i >= 1; i--) { 188 | adapter.remove(btree, i); 189 | 190 | expect(Array.from(adapter.getIterable(btree))).toEqual(i === 1 ? [] : range(1, i - 1)); 191 | } 192 | }); 193 | 194 | it('removes items descending from 20 (same ordering key)', () => { 195 | const adapter = new SortedCollectionAdapter({ orderComparer: () => 0, maxItemsPerLevel: 4 }); 196 | const btree = adapter.create(); 197 | 198 | for (let i = 1; i <= 20; i++) { 199 | adapter.insert(btree, i); 200 | } 201 | 202 | for (let i = 20; i >= 1; i--) { 203 | adapter.remove(btree, i); 204 | 205 | expect(Array.from(adapter.getIterable(btree))).toEqual(i === 1 ? [] : range(1, i - 1)); 206 | } 207 | }); 208 | 209 | it('removes items ascending to 20', () => { 210 | const adapter = new SortedCollectionAdapter({ orderComparer, maxItemsPerLevel: 4 }); 211 | const btree = adapter.create(); 212 | 213 | for (let i = 1; i <= 20; i++) { 214 | adapter.insert(btree, i); 215 | } 216 | 217 | expect(adapter.getSize(btree)).toBe(20); 218 | 219 | for (let i = 1; i <= 20; i++) { 220 | adapter.remove(btree, i); 221 | 222 | expect(Array.from(adapter.getIterable(btree))).toEqual(i === 20 ? [] : range(i + 1, 20)); 223 | expect(adapter.getSize(btree)).toBe(20 - i); 224 | } 225 | }); 226 | 227 | it('removes items ascending to 20 (same ordering key)', () => { 228 | const adapter = new SortedCollectionAdapter({ orderComparer: () => 0, maxItemsPerLevel: 4 }); 229 | const btree = adapter.create(); 230 | 231 | for (let i = 1; i <= 20; i++) { 232 | adapter.insert(btree, i); 233 | } 234 | 235 | for (let i = 1; i <= 20; i++) { 236 | adapter.remove(btree, i); 237 | 238 | expect(Array.from(adapter.getIterable(btree))).toEqual(i === 20 ? [] : range(i + 1, 20)); 239 | } 240 | }); 241 | 242 | it('removes items from middle', () => { 243 | const adapter = new SortedCollectionAdapter({ orderComparer, maxItemsPerLevel: 4 }); 244 | const btree = adapter.create(); 245 | const removalOrder = [10, 11, 9, 12, 8, 13, 7, 14, 6, 15, 5, 16, 4, 17, 3, 18, 2, 19, 1, 20]; 246 | 247 | for (let i = 1; i <= 20; i++) { 248 | adapter.insert(btree, i); 249 | } 250 | 251 | for (let i = 0; i < 20; i++) { 252 | adapter.remove(btree, removalOrder[i]); 253 | 254 | expect(Array.from(adapter.getIterable(btree))).toEqual(removalOrder.slice(i + 1).sort((a, b) => a - b)); 255 | } 256 | }); 257 | 258 | it('removes items in a specific order', () => { 259 | const adapter = new SortedCollectionAdapter({ orderComparer, maxItemsPerLevel: 4 }); 260 | const btree = adapter.create(); 261 | // This order covers some cases not covered by other tests. 262 | const removalOrder = [ 263 | 9, 6, 10, 21, 32, 22, 11, 23, 18, 24, 264 | 16, 25, 34, 26, 17, 27, 20, 28, 4, 15, 265 | 36, 2, 38, 14, 40, 1, 13, 30, 5, 8, 266 | 29, 7, 12, 35, 39, 19, 37, 3, 33, 31 ]; 267 | 268 | for (let i = 1; i <= 40; i++) { 269 | adapter.insert(btree, i); 270 | } 271 | 272 | for (let i = 0; i < 40; i++) { 273 | adapter.remove(btree, removalOrder[i]); 274 | 275 | expect(Array.from(adapter.getIterable(btree))).toEqual(removalOrder.slice(i + 1).sort((a, b) => a - b)); 276 | } 277 | }); 278 | 279 | it('removes one instance of duplicates', () => { 280 | const adapter = new SortedCollectionAdapter({ orderComparer, maxItemsPerLevel: 4 }); 281 | const btree = adapter.create(); 282 | 283 | for (let i = 1; i <= 20; i++) { 284 | adapter.insert(btree, i); 285 | adapter.insert(btree, i); 286 | } 287 | 288 | expect(Array.from(adapter.getIterable(btree))).toEqual(range(1, 20).concat(range(1, 20)).sort(orderComparer)); 289 | 290 | for (let i = 1; i <= 20; i++) { 291 | adapter.remove(btree, i); 292 | 293 | expect(Array.from(adapter.getIterable(btree))).toEqual((i === 20 ? [] : range(i + 1, 20)).concat(range(1, 20)).sort(orderComparer)); 294 | } 295 | }); 296 | 297 | it('removes a non-existent item (middle)', () => { 298 | const adapter = new SortedCollectionAdapter({ orderComparer }); 299 | const btree = adapter.create(); 300 | 301 | adapter.insert(btree, 1); 302 | adapter.insert(btree, 2); 303 | adapter.insert(btree, 4); 304 | adapter.insert(btree, 5); 305 | 306 | expect(Array.from(adapter.getIterable(btree))).toEqual([1, 2, 4, 5]); 307 | 308 | adapter.remove(btree, 3); 309 | 310 | expect(Array.from(adapter.getIterable(btree))).toEqual([1, 2, 4, 5]); 311 | }); 312 | 313 | it('removes a non-existent item', () => { 314 | const adapter = new SortedCollectionAdapter({ orderComparer }); 315 | const btree = adapter.create(); 316 | 317 | adapter.remove(btree, 1); 318 | 319 | expect(Array.from(adapter.getIterable(btree))).toEqual([]); 320 | }); 321 | 322 | it('removes an item with many items sharing the same order key', () => { 323 | const adapter = new SortedCollectionAdapter({ 324 | orderComparer: objOrderComparer, 325 | equalityComparer: (a, b) => a.key === b.key, 326 | }); 327 | const btree = adapter.create(); 328 | const items = [ 329 | { key: '1', order: -1 }, 330 | { key: '2', order: 0 }, 331 | { key: '3', order: 0 }, 332 | { key: '4', order: 0 }, 333 | { key: '5', order: 1 }, 334 | ]; 335 | 336 | items.forEach(item => adapter.insert(btree, item)); 337 | 338 | expect(Array.from(adapter.getIterable(btree))).toEqual(items); 339 | adapter.remove(btree, { key: '4', order: 0 }); 340 | 341 | expect(Array.from(adapter.getIterable(btree))).toEqual(items.filter(item => item.key !== '4')); 342 | }); 343 | 344 | it('reorders an item', () => { 345 | const adapter = new SortedCollectionAdapter({ orderComparer: objOrderComparer, maxItemsPerLevel: 4 }); 346 | const btree = adapter.create(); 347 | const items = []; 348 | 349 | for (let i = 1; i <= 20; i++) { 350 | items[i] = { key: i.toString(), order: i }; 351 | adapter.insert(btree, items[items.length - 1]); 352 | } 353 | 354 | adapter.update(btree, items[10], (item) => { item.order = 30; }); 355 | 356 | expect(Array.from(adapter.getIterable(btree))).toEqual( 357 | range(1, 9).concat(range(11, 20)).map(i => ({ key: i.toString(), order: i })).concat({ key: '10', order: 30 }) 358 | ) 359 | }); 360 | 361 | it('reorders the first item', () => { 362 | const adapter = new SortedCollectionAdapter({ orderComparer: objOrderComparer, maxItemsPerLevel: 4 }); 363 | const btree = adapter.create(); 364 | const items = []; 365 | 366 | for (let i = 1; i <= 20; i++) { 367 | items[i] = { key: i.toString(), order: i }; 368 | adapter.insert(btree, items[items.length - 1]); 369 | } 370 | 371 | adapter.update(btree, items[1], (item) => { item.order = 30; }); 372 | 373 | expect(Array.from(adapter.getIterable(btree))).toEqual( 374 | range(2, 20).map(i => ({ key: i.toString(), order: i })).concat({ key: '1', order: 30 }) 375 | ) 376 | }); 377 | 378 | it('reorders the last item', () => { 379 | const adapter = new SortedCollectionAdapter({ orderComparer: objOrderComparer, maxItemsPerLevel: 4 }); 380 | const btree = adapter.create(); 381 | const items = []; 382 | 383 | for (let i = 1; i <= 20; i++) { 384 | items[i] = { key: i.toString(), order: i }; 385 | adapter.insert(btree, items[items.length - 1]); 386 | } 387 | 388 | adapter.update(btree, items[20], (item) => { item.order = 0; }); 389 | 390 | expect(Array.from(adapter.getIterable(btree))).toEqual( 391 | [{ key: '20', order: 0 }].concat(range(1, 19).map(i => ({ key: i.toString(), order: i }))) 392 | ) 393 | }); 394 | 395 | it('gets the first item', () => { 396 | const adapter = new SortedCollectionAdapter({ orderComparer, maxItemsPerLevel: 4 }); 397 | const btree = adapter.create(); 398 | 399 | for (let i = 1; i <= 10; i++) { 400 | adapter.insert(btree, i); 401 | } 402 | 403 | expect(adapter.getFirst(btree)).toEqual(1); 404 | }); 405 | 406 | it('gets the last item', () => { 407 | const adapter = new SortedCollectionAdapter({ orderComparer, maxItemsPerLevel: 4 }); 408 | const btree = adapter.create(); 409 | 410 | for (let i = 1; i <= 10; i++) { 411 | adapter.insert(btree, i); 412 | } 413 | 414 | expect(adapter.getLast(btree)).toEqual(10); 415 | }); 416 | 417 | it('gets undefined for the first item of an empty collection', () => { 418 | const adapter = new SortedCollectionAdapter({ orderComparer, maxItemsPerLevel: 4 }); 419 | const btree = adapter.create(); 420 | 421 | expect(adapter.getFirst(btree)).toBeUndefined(); 422 | }); 423 | 424 | it('gets undefined for the last item of an empty collection', () => { 425 | const adapter = new SortedCollectionAdapter({ orderComparer, maxItemsPerLevel: 4 }); 426 | const btree = adapter.create(); 427 | 428 | expect(adapter.getLast(btree)).toBeUndefined(); 429 | }); 430 | 431 | it('gets backwards iterables from 0 to 50', () => { 432 | const adapter = new SortedCollectionAdapter({ orderComparer, maxItemsPerLevel: 4 }); 433 | const btree = adapter.create(); 434 | 435 | for (let i = 0; i < 50; i++) { 436 | adapter.insert(btree, i); 437 | 438 | expect(Array.from(adapter.getIterable(btree, 'backward')).reverse()).toEqual(range(0, i)); 439 | } 440 | }); 441 | 442 | it('gets backwards iterables from 50 to 0', () => { 443 | const adapter = new SortedCollectionAdapter({ orderComparer, maxItemsPerLevel: 4 }); 444 | const btree = adapter.create(); 445 | 446 | for (let i = 50; i >= 0; i--) { 447 | adapter.insert(btree, i); 448 | 449 | expect(Array.from(adapter.getIterable(btree, 'backward')).reverse()).toEqual(range(i, 50)); 450 | } 451 | }); 452 | 453 | it('iterates iterables multiple times', () => { 454 | const adapter = new SortedCollectionAdapter({ orderComparer, maxItemsPerLevel: 4 }); 455 | const btree = adapter.create(); 456 | 457 | for (let i = 0; i < 20; i++) { 458 | adapter.insert(btree, i); 459 | } 460 | 461 | const iterable = adapter.getIterable(btree, 'forward'); 462 | const backwardIterable = adapter.getIterable(btree, 'backward'); 463 | 464 | expect(Array.from(iterable)).toEqual(Array.from(iterable)); 465 | expect(Array.from(backwardIterable)).toEqual(Array.from(backwardIterable)); 466 | 467 | expect(Array.from(iterable).length).toBeGreaterThan(0); 468 | }); 469 | }); -------------------------------------------------------------------------------- /src/sortedcollection.ts: -------------------------------------------------------------------------------- 1 | import {iterableToIterableIterator} from './util'; 2 | 3 | export interface IBTreeNode { 4 | isRoot?: boolean; 5 | items: IBTreeValueNode[]; 6 | children?: Array>; 7 | } 8 | 9 | export interface ISortedCollection { 10 | root: IBTreeNode; 11 | size: number; 12 | } 13 | 14 | export interface IBTreeValueNode { 15 | value: T; 16 | } 17 | 18 | export type ParentPath = { 19 | index: number; 20 | node: IBTreeNode; 21 | }[]; 22 | 23 | export interface LookupNodeInfo { 24 | valueNode: IBTreeValueNode; 25 | parentPath: ParentPath; 26 | } 27 | 28 | export type Comparer = (a: T, b: T) => number; 29 | 30 | export type EqualityComparer = (a: T, b: T) => boolean; 31 | 32 | const MAX_ITEMS_PER_LEVEL = 64; // Must be even for this implementation 33 | 34 | /** 35 | * This adapter class can be used to interact with an Immerutable Sorted Collection stored in the ngrx store. 36 | * This collection is backed by a B-tree which can efficiently handle insertions and deletions while maintaining 37 | * the sorted order of the collection. B-trees also allow for structural sharing such that only a portion 38 | * of the data structure needs to be shallow copied when items are added or removed. 39 | * 40 | * This data structure allows duplicates and does not provide key-based lookups. A SortedMap should be used instead 41 | * if these properties are desirable. 42 | * 43 | * Runtimes: 44 | * Insert: O(log(n)) 45 | * Remove: O(log(n)) 46 | * Iterate: O(n) 47 | */ 48 | export class SortedCollectionAdapter { 49 | orderComparer: Comparer; 50 | equalityComparer: EqualityComparer; 51 | private maxItemsPerLevel: number; 52 | private minItemsPerLevel: number; 53 | 54 | /** 55 | * @param args.orderComparer A function which compares two values (similar to Array.sort). Used for ordering this collection. 56 | * @param args.equalityComparer A function which tests two values for equality. Optional (by default uses ===). 57 | * @param args.maxItemsPerLevel Don't set this value; it is only available for use in unit testing. 58 | */ 59 | constructor(args: { 60 | orderComparer: Comparer, 61 | equalityComparer?: EqualityComparer, 62 | maxItemsPerLevel?: number, 63 | }) { 64 | this.orderComparer = args.orderComparer; 65 | this.equalityComparer = args.equalityComparer || ((a, b) => a === b); 66 | this.maxItemsPerLevel = args.maxItemsPerLevel || MAX_ITEMS_PER_LEVEL; 67 | 68 | if (this.maxItemsPerLevel % 2 === 1) throw new Error('maxItemsPerLevel must be even'); 69 | if (this.maxItemsPerLevel < 3) throw new Error('maxItemsPerLevel must be at least 3'); 70 | 71 | this.minItemsPerLevel = Math.floor(this.maxItemsPerLevel / 2); 72 | } 73 | 74 | /** 75 | * Creates a new Immerutable Sorted Collection. This collection should be stored in the ngrx store. 76 | * It should not be modified or read from directly. All interaction with this collection should 77 | * happen through this adapter class. 78 | */ 79 | create(): ISortedCollection { 80 | return this.createBTreeRootNode(); 81 | } 82 | 83 | /** Inserts an item into the collection in sorted order. */ 84 | insert(collection: ISortedCollection, value: T): void { 85 | collection.size++; 86 | 87 | this.insertInBTreeNode(collection.root, collection.root, undefined, value); 88 | } 89 | 90 | getSize(collection: ISortedCollection) { 91 | return collection.size; 92 | } 93 | 94 | update(collection: ISortedCollection, value: T, updater: (item: T) => T|void|undefined): void|T { 95 | const existing = this._lookupValuePath(collection.root, value); 96 | if (!existing) return; 97 | 98 | const updated = updater(existing.valueNode.value) as T|undefined; 99 | 100 | // Replace the value if a new value was returned. 101 | if (updated !== undefined) { 102 | const parentNode = existing.parentPath[existing.parentPath.length - 1]; 103 | parentNode.node.items[parentNode.index].value = updated; 104 | existing.valueNode.value = updated; 105 | } 106 | 107 | this.ensureSortedOrderOfNode(collection, existing); 108 | 109 | return updated; 110 | } 111 | 112 | /** 113 | * Ensures that a value is still in sorted order after being mutated. 114 | * In general, prefer using the "update" method instead. 115 | * 116 | * @param nodeInfo The return value of of calling lookupValuePath for the value being mutated. 117 | */ 118 | ensureSortedOrderOfNode(collection: ISortedCollection, nodeInfo: LookupNodeInfo): void { 119 | const precedingItem = this.getPreviousValue(nodeInfo.parentPath); 120 | const nextItem = this.getNextValue(nodeInfo.parentPath); 121 | const value = nodeInfo.valueNode.value; 122 | 123 | if ( 124 | (precedingItem && this.orderComparer(value, precedingItem) < 0) || 125 | (nextItem && this.orderComparer(value, nextItem) > 0) 126 | ) { 127 | // Item is out of order, remove and re-insert it to fix up the order. 128 | this.removeByPath(nodeInfo); 129 | collection.size--; 130 | 131 | this.insert(collection, value); 132 | } 133 | } 134 | 135 | lookupValuePath(collection: ISortedCollection, value: T): LookupNodeInfo|undefined { 136 | return this._lookupValuePath(collection.root, value); 137 | } 138 | 139 | getFirst(collection: ISortedCollection): T|undefined { 140 | if (collection.root.items.length === 0) return; 141 | 142 | return this.getFurthestLeftValue(collection.root); 143 | } 144 | 145 | getLast(collection: ISortedCollection): T|undefined { 146 | if (collection.root.items.length === 0) return; 147 | 148 | return this.getFurthestRightValue(collection.root); 149 | } 150 | 151 | remove(collection: ISortedCollection, value: T): void { 152 | const existingInfo = this._lookupValuePath(collection.root, value); 153 | if (existingInfo === undefined) return; 154 | 155 | collection.size--; 156 | 157 | return this.removeByPath(existingInfo); 158 | } 159 | 160 | getIterable(collection: ISortedCollection, direction: 'forward'|'backward' = 'forward'): Iterable { 161 | if (direction === 'forward') { 162 | return this.getForwardIterable(collection); 163 | } else { 164 | return this.getBackwardIterable(collection); 165 | } 166 | } 167 | 168 | private getForwardIterable(collection: ISortedCollection): Iterable { 169 | type Frame = { 170 | index: number, 171 | onChildren: boolean, 172 | items: IBTreeValueNode[], 173 | children?: IBTreeNode[] 174 | }; 175 | 176 | return { 177 | [Symbol.iterator]: () => { 178 | const stack: Frame[] = [{ onChildren: true, index: 0, items: collection.root.items, children: collection.root.children }]; 179 | 180 | function traverseToFurthestLeft(frame: Frame): T|undefined { 181 | if (frame === undefined) return undefined; 182 | 183 | if ( 184 | frame.index < frame.items.length || 185 | (frame.children !== undefined && frame.onChildren && frame.index < frame.children.length) 186 | ) { 187 | if (frame.children !== undefined && frame.onChildren) { 188 | const child = frame.children[frame.index]; 189 | const nextFrame = { items: child.items, onChildren: true, children: child.children, index: 0 }; 190 | stack.push(nextFrame); 191 | 192 | frame.onChildren = false; 193 | return traverseToFurthestLeft(nextFrame); 194 | } else { 195 | const item = frame.items[frame.index++]; 196 | frame.onChildren = true; 197 | return item.value; 198 | } 199 | } else { 200 | stack.pop(); 201 | 202 | return traverseToFurthestLeft(stack[stack.length - 1]); 203 | } 204 | } 205 | 206 | return { 207 | next: () => { 208 | const value = traverseToFurthestLeft(stack[stack.length - 1]); 209 | 210 | if (value !== undefined) { 211 | return { 212 | value: value as T, 213 | done: false, 214 | }; 215 | } else { 216 | return { 217 | value: undefined as any as T, 218 | done: true, 219 | }; 220 | } 221 | } 222 | }; 223 | } 224 | }; 225 | } 226 | 227 | private getBackwardIterable(collection: ISortedCollection): Iterable { 228 | type Frame = { 229 | index: number, 230 | onChildren: boolean, 231 | items: IBTreeValueNode[], 232 | children?: IBTreeNode[] 233 | }; 234 | 235 | return { 236 | [Symbol.iterator]: () => { 237 | const stack: Frame[] = [{ 238 | onChildren: true, 239 | index: collection.root.children ? collection.root.children.length - 1 : collection.root.items.length, 240 | items: collection.root.items, 241 | children: collection.root.children 242 | }]; 243 | 244 | function traverseToFurthestRight(frame: Frame): T|undefined { 245 | if (frame === undefined) return undefined; 246 | 247 | if ( 248 | frame.index > 0 || 249 | (frame.children !== undefined && frame.onChildren && frame.index >= 0) 250 | ) { 251 | if (frame.children !== undefined && frame.onChildren) { 252 | const child = frame.children[frame.index]; 253 | const nextFrame = { 254 | items: child.items, 255 | onChildren: true, 256 | children: child.children, 257 | index: child.children ? child.children.length - 1 : child.items.length 258 | }; 259 | stack.push(nextFrame); 260 | 261 | frame.onChildren = false; 262 | return traverseToFurthestRight(nextFrame); 263 | } else { 264 | const item = frame.items[--frame.index]; 265 | 266 | frame.onChildren = true; 267 | return item.value; 268 | } 269 | } else { 270 | stack.pop(); 271 | 272 | return traverseToFurthestRight(stack[stack.length - 1]); 273 | } 274 | } 275 | 276 | return { 277 | next: () => { 278 | const value = traverseToFurthestRight(stack[stack.length - 1]); 279 | 280 | if (value !== undefined) { 281 | return { 282 | value: value as T, 283 | done: false, 284 | }; 285 | } else { 286 | return { 287 | value: undefined as any as T, 288 | done: true, 289 | }; 290 | } 291 | } 292 | }; 293 | } 294 | }; 295 | } 296 | 297 | private insertInBTreeNode(node: IBTreeNode, parent: IBTreeNode|undefined, parentIndex: number|undefined, value: T): void { 298 | const isLeafNode = this.isLeafNode(node); 299 | if (parent !== undefined && ((isLeafNode && node.items.length >= this.maxItemsPerLevel) || (!isLeafNode && node.children!.length >= this.maxItemsPerLevel))) { 300 | // Instead of splitting the rightmost leaf in half, split it such that all (but one) of the items are in the left 301 | // subtree, leaving the right subtree empty. This optimizes for increasing in-order insertions. 302 | // Note that this doesn't detect if it's actually the furthest right node in the tree; it just checks for the 303 | // current parent. Trying to make it more accurate is slow enough that it negates the perf benefit. 304 | const isRightMostLeaf = isLeafNode && parentIndex !== undefined && parentIndex === parent.children!.length - 1; 305 | const isLeftMostLeaf = isLeafNode && parentIndex === 0; 306 | const {left, mid, right} = isRightMostLeaf ? this.splitLeafNodeLeftHeavy(node) : isLeftMostLeaf ? this.splitLeafNodeRightHeavy(node) : this.splitNode(node); 307 | 308 | if (parentIndex === undefined) { 309 | // This is the root of the tree. Create a new root. 310 | parent.items = [mid]; 311 | parent.children = [left, right]; 312 | parent.isRoot = true; 313 | } else { 314 | // Insert pointers to the new arrays and value into the parent node. 315 | parent.children!.splice(parentIndex, 1, left, right); 316 | parent.items.splice(parentIndex, 0, mid); 317 | } 318 | 319 | return this.insertInBTreeNode(parent, undefined, undefined, value); 320 | } 321 | 322 | if (isLeafNode) { 323 | const insertionIndex = this.findLeafNodeInsertionPoint(node, value); 324 | 325 | if (insertionIndex >= node.items.length) { 326 | node.items.push(this.createBTreeValueNode(value)); 327 | } else { 328 | node.items.splice(insertionIndex, 0, this.createBTreeValueNode(value)); 329 | } 330 | } else { 331 | const interiorNode = node as IBTreeNode; 332 | const recursionIndex = this.findRecursionIndex(interiorNode, value); 333 | this.insertInBTreeNode(interiorNode.children![recursionIndex], interiorNode, recursionIndex, value); 334 | } 335 | } 336 | 337 | private isLeafNode(node: IBTreeNode): boolean { 338 | return (node as IBTreeNode).children === undefined; 339 | } 340 | 341 | private splitNode(node: IBTreeNode) { 342 | const {children, items} = node; 343 | let midpoint = Math.floor(items.length / 2); 344 | return { 345 | left: this.createBTreeNode(items.slice(0, midpoint), children && children.slice(0, midpoint + 1)), 346 | mid: items[midpoint], 347 | right: this.createBTreeNode(items.slice(midpoint + 1), children && children.slice(midpoint + 1)), 348 | }; 349 | } 350 | 351 | private splitLeafNodeLeftHeavy(node: IBTreeNode) { 352 | const {items} = node; 353 | 354 | // Need to leave an item in the right subtree to make sure one is available for rebalancing. 355 | return { 356 | left: this.createBTreeNode(items.slice(0, items.length - 2)), 357 | mid: items[items.length - 2], 358 | right: this.createBTreeNode([items[items.length - 1]]), 359 | }; 360 | } 361 | 362 | private splitLeafNodeRightHeavy(node: IBTreeNode) { 363 | const {items} = node; 364 | 365 | // Need to leave an item in the left subtree to make sure one is available for rebalancing. 366 | return { 367 | left: this.createBTreeNode([items[0]]), 368 | mid: items[1], 369 | right: this.createBTreeNode(items.slice(2)), 370 | }; 371 | } 372 | 373 | private findLeafNodeInsertionPoint(leafNode: IBTreeNode, value: T) { 374 | return this.binarySearchForInsertion(leafNode.items, value); 375 | } 376 | 377 | private findRecursionIndex(node: IBTreeNode, value: T) { 378 | return this.binarySearchForInsertion(node.items, value); 379 | } 380 | 381 | private removeByPath(nodeInfo: LookupNodeInfo): void { 382 | const containerInfo = nodeInfo.parentPath[nodeInfo.parentPath.length - 1]; 383 | containerInfo.node.items.splice(containerInfo.index, 1); 384 | const isLeafNode = this.isLeafNode(containerInfo.node); 385 | 386 | // When removing from an internal node, take the last item of the left subtree or first item of the right subtree. 387 | // Then, start rebalancing from the leaf node from which the item was taken. 388 | if (!isLeafNode) { 389 | const leftSibling = containerInfo.node.children![containerInfo.index]; 390 | let valueInfo = this.lookupRightMostValueWithParentPath(leftSibling, nodeInfo.parentPath); 391 | 392 | if (valueInfo.valueNode === undefined) { 393 | const rightSibling = containerInfo.node.children![containerInfo.index + 1]; 394 | valueInfo = this.lookupLeftMostValueWithParentPath(rightSibling, nodeInfo.parentPath); 395 | } 396 | 397 | const valueContainer = valueInfo.parentPath[valueInfo.parentPath.length - 1]; 398 | valueContainer.node.items.splice(valueContainer.index); 399 | containerInfo.node.items.splice(containerInfo.index, 0, valueInfo.valueNode); 400 | 401 | this.rebalance(valueInfo.parentPath); 402 | } else { 403 | this.rebalance(nodeInfo.parentPath); 404 | } 405 | } 406 | 407 | // Fix up a leaf or internal node which is deficient by taking items from nearby nodes or combining nodes. 408 | private rebalance(parentPath: ParentPath) { 409 | const containerInfo = parentPath[parentPath.length - 1]; 410 | if (containerInfo.node.isRoot) return; 411 | const isLeafNode = this.isLeafNode(containerInfo.node); 412 | 413 | // No rebalancing is necessary if the current node meets btree constraints 414 | if (this.isNodeDeficient(containerInfo.node, isLeafNode)) { 415 | const parentInfo = parentPath[parentPath.length - 2]!; 416 | const rightSibling = parentInfo.node.children![parentInfo.index + 1]; 417 | 418 | // The node has a right sibling that can spare an item. 419 | if (rightSibling && this.canNodeLoseItem(rightSibling, isLeafNode)) { 420 | const rightItem = rightSibling.items.shift()!; 421 | const separator = parentInfo.node.items.splice(parentInfo.index, 1, rightItem)[0]; 422 | containerInfo.node.items.push(separator); 423 | 424 | if (!isLeafNode) { 425 | containerInfo.node.children!.push(rightSibling.children!.shift()!); 426 | } 427 | 428 | return; 429 | } 430 | 431 | const leftSibling = parentInfo.node.children![parentInfo.index - 1]; 432 | 433 | // The node has a left sibling that can spare an item. 434 | if (leftSibling && this.canNodeLoseItem(leftSibling, isLeafNode)) { 435 | const leftItem = leftSibling.items.pop()!; 436 | const separator = parentInfo.node.items.splice(parentInfo.index - 1, 1, leftItem)[0]; 437 | containerInfo.node.items.unshift(separator); 438 | 439 | if (!isLeafNode) { 440 | containerInfo.node.children!.unshift(leftSibling.children!.pop()!); 441 | } 442 | 443 | return; 444 | } 445 | 446 | const separator = parentInfo.node.items.splice(leftSibling ? parentInfo.index - 1 : parentInfo.index, 1)[0]; 447 | 448 | // Both left and right siblings are deficient. Combine them into one node. 449 | const copyInto = leftSibling || containerInfo.node; 450 | const copyFrom = leftSibling ? containerInfo.node : rightSibling; 451 | 452 | parentInfo.node.children!.splice(leftSibling ? parentInfo.index : parentInfo.index + 1, 1); 453 | parentInfo.index--; 454 | copyInto.items.push(separator); 455 | copyInto.items.push.apply(copyInto.items, copyFrom.items); 456 | 457 | if (!isLeafNode) { 458 | copyInto.children!.push.apply(copyInto.children, copyFrom.children!); 459 | } 460 | 461 | // If the current root is empty, make the current node the new root. 462 | if (parentInfo.node.items.length === 0 && parentInfo.node.isRoot) { 463 | // Make copyInto the new root 464 | parentInfo.node.items = copyInto.items; 465 | parentInfo.node.children = copyInto.children; 466 | } else { 467 | // Rebalance the parent (which may or may not need rebalancing) 468 | parentPath.pop(); 469 | this.rebalance(parentPath); 470 | } 471 | } 472 | } 473 | 474 | private isNodeDeficient(node: IBTreeNode, isLeafNode: boolean) { 475 | return (isLeafNode && node.items.length < this.minItemsPerLevel) || 476 | (!isLeafNode && node.children!.length < this.minItemsPerLevel); 477 | } 478 | 479 | private canNodeLoseItem(node: IBTreeNode, isLeafNode: boolean) { 480 | return (isLeafNode && node.items.length > this.minItemsPerLevel) || 481 | (!isLeafNode && node.children!.length > this.minItemsPerLevel); 482 | } 483 | 484 | private lookupLeftMostValueWithParentPath( 485 | node: IBTreeNode, 486 | parentPath: ParentPath = [] 487 | ): LookupNodeInfo { 488 | if (this.isLeafNode(node)) { 489 | return { 490 | valueNode: node.items[0], 491 | parentPath: parentPath.concat({ node, index: 0 }) 492 | }; 493 | } else { 494 | return this.lookupLeftMostValueWithParentPath( 495 | node.children![0], 496 | parentPath.concat({ node, index: 0 }) 497 | ); 498 | } 499 | } 500 | 501 | private lookupRightMostValueWithParentPath( 502 | node: IBTreeNode, 503 | parentPath: ParentPath 504 | ): LookupNodeInfo { 505 | 506 | if (this.isLeafNode(node)) { 507 | return { 508 | valueNode: node.items[node.items.length - 1], 509 | parentPath: parentPath.concat({ node, index: node.items.length - 1 }) 510 | }; 511 | } else { 512 | return this.lookupRightMostValueWithParentPath( 513 | node.children![node.children!.length - 1], 514 | parentPath.concat({ node, index: node.children!.length - 1 }) 515 | ); 516 | } 517 | } 518 | 519 | private getFurthestLeftValue(node: IBTreeNode): T|undefined { 520 | if (this.isLeafNode(node)) { 521 | return node.items.length ? node.items[0].value : undefined; 522 | } else { 523 | return this.getFurthestLeftValue(node.children![0]); 524 | } 525 | } 526 | 527 | private getFurthestRightValue(node: IBTreeNode): T|undefined { 528 | if (this.isLeafNode(node)) { 529 | return node.items.length ? node.items[node.items.length - 1].value : undefined; 530 | } else { 531 | return this.getFurthestRightValue(node.children![node.children!.length - 1]); 532 | } 533 | } 534 | 535 | private getPreviousValue(parentPath: ParentPath): T|undefined { 536 | const containerInfo = parentPath[parentPath.length - 1]; 537 | 538 | if (this.isLeafNode(containerInfo.node)) { 539 | if (containerInfo.index > 0) { 540 | return containerInfo.node.items[containerInfo.index - 1].value; 541 | } 542 | 543 | for (let i = parentPath.length - 2; i >= 0; i--) { 544 | const parentInfo = parentPath[i]; 545 | 546 | const prev = parentInfo.node.items[parentInfo.index - 1]; 547 | if (prev) { 548 | return prev.value; 549 | } 550 | } 551 | 552 | return; 553 | } else { 554 | const child = containerInfo.node.children![containerInfo.index]; 555 | return this.getFurthestRightValue(child); 556 | } 557 | } 558 | 559 | private getNextValue(parentPath: ParentPath): T|undefined { 560 | const containerInfo = parentPath[parentPath.length - 1]; 561 | 562 | if (this.isLeafNode(containerInfo.node)) { 563 | if (containerInfo.index < containerInfo.node.items.length - 1) { 564 | return containerInfo.node.items[containerInfo.index + 1].value; 565 | } 566 | 567 | for (let i = parentPath.length - 2; i >= 0; i--) { 568 | const parentInfo = parentPath[i]; 569 | 570 | const next = parentInfo.node.items[parentInfo.index]; 571 | if (next) { 572 | return next.value; 573 | } 574 | } 575 | 576 | return; 577 | } else { 578 | const child = containerInfo.node.children![containerInfo.index + 1]; 579 | return this.getFurthestLeftValue(child); 580 | } 581 | } 582 | 583 | private _lookupValuePath( 584 | node: IBTreeNode, 585 | value: T, 586 | parentPath: ParentPath = [] 587 | ): LookupNodeInfo|undefined { 588 | const index = this.binarySearchForLookup(node.items, value); 589 | 590 | const currNode = node.items[index]; 591 | if (currNode && this.equalityComparer(currNode.value, value)) { 592 | return { valueNode: currNode, parentPath: parentPath.concat({ node, index }) }; 593 | } 594 | 595 | for (let next = index; ; next++) { 596 | const nextNode = node.items[next]; 597 | if (nextNode && this.equalityComparer(nextNode.value, value)) { 598 | return { valueNode: node.items[next], parentPath: parentPath.concat({ node, index: next }) }; 599 | } 600 | 601 | if (node.children !== undefined) { 602 | const nextChild = node.children[next]; 603 | if (!nextChild) break; 604 | 605 | const subtreeResult = this._lookupValuePath(nextChild, value, parentPath.concat({ node, index: next })); 606 | if (subtreeResult !== undefined) { 607 | return subtreeResult; 608 | } 609 | } 610 | 611 | if (!nextNode || this.orderComparer(value, nextNode.value) !== 0) { 612 | break; 613 | } 614 | } 615 | 616 | for (let prev = index - 1; ; prev--) { 617 | const prevNode = node.items[prev]; 618 | if (prevNode && this.equalityComparer(prevNode.value, value)) { 619 | return { valueNode: node.items[prev], parentPath: parentPath.concat({ node, index: prev }) }; 620 | } 621 | 622 | if (node.children !== undefined) { 623 | const prevChild = node.children[prev]; 624 | if (!prevChild) break; 625 | 626 | const subtreeResult = this._lookupValuePath(prevChild, value, parentPath.concat({ node, index: prev })); 627 | if (subtreeResult !== undefined) { 628 | return subtreeResult; 629 | } 630 | } 631 | 632 | if (!prevNode || this.orderComparer(value, prevNode.value) !== 0) { 633 | break; 634 | } 635 | } 636 | 637 | return; 638 | } 639 | 640 | private binarySearchForInsertion(items: IBTreeValueNode[], value: T) { 641 | if (items.length === 0) return 0; 642 | 643 | // Optimize increasing order insertions. 644 | const lastItemValue = (items[items.length - 1]).value; 645 | if (this.orderComparer(value, lastItemValue) >= 0) { 646 | return items.length; 647 | } 648 | 649 | // Optimize decreasing order insertions. 650 | const firstItemValue = items[0].value; 651 | if (this.orderComparer(value, firstItemValue) <= 0) { 652 | return 0; 653 | } 654 | 655 | // -2 because we already compared with the last value 656 | return this._binarySearch(items, value, 1, items.length - 2); 657 | } 658 | 659 | private binarySearchForLookup(items: IBTreeValueNode[], value: T) { 660 | if (items.length === 0) return 0; 661 | 662 | return this._binarySearch(items, value, 0, items.length - 1); 663 | } 664 | 665 | private _binarySearch(items: IBTreeValueNode[], value: T, low: number, high: number): number { 666 | if (high < low) { 667 | return low; 668 | } 669 | 670 | const mid = Math.floor(low + (high - low) / 2); 671 | const currValue = items[mid].value; 672 | const comparison = this.orderComparer(value, currValue); 673 | 674 | if (comparison < 0) { 675 | return this._binarySearch(items, value, low, mid - 1); 676 | } else if (comparison > 0) { 677 | return this._binarySearch(items, value, mid + 1, high); 678 | } else { 679 | return mid; 680 | } 681 | }; 682 | 683 | private createBTreeNode(items: IBTreeValueNode[], children?: Array>, isRoot = false): IBTreeNode { 684 | if (isRoot) { 685 | return { isRoot, items }; 686 | } else if (children) { 687 | return { children, items }; 688 | } else { 689 | return { items }; 690 | } 691 | } 692 | 693 | private createBTreeRootNode(): ISortedCollection { 694 | return { 695 | root: this.createBTreeNode([], undefined, true), 696 | size: 0, 697 | }; 698 | } 699 | 700 | private createBTreeValueNode(value: T) { 701 | return { 702 | 'value': value, 703 | }; 704 | } 705 | } 706 | 707 | -------------------------------------------------------------------------------- /src/sortedmap.test.ts: -------------------------------------------------------------------------------- 1 | import {SortedMapAdapter} from './sortedmap'; 2 | 3 | interface TestObject { 4 | data: string; 5 | order: number; 6 | } 7 | 8 | const getOrderingKey = (obj: TestObject) => obj.order; 9 | const range = (start: number, end: number) => new Array(end - start + 1).join().split(',').map((empty, i) => i + start); 10 | const toTestArr = (i: number) => [ `data ${i}`, { data: i.toString(), order: i }]; 11 | 12 | describe('Sorted map', () => { 13 | it('adds 20 items in order', () => { 14 | const adapter = new SortedMapAdapter({ getOrderingKey }); 15 | const sortedMap = adapter.create(); 16 | 17 | for (let i = 1; i <= 20; i++) { 18 | adapter.set(sortedMap, `data ${i}`, { data: i.toString(), order: i }); 19 | } 20 | 21 | expect(adapter.getSize(sortedMap)).toEqual(20); 22 | expect(Array.from(adapter.getIterable(sortedMap))).toEqual(range(1, 20).map(toTestArr)); 23 | }); 24 | 25 | it('adds 20 items in reverse order', () => { 26 | const adapter = new SortedMapAdapter({ getOrderingKey }); 27 | const sortedMap = adapter.create(); 28 | 29 | for (let i = 20; i > 0; i--) { 30 | adapter.set(sortedMap, `data ${i}`, { data: i.toString(), order: i }); 31 | } 32 | 33 | expect(adapter.getSize(sortedMap)).toEqual(20); 34 | expect(Array.from(adapter.getIterable(sortedMap))).toEqual(range(1, 20).map(toTestArr)); 35 | }); 36 | 37 | it('gets a value iterable', () => { 38 | const adapter = new SortedMapAdapter({ getOrderingKey }); 39 | const sortedMap = adapter.create(); 40 | 41 | for (let i = 1; i <= 20; i++) { 42 | adapter.set(sortedMap, `data ${i}`, { data: i.toString(), order: i }); 43 | } 44 | 45 | expect(adapter.getSize(sortedMap)).toEqual(20); 46 | expect(Array.from(adapter.getValuesIterable(sortedMap))).toEqual(range(1, 20).map((n) => toTestArr(n)[1])); 47 | }); 48 | 49 | it('gets items', () => { 50 | const adapter = new SortedMapAdapter({ getOrderingKey }); 51 | const sortedMap = adapter.create(); 52 | 53 | for (let i = 1; i <= 20; i++) { 54 | adapter.set(sortedMap, `data ${i}`, { data: i.toString(), order: i }); 55 | } 56 | 57 | for (let i = 1; i <= 20; i++) { 58 | expect(adapter.get(sortedMap, `data ${i}`)).toEqual({ data: i.toString(), order: i }); 59 | } 60 | }); 61 | 62 | it('returns undefined when getting a non-existent item', () => { 63 | const adapter = new SortedMapAdapter({ getOrderingKey }); 64 | const sortedMap = adapter.create(); 65 | 66 | for (let i = 1; i <= 20; i++) { 67 | adapter.set(sortedMap, `data ${i}`, { data: i.toString(), order: i }); 68 | } 69 | 70 | expect(adapter.get(sortedMap, 'does not exist')).toBeUndefined(); 71 | }); 72 | 73 | it('reorders when updating item 10 to the end', () => { 74 | const adapter = new SortedMapAdapter({ getOrderingKey }); 75 | const sortedMap = adapter.create(); 76 | 77 | for (let i = 1; i <= 20; i++) { 78 | adapter.set(sortedMap, `data ${i}`, { data: i.toString(), order: i }); 79 | } 80 | 81 | adapter.update(sortedMap, 'data 10', item => { item.order = 25; }); 82 | 83 | expect(adapter.getSize(sortedMap)).toEqual(20); 84 | expect(Array.from(adapter.getIterable(sortedMap))).toEqual( 85 | range(1, 9).concat(range(11, 20)).map(toTestArr).concat([['data 10', { data: '10', order: 25 }]]) 86 | ); 87 | }); 88 | 89 | it('reorders when updating item 15 to the beginning', () => { 90 | const adapter = new SortedMapAdapter({ getOrderingKey }); 91 | const sortedMap = adapter.create(); 92 | 93 | for (let i = 1; i <= 20; i++) { 94 | adapter.set(sortedMap, `data ${i}`, { data: i.toString(), order: i }); 95 | } 96 | 97 | adapter.update(sortedMap, 'data 15', (item) => { item.order = -1; }); 98 | 99 | expect(adapter.getSize(sortedMap)).toEqual(20); 100 | expect(Array.from(adapter.getIterable(sortedMap))).toEqual( 101 | [['data 15', { data: '15', order: -1 }]].concat(range(1, 14).concat(range(16, 20)).map(toTestArr)) 102 | ); 103 | }); 104 | 105 | it('reorders when updating item 1 to the middle', () => { 106 | const adapter = new SortedMapAdapter({ getOrderingKey }); 107 | const sortedMap = adapter.create(); 108 | 109 | for (let i = 1; i <= 20; i++) { 110 | adapter.set(sortedMap, `data ${i}`, { data: i.toString(), order: i }); 111 | } 112 | 113 | adapter.update(sortedMap, 'data 1', (item) => { item.order = 10.5; }); 114 | 115 | expect(adapter.getSize(sortedMap)).toEqual(20); 116 | expect(Array.from(adapter.getIterable(sortedMap))).toEqual( 117 | range(2, 10).map(toTestArr) 118 | .concat([['data 1', { data: '1', order: 10.5 }]], range(11, 20).map(toTestArr)) 119 | ); 120 | }); 121 | 122 | it('reorders when using set to update an item', () => { 123 | const adapter = new SortedMapAdapter({ getOrderingKey }); 124 | const sortedMap = adapter.create(); 125 | 126 | for (let i = 1; i <= 20; i++) { 127 | adapter.set(sortedMap, `data ${i}`, { data: i.toString(), order: i }); 128 | } 129 | 130 | adapter.set(sortedMap, 'data 10', { data: '10', order: 25}); 131 | 132 | expect(adapter.getSize(sortedMap)).toEqual(20); 133 | expect(Array.from(adapter.getIterable(sortedMap))).toEqual( 134 | range(1, 9).concat(range(11, 20)).map(toTestArr).concat([['data 10', { data: '10', order: 25 }]]) 135 | ); 136 | }); 137 | 138 | it('does nothing when updating a non-existent item', () => { 139 | const adapter = new SortedMapAdapter({ getOrderingKey }); 140 | const sortedMap = adapter.create(); 141 | 142 | for (let i = 1; i <= 20; i++) { 143 | adapter.set(sortedMap, `data ${i}`, { data: i.toString(), order: i }); 144 | } 145 | 146 | adapter.update(sortedMap, 'does not exist', item => { item.data = 'test'; }); 147 | expect(Array.from(adapter.getIterable(sortedMap))).toEqual(range(1, 20).map(toTestArr)); 148 | }); 149 | 150 | it('uses a custom ordering function', () => { 151 | const adapter = new SortedMapAdapter({ 152 | getOrderingKey, 153 | orderComparer: (a, b) => b - a, 154 | }); 155 | const sortedMap = adapter.create(); 156 | 157 | for (let i = 1; i <= 20; i++) { 158 | adapter.set(sortedMap, `data ${i}`, { data: i.toString(), order: i }); 159 | } 160 | 161 | expect(Array.from(adapter.getIterable(sortedMap))).toEqual(range(1, 20).reverse().map(toTestArr)); 162 | }); 163 | 164 | it('removes an item', () => { 165 | const adapter = new SortedMapAdapter({ getOrderingKey }); 166 | const sortedMap = adapter.create(); 167 | 168 | for (let i = 1; i <= 20; i++) { 169 | adapter.set(sortedMap, `data ${i}`, { data: i.toString(), order: i }); 170 | } 171 | 172 | adapter.remove(sortedMap, 'data 20'); 173 | 174 | expect(adapter.getSize(sortedMap)).toEqual(19); 175 | expect(Array.from(adapter.getIterable(sortedMap))).toEqual(range(1, 19).map(toTestArr)); 176 | }); 177 | 178 | it('gets the first item', () => { 179 | const adapter = new SortedMapAdapter({ getOrderingKey }); 180 | const sortedMap = adapter.create(); 181 | 182 | for (let i = 1; i <= 20; i++) { 183 | adapter.set(sortedMap, `data ${i}`, { data: i.toString(), order: i }); 184 | } 185 | 186 | expect(adapter.getFirst(sortedMap)).toEqual({ data: '1', order: 1 }); 187 | }); 188 | 189 | it('gets the last item', () => { 190 | const adapter = new SortedMapAdapter({ getOrderingKey }); 191 | const sortedMap = adapter.create(); 192 | 193 | for (let i = 1; i <= 20; i++) { 194 | adapter.set(sortedMap, `data ${i}`, { data: i.toString(), order: i }); 195 | } 196 | 197 | expect(adapter.getLast(sortedMap)).toEqual({ data: '20', order: 20 }); 198 | }); 199 | 200 | it('gets a backwards iterable', () => { 201 | const adapter = new SortedMapAdapter({ getOrderingKey }); 202 | const sortedMap = adapter.create(); 203 | 204 | for (let i = 1; i <= 20; i++) { 205 | adapter.set(sortedMap, `data ${i}`, { data: i.toString(), order: i }); 206 | } 207 | 208 | expect(Array.from(adapter.getIterable(sortedMap, 'backward'))).toEqual(range(1, 20).reverse().map(toTestArr)); 209 | }); 210 | 211 | it('gets a backwards values iterable', () => { 212 | const adapter = new SortedMapAdapter({ getOrderingKey }); 213 | const sortedMap = adapter.create(); 214 | 215 | for (let i = 1; i <= 20; i++) { 216 | adapter.set(sortedMap, `data ${i}`, { data: i.toString(), order: i }); 217 | } 218 | 219 | expect(Array.from(adapter.getValuesIterable(sortedMap, 'backward'))).toEqual(range(1, 20).reverse().map(x => toTestArr(x)[1])); 220 | }); 221 | 222 | it('indicates when it has an item', () => { 223 | const adapter = new SortedMapAdapter({ getOrderingKey }); 224 | const sortedMap = adapter.create(); 225 | 226 | adapter.set(sortedMap, 'key', { data: 'data', order: 1 }); 227 | 228 | expect(adapter.has(sortedMap, 'key')).toBe(true); 229 | }); 230 | 231 | it('indicates when it does not have an item', () => { 232 | const adapter = new SortedMapAdapter({ getOrderingKey }); 233 | const sortedMap = adapter.create(); 234 | 235 | expect(adapter.has(sortedMap, 'key')).toBe(false); 236 | }); 237 | 238 | it('iterables can be iterated multiple times', () => { 239 | const adapter = new SortedMapAdapter({ getOrderingKey }); 240 | const sortedMap = adapter.create(); 241 | 242 | for (let i = 1; i <= 20; i++) { 243 | adapter.set(sortedMap, `data ${i}`, { data: i.toString(), order: i }); 244 | } 245 | 246 | const iterable = adapter.getIterable(sortedMap); 247 | const keysIterable = adapter.getKeysIterable(sortedMap); 248 | const valuesIterable = adapter.getValuesIterable(sortedMap); 249 | 250 | expect(Array.from(iterable)).toEqual(Array.from(iterable)); 251 | expect(Array.from(keysIterable)).toEqual(Array.from(keysIterable)); 252 | expect(Array.from(valuesIterable)).toEqual(Array.from(valuesIterable)); 253 | 254 | expect(Array.from(iterable).length).toBeGreaterThan(0); 255 | }); 256 | 257 | describe('asReadonlyMap', () => { 258 | const adapter = new SortedMapAdapter({ getOrderingKey }); 259 | const sortedMap = adapter.create(); 260 | 261 | for (let i = 1; i <= 20; i++) { 262 | adapter.set(sortedMap, `data ${i}`, { data: i.toString(), order: i }); 263 | } 264 | 265 | const readonlyMap = adapter.asReadonlyMap(sortedMap); 266 | 267 | it('iterates', () => { 268 | expect(Array.from(readonlyMap)).toEqual(range(1, 20).map((n) => toTestArr(n))); 269 | }); 270 | 271 | it('gets entries', () => { 272 | expect(Array.from(readonlyMap.entries())).toEqual(range(1, 20).map((n) => toTestArr(n))); 273 | }); 274 | 275 | it('gets keys', () => { 276 | expect(Array.from(readonlyMap.keys())).toEqual(range(1, 20).map((n) => toTestArr(n)[0])); 277 | }); 278 | 279 | it('gets values', () => { 280 | expect(Array.from(readonlyMap.values())).toEqual(range(1, 20).map((n) => toTestArr(n)[1])); 281 | }); 282 | 283 | it('can be iterated multiple times', () => { 284 | expect(Array.from(readonlyMap)).toEqual(Array.from(readonlyMap)); 285 | expect(Array.from(readonlyMap.entries())).toEqual(Array.from(readonlyMap.entries())); 286 | expect(Array.from(readonlyMap.keys())).toEqual(Array.from(readonlyMap.keys())); 287 | expect(Array.from(readonlyMap.values())).toEqual(Array.from(readonlyMap.values())); 288 | }); 289 | 290 | it('foreaches', () => { 291 | const foreached: Array<{key: string, value: TestObject}> = []; 292 | 293 | readonlyMap.forEach((value, key) => { 294 | foreached.push({key, value}); 295 | }); 296 | 297 | expect(foreached).toEqual(range(1, 20).map((n) => { 298 | const item = toTestArr(n); 299 | return { key: item[0], value: item[1] }; 300 | })); 301 | }); 302 | 303 | it('gets a value', () => { 304 | expect(readonlyMap.get('data 10')).toEqual({ data: '10', order: 10 }); 305 | }); 306 | 307 | it('indicates when it has an item', () => { 308 | expect(readonlyMap.has('data 10')).toBe(true); 309 | }); 310 | 311 | it('indicates when it does not have an item', () => { 312 | expect(readonlyMap.has('data 99')).toBe(false); 313 | }); 314 | 315 | it('has the right size', () => { 316 | expect(readonlyMap.size).toBe(20); 317 | }); 318 | }); 319 | 320 | describe('keysAsReadonlySet', () => { 321 | const adapter = new SortedMapAdapter({ getOrderingKey }); 322 | const sortedMap = adapter.create(); 323 | 324 | for (let i = 1; i <= 20; i++) { 325 | adapter.set(sortedMap, `data ${i}`, { data: i.toString(), order: i }); 326 | } 327 | 328 | const readonlySet = adapter.keysAsReadonlySet(sortedMap); 329 | 330 | it('iterates', () => { 331 | expect(Array.from(readonlySet)).toEqual(range(1, 20).map((n) => toTestArr(n)[0])); 332 | }); 333 | 334 | it('gets entries', () => { 335 | expect(Array.from(readonlySet.entries())).toEqual(range(1, 20).map((n) => { 336 | const item = toTestArr(n); 337 | return [item[0], item[0]]; 338 | })); 339 | }); 340 | 341 | it('gets keys', () => { 342 | expect(Array.from(readonlySet.keys())).toEqual(range(1, 20).map((n) => toTestArr(n)[0])); 343 | }); 344 | 345 | it('gets values', () => { 346 | expect(Array.from(readonlySet.values())).toEqual(range(1, 20).map((n) => toTestArr(n)[0])); 347 | }); 348 | 349 | it('can be iterated multiple times', () => { 350 | expect(Array.from(readonlySet)).toEqual(Array.from(readonlySet)); 351 | expect(Array.from(readonlySet.entries())).toEqual(Array.from(readonlySet.entries())); 352 | expect(Array.from(readonlySet.keys())).toEqual(Array.from(readonlySet.keys())); 353 | expect(Array.from(readonlySet.values())).toEqual(Array.from(readonlySet.values())); 354 | }); 355 | 356 | it('foreaches', () => { 357 | const foreached: string[] = []; 358 | 359 | readonlySet.forEach((key) => { 360 | foreached.push(key); 361 | }); 362 | 363 | expect(foreached).toEqual(range(1, 20).map((n) => toTestArr(n)[0])); 364 | }); 365 | 366 | it('indicates when it has an item', () => { 367 | expect(readonlySet.has('data 10')).toBe(true); 368 | }); 369 | 370 | it('indicates when it does not have an item', () => { 371 | expect(readonlySet.has('data 99')).toBe(false); 372 | }); 373 | 374 | it('has the right size', () => { 375 | expect(readonlySet.size).toBe(20); 376 | }); 377 | }); 378 | }); -------------------------------------------------------------------------------- /src/sortedmap.ts: -------------------------------------------------------------------------------- 1 | import {IMap, MapAdapter} from './map'; 2 | import {Comparer, ISortedCollection, SortedCollectionAdapter} from './sortedcollection'; 3 | import {iterableToIterableIterator, mapIterable} from './util'; 4 | 5 | export type Key = string | number; 6 | 7 | export interface IKeyWithOrder { 8 | key: K; 9 | order: O; 10 | } 11 | 12 | export interface ISortedMap { 13 | map: IMap, 14 | sortedCollection: ISortedCollection>, 15 | } 16 | 17 | export type GetOrderingKey = (value: V) => O; 18 | 19 | export class SortedMapAdapter { 20 | private getOrderingKey: GetOrderingKey; 21 | private mapAdapter = new MapAdapter(); 22 | private sortedCollectionAdapter: SortedCollectionAdapter>; 23 | 24 | constructor(args: { 25 | getOrderingKey: GetOrderingKey, 26 | orderComparer?: Comparer, 27 | }) { 28 | this.getOrderingKey = args.getOrderingKey; 29 | 30 | const orderComparer: Comparer> = args.orderComparer ? 31 | (a, b) => args.orderComparer!(a.order, b.order) : 32 | (a, b) => a.order < b.order ? -1 : a.order > b.order ? 1 : 0; 33 | 34 | this.sortedCollectionAdapter = new SortedCollectionAdapter({ 35 | equalityComparer: (a, b) => a.key === b.key, 36 | orderComparer, 37 | }); 38 | } 39 | 40 | create(): ISortedMap { 41 | return { 42 | map: this.mapAdapter.create(), 43 | sortedCollection: this.sortedCollectionAdapter.create(), 44 | }; 45 | } 46 | 47 | get(sortedMap: ISortedMap, key: K): V|undefined { 48 | return this.mapAdapter.get(sortedMap.map, key); 49 | } 50 | 51 | has(sortedMap: ISortedMap, key: K): boolean { 52 | return this.mapAdapter.has(sortedMap.map, key); 53 | } 54 | 55 | getIterable(sortedMap: ISortedMap, direction: 'forward'|'backward' = 'forward'): Iterable<[K, V]> { 56 | return mapIterable(this.sortedCollectionAdapter.getIterable(sortedMap.sortedCollection, direction), (item) => { 57 | return [ item.key, this.mapAdapter.get(sortedMap.map, item.key)! ] as [K, V]; 58 | }); 59 | } 60 | 61 | getValuesIterable(sortedMap: ISortedMap, direction: 'forward'|'backward' = 'forward'): Iterable { 62 | return mapIterable(this.sortedCollectionAdapter.getIterable(sortedMap.sortedCollection, direction), (item) => { 63 | return this.mapAdapter.get(sortedMap.map, item.key)!; 64 | }); 65 | } 66 | 67 | getKeysIterable(sortedMap: ISortedMap): Iterable { 68 | return mapIterable(this.sortedCollectionAdapter.getIterable(sortedMap.sortedCollection), (item) => { 69 | return item.key; 70 | }); 71 | } 72 | 73 | set(sortedMap: ISortedMap, key: K, value: V): void { 74 | const exists = this.mapAdapter.has(sortedMap.map, key); 75 | 76 | if (!exists) { 77 | this.sortedCollectionAdapter.insert(sortedMap.sortedCollection, { key, order: this.getOrderingKey(value) }); 78 | this.mapAdapter.set(sortedMap.map, key, value); 79 | } else { 80 | this.update(sortedMap, key, () => value); 81 | } 82 | } 83 | 84 | remove(sortedMap: ISortedMap, key: K): void { 85 | const existing = this.mapAdapter.get(sortedMap.map, key); 86 | if (existing === undefined) return; 87 | 88 | this.sortedCollectionAdapter.remove(sortedMap.sortedCollection, { key: key, order: this.getOrderingKey(existing) }); 89 | this.mapAdapter.remove(sortedMap.map, key); 90 | } 91 | 92 | update(sortedMap: ISortedMap, key: K, updater: (item: V) => V|void): void|V { 93 | const existing = this.mapAdapter.get(sortedMap.map, key); 94 | if (!existing) return; 95 | 96 | const existingSorted = this.sortedCollectionAdapter.lookupValuePath( 97 | sortedMap.sortedCollection, 98 | { key, order: this.getOrderingKey(existing) }, 99 | ); 100 | 101 | if (existingSorted === undefined) { 102 | throw new Error(`Key ${key} not found in sorted collection`); 103 | } 104 | 105 | const updated = updater(existing) as V|undefined; 106 | 107 | if (updated !== undefined) { 108 | this.mapAdapter.set(sortedMap.map, key, updated); 109 | } 110 | 111 | const updatedOrExisting = updated || existing; 112 | const updatedOrderingKey = this.getOrderingKey(updatedOrExisting); 113 | 114 | if (existingSorted.valueNode.value.order !== updatedOrderingKey) { 115 | existingSorted.valueNode.value.order = updatedOrderingKey; 116 | this.sortedCollectionAdapter.ensureSortedOrderOfNode(sortedMap.sortedCollection, existingSorted); 117 | } 118 | 119 | return updatedOrExisting; 120 | } 121 | 122 | getSize(sortedMap: ISortedMap): number { 123 | // Sorted collection is used to retrieve the size to support the use case where the map may be shared between 124 | // multiple sorted collections. 125 | return this.sortedCollectionAdapter.getSize(sortedMap.sortedCollection); 126 | } 127 | 128 | getFirst(sortedMap: ISortedMap): V|undefined { 129 | const firstKeyWithOrder = this.sortedCollectionAdapter.getFirst(sortedMap.sortedCollection); 130 | if (firstKeyWithOrder === undefined) return; 131 | 132 | return this.mapAdapter.get(sortedMap.map, firstKeyWithOrder.key); 133 | } 134 | 135 | getLast(sortedMap: ISortedMap): V|undefined { 136 | const lastKeyWithOrder = this.sortedCollectionAdapter.getLast(sortedMap.sortedCollection); 137 | if (lastKeyWithOrder === undefined) return; 138 | 139 | return this.mapAdapter.get(sortedMap.map, lastKeyWithOrder.key); 140 | } 141 | 142 | asReadonlyMap(sortedMap: ISortedMap): ReadonlyMap { 143 | const readonlyMap: ReadonlyMap = { 144 | [Symbol.iterator]: () => iterableToIterableIterator(this.getIterable(sortedMap))[Symbol.iterator](), 145 | entries: () => iterableToIterableIterator(this.getIterable(sortedMap)), 146 | keys: () => iterableToIterableIterator(this.getKeysIterable(sortedMap)), 147 | values: () => iterableToIterableIterator(this.getValuesIterable(sortedMap)), 148 | forEach: (callbackfn: (value: V, key: K, map: ReadonlyMap) => void, thisArg?: any) => { 149 | const iterator = readonlyMap.entries(); 150 | while (true) { 151 | const next = iterator.next(); 152 | if (next.done) break; 153 | callbackfn.call(thisArg, next.value[1], next.value[0], readonlyMap); 154 | } 155 | }, 156 | get: (key: K) => this.get(sortedMap, key), 157 | has: (key: K) => this.has(sortedMap, key), 158 | size: this.getSize(sortedMap), 159 | }; 160 | 161 | return readonlyMap; 162 | } 163 | 164 | keysAsReadonlySet(sortedMap: ISortedMap): ReadonlySet { 165 | const readonlySet: ReadonlySet = { 166 | [Symbol.iterator]: () => iterableToIterableIterator(this.getKeysIterable(sortedMap))[Symbol.iterator](), 167 | entries: () => iterableToIterableIterator(mapIterable(this.getKeysIterable(sortedMap), (key) => [key, key] as [K, K])), 168 | keys: () => iterableToIterableIterator(this.getKeysIterable(sortedMap)), 169 | values: () => iterableToIterableIterator(this.getKeysIterable(sortedMap)), 170 | forEach: (callbackfn: (value: K, key: K, set: ReadonlySet) => void, thisArg?: any) => { 171 | const iterator = readonlySet.keys(); 172 | while (true) { 173 | const next = iterator.next(); 174 | if (next.done) break; 175 | callbackfn.call(thisArg, next.value, next.value, readonlySet); 176 | } 177 | }, 178 | has: (key: K) => this.has(sortedMap, key), 179 | size: this.getSize(sortedMap), 180 | }; 181 | 182 | return readonlySet; 183 | } 184 | } -------------------------------------------------------------------------------- /src/util.ts: -------------------------------------------------------------------------------- 1 | export function shallowCopy(value: any) { 2 | if (value == null) { 3 | return value; 4 | } 5 | 6 | if (Array.isArray(value)) { 7 | return value.slice(0); 8 | } else if (typeof value === 'object') { 9 | return {...value}; 10 | } else { 11 | return value; 12 | } 13 | } 14 | 15 | export function iterableToIterableIterator(iterable: Iterable): IterableIterator { 16 | const getIterableIterator = (iterator?: Iterator) => { 17 | return { 18 | [Symbol.iterator]: () => getIterableIterator(iterable[Symbol.iterator]()), 19 | next: iterator ? () => iterator!.next() : (() => { 20 | if (!iterator) iterator = iterable[Symbol.iterator](); 21 | 22 | return iterator.next(); 23 | }), 24 | [Symbol.dispose]: () => { 25 | if (Symbol.dispose in iterable) { 26 | (iterable as Iterable & Disposable)[Symbol.dispose](); 27 | } 28 | } 29 | }; 30 | }; 31 | 32 | return getIterableIterator(); 33 | } 34 | 35 | export function mapIterable(iterable: Iterable, transform: (item: T) => U): Iterable { 36 | return { 37 | [Symbol.iterator]() { 38 | const iterator = iterable[Symbol.iterator](); 39 | 40 | return { 41 | next() { 42 | const next = iterator.next(); 43 | 44 | if (next.done) { 45 | return { value: undefined as any as U, done: true }; 46 | } else { 47 | return { value: transform(next.value), done: false }; 48 | } 49 | } 50 | }; 51 | } 52 | }; 53 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "sourceMap": false, 4 | "declaration": true, 5 | "downlevelIteration": true, 6 | "target": "ES5", 7 | "lib": ["es2015", "es2015.iterable"], 8 | "module": "commonjs", 9 | "moduleResolution": "node", 10 | "noFallthroughCasesInSwitch": true, 11 | "noImplicitAny": true, 12 | "noImplicitThis": true, 13 | "noImplicitReturns": true, 14 | "strict": true, 15 | "inlineSourceMap": true, 16 | "outDir": "dist" 17 | }, 18 | "include": [ 19 | "benchmark/**/*.ts", 20 | "src/**/*.ts" 21 | ], 22 | "exclude": [ 23 | "node_modules" 24 | ] 25 | } --------------------------------------------------------------------------------