diff --git a/MIGRATION.md b/MIGRATION.md new file mode 100644 index 0000000000..e69de29bb2 diff --git a/modules/store/index.ts b/modules/store/index.ts index 6adbdab076..9834210391 100644 --- a/modules/store/index.ts +++ b/modules/store/index.ts @@ -1,10 +1,11 @@ -export { Action, ActionReducer, ActionReducerMap, ActionReducerFactory } from './src/models'; +export { Action, ActionReducer, ActionReducerMap, ActionReducerFactory, Selector } from './src/models'; export { StoreModule } from './src/store_module'; export { Store } from './src/store'; export { combineReducers, compose } from './src/utils'; export { ActionsSubject, INIT } from './src/actions_subject'; export { ReducerManager, ReducerObservable, ReducerManagerDispatcher, UPDATE } from './src/reducer_manager'; export { ScannedActionsSubject } from './src/scanned_actions_subject'; +export { createSelector, createFeatureSelector, MemoizedSelector } from './src/selector'; export { State, StateObservable, reduceState } from './src/state'; export { INITIAL_STATE, REDUCER_FACTORY, INITIAL_REDUCERS, STORE_FEATURES } from './src/tokens'; export { StoreRootModule, StoreFeatureModule } from './src/store_module'; diff --git a/modules/store/spec/selector.spec.ts b/modules/store/spec/selector.spec.ts new file mode 100644 index 0000000000..4ed0d98a7c --- /dev/null +++ b/modules/store/spec/selector.spec.ts @@ -0,0 +1,109 @@ +import 'rxjs/add/operator/distinctUntilChanged'; +import 'rxjs/add/operator/map'; +import { cold } from 'jasmine-marbles'; +import { createSelector, createFeatureSelector, MemoizedSelector } from '../'; + + +describe('Selectors', () => { + let countOne: number; + let countTwo: number; + let countThree: number; + + let incrementOne: jasmine.Spy; + let incrementTwo: jasmine.Spy; + let incrementThree: jasmine.Spy; + + beforeEach(() => { + countOne = 0; + countTwo = 0; + countThree = 0; + + incrementOne = jasmine.createSpy('incrementOne').and.callFake(() => { + return ++countOne; + }); + + incrementTwo = jasmine.createSpy('incrementTwo').and.callFake(() => { + return ++countTwo; + }); + + incrementThree = jasmine.createSpy('incrementThree').and.callFake(() => { + return ++countThree; + }); + }); + + describe('createSelector', () => { + it('should deliver the value of selectors to the projection function', () => { + const projectFn = jasmine.createSpy('projectionFn'); + + const selector = createSelector(incrementOne, incrementTwo, projectFn)({ }); + + expect(projectFn).toHaveBeenCalledWith(countOne, countTwo); + }); + + it('should memoize the function', () => { + const firstState = { first: 'state' }; + const secondState = { second: 'state' }; + const projectFn = jasmine.createSpy('projectionFn'); + const selector = createSelector(incrementOne, incrementTwo, incrementThree, projectFn); + + selector(firstState); + selector(firstState); + selector(firstState); + selector(secondState); + + expect(incrementOne).toHaveBeenCalledTimes(2); + expect(incrementTwo).toHaveBeenCalledTimes(2); + expect(incrementThree).toHaveBeenCalledTimes(2); + expect(projectFn).toHaveBeenCalledTimes(2); + }); + + it('should allow you to release memoized arguments', () => { + const state = { first: 'state' }; + const projectFn = jasmine.createSpy('projectionFn'); + const selector = createSelector(incrementOne, projectFn); + + selector(state); + selector(state); + selector.release(); + selector(state); + selector(state); + + expect(projectFn).toHaveBeenCalledTimes(2); + }); + + it('should recursively release ancestor selectors', () => { + 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(); + + child.release(); + + expect(grandparent.release).toHaveBeenCalled(); + expect(parent.release).toHaveBeenCalled(); + }); + }); + + describe('createFeatureSelector', () => { + let featureName = '@ngrx/router-store'; + let featureSelector: MemoizedSelector; + + beforeEach(() => { + featureSelector = createFeatureSelector(featureName); + }); + + it('should memoize the result', () => { + const firstValue = { first: 'value' }; + const firstState = { [featureName]: firstValue }; + const secondValue = { secondValue: 'value' }; + const secondState = { [featureName]: secondValue }; + + const state$ = cold('--a--a--a--b--', { a: firstState, b: secondState }); + const expected$ = cold('--a--------b--', { a: firstValue, b: secondValue }); + const featureState$ = state$.map(featureSelector).distinctUntilChanged(); + + expect(featureState$).toBeObservable(expected$); + }); + }); +}); diff --git a/modules/store/src/models.ts b/modules/store/src/models.ts index 83a958a65d..3e29d4b68c 100644 --- a/modules/store/src/models.ts +++ b/modules/store/src/models.ts @@ -20,3 +20,7 @@ export interface StoreFeature { reducerFactory: ActionReducerFactory; initialState: T | undefined; } + +export interface Selector { + (state: T): V; +} diff --git a/modules/store/src/selector.ts b/modules/store/src/selector.ts new file mode 100644 index 0000000000..aab037f264 --- /dev/null +++ b/modules/store/src/selector.ts @@ -0,0 +1,127 @@ +import { Selector } from './models'; + + +export interface MemoizedSelector extends Selector { + release(): void; +} + +export type AnyFn = (...args: any[]) => any; + +export function memoize(t: AnyFn): { memoized: AnyFn, reset: () => void } { + let lastArguments: null | IArguments = null; + let lastResult: any = null; + + function reset() { + lastArguments = null; + lastResult = null; + } + + function memoized(): any { + if (!lastArguments) { + lastResult = t.apply(null, arguments); + lastArguments = arguments; + + return lastResult; + } + for (let i = 0; i < arguments.length; i++) { + if (arguments[i] !== lastArguments[i]) { + lastResult = t.apply(null, arguments); + lastArguments = arguments; + + return lastResult; + } + } + + return lastResult; + } + + return { memoized, reset }; +} + +export function createSelector( + s1: Selector, + projector: (S1: S1) => Result +): MemoizedSelector; +export function createSelector( + s1: Selector, + s2: Selector, + projector: (s1: S1, s2: S2) => Result, +): MemoizedSelector; +export function createSelector( + s1: Selector, + s2: Selector, + s3: Selector, + projector: (s1: S1, s2: S2, s3: S3) => Result, +): MemoizedSelector; +export function createSelector( + s1: Selector, + s2: Selector, + s3: Selector, + s4: Selector, + projector: (s1: S1, s2: S2, s3: S3) => Result, +): MemoizedSelector; +export function createSelector( + s1: Selector, + s2: Selector, + s3: Selector, + s4: Selector, + s5: Selector, + projector: (s1: S1, s2: S2, s3: S3, s4: S4, s5: S5) => Result, +): MemoizedSelector; +export function createSelector( + s1: Selector, + s2: Selector, + s3: Selector, + s4: Selector, + s5: Selector, + s6: Selector, + projector: (s1: S1, s2: S2, s3: S3, s4: S4, s5: S5, s6: S6) => Result, +): MemoizedSelector; +export function createSelector( + s1: Selector, + s2: Selector, + s3: Selector, + s4: Selector, + s5: Selector, + s6: Selector, + s7: Selector, + projector: (s1: S1, s2: S2, s3: S3, s4: S4, s5: S5, s6: S6, s7: S7) => Result, +): MemoizedSelector; +export function createSelector( + s1: Selector, + s2: Selector, + s3: Selector, + s4: Selector, + s5: Selector, + s6: Selector, + s7: Selector, + s8: Selector, + projector: (s1: S1, s2: S2, s3: S3, s4: S4, s5: S5, s6: S6, s7: S7, s8: S8) => Result, +): MemoizedSelector; +export function createSelector(...args: any[]): Selector { + const selectors = args.slice(0, args.length - 1); + const projector = args[args.length - 1]; + const memoizedSelectors = selectors.filter((selector: any) => selector.release && typeof selector.release === 'function'); + + const { memoized, reset } = memoize(function (state: any) { + const args = selectors.map(fn => fn(state)); + + return projector.apply(null, args); + }); + + function release() { + reset(); + + memoizedSelectors.forEach(selector => selector.release()); + } + + return Object.assign(memoized, { release }); +} + +export function createFeatureSelector(featureName: string): MemoizedSelector { + const { memoized, reset } = memoize(function (state: any): any { + return state[featureName]; + }); + + return Object.assign(memoized, { release: reset }); +}