Skip to content

Commit

Permalink
feat(selectors): createSelectorFactoryWithCache
Browse files Browse the repository at this point in the history
this allows to keep selectors (and their memorized results) in a cache, useful when the projection functions are expensive and the set of values for props is limited and re-used.
  • Loading branch information
Nadav Sinai committed Sep 3, 2019
1 parent cb74051 commit f729e69
Show file tree
Hide file tree
Showing 3 changed files with 162 additions and 80 deletions.
173 changes: 110 additions & 63 deletions modules/store/spec/selector.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import {
createSelectorFactory,
resultMemoize,
MemoizedProjection,
createSelectorFactoryWithCache,
SelectorFactoryWithParam,
} from '@ngrx/store';
import { map, distinctUntilChanged } from 'rxjs/operators';

Expand Down Expand Up @@ -41,23 +43,17 @@ describe('Selectors', () => {
it('should deliver the value of selectors to the projection function', () => {
const projectFn = jasmine.createSpy('projectionFn');

const selector = createSelector(
incrementOne,
incrementTwo,
projectFn
)({});
const selector = createSelector(incrementOne, incrementTwo, projectFn)(
{}
);

expect(projectFn).toHaveBeenCalledWith(countOne, countTwo);
});

it('should allow an override of the selector return', () => {
const projectFn = jasmine.createSpy('projectionFn').and.returnValue(2);

const selector = createSelector(
incrementOne,
incrementTwo,
projectFn
);
const selector = createSelector(incrementOne, incrementTwo, projectFn);

expect(selector.projector()).toBe(2);

Expand All @@ -70,11 +66,7 @@ describe('Selectors', () => {

it('should be possible to test a projector fn independent from the selectors it is composed of', () => {
const projectFn = jasmine.createSpy('projectionFn');
const selector = createSelector(
incrementOne,
incrementTwo,
projectFn
);
const selector = createSelector(incrementOne, incrementTwo, projectFn);

selector.projector('', '');

Expand All @@ -92,10 +84,7 @@ describe('Selectors', () => {
return state.unchanged;
});
const projectFn = jasmine.createSpy('projectionFn');
const selector = createSelector(
neverChangingSelector,
projectFn
);
const selector = createSelector(neverChangingSelector, projectFn);

selector(firstState);
selector(secondState);
Expand Down Expand Up @@ -129,10 +118,7 @@ describe('Selectors', () => {
it('should allow you to release memoized arguments', () => {
const state = { first: 'state' };
const projectFn = jasmine.createSpy('projectionFn');
const selector = createSelector(
incrementOne,
projectFn
);
const selector = createSelector(incrementOne, projectFn);

selector(state);
selector(state);
Expand All @@ -144,18 +130,9 @@ describe('Selectors', () => {
});

it('should recursively release ancestor selectors', () => {
const grandparent = createSelector(
incrementOne,
a => a
);
const parent = createSelector(
grandparent,
a => a
);
const child = createSelector(
parent,
a => a
);
const grandparent = createSelector(incrementOne, a => a);
const parent = createSelector(grandparent, a => a);
const child = createSelector(parent, a => a);
spyOn(grandparent, 'release').and.callThrough();
spyOn(parent, 'release').and.callThrough();

Expand Down Expand Up @@ -271,20 +248,16 @@ describe('Selectors', () => {
describe('createSelector with arrays', () => {
it('should deliver the value of selectors to the projection function', () => {
const projectFn = jasmine.createSpy('projectionFn');
const selector = createSelector(
[incrementOne, incrementTwo],
projectFn
)({});
const selector = createSelector([incrementOne, incrementTwo], projectFn)(
{}
);

expect(projectFn).toHaveBeenCalledWith(countOne, countTwo);
});

it('should be possible to test a projector fn independent from the selectors it is composed of', () => {
const projectFn = jasmine.createSpy('projectionFn');
const selector = createSelector(
[incrementOne, incrementTwo],
projectFn
);
const selector = createSelector([incrementOne, incrementTwo], projectFn);

selector.projector('', '');

Expand All @@ -302,10 +275,7 @@ describe('Selectors', () => {
return state.unchanged;
});
const projectFn = jasmine.createSpy('projectionFn');
const selector = createSelector(
[neverChangingSelector],
projectFn
);
const selector = createSelector([neverChangingSelector], projectFn);

selector(firstState);
selector(secondState);
Expand Down Expand Up @@ -337,10 +307,7 @@ describe('Selectors', () => {
it('should allow you to release memoized arguments', () => {
const state = { first: 'state' };
const projectFn = jasmine.createSpy('projectionFn');
const selector = createSelector(
[incrementOne],
projectFn
);
const selector = createSelector([incrementOne], projectFn);

selector(state);
selector(state);
Expand All @@ -352,18 +319,9 @@ describe('Selectors', () => {
});

it('should recursively release ancestor selectors', () => {
const grandparent = createSelector(
[incrementOne],
a => a
);
const parent = createSelector(
[grandparent],
a => a
);
const child = createSelector(
[parent],
a => a
);
const grandparent = createSelector([incrementOne], a => a);
const parent = createSelector([grandparent], a => a);
const child = createSelector([parent], a => a);
spyOn(grandparent, 'release').and.callThrough();
spyOn(parent, 'release').and.callThrough();

Expand Down Expand Up @@ -665,4 +623,93 @@ describe('Selectors', () => {
expect(result1).not.toEqual(result2);
});
});
describe('createSelectorFactoryWithCache', () => {
const mockState = {
propA: {
a: 1,
b: 2,
c: 3,
},
propB: {
d: 4,
e: 5,
f: 6,
},
};

const selectPropA = createFeatureSelector<typeof mockState.propA>('propA');

describe('Use a selector created by createSelectorFactoryWithCache', () => {
let selectCMultipliedBy: SelectorFactoryWithParam<
typeof mockState,
number,
number
>;
let insideProjection: jasmine.Spy;
beforeEach(() => {
insideProjection = jasmine.createSpy('projection');
selectCMultipliedBy = createSelectorFactoryWithCache(
(multiplier: number) =>
createSelector(
selectPropA,
(propA: { a: number; b: number; c: number }) => {
insideProjection();
return propA.c * multiplier;
}
)
);
});

it('Should select the correct data', () => {
expect(selectCMultipliedBy(4)(mockState)).toEqual(
mockState.propA.c * 4
);
});

describe('Using the selector with several different parameters', () => {
it('projection function should be called once for each parameter', () => {
const times5 = selectCMultipliedBy(5)(mockState);
const times7 = selectCMultipliedBy(7)(mockState);
expect(insideProjection).toHaveBeenCalledTimes(2);
});

it('projection function should not be called again if called with the same parameter', () => {
const times5 = selectCMultipliedBy(5)(mockState);
const times7 = selectCMultipliedBy(7)(mockState);
const times5again = selectCMultipliedBy(5)(mockState);
expect(insideProjection).toHaveBeenCalledTimes(2);
});

it('selector data should be correct even when called with different params', () => {
const times5 = selectCMultipliedBy(5)(mockState);
const times7 = selectCMultipliedBy(7)(mockState);
const times5again = selectCMultipliedBy(5)(mockState);
const times7again = selectCMultipliedBy(7)(mockState);
expect(times5).toEqual(mockState.propA.c * 5);
expect(times5again).toEqual(mockState.propA.c * 5);
expect(times7).toEqual(mockState.propA.c * 7);
expect(times7again).toEqual(mockState.propA.c * 7);
});
});

describe('underlying state change', () => {
it('projection should be called again if state changed', () => {
expect(selectCMultipliedBy(5)(mockState)).toEqual(
mockState.propA.c * 5
);
expect(insideProjection).toHaveBeenCalledTimes(1);
expect(selectCMultipliedBy(5)(mockState)).toEqual(
mockState.propA.c * 5
);
expect(insideProjection).toHaveBeenCalledTimes(1);
expect(
selectCMultipliedBy(5)(
Object.assign({}, mockState, { propA: { c: 100 } })
)
).toEqual(500);
expect(insideProjection).toHaveBeenCalledTimes(2);
});
});
});
});
});
2 changes: 2 additions & 0 deletions modules/store/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ export {
MemoizedSelectorWithProps,
resultMemoize,
DefaultProjectorFn,
createSelectorFactoryWithCache,
SelectorFactoryWithParam,
} from './selector';
export { State, StateObservable, reduceState } from './state';
export {
Expand Down
67 changes: 50 additions & 17 deletions modules/store/src/selector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -603,22 +603,55 @@ export function createFeatureSelector<T, V>(
export function createFeatureSelector(
featureName: any
): MemoizedSelector<any, any> {
return createSelector(
(state: any) => {
const featureState = state[featureName];
if (isDevMode() && featureState === undefined) {
console.warn(
`The feature name \"${featureName}\" does ` +
'not exist in the state, therefore createFeatureSelector ' +
'cannot access it. Be sure it is imported in a loaded module ' +
`using StoreModule.forRoot('${featureName}', ...) or ` +
`StoreModule.forFeature('${featureName}', ...). If the default ` +
'state is intended to be undefined, as is the case with router ' +
'state, this development-only warning message can be ignored.'
);
}
return featureState;
return createSelector((state: any) => {
const featureState = state[featureName];
if (isDevMode() && featureState === undefined) {
console.warn(
`The feature name \"${featureName}\" does ` +
'not exist in the state, therefore createFeatureSelector ' +
'cannot access it. Be sure it is imported in a loaded module ' +
`using StoreModule.forRoot('${featureName}', ...) or ` +
`StoreModule.forFeature('${featureName}', ...). If the default ` +
'state is intended to be undefined, as is the case with router ' +
'state, this development-only warning message can be ignored.'
);
}
return featureState;
}, (featureState: any) => featureState);
}
export interface SelectorFactoryByParams<State, Props, Result> {
(props: Props): MemoizedSelector<State, Result>;
}
export interface SelectorFactoryWithParam<State, Props, Result>
extends SelectorFactoryByParams<State, Props, Result> {
release(): void;

setResult(value: any): void;
}

export function createSelectorFactoryWithCache<State, Props, Result>(
selectorFactory: SelectorFactoryByParams<State, Props, Result>
): SelectorFactoryWithParam<State, Props, Result> {
const selectors: Map<Props, MemoizedSelector<State, Result>> = new Map();
let selector: MemoizedSelector<State, Result> | undefined;

function CachedSelectorFactory(
param: Props
): MemoizedSelector<State, Result> {
selector = selectors.get(param);
if (selector === undefined) {
selector = selectorFactory(param);
selectors.set(param, selector);
}
return selector;
}

return Object.assign(CachedSelectorFactory, {
release: () => {
selectors.clear();
},
(featureState: any) => featureState
);
setResult: (result: any) => {
selector = createSelector({} as any, () => result);
},
});
}

0 comments on commit f729e69

Please sign in to comment.