Skip to content

Commit

Permalink
[WIP] fix: testability improvements for beta-10 (#172)
Browse files Browse the repository at this point in the history
feat: ClearCollections, LoadCollections, testability improvements (beta-10)
  • Loading branch information
wardbell authored Jul 17, 2018
1 parent 86349cd commit 8d0d294
Show file tree
Hide file tree
Showing 9 changed files with 329 additions and 70 deletions.
24 changes: 24 additions & 0 deletions lib/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,29 @@
# Angular ngrx-data library ChangeLog

<a id="6.0.2-beta.10"></a>

# 6.0.2-beta.10 (2018-07-17)

**Feature: ClearCollections and LoadCollections cache actions**

Added `ClearCollections` and `LoadCollections` entity cache actions so you can
clear or load multiple collections at the same time.
See `entity-cache-reducer.spec.ts` for examples.

**Minor tweaks to improve testability**:

* `EntityServicesBase.constructor` implementation is now empty.
Public readonly fields that were wired inside the constructor are now getter properties
reading from `EntityServicesElements`.
This makes it possible to instantiate an `EntityServicesBase` derivative with a null `EntityServicesElement` argument, which makes testing with derivative classes a little easier.

**Breaking changes (unlikely to affect anyone)**:

* `EntityServicesElements` public members changed to deliver what `EntityServices` needs without dotting to get there. This makes it easier to mock.

* `EntityCollectionServiceElementsFactory.getServiceElements()` renamed `create()`,
the proper verb for a factory class.

<a id="6.0.2-beta.9"></a>

# 6.0.2-beta.9 (2018-07-02)
Expand Down
2 changes: 1 addition & 1 deletion lib/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "ngrx-data",
"version": "6.0.2-beta.9",
"version": "6.0.2-beta.10",
"repository": "https://github.com/johnpapa/angular-ngrx-data.git",
"license": "MIT",
"peerDependencies": {
Expand Down
48 changes: 45 additions & 3 deletions lib/src/actions/entity-cache-action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import { EntityActionOptions } from '../actions/entity-action';
import { MergeStrategy } from '../actions/merge-strategy';

export enum EntityCacheAction {
CLEAR_COLLECTIONS = 'ngrx-data/entity-cache/clear-collections',
LOAD_COLLECTIONS = 'ngrx-data/entity-cache/load-collections',
MERGE_QUERY_SET = 'ngrx-data/entity-cache/merge-query-set',
SET_ENTITY_CACHE = 'ngrx-data/entity-cache/set-cache'
}
Expand All @@ -21,6 +23,37 @@ export interface EntityCacheQuerySet {
[entityName: string]: any[];
}

/**
* Clear the collections identified in the collectionSet.
* @param [collections] Array of names of the collections to clear.
* If empty array, does nothing. If no array, clear all collections.
* @param [tag] Optional tag to identify the operation from the app perspective.
*/
export class ClearCollections implements Action {
readonly payload: { collections: string[]; tag: string };
readonly type = EntityCacheAction.CLEAR_COLLECTIONS;

constructor(collections?: string[], tag?: string) {
this.payload = { collections, tag };
}
}

/**
* Create entity cache action that loads multiple entity collections at the same time.
* before any selectors$ observables emit.
* @param querySet The collections to load, typically the result of a query.
* @param [tag] Optional tag to identify the operation from the app perspective.
* in the form of a map of entity collections.
*/
export class LoadCollections implements Action {
readonly payload: { collections: EntityCacheQuerySet; tag: string };
readonly type = EntityCacheAction.LOAD_COLLECTIONS;

constructor(collections: EntityCacheQuerySet, tag?: string) {
this.payload = { collections, tag };
}
}

/**
* Create entity cache action that merges entities from a query result
* that returned entities from multiple collections.
Expand All @@ -30,19 +63,22 @@ export interface EntityCacheQuerySet {
* These are the entity data to merge into the respective collections.
* @param mergeStrategy How to merge a queried entity when it is already in the collection.
* The default is MergeStrategy.PreserveChanges
* @param [tag] Optional tag to identify the operation from the app perspective.
*/
export class MergeQuerySet implements Action {
readonly payload: {
querySet: EntityCacheQuerySet;
mergeStrategy?: MergeStrategy;
tag?: string;
};

readonly type = EntityCacheAction.MERGE_QUERY_SET;

constructor(querySet: EntityCacheQuerySet, mergeStrategy?: MergeStrategy) {
constructor(querySet: EntityCacheQuerySet, mergeStrategy?: MergeStrategy, tag?: string) {
this.payload = {
querySet,
mergeStrategy: mergeStrategy === null ? MergeStrategy.PreserveChanges : mergeStrategy
mergeStrategy: mergeStrategy === null ? MergeStrategy.PreserveChanges : mergeStrategy,
tag
};
}
}
Expand All @@ -51,8 +87,14 @@ export class MergeQuerySet implements Action {
* Create entity cache action for replacing the entire entity cache.
* Dangerous because brute force but useful as when re-hydrating an EntityCache
* from local browser storage when the application launches.
* @param cache New state of the entity cache
* @param [tag] Optional tag to identify the operation from the app perspective.
*/
export class SetEntityCache implements Action {
readonly payload: { cache: EntityCache; tag: string };
readonly type = EntityCacheAction.SET_ENTITY_CACHE;
constructor(public readonly payload: EntityCache) {}

constructor(public readonly cache: EntityCache, tag?: string) {
this.payload = { cache, tag };
}
}
2 changes: 1 addition & 1 deletion lib/src/entity-services/entity-collection-service-base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ export class EntityCollectionServiceBase<T, S$ extends EntitySelectors$<T> = Ent
serviceElementsFactory: EntityCollectionServiceElementsFactory
) {
entityName = entityName.trim();
const { dispatcher, selectors, selectors$ } = serviceElementsFactory.getServiceElements<T, S$>(entityName);
const { dispatcher, selectors, selectors$ } = serviceElementsFactory.create<T, S$>(entityName);

this.entityName = entityName;
this.dispatcher = dispatcher;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export class EntityCollectionServiceElementsFactory {
* Get the ingredients for making an EntityCollectionService for this entity type
* @param entityName - name of the entity type
*/
getServiceElements<T, S$ extends EntitySelectors$<T> = EntitySelectors$<T>>(entityName: string): EntityCollectionServiceElements<T, S$> {
create<T, S$ extends EntitySelectors$<T> = EntitySelectors$<T>>(entityName: string): EntityCollectionServiceElements<T, S$> {
entityName = entityName.trim();
const definition = this.entityDefinitionService.getDefinition<T>(entityName);
const dispatcher = this.entityDispatcherFactory.create<T>(entityName, definition.selectId, definition.entityDispatcherOptions);
Expand Down
52 changes: 32 additions & 20 deletions lib/src/entity-services/entity-services-base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,42 +37,54 @@ import { EntityServicesElements } from './entity-services-elements';
*/
@Injectable()
export class EntityServicesBase implements EntityServices {
// Dear ngrx-data developer: think hard before changing the constructor signature.
// Dear ngrx-data developer: think hard before changing the constructor.
// Doing so will break apps that derive from this base class,
// and many apps will derive from this class.
constructor(entityServicesElements: EntityServicesElements) {
this.entityActionErrors$ = entityServicesElements.entitySelectors$Factory.entityActionErrors$;
this.entityCache$ = entityServicesElements.entitySelectors$Factory.entityCache$;
this.entityCollectionServiceFactory = entityServicesElements.entityCollectionServiceFactory;
this.reducedActions$ = entityServicesElements.entityDispatcherFactory.reducedActions$;
this.store = entityServicesElements.store;
}
//
// Do not give this constructor an implementation.
// Doing so makes it hard to mock classes that derive from this class.
// Use getter properties instead. For example, see entityCache$
constructor(private entityServicesElements: EntityServicesElements) {}

/** Dispatch any action to the store */
dispatch(action: Action) {
this.store.dispatch(action);
}
// #region EntityServicesElement-based properties

/** Observable of error EntityActions (e.g. QUERY_ALL_ERROR) for all entity types */
readonly entityActionErrors$: Observable<EntityAction>;
get entityActionErrors$(): Observable<EntityAction> {
return this.entityServicesElements.entityActionErrors$;
}

/** Observable of the entire entity cache */
readonly entityCache$: Observable<EntityCache> | Store<EntityCache>;
get entityCache$(): Observable<EntityCache> | Store<EntityCache> {
return this.entityServicesElements.entityCache$;
}

/** Factory to create a default instance of an EntityCollectionService */
private readonly entityCollectionServiceFactory: EntityCollectionServiceFactory;

/** Registry of EntityCollectionService instances */
private readonly EntityCollectionServices: EntityCollectionServiceMap = {};
get entityCollectionServiceFactory(): EntityCollectionServiceFactory {
return this.entityServicesElements.entityCollectionServiceFactory;
}

/**
* Actions scanned by the store after it processed them with reducers.
* A replay observable of the most recent action reduced by the store.
*/
readonly reducedActions$: Observable<Action>;
get reducedActions$(): Observable<Action> {
return this.entityServicesElements.reducedActions$;
}

/** The ngrx store, scoped to the EntityCache */
protected readonly store: Store<EntityCache>;
protected get store(): Store<EntityCache> {
return this.entityServicesElements.store;
}

// #endregion EntityServicesElement-based properties

/** Dispatch any action to the store */
dispatch(action: Action) {
this.store.dispatch(action);
}

/** Registry of EntityCollectionService instances */
private readonly EntityCollectionServices: EntityCollectionServiceMap = {};

/**
* Create a new default instance of an EntityCollectionService.
Expand Down
26 changes: 22 additions & 4 deletions lib/src/entity-services/entity-services-elements.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { Injectable } from '@angular/core';
import { Store } from '@ngrx/store';
import { Action, Store } from '@ngrx/store';
import { Observable } from 'rxjs';

import { EntityAction } from '../actions/entity-action';
import { EntityCache } from '../reducers/entity-cache';
import { EntityDispatcherFactory } from '../dispatchers/entity-dispatcher-factory';
import { EntitySelectors$Factory } from '../selectors/entity-selectors$';
Expand All @@ -16,10 +18,26 @@ export class EntityServicesElements {
*/
public readonly entityCollectionServiceFactory: EntityCollectionServiceFactory,
/** Creates EntityDispatchers for entity collections */
public readonly entityDispatcherFactory: EntityDispatcherFactory,
entityDispatcherFactory: EntityDispatcherFactory,
/** Creates observable EntitySelectors$ for entity collections. */
public readonly entitySelectors$Factory: EntitySelectors$Factory,
entitySelectors$Factory: EntitySelectors$Factory,
/** The ngrx store, scoped to the EntityCache */
public readonly store: Store<EntityCache>
) {}
) {
this.entityActionErrors$ = entitySelectors$Factory.entityActionErrors$;
this.entityCache$ = entitySelectors$Factory.entityCache$;
this.reducedActions$ = entityDispatcherFactory.reducedActions$;
}

/** Observable of error EntityActions (e.g. QUERY_ALL_ERROR) for all entity types */
readonly entityActionErrors$: Observable<EntityAction>;

/** Observable of the entire entity cache */
readonly entityCache$: Observable<EntityCache> | Store<EntityCache>;

/**
* Actions scanned by the store after it processed them with reducers.
* A replay observable of the most recent action reduced by the store.
*/
readonly reducedActions$: Observable<Action>;
}
67 changes: 60 additions & 7 deletions lib/src/reducers/entity-cache-reducer-factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { Action, ActionReducer } from '@ngrx/store';

import { EntityAction } from '../actions/entity-action';
import { EntityCache } from './entity-cache';
import { EntityCacheAction, MergeQuerySet } from '../actions/entity-cache-action';
import { EntityCacheAction, ClearCollections, LoadCollections, MergeQuerySet } from '../actions/entity-cache-action';
import { EntityCollection } from './entity-collection';
import { EntityCollectionCreator } from './entity-collection-creator';
import { EntityCollectionReducerRegistry } from './entity-collection-reducer-registry';
Expand Down Expand Up @@ -34,16 +34,22 @@ export class EntityCacheReducerFactory {
): EntityCache {
// EntityCache actions
switch (action.type) {
case EntityCacheAction.SET_ENTITY_CACHE: {
// Completely replace the EntityCache. Be careful!
return action.payload;
case EntityCacheAction.CLEAR_COLLECTIONS: {
return this.clearCollectionsReducer(entityCache, action as ClearCollections);
}

case EntityCacheAction.LOAD_COLLECTIONS: {
return this.loadCollectionsReducer(entityCache, action as LoadCollections);
}

// Merge entities from each collection in the QuerySet
// using collection reducer's upsert operation
case EntityCacheAction.MERGE_QUERY_SET: {
return this.mergeQuerySetReducer(entityCache, action as MergeQuerySet);
}

case EntityCacheAction.SET_ENTITY_CACHE: {
// Completely replace the EntityCache. Be careful!
return action.payload.cache;
}
}

// Apply collection reducer if this is a valid EntityAction for a collection
Expand Down Expand Up @@ -74,14 +80,61 @@ export class EntityCacheReducerFactory {
return action.payload.error || collection === newCollection ? cache : { ...cache, [entityName]: newCollection };
}

/**
* Reducer to clear multiple collections at the same time.
* @param entityCache the entity cache
* @param action a ClearCollections action whose payload is an array of collection names.
* If empty array, does nothing. If no array, clears all the collections.
*/
protected clearCollectionsReducer(entityCache: EntityCache, action: ClearCollections) {
// tslint:disable-next-line:prefer-const
let { collections, tag } = action.payload;
const entityOp = EntityOp.REMOVE_ALL;

if (!collections) {
// Collections is not defined. Clear all collections.
collections = Object.keys(entityCache);
}

entityCache = collections.reduce((newCache, entityName) => {
const payload = { entityName, entityOp };
const act: EntityAction = { type: `[${entityName}] ${action.type}`, payload };
newCache = this.applyCollectionReducer(newCache, act);
return newCache;
}, entityCache);
return entityCache;
}

/**
* Reducer to load collection in the form of a hash of entity data for multiple collections.
* @param entityCache the entity cache
* @param action a LoadCollections action whose payload is the QuerySet of entity collections to load
*/
protected loadCollectionsReducer(entityCache: EntityCache, action: LoadCollections) {
const { collections, tag } = action.payload;
const entityOp = EntityOp.ADD_ALL;
const entityNames = Object.keys(collections);
entityCache = entityNames.reduce((newCache, entityName) => {
const payload = {
entityName,
entityOp,
data: collections[entityName]
};
const act: EntityAction = { type: `[${entityName}] ${action.type}`, payload };
newCache = this.applyCollectionReducer(newCache, act);
return newCache;
}, entityCache);
return entityCache;
}

/**
* Reducer to merge query sets in the form of a hash of entity data for multiple collections.
* @param entityCache the entity cache
* @param action a MergeQuerySet action with the query set and a MergeStrategy
*/
protected mergeQuerySetReducer(entityCache: EntityCache, action: MergeQuerySet) {
// tslint:disable-next-line:prefer-const
let { mergeStrategy, querySet } = action.payload;
let { mergeStrategy, querySet, tag } = action.payload;
mergeStrategy = mergeStrategy === null ? MergeStrategy.PreserveChanges : mergeStrategy;
const entityOp = EntityOp.UPSERT_MANY;

Expand Down
Loading

0 comments on commit 8d0d294

Please sign in to comment.