diff --git a/projects/s-rxjs-utils/src/lib/map-with-caching.spec.ts b/projects/s-rxjs-utils/src/lib/map-with-caching.spec.ts new file mode 100644 index 0000000..c1a927f --- /dev/null +++ b/projects/s-rxjs-utils/src/lib/map-with-caching.spec.ts @@ -0,0 +1,100 @@ +import { Subject } from "rxjs"; +import { mapArrayWithCaching } from "./map-with-caching"; + +describe("mapArrayWithCaching()", () => { + it("maps over the array using the given function", () => { + const source = new Subject(); + let lastEmittedThing: number[] = []; + + source + .asObservable() + .pipe(mapArrayWithCaching((item) => item.toString(), (item) => item * 3)) + .subscribe((value) => { + lastEmittedThing = value; + }); + + const array = [1, 2, 3, 4, 5, 6]; + source.next(array); + expect(lastEmittedThing).toEqual([3, 6, 9, 12, 15, 18]); + + array.splice(2, 2); // array = [1, 2, 5, 6] + source.next(array); + expect(lastEmittedThing).toEqual([3, 6, 15, 18]); + + array.push(10); // array = [1, 2, 5, 6, 10] + source.next(array); + expect(lastEmittedThing).toEqual([3, 6, 15, 18, 30]); + }); + + it("emits the same object reference for items that have the same cache key", async () => { + const source = new Subject>(); + let lastEmittedThing: Array<{ index: number }> = []; + + source + .asObservable() + .pipe( + mapArrayWithCaching( + (item) => item.index.toString(), + (item) => ({ index: item.index + 1 }), + ), + ) + .subscribe((value) => { + lastEmittedThing = value; + }); + + source.next([{ index: 1 }]); + expect(lastEmittedThing).toEqual([{ index: 2 }]); + const mappedItem1 = lastEmittedThing[0]; + + source.next([{ index: 1 }, { index: 2 }]); + expect(lastEmittedThing).toEqual([{ index: 2 }, { index: 3 }]); + expect(lastEmittedThing[0]).toBe(mappedItem1); + }); + + it("handles the upstream source completing", () => { + const source = new Subject(); + const complete = jasmine.createSpy(); + + source + .pipe(mapArrayWithCaching((item) => item.toString(), (item) => item + 1)) + .subscribe(undefined, undefined, complete); + + source.complete(); + + expect(source.observers.length).toBe(0); + expect(complete).toHaveBeenCalledTimes(1); + }); + + it("handles errors", () => { + const source = new Subject(); + const error = jasmine.createSpy(); + + source + .pipe(mapArrayWithCaching((item) => item.toString(), (item) => item + 1)) + .subscribe(undefined, error); + + source.error("fire!!"); + + expect(source.observers.length).toBe(0); + expect(error).toHaveBeenCalledTimes(1); + expect(error).toHaveBeenCalledWith("fire!!"); + }); + + it("handles unsubscribes", () => { + const source = new Subject(); + + const subscription1 = source + .pipe(mapArrayWithCaching((item) => item.toString(), (item) => item + 1)) + .subscribe(); + const subscription2 = source + .pipe(mapArrayWithCaching((item) => item.toString(), (item) => item + 1)) + .subscribe(); + expect(source.observers.length).toBe(2); + + subscription1.unsubscribe(); + expect(source.observers.length).toBe(1); + + subscription2.unsubscribe(); + expect(source.observers.length).toBe(0); + }); +}); diff --git a/projects/s-rxjs-utils/src/lib/map-with-caching.ts b/projects/s-rxjs-utils/src/lib/map-with-caching.ts new file mode 100644 index 0000000..a67bf6d --- /dev/null +++ b/projects/s-rxjs-utils/src/lib/map-with-caching.ts @@ -0,0 +1,46 @@ +import { ObjectWith } from "micro-dash"; +import { map } from "rxjs/operators"; + +/** + * Applies a given function to each item in the upstream array and emits the result. Each item is then cached using the key generated by `buildCacheKey` so that the next emission contains references to the matching objects from previous emission, without running `buildDownstreamItem` again. The cache is only held between successive emissions. + * + * @param buildCacheKey A function that converts an upstream object into a string to use as the cache key. Needs to return a unique key for each item in the source array. + * @param buildDownstreamItem A function that converts an upstream object into a downstream object + * + * ``` + * const mapWithCaching = mapArrayWithCaching( + * (item) => item.toString(), + * (item) => item + 1 + * ) + * + * source: -[1, 2]---[1, 2, 3]---[2]--| + * mapWithCaching: -[2, 3]---[2, 3, 4]---[3]--| + * ``` + */ +export function mapArrayWithCaching( + buildCacheKey: (upstreamItem: U) => string, + buildDownstreamItem: (upstreamItem: U) => D, +) { + let cache: ObjectWith = {}; + + return map((upstreamItems: U[]) => { + const nextCache: ObjectWith = {}; + + const downstreamItems = upstreamItems.map((upstreamItem) => { + const cacheKey = buildCacheKey(upstreamItem); + + let downstreamItem: D; + if (cache.hasOwnProperty(cacheKey)) { + downstreamItem = cache[cacheKey]; + } else { + downstreamItem = buildDownstreamItem(upstreamItem); + } + + nextCache[cacheKey] = downstreamItem; + return downstreamItem; + }); + + cache = nextCache; + return downstreamItems; + }); +}