fix(nx): adds the notion of "entity id" to data persistence

This commit is contained in:
vsavkin 2017-10-04 11:07:20 -04:00
parent 06df591d59
commit f287389ecb
2 changed files with 130 additions and 26 deletions

View File

@ -1,3 +1,5 @@
import 'rxjs/add/operator/delay';
import {Component, Injectable} from '@angular/core';
import {ComponentFixture, fakeAsync, TestBed, tick} from '@angular/core/testing';
import {ActivatedRouteSnapshot, Router} from '@angular/router';
@ -9,6 +11,7 @@ import {Store, StoreModule} from '@ngrx/store';
import {Observable} from 'rxjs/Observable';
import {of} from 'rxjs/observable/of';
import {_throw} from 'rxjs/observable/throw';
import {delay} from 'rxjs/operator/delay';
import {Subject} from 'rxjs/Subject';
import {DataPersistence} from '../index';
@ -212,13 +215,14 @@ describe('DataPersistence', () => {
TestBed.configureTestingModule({providers: [DataPersistence]});
});
describe('successful', () => {
describe('no id', () => {
@Injectable()
class TodoEffects {
@Effect()
loadTodo = this.s.fetch('GET_TODOS', {
loadTodos = this.s.fetch('GET_TODOS', {
run(a: any, state: TodosState) {
return ({type: 'TODOS', payload: {user: state.user, todos: 'some todos'}});
// we need to introduce the delay to "enable" switchMap
return of ({type: 'TODOS', payload: {user: state.user, todos: 'some todos'}}).delay(1);
},
onError(a: UpdateTodo, e: any) {
@ -244,10 +248,60 @@ describe('DataPersistence', () => {
});
it('should work', async (done) => {
actions = of({type: 'GET_TODOS', payload: {}});
actions = of({type: 'GET_TODOS', payload: {}}, {type: 'GET_TODOS', payload: {}});
expect(await readAll(TestBed.get(TodoEffects).loadTodos)).toEqual([
{type: 'TODOS', payload: {user: 'bob', todos: 'some todos'}},
{type: 'TODOS', payload: {user: 'bob', todos: 'some todos'}}
]);
done();
});
});
describe('id', () => {
@Injectable()
class TodoEffects {
@Effect()
loadTodo = this.s.fetch('GET_TODO', {
id(a: any, state: TodosState) {
return a.payload.id;
},
run(a: any, state: TodosState) {
// we need to introduce the delay to "enable" switchMap
return of ({type: 'TODO', payload: a.payload}).delay(1);
},
onError(a: UpdateTodo, e: any) {
return null;
}
});
constructor(private s: DataPersistence<any>) {}
}
function userReducer() {
return 'bob';
}
let actions: Observable<any>;
beforeEach(() => {
actions = new Subject<any>();
TestBed.configureTestingModule({
providers: [TodoEffects, provideMockActions(() => actions)],
imports: [StoreModule.forRoot({user: userReducer})]
})
});
it('should work', async (done) => {
actions =
of({type: 'GET_TODO', payload: {id: 1, value: '1'}}, {type: 'GET_TODO', payload: {id: 2, value: '2a'}},
{type: 'GET_TODO', payload: {id: 2, value: '2b'}});
expect(await readAll(TestBed.get(TodoEffects).loadTodo)).toEqual([
{type: 'TODOS', payload: {user: 'bob', todos: 'some todos'}}
{type: 'TODO', payload: {id: 1, value: '1'}}, {type: 'TODO', payload: {id: 2, value: '2b'}}
]);
done();

View File

@ -8,7 +8,9 @@ import {of} from 'rxjs/observable/of';
import {_catch} from 'rxjs/operator/catch';
import {concatMap} from 'rxjs/operator/concatMap';
import {filter} from 'rxjs/operator/filter';
import {groupBy} from 'rxjs/operator/groupBy';
import {map} from 'rxjs/operator/map';
import {mergeMap} from 'rxjs/operator/mergeMap';
import {switchMap} from 'rxjs/operator/switchMap';
import {withLatestFrom} from 'rxjs/operator/withLatestFrom';
@ -31,6 +33,7 @@ export interface OptimisticUpdateOpts {
* See {@link DataPersistence.navigation} for more information.
*/
export interface FetchOpts {
id?(a: Action, state?: any): any;
run(a: Action, state?: any): Observable<Action>|Action|void;
onError?(a: Action, e: any): Observable<any>|any;
}
@ -43,8 +46,10 @@ export interface HandleNavigationOpts {
onError?(a: ActivatedRouteSnapshot, e: any): Observable<any>|any;
}
type Pair = [Action, any];
/**
* @whatItDoes Provides convenience methods for implementing common operations of talking to the backend.
* @whatItDoes Provides convenience methods for implementing common operations of persisting data.
*/
@Injectable()
export class DataPersistence<T> {
@ -54,11 +59,11 @@ export class DataPersistence<T> {
*
* @whatItDoes Handles pessimistic updates (updating the server first).
*
* It provides the action and the current state. It runs all updates in order by using `concatMap` to prevent race
* conditions.
* Update the server implemented naively suffers from race conditions and poor error handling.
*
* `pessimisticUpdate` addresses these problems--it runs all fetches in order, which removes race conditions
* and forces the developer to handle errors.
*
* * `run` callback must return an action or an observable with an action.
* * `onError` is called when a server update was not successful.
* ## Example:
*
* ```typescript
@ -96,12 +101,13 @@ export class DataPersistence<T> {
*
* @whatItDoes Handles optimistic updates (updating the client first).
*
* It provides the action and the current state. It runs all updates in order by using `concatMap` to prevent race
* conditions.
* `optimisticUpdate` addresses these problems--it runs all fetches in order, which removes race conditions
* and forces the developer to handle errors.
*
* * `run` callback must return an action or an observable with an action.
* * `undoAction` is called server update was not successful. It must return an action or an observable with an action
* to undo the changes in the client state.
* `optimisticUpdate` is different from `pessimisticUpdate`. In case of a failure, when using `optimisticUpdate`,
* the developer already updated the state locally, so the developer must provide an undo action.
*
* The error handling must be done in the callback, or by means of the undo action.
*
* ## Example:
*
@ -137,17 +143,17 @@ export class DataPersistence<T> {
*
* @whatItDoes Handles data fetching.
*
* It provides the action and the current state. It only runs the last fetch by using `switchMap`.
* Data fetching implemented naively suffers from race conditions and poor error handling.
*
* * `run` callback must return an action or an observable with an action.
* * `onError` is called when a server request was not successful.
* `fetch` addresses these problems--it runs all fetches in order, which removes race conditions
* and forces the developer to handle errors.
*
* ## Example:
*
* ```typescript
* @Injectable()
* class TodoEffects {
* @Effect() loadTodo = this.s.fetch('GET_TODOS', {
* @Effect() loadTodos = this.s.fetch('GET_TODOS', {
* // provides an action and the current state of the store
* run(a: GetTodos, state: TodosState) {
* return this.backend(state.user, a.payload).map(r => ({
@ -165,22 +171,66 @@ export class DataPersistence<T> {
* constructor(private s: DataPersistence<TodosState>, private backend: Backend) {}
* }
* ```
*
* This is correct, but because it set the concurrency to 1, it may not be performant.
*
* To fix that, you can provide the `id` function, like this:
*
* ```typescript
* @Injectable()
* class TodoEffects {
* @Effect() loadTodo = this.s.fetch('GET_TODO', {
* run(a: GetTodo, state: TodosState) {
* return a.payload.id;
* }
*
* // provides an action and the current state of the store
* run(a: GetTodo, state: TodosState) {
* return this.backend(state.user, a.payload).map(r => ({
* type: 'TODO',
* payload: r
* });
* },
*
* onError(a: GetTodo, e: any): Action {
* // dispatch an undo action to undo the changes in the client state
* // return null;
* }
* });
*
* constructor(private s: DataPersistence<TodosState>, private backend: Backend) {}
* }
* ```
*
* With this setup, the requests for Todo 1 will run concurrently with the requests for Todo 2.
*
* In addition, if DataPersistence notices that there are multiple requests for Todo 1 scheduled,
* it will only run the last one.
*/
fetch(actionType: string, opts: FetchOpts): Observable<any> {
const nav = this.actions.ofType(actionType);
const pairs = withLatestFrom.call(nav, this.store);
return switchMap.call(pairs, this.runWithErrorHandling(opts.run, opts.onError));
const allPairs = withLatestFrom.call(nav, this.store);
if (opts.id) {
const groupedFetches: Observable<Observable<Pair>> = groupBy.call(allPairs, (p) => opts.id(p[0], p[1]));
return mergeMap.call(
groupedFetches,
(pairs: Observable<Pair>) => switchMap.call(pairs, this.runWithErrorHandling(opts.run, opts.onError)));
} else {
return concatMap.call(allPairs, this.runWithErrorHandling(opts.run, opts.onError));
}
}
/**
* @whatItDoes Handles data fetching as part of router navigation.
*
* It checks if an activated router state contains the passed in component type, and, if it does, runs the `run`
* callback. It provides the activated snapshot associated with the component and the current state. It only runs the
* last request by using `switchMap`.
* Data fetching implemented naively suffers from race conditions and poor error handling.
*
* * `run` callback must return an action or an observable with an action.
* * `onError` is called when a server request was not successful.
* `navigation` addresses these problems.
*
* It checks if an activated router state contains the passed in component type, and, if it does, runs the `run`
* callback. It provides the activated snapshot associated with the component and the current state. And it only runs
* the last request.
*
* ## Example:
*