Skip to content

Commit

Permalink
feat: add mapAndCacheObjectElements()
Browse files Browse the repository at this point in the history
BREAKING CHANGE: `mapAndCacheElements()` is now `mapAndCacheArrayElements()`
  • Loading branch information
ersimont committed May 4, 2019
1 parent b44d7ff commit 9769a34
Show file tree
Hide file tree
Showing 7 changed files with 220 additions and 62 deletions.
3 changes: 2 additions & 1 deletion projects/s-rxjs-utils/src/lib/operators/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export { cache } from "./cache";
export { filterBehavior } from "./filter-behavior";
export { mapAndCacheElements } from "./map-and-cache-elements";
export { mapAndCacheArrayElements } from "./map-and-cache-array-elements";
export { mapAndCacheObjectElements } from "./map-and-cache-object-elements";
export { skipAfter } from "./skip-after";
export { withHistory } from "./with-history";
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,13 @@ import {
testErrorPropagation,
testUnsubscribePropagation,
} from "../../test-helpers";
import { mapAndCacheElements } from "./map-and-cache-elements";
import { mapAndCacheArrayElements } from "./map-and-cache-array-elements";

describe("mapAndCacheElements()", () => {
describe("mapAndCacheArrayElements()", () => {
it("maps over the array using the given function", async () => {
await expectPipeResult(
[[1, 2, 3, 4, 5, 6], [1, 2, 5, 6], [1, 2, 5, 6, 10]],
mapAndCacheElements((item) => item.toString(), (item) => item * 3),
mapAndCacheArrayElements(identity, (item) => item * 3),
[[3, 6, 9, 12, 15, 18], [3, 6, 15, 18], [3, 6, 15, 18, 30]],
);
});
Expand All @@ -24,8 +24,8 @@ describe("mapAndCacheElements()", () => {

source
.pipe(
mapAndCacheElements(
(item) => item.index.toString(),
mapAndCacheArrayElements(
(item) => item.index,
(item) => ({ index: item.index + 1 }),
),
)
Expand All @@ -48,33 +48,33 @@ describe("mapAndCacheElements()", () => {
const buildDownstreamItem = jasmine.createSpy();

source
.pipe(mapAndCacheElements((item) => item.toString(), buildDownstreamItem))
.pipe(mapAndCacheArrayElements(identity, buildDownstreamItem))
.subscribe();

source.next([10]);
expectSingleCallAndReset(buildDownstreamItem, 10);
expectSingleCallAndReset(buildDownstreamItem, 10, 0);

source.next([10, 15]);
expectSingleCallAndReset(buildDownstreamItem, 15);
expectSingleCallAndReset(buildDownstreamItem, 15, 1);
});

it("only calls `buildDownstreamItem` once for a given cache key", () => {
const source = new Subject<number[]>();
const buildDownstreamItem = jasmine.createSpy();

source
.pipe(mapAndCacheElements((item) => item.toString(), buildDownstreamItem))
.pipe(mapAndCacheArrayElements(identity, buildDownstreamItem))
.subscribe();

source.next([5, 5, 5, 20]);
expect(buildDownstreamItem).toHaveBeenCalledTimes(2);
expect(buildDownstreamItem).toHaveBeenCalledWith(5);
expect(buildDownstreamItem).toHaveBeenCalledWith(20);
expect(buildDownstreamItem).toHaveBeenCalledWith(5, 0);
expect(buildDownstreamItem).toHaveBeenCalledWith(20, 3);

buildDownstreamItem.calls.reset();
source.next([5, 5, 5, 20, 25, 25]);
expect(buildDownstreamItem).toHaveBeenCalledTimes(1);
expect(buildDownstreamItem).toHaveBeenCalledWith(25);
expect(buildDownstreamItem).toHaveBeenCalledWith(25, 4);
});

it("always returns the same object reference for a given cache key", () => {
Expand All @@ -83,8 +83,8 @@ describe("mapAndCacheElements()", () => {

source
.pipe(
mapAndCacheElements(
(item) => item.index.toString(),
mapAndCacheArrayElements(
(item) => item.index,
(item) => ({ index: item.index + 1 }),
),
)
Expand All @@ -104,19 +104,17 @@ describe("mapAndCacheElements()", () => {

it("passes along unsubscribes", () => {
testUnsubscribePropagation(() =>
mapAndCacheElements((item) => item.toString(), identity),
mapAndCacheArrayElements(identity, identity),
);
});

it("passes along errors", () => {
testErrorPropagation(() =>
mapAndCacheElements((item) => item.toString(), identity),
);
testErrorPropagation(() => mapAndCacheArrayElements(identity, identity));
});

it("passes along completion", () => {
testCompletionPropagation(() =>
mapAndCacheElements((item) => item.toString(), identity),
mapAndCacheArrayElements(identity, identity),
);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { ArrayIteratee } from "micro-dash";
import { OperatorFunction } from "rxjs";
import { mapAndCacheElements } from "./map-and-cache-elements";

/**
* Applies `buildDownstreamItem` to each item in the upstream array and emits the result. Each downstream item is cached using the key generated by `buildCacheKey` so that the next emission contains references to the matching objects from the previous emission, without running `buildDownstreamItem` again. The cache is only held between successive emissions.
*
* This is useful e.g. when using the result in an `*ngFor` expression of an angular template, to prevent angular from rebuilding the inner component and to allow `OnPush` optimizations in the inner component.
*
* If multiple items in an upstream array have the same cache key, it will only call `buildDownstreamItem` once.
*
* ```ts
* const mapWithCaching = mapAndCacheElements(
* (item) => item,
* (item) => item + 1
* )
* ```
* ```
* source: -[1, 2]---[1, 2, 3]---[2]--|
* mapWithCaching: -[2, 3]---[2, 3, 4]---[3]--|
* ```
*/
export const mapAndCacheArrayElements = mapAndCacheElements as <
UpstreamType,
DownstreamType
>(
buildCacheKey: ArrayIteratee<UpstreamType, any>,
buildDownstreamItem: ArrayIteratee<UpstreamType, DownstreamType>,
) => OperatorFunction<UpstreamType[], DownstreamType[]>;
54 changes: 18 additions & 36 deletions projects/s-rxjs-utils/src/lib/operators/map-and-cache-elements.ts
Original file line number Diff line number Diff line change
@@ -1,49 +1,31 @@
import { ObjectWith } from "micro-dash";
import { map as _map } from "micro-dash";
import { map } from "rxjs/operators";

/**
* Applies `buildDownstreamItem` to each item in the upstream array and emits the result. Each item is cached using the key generated by `buildCacheKey` so that the next emission contains references to the matching objects from the previous emission, without running `buildDownstreamItem` again. The cache is only held between successive emissions.
*
* This is useful e.g. when using the result in an `*ngFor` expression of an angular template, to prevent angular from rebuilding the inner component and to allow `OnPush` optimizations in the inner component.
*
* If multiple items in an upstream array have the same cache key, it will only call `buildDownstreamItem` once.
*
* ```ts
* const mapWithCaching = mapAndCacheElements(
* (item) => item.toString(),
* (item) => item + 1
* )
* ```
* ```
* source: -[1, 2]---[1, 2, 3]---[2]--|
* mapWithCaching: -[2, 3]---[2, 3, 4]---[3]--|
* ```
*/
export function mapAndCacheElements<
UpstreamType,
DownstreamType = UpstreamType
>(
buildCacheKey: (upstreamItem: UpstreamType) => string,
buildDownstreamItem: (upstreamItem: UpstreamType) => DownstreamType,
export function mapAndCacheElements<UpstreamType, DownstreamType>(
buildCacheKey: (upstreamItem: UpstreamType, key: keyof any) => any,
buildDownstreamItem: (
upstreamItem: UpstreamType,
key: keyof any,
) => DownstreamType,
) {
let cache: ObjectWith<DownstreamType> = {};
let cache: Map<any, DownstreamType> = new Map();

return map((upstreamItems: UpstreamType[]) => {
const nextCache: ObjectWith<DownstreamType> = {};
return map((upstreamItems: any) => {
const nextCache: Map<any, DownstreamType> = new Map();

const downstreamItems = upstreamItems.map((upstreamItem) => {
const cacheKey = buildCacheKey(upstreamItem);
const downstreamItems = _map(upstreamItems, (upstreamItem, key) => {
const cacheKey = buildCacheKey(upstreamItem, key);

let downstreamItem: DownstreamType;
if (cache.hasOwnProperty(cacheKey)) {
downstreamItem = cache[cacheKey];
} else if (nextCache.hasOwnProperty(cacheKey)) {
downstreamItem = nextCache[cacheKey];
if (cache.has(cacheKey)) {
downstreamItem = cache.get(cacheKey)!;
} else if (nextCache.has(cacheKey)) {
downstreamItem = nextCache.get(cacheKey)!;
} else {
downstreamItem = buildDownstreamItem(upstreamItem);
downstreamItem = buildDownstreamItem(upstreamItem, key);
}

nextCache[cacheKey] = downstreamItem;
nextCache.set(cacheKey, downstreamItem);
return downstreamItem;
});

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { identity, ObjectWith } from "micro-dash";
import { Subject } from "rxjs";
import { expectSingleCallAndReset } from "s-ng-test-utils";
import {
expectPipeResult,
testCompletionPropagation,
testErrorPropagation,
testUnsubscribePropagation,
} from "../../test-helpers";
import { mapAndCacheObjectElements } from "./map-and-cache-object-elements";

describe("mapAndCacheObjectElements()", () => {
it("maps over the object using the given function", async () => {
await expectPipeResult<ObjectWith<number>, number[]>(
[{ a: 1, b: 2, c: 3 }, { a: 1, b: 2, e: 5 }, { a: 1, e: 5, f: 6 }],
mapAndCacheObjectElements(identity, (item) => item * 3),
[[3, 6, 9], [3, 6, 15], [3, 15, 18]],
);
});

it("emits the same object reference for items that have the same cache key", () => {
const source = new Subject<ObjectWith<{ index: number }>>();
const next = jasmine.createSpy();

source
.pipe(
mapAndCacheObjectElements(
(item, key) => key,
(item) => ({ index: item.index + 1 }),
),
)
.subscribe(next);

source.next({ a: { index: 1 } });
const emission1 = next.calls.mostRecent().args[0];

source.next({ a: { index: 1 }, b: { index: 2 } });
const emission2 = next.calls.mostRecent().args[0];

expect(next).toHaveBeenCalledTimes(2);
expect(emission1).toEqual([{ index: 2 }]);
expect(emission2).toEqual([{ index: 2 }, { index: 3 }]);
expect(emission1[0]).toBe(emission2[0]);
});

it("does not call `buildDownstreamItem` if there is a match in the cache", () => {
const source = new Subject<ObjectWith<number>>();
const buildDownstreamItem = jasmine.createSpy();

source
.pipe(mapAndCacheObjectElements(identity, buildDownstreamItem))
.subscribe();

source.next({ a: 10 });
expectSingleCallAndReset(buildDownstreamItem, 10, "a");

source.next({ a: 10, b: 15 });
expectSingleCallAndReset(buildDownstreamItem, 15, "b");
});

it("only calls `buildDownstreamItem` once for a given cache key", () => {
const source = new Subject<ObjectWith<number>>();
const buildDownstreamItem = jasmine.createSpy();

source
.pipe(mapAndCacheObjectElements(identity, buildDownstreamItem))
.subscribe();

source.next({ a: 5, b: 5, c: 5, d: 20 });
expect(buildDownstreamItem).toHaveBeenCalledTimes(2);
expect(buildDownstreamItem).toHaveBeenCalledWith(5, "a");
expect(buildDownstreamItem).toHaveBeenCalledWith(20, "d");

buildDownstreamItem.calls.reset();
source.next({ a: 5, b: 5, c: 5, d: 20, e: 25, f: 25 });
expect(buildDownstreamItem).toHaveBeenCalledTimes(1);
expect(buildDownstreamItem).toHaveBeenCalledWith(25, "e");
});

it("always returns the same object reference for a given cache key", () => {
const source = new Subject<ObjectWith<{ index: number }>>();
const next = jasmine.createSpy();

source
.pipe(
mapAndCacheObjectElements(
(item) => item.index,
(item) => ({ index: item.index + 1 }),
),
)
.subscribe(next);

source.next({ a: { index: 1 }, b: { index: 1 } });
const emission1 = next.calls.mostRecent().args[0];

source.next({ c: { index: 1 }, d: { index: 1 }, e: { index: 1 } });
const emission2 = next.calls.mostRecent().args[0];

expect(next).toHaveBeenCalledTimes(2);
for (const value of [...emission1, ...emission2]) {
expect(value).toBe(emission1[0]);
}
});

it("passes along unsubscribes", () => {
testUnsubscribePropagation(() =>
mapAndCacheObjectElements(identity, identity),
);
});

it("passes along errors", () => {
testErrorPropagation(() => mapAndCacheObjectElements(identity, identity));
});

it("passes along completion", () => {
testCompletionPropagation(() =>
mapAndCacheObjectElements(identity, identity),
);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { ObjectIteratee } from "micro-dash";
import { OperatorFunction } from "rxjs";
import { mapAndCacheElements } from "./map-and-cache-elements";

/**
* Applies `buildDownstreamItem` to each item in the upstream object and emits an array containing the results. Each downstream item is cached using the key generated by `buildCacheKey` so that the next emission contains references to the matching objects from the previous emission, without running `buildDownstreamItem` again. The cache is only held between successive emissions.
*
* This is useful e.g. when using the result in an `*ngFor` expression of an angular template, to prevent angular from rebuilding the inner component and to allow `OnPush` optimizations in the inner component.
*
* If multiple items in an upstream object have the same cache key, it will only call `buildDownstreamItem` once.
*
* ```ts
* const mapWithCaching = mapAndCacheElements(
* (item, key) => key,
* (item, key) => item + 1
* )
* ```
* ```
* source: -{ a: 1, b: 2 }---{ a: 1, b: 2, c: 3 }---{ b: 2 }--|
* mapWithCaching: -[2, 3]-----------[2, 3, 4]--------------[3]--|
* ```
*/
export const mapAndCacheObjectElements = mapAndCacheElements as <
UpstreamType,
DownstreamType = UpstreamType[keyof UpstreamType]
>(
buildCacheKey: ObjectIteratee<UpstreamType, any>,
buildDownstreamItem: ObjectIteratee<UpstreamType, DownstreamType>,
) => OperatorFunction<UpstreamType, DownstreamType[]>;
11 changes: 5 additions & 6 deletions src/app/app.component.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import { Component } from "@angular/core";
import { noop } from "micro-dash";
import { noop, identity } from "micro-dash";
import { Subject } from "rxjs";
import {
cache,
createOperatorFunction,
createPipeable,
filterBehavior,
mapAndCacheElements,
mapAndCacheArrayElements,
mapAndCacheObjectElements,
skipAfter,
SubscriptionManager,
withHistory,
Expand All @@ -32,10 +33,8 @@ export class AppComponent {

// switch type to number[]
withHistory(3),
mapAndCacheElements(
(item: number) => item.toString(),
(item: number) => item + 1,
),
mapAndCacheArrayElements(identity, identity),
mapAndCacheObjectElements(identity, identity),
),
);

Expand Down

0 comments on commit 9769a34

Please sign in to comment.