diff --git a/.circleci/bazel.rc b/.circleci/bazel.rc index 803a2cd5a3..d00665cebb 100644 --- a/.circleci/bazel.rc +++ b/.circleci/bazel.rc @@ -19,7 +19,7 @@ build --experimental_strict_action_env # Bazel doesn't calculate the memory ceiling correctly when running under Docker. # Limit Bazel to consuming resources that fit in CircleCI "medium" class which is the default: # https://circleci.com/docs/2.0/configuration-reference/#resource_class -build --jobs 3 --local_resources=2506,2.0,1.0 +build --jobs 3 --local_resources=3072,2.0,1.0 # Also limit Bazel's own JVM heap to stay within our 4G container limit startup --host_jvm_args=-Xmx1g diff --git a/modules/data/BUILD b/modules/data/BUILD new file mode 100644 index 0000000000..1aa37a5681 --- /dev/null +++ b/modules/data/BUILD @@ -0,0 +1,33 @@ +package(default_visibility = ["//visibility:public"]) + +load("//tools:defaults.bzl", "ng_module", "ng_package") + +ng_module( + name = "data", + srcs = glob([ + "*.ts", + "src/**/*.ts", + ]), + module_name = "@ngrx/data", + deps = [ + "//modules/effects", + "//modules/entity", + "//modules/store", + "@npm//@angular/common", + "@npm//@angular/core", + "@npm//rxjs", + ], +) + +ng_package( + name = "npm_package", + srcs = glob(["**/*.externs.js"]) + [ + "package.json", + ], + entry_point = "modules/data/index.js", + packages = [ + ], + deps = [ + ":data", + ], +) diff --git a/modules/data/CHANGELOG.md b/modules/data/CHANGELOG.md new file mode 100644 index 0000000000..3b9c4d91aa --- /dev/null +++ b/modules/data/CHANGELOG.md @@ -0,0 +1,3 @@ +# Change Log + +See [CHANGELOG.md](https://github.com/ngrx/platform/blob/master/CHANGELOG.md) diff --git a/modules/data/README.md b/modules/data/README.md new file mode 100644 index 0000000000..8af6b69370 --- /dev/null +++ b/modules/data/README.md @@ -0,0 +1,5 @@ +# @ngrx/data + +The sources for this package are in the main [NgRx](https://github.com/ngrx/platform) repo. Please file issues and pull requests against that repo. + +License: MIT diff --git a/modules/data/index.ts b/modules/data/index.ts new file mode 100644 index 0000000000..637e1cf2bf --- /dev/null +++ b/modules/data/index.ts @@ -0,0 +1,7 @@ +/** + * DO NOT EDIT + * + * This file is automatically generated at build + */ + +export * from './public_api'; diff --git a/modules/data/package.json b/modules/data/package.json new file mode 100644 index 0000000000..d84f13cd50 --- /dev/null +++ b/modules/data/package.json @@ -0,0 +1,32 @@ +{ + "name": "@ngrx/data", + "version": "0.0.0-PLACEHOLDER", + "description": "API management for NgRx", + "repository": { + "type": "git", + "url": "https://github.com/ngrx/platform.git" + }, + "keywords": [ + "Angular", + "Redux", + "NgRx", + "Schematics", + "Angular CLI" + ], + "author": "NgRx", + "license": "MIT", + "bugs": { + "url": "https://github.com/ngrx/platform/issues" + }, + "homepage": "https://github.com/ngrx/platform#readme", + "peerDependencies": { + "@angular/common": "NG_VERSION", + "@angular/core": "NG_VERSION", + "@ngrx/store": "0.0.0-PLACEHOLDER", + "@ngrx/effects": "0.0.0-PLACEHOLDER", + "@ngrx/entity": "0.0.0-PLACEHOLDER", + "rxjs": "RXJS_VERSION" + }, + "schematics": "MODULE_SCHEMATICS_COLLECTION", + "sideEffects": false +} diff --git a/modules/data/public_api.ts b/modules/data/public_api.ts new file mode 100644 index 0000000000..cba1843545 --- /dev/null +++ b/modules/data/public_api.ts @@ -0,0 +1 @@ +export * from './src/index'; diff --git a/modules/data/rollup.config.js b/modules/data/rollup.config.js new file mode 100644 index 0000000000..6bee50d2a3 --- /dev/null +++ b/modules/data/rollup.config.js @@ -0,0 +1,12 @@ +export default { + entry: './dist/data/@ngrx/data.es5.js', + dest: './dist/data/bundles/data.umd.js', + format: 'umd', + exports: 'named', + moduleName: 'ngrx.data', + globals: { + '@ngrx/store': 'ngrx.store', + '@ngrx/effects': 'ngrx.effects', + '@ngrx/entity': 'ngrx.entity', + } +} diff --git a/modules/data/spec/BUILD b/modules/data/spec/BUILD new file mode 100644 index 0000000000..c0e9db7d99 --- /dev/null +++ b/modules/data/spec/BUILD @@ -0,0 +1,30 @@ +load("//tools:defaults.bzl", "jasmine_node_test", "ts_test_library") + +ts_test_library( + name = "test_lib", + srcs = glob( + [ + "**/*.ts", + ], + ), + deps = [ + "//modules/data", + "//modules/effects", + "//modules/effects/testing", + "//modules/entity", + "//modules/store", + "@npm//@angular/common", + "@npm//rxjs", + ], +) + +jasmine_node_test( + name = "test", + deps = [ + ":test_lib", + "//modules/data", + "//modules/effects", + "//modules/entity", + "//modules/store", + ], +) diff --git a/modules/data/spec/actions/entity-action-factory.spec.ts b/modules/data/spec/actions/entity-action-factory.spec.ts new file mode 100644 index 0000000000..0c1801ea8f --- /dev/null +++ b/modules/data/spec/actions/entity-action-factory.spec.ts @@ -0,0 +1,212 @@ +import { + EntityAction, + EntityActionOptions, + EntityActionPayload, + EntityOp, + EntityActionFactory, + MergeStrategy, + CorrelationIdGenerator, +} from '../../'; + +class Hero { + id: number; + name: string; +} + +describe('EntityActionFactory', () => { + let factory: EntityActionFactory; + + beforeEach(() => { + factory = new EntityActionFactory(); + }); + + it('#create should create an EntityAction from entityName and entityOp', () => { + const action = factory.create('Hero', EntityOp.QUERY_ALL); + const { entityName, entityOp, data } = action.payload; + expect(entityName).toBe('Hero'); + expect(entityOp).toBe(EntityOp.QUERY_ALL); + expect(data).toBeUndefined('no data property'); + }); + + it('#create should create an EntityAction with the given data', () => { + const hero: Hero = { id: 42, name: 'Francis' }; + const action = factory.create('Hero', EntityOp.ADD_ONE, hero); + const { entityName, entityOp, data } = action.payload; + expect(entityName).toBe('Hero'); + expect(entityOp).toBe(EntityOp.ADD_ONE); + expect(data).toBe(hero); + }); + + it('#create should create an EntityAction with options', () => { + const options: EntityActionOptions = { + correlationId: 'CRID42', + isOptimistic: true, + mergeStrategy: MergeStrategy.OverwriteChanges, + tag: 'Foo', + }; + + // Don't forget placeholder for missing optional data! + const action = factory.create( + 'Hero', + EntityOp.QUERY_ALL, + undefined, + options + ); + const { + entityName, + entityOp, + data, + correlationId, + isOptimistic, + mergeStrategy, + tag, + } = action.payload; + expect(entityName).toBe('Hero'); + expect(entityOp).toBe(EntityOp.QUERY_ALL); + expect(data).toBeUndefined(); + expect(correlationId).toBe(options.correlationId); + expect(isOptimistic).toBe(options.isOptimistic); + expect(mergeStrategy).toBe(options.mergeStrategy); + expect(tag).toBe(options.tag); + }); + + it('#create create an EntityAction from an EntityActionPayload', () => { + const hero: Hero = { id: 42, name: 'Francis' }; + const payload: EntityActionPayload = { + entityName: 'Hero', + entityOp: EntityOp.ADD_ONE, + data: hero, + correlationId: 'CRID42', + isOptimistic: true, + mergeStrategy: MergeStrategy.OverwriteChanges, + tag: 'Foo', + }; + const action = factory.create(payload); + + const { + entityName, + entityOp, + data, + correlationId, + isOptimistic, + mergeStrategy, + tag, + } = action.payload; + expect(entityName).toBe(payload.entityName); + expect(entityOp).toBe(payload.entityOp); + expect(data).toBe(payload.data); + expect(correlationId).toBe(payload.correlationId); + expect(isOptimistic).toBe(payload.isOptimistic); + expect(mergeStrategy).toBe(payload.mergeStrategy); + expect(tag).toBe(payload.tag); + }); + + it('#createFromAction should create EntityAction from another EntityAction', () => { + // pessimistic save + const hero1: Hero = { id: undefined as any, name: 'Francis' }; + const action1 = factory.create('Hero', EntityOp.SAVE_ADD_ONE, hero1); + + // after save succeeds + const hero: Hero = { ...hero1, id: 42 }; + const action = factory.createFromAction(action1, { + entityOp: EntityOp.SAVE_ADD_ONE_SUCCESS, + data: hero, + }); + const { entityName, entityOp, data } = action.payload; + + expect(entityName).toBe('Hero'); + expect(entityOp).toBe(EntityOp.SAVE_ADD_ONE_SUCCESS); + expect(data).toBe(hero); + const expectedType = factory.formatActionType( + EntityOp.SAVE_ADD_ONE_SUCCESS, + 'Hero' + ); + expect(action.type).toEqual(expectedType); + }); + + it('#createFromAction should copy the options from the source action', () => { + const options: EntityActionOptions = { + correlationId: 'CRID42', + isOptimistic: true, + mergeStrategy: MergeStrategy.OverwriteChanges, + tag: 'Foo', + }; + // Don't forget placeholder for missing optional data! + const sourceAction = factory.create( + 'Hero', + EntityOp.QUERY_ALL, + undefined, + options + ); + + const queryResults: Hero[] = [ + { id: 1, name: 'Francis' }, + { id: 2, name: 'Alex' }, + ]; + const action = factory.createFromAction(sourceAction, { + entityOp: EntityOp.QUERY_ALL_SUCCESS, + data: queryResults, + }); + + const { + entityName, + entityOp, + data, + correlationId, + isOptimistic, + mergeStrategy, + tag, + } = action.payload; + expect(entityName).toBe('Hero'); + expect(entityOp).toBe(EntityOp.QUERY_ALL_SUCCESS); + expect(data).toBe(queryResults); + expect(correlationId).toBe(options.correlationId); + expect(isOptimistic).toBe(options.isOptimistic); + expect(mergeStrategy).toBe(options.mergeStrategy); + expect(tag).toBe(options.tag); + }); + + it('#createFromAction can suppress the data property', () => { + const hero: Hero = { id: 42, name: 'Francis' }; + const action1 = factory.create('Hero', EntityOp.ADD_ONE, hero); + const action = factory.createFromAction(action1, { + entityOp: EntityOp.SAVE_ADD_ONE, + data: undefined, + }); + const { entityName, entityOp, data } = action.payload; + expect(entityName).toBe('Hero'); + expect(entityOp).toBe(EntityOp.SAVE_ADD_ONE); + expect(data).toBeUndefined(); + }); + + it('#formatActionType should format type with the entityName', () => { + const action = factory.create('Hero', EntityOp.QUERY_ALL); + const expectedFormat = factory.formatActionType(EntityOp.QUERY_ALL, 'Hero'); + expect(action.type).toBe(expectedFormat); + }); + + it('#formatActionType should format type with given tag instead of the entity name', () => { + const tag = 'Hero - Tag Test'; + const action = factory.create('Hero', EntityOp.QUERY_ALL, null, { tag }); + expect(action.type).toContain(tag); + }); + + it('can re-format generated action.type with a custom #formatActionType()', () => { + factory.formatActionType = (op, entityName) => + `${entityName}_${op}`.toUpperCase(); + + const expected = ('Hero_' + EntityOp.QUERY_ALL).toUpperCase(); + const action = factory.create('Hero', EntityOp.QUERY_ALL); + expect(action.type).toBe(expected); + }); + + it('should throw if do not specify entityName', () => { + expect(() => factory.create(null as any)).toThrow(); + }); + + it('should throw if do not specify EntityOp', () => { + expect(() => + factory.create({ entityName: 'Hero', entityOp: null as any }) + ).toThrow(); + }); +}); diff --git a/modules/data/spec/actions/entity-action-guard.spec.ts b/modules/data/spec/actions/entity-action-guard.spec.ts new file mode 100644 index 0000000000..d1ffdd35a1 --- /dev/null +++ b/modules/data/spec/actions/entity-action-guard.spec.ts @@ -0,0 +1,3 @@ +describe('EntityActionGuard', () => { + // TODO: write some tests +}); diff --git a/modules/data/spec/actions/entity-action-operators.spec.ts b/modules/data/spec/actions/entity-action-operators.spec.ts new file mode 100644 index 0000000000..02d2b8f2d7 --- /dev/null +++ b/modules/data/spec/actions/entity-action-operators.spec.ts @@ -0,0 +1,165 @@ +import { Action } from '@ngrx/store'; +import { Actions } from '@ngrx/effects'; + +import { Subject } from 'rxjs'; + +import { + EntityAction, + EntityActionFactory, + EntityOp, + ofEntityType, + ofEntityOp, +} from '../../'; + +class Hero { + id: number; + name: string; +} + +// Todo: consider marble testing +describe('EntityAction Operators', () => { + // factory never changes in these tests + const entityActionFactory = new EntityActionFactory(); + + let results: any[]; + let actions: Subject; + + const testActions = { + foo: { type: 'Foo' }, + hero_query_all: entityActionFactory.create('Hero', EntityOp.QUERY_ALL), + villain_query_many: entityActionFactory.create( + 'Villain', + EntityOp.QUERY_MANY + ), + hero_delete: entityActionFactory.create( + 'Hero', + EntityOp.SAVE_DELETE_ONE, + 42 + ), + bar: ({ type: 'Bar', payload: 'bar' }), + }; + + function dispatchTestActions() { + Object.keys(testActions).forEach(a => actions.next((testActions)[a])); + } + + beforeEach(() => { + actions = new Subject(); + results = []; + }); + + /////////////// + + it('#ofEntityType()', () => { + // EntityActions of any kind + actions.pipe(ofEntityType()).subscribe(ea => results.push(ea)); + + const expectedActions = [ + testActions.hero_query_all, + testActions.villain_query_many, + testActions.hero_delete, + ]; + dispatchTestActions(); + expect(results).toEqual(expectedActions); + }); + + it(`#ofEntityType('SomeType')`, () => { + // EntityActions of one type + actions.pipe(ofEntityType('Hero')).subscribe(ea => results.push(ea)); + + const expectedActions = [ + testActions.hero_query_all, + testActions.hero_delete, + ]; + dispatchTestActions(); + expect(results).toEqual(expectedActions); + }); + + it(`#ofEntityType('Type1', 'Type2', 'Type3')`, () => { + // n.b. 'Bar' is not an EntityType even though it is an action type + actions + .pipe(ofEntityType('Hero', 'Villain', 'Bar')) + .subscribe(ea => results.push(ea)); + + ofEntityTypeTest(); + }); + + it('#ofEntityType(...arrayOfTypeNames)', () => { + const types = ['Hero', 'Villain', 'Bar']; + + actions.pipe(ofEntityType(...types)).subscribe(ea => results.push(ea)); + ofEntityTypeTest(); + }); + + it('#ofEntityType(arrayOfTypeNames)', () => { + const types = ['Hero', 'Villain', 'Bar']; + + actions.pipe(ofEntityType(types)).subscribe(ea => results.push(ea)); + ofEntityTypeTest(); + }); + + function ofEntityTypeTest() { + const expectedActions = [ + testActions.hero_query_all, + testActions.villain_query_many, + testActions.hero_delete, + // testActions.bar, // 'Bar' is not an EntityType + ]; + dispatchTestActions(); + expect(results).toEqual(expectedActions); + } + + it('#ofEntityType(...) is case sensitive', () => { + // EntityActions of the 'hero' type, but it's lowercase so shouldn't match + actions.pipe(ofEntityType('hero')).subscribe(ea => results.push(ea)); + + dispatchTestActions(); + expect(results).toEqual([], 'should not match anything'); + }); + + /////////////// + + it('#ofEntityOp with string args', () => { + actions + .pipe(ofEntityOp(EntityOp.QUERY_ALL, EntityOp.QUERY_MANY)) + .subscribe(ea => results.push(ea)); + + ofEntityOpTest(); + }); + + it('#ofEntityOp with ...rest args', () => { + const ops = [EntityOp.QUERY_ALL, EntityOp.QUERY_MANY]; + + actions.pipe(ofEntityOp(...ops)).subscribe(ea => results.push(ea)); + ofEntityOpTest(); + }); + + it('#ofEntityOp with array args', () => { + const ops = [EntityOp.QUERY_ALL, EntityOp.QUERY_MANY]; + + actions.pipe(ofEntityOp(ops)).subscribe(ea => results.push(ea)); + ofEntityOpTest(); + }); + + it('#ofEntityOp()', () => { + // EntityOps of any kind + actions.pipe(ofEntityOp()).subscribe(ea => results.push(ea)); + + const expectedActions = [ + testActions.hero_query_all, + testActions.villain_query_many, + testActions.hero_delete, + ]; + dispatchTestActions(); + expect(results).toEqual(expectedActions); + }); + + function ofEntityOpTest() { + const expectedActions = [ + testActions.hero_query_all, + testActions.villain_query_many, + ]; + dispatchTestActions(); + expect(results).toEqual(expectedActions); + } +}); diff --git a/modules/data/spec/actions/entity-cache-changes-set.spec.ts b/modules/data/spec/actions/entity-cache-changes-set.spec.ts new file mode 100644 index 0000000000..3c53af7020 --- /dev/null +++ b/modules/data/spec/actions/entity-cache-changes-set.spec.ts @@ -0,0 +1,32 @@ +import { + ChangeSet, + ChangeSetOperation, + changeSetItemFactory as cif, +} from '../../'; + +describe('changeSetItemFactory', () => { + const hero = { id: 1, name: 'Hero 1' }; + const villains = [{ id: 2, name: 'Villain 2' }, { id: 3, name: 'Villain 3' }]; + + it('should create an Add item with array of entities from single entity', () => { + const heroItem = cif.add('Hero', hero); + expect(heroItem.op).toBe(ChangeSetOperation.Add); + expect(heroItem.entityName).toBe('Hero'); + expect(heroItem.entities).toEqual([hero]); + }); + + it('should create a Delete item from an array entity keys', () => { + const ids = villains.map(v => v.id); + const heroItem = cif.delete('Villain', ids); + expect(heroItem.op).toBe(ChangeSetOperation.Delete); + expect(heroItem.entityName).toBe('Villain'); + expect(heroItem.entities).toEqual(ids); + }); + + it('should create an Add item with empty array when given no entities', () => { + const heroItem = cif.add('Hero', null); + expect(heroItem.op).toBe(ChangeSetOperation.Add); + expect(heroItem.entityName).toBe('Hero'); + expect(heroItem.entities).toEqual([]); + }); +}); diff --git a/modules/data/spec/dataservices/data-service-error.spec.ts b/modules/data/spec/dataservices/data-service-error.spec.ts new file mode 100644 index 0000000000..4ef9fbb219 --- /dev/null +++ b/modules/data/spec/dataservices/data-service-error.spec.ts @@ -0,0 +1,31 @@ +import { HttpErrorResponse } from '@angular/common/http'; +import { DataServiceError, RequestData } from '../../'; + +describe('DataServiceError', () => { + describe('#message', () => { + it('should define message when ctor error is string', () => { + const expected = 'The error'; + const dse = new DataServiceError(expected, null); + expect(dse.message).toBe(expected); + }); + + it('should define message when ctor error is new Error("message")', () => { + const expected = 'The error'; + const dse = new DataServiceError(new Error(expected), null); + expect(dse.message).toBe(expected); + }); + + it('should define message when ctor error is typical HttpResponseError', () => { + const expected = 'The error'; + const body = expected; // server error is typically in the body of the server response + const httpErr = new HttpErrorResponse({ + status: 400, + statusText: 'Bad Request', + url: 'http://foo.com/bad', + error: body, + }); + const dse = new DataServiceError(httpErr, null); + expect(dse.message).toBe(expected); + }); + }); +}); diff --git a/modules/data/spec/dataservices/default-data.service.spec.ts b/modules/data/spec/dataservices/default-data.service.spec.ts new file mode 100644 index 0000000000..071806d67e --- /dev/null +++ b/modules/data/spec/dataservices/default-data.service.spec.ts @@ -0,0 +1,597 @@ +import { TestBed } from '@angular/core/testing'; + +import { HttpClient, HttpResponse } from '@angular/common/http'; +import { + HttpClientTestingModule, + HttpTestingController, +} from '@angular/common/http/testing'; + +import { of } from 'rxjs'; + +import { Update } from '@ngrx/entity'; + +import { + DefaultDataService, + DefaultDataServiceFactory, + DefaultHttpUrlGenerator, + EntityHttpResourceUrls, + HttpUrlGenerator, + DefaultDataServiceConfig, + DataServiceError, +} from '../../'; + +class Hero { + id: number; + name: string; + version?: number; +} + +//////// Tests ///////////// +describe('DefaultDataService', () => { + let httpClient: HttpClient; + let httpTestingController: HttpTestingController; + const heroUrl = 'api/hero/'; + const heroesUrl = 'api/heroes/'; + let httpUrlGenerator: HttpUrlGenerator; + let service: DefaultDataService; + + //// HttpClient testing boilerplate + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule], + }); + httpClient = TestBed.get(HttpClient); + httpTestingController = TestBed.get(HttpTestingController); + + httpUrlGenerator = new DefaultHttpUrlGenerator(null as any); + httpUrlGenerator.registerHttpResourceUrls({ + Hero: { + entityResourceUrl: heroUrl, + collectionResourceUrl: heroesUrl, + }, + }); + + service = new DefaultDataService('Hero', httpClient, httpUrlGenerator); + }); + + afterEach(() => { + // After every test, assert that there are no pending requests. + httpTestingController.verify(); + }); + /////////////////// + + describe('property inspection', () => { + // Test wrapper exposes protected properties + class TestService extends DefaultDataService { + properties = { + entityUrl: this.entityUrl, + entitiesUrl: this.entitiesUrl, + getDelay: this.getDelay, + saveDelay: this.saveDelay, + timeout: this.timeout, + }; + } + + // tslint:disable-next-line:no-shadowed-variable + let service: TestService; + + beforeEach(() => { + // use test wrapper class to get to protected properties + service = new TestService('Hero', httpClient, httpUrlGenerator); + }); + + it('has expected name', () => { + expect(service.name).toBe('Hero DefaultDataService'); + }); + + it('has expected single-entity url', () => { + expect(service.properties.entityUrl).toBe(heroUrl); + }); + + it('has expected multiple-entities url', () => { + expect(service.properties.entitiesUrl).toBe(heroesUrl); + }); + }); + + describe('#getAll', () => { + let expectedHeroes: Hero[]; + + beforeEach(() => { + expectedHeroes = [{ id: 1, name: 'A' }, { id: 2, name: 'B' }] as Hero[]; + }); + + it('should return expected heroes (called once)', () => { + service + .getAll() + .subscribe( + heroes => + expect(heroes).toEqual( + expectedHeroes, + 'should return expected heroes' + ), + fail + ); + + // HeroService should have made one request to GET heroes from expected URL + const req = httpTestingController.expectOne(heroesUrl); + expect(req.request.method).toEqual('GET'); + + expect(req.request.body).toBeNull('should not send data'); + + // Respond with the mock heroes + req.flush(expectedHeroes); + }); + + it('should be OK returning no heroes', () => { + service + .getAll() + .subscribe( + heroes => + expect(heroes.length).toEqual(0, 'should have empty heroes array'), + fail + ); + + const req = httpTestingController.expectOne(heroesUrl); + req.flush([]); // Respond with no heroes + }); + + it('should return expected heroes (called multiple times)', () => { + service.getAll().subscribe(); + service.getAll().subscribe(); + service + .getAll() + .subscribe( + heroes => + expect(heroes).toEqual( + expectedHeroes, + 'should return expected heroes' + ), + fail + ); + + const requests = httpTestingController.match(heroesUrl); + expect(requests.length).toEqual(3, 'calls to getAll()'); + + // Respond to each request with different mock hero results + requests[0].flush([]); + requests[1].flush([{ id: 1, name: 'bob' }]); + requests[2].flush(expectedHeroes); + }); + + it('should turn 404 into Observable', () => { + const msg = 'deliberate 404 error'; + + service.getAll().subscribe( + heroes => fail('getAll succeeded when expected it to fail with a 404'), + err => { + expect(err).toBeDefined('request should have failed'); + expect(err instanceof DataServiceError).toBe( + true, + 'is DataServiceError' + ); + expect(err.error.status).toEqual(404, 'has 404 status'); + expect(err.message).toEqual(msg, 'has expected error message'); + } + ); + + const req = httpTestingController.expectOne(heroesUrl); + + const errorEvent = { + // Source of the service's not-so-friendly user-facing message + message: msg, + + // The rest of this is optional and not used. Just showing that you could. + filename: 'DefaultDataService.ts', + lineno: 42, + colno: 21, + } as ErrorEvent; + + req.error(errorEvent, { status: 404, statusText: 'Not Found' }); + }); + }); + + describe('#getById', () => { + let expectedHero: Hero; + const heroUrlId1 = heroUrl + '1'; + + it('should return expected hero when id is found', () => { + expectedHero = { id: 1, name: 'A' }; + + service + .getById(1) + .subscribe( + hero => + expect(hero).toEqual(expectedHero, 'should return expected hero'), + fail + ); + + // One request to GET hero from expected URL + const req = httpTestingController.expectOne(heroUrlId1); + + expect(req.request.body).toBeNull('should not send data'); + + // Respond with the expected hero + req.flush(expectedHero); + }); + + it('should turn 404 when id not found', () => { + service.getById(1).subscribe( + heroes => fail('getById succeeded when expected it to fail with a 404'), + err => { + expect(err instanceof DataServiceError).toBe(true); + } + ); + + const req = httpTestingController.expectOne(heroUrlId1); + const errorEvent = { message: 'boom!' } as ErrorEvent; + req.error(errorEvent, { status: 404, statusText: 'Not Found' }); + }); + + it('should throw when no id given', () => { + service.getById(undefined as any).subscribe( + heroes => fail('getById succeeded when expected it to fail'), + err => { + expect(err.error).toMatch(/No "Hero" key/); + } + ); + }); + }); + + describe('#getWithQuery', () => { + let expectedHeroes: Hero[]; + + beforeEach(() => { + expectedHeroes = [{ id: 1, name: 'BA' }, { id: 2, name: 'BB' }] as Hero[]; + }); + + it('should return expected selected heroes w/ object params', () => { + service + .getWithQuery({ name: 'B' }) + .subscribe( + heroes => + expect(heroes).toEqual( + expectedHeroes, + 'should return expected heroes' + ), + fail + ); + + // HeroService should have made one request to GET heroes + // from expected URL with query params + const req = httpTestingController.expectOne(heroesUrl + '?name=B'); + expect(req.request.method).toEqual('GET'); + + expect(req.request.body).toBeNull('should not send data'); + + // Respond with the mock heroes + req.flush(expectedHeroes); + }); + + it('should return expected selected heroes w/ string params', () => { + service + .getWithQuery('name=B') + .subscribe( + heroes => + expect(heroes).toEqual( + expectedHeroes, + 'should return expected heroes' + ), + fail + ); + + // HeroService should have made one request to GET heroes + // from expected URL with query params + const req = httpTestingController.expectOne(heroesUrl + '?name=B'); + expect(req.request.method).toEqual('GET'); + + // Respond with the mock heroes + req.flush(expectedHeroes); + }); + + it('should be OK returning no heroes', () => { + service + .getWithQuery({ name: 'B' }) + .subscribe( + heroes => + expect(heroes.length).toEqual(0, 'should have empty heroes array'), + fail + ); + + const req = httpTestingController.expectOne(heroesUrl + '?name=B'); + req.flush([]); // Respond with no heroes + }); + + it('should turn 404 into Observable', () => { + const msg = 'deliberate 404 error'; + + service.getWithQuery({ name: 'B' }).subscribe( + heroes => + fail('getWithQuery succeeded when expected it to fail with a 404'), + err => { + expect(err).toBeDefined('request should have failed'); + expect(err instanceof DataServiceError).toBe( + true, + 'is DataServiceError' + ); + expect(err.error.status).toEqual(404, 'has 404 status'); + expect(err.message).toEqual(msg, 'has expected error message'); + } + ); + + const req = httpTestingController.expectOne(heroesUrl + '?name=B'); + + const errorEvent = { message: msg } as ErrorEvent; + + req.error(errorEvent, { status: 404, statusText: 'Not Found' }); + }); + }); + + describe('#add', () => { + let expectedHero: Hero; + + it('should return expected hero with id', () => { + expectedHero = { id: 42, name: 'A' }; + const heroData: Hero = { id: undefined, name: 'A' } as any; + + service + .add(heroData) + .subscribe( + hero => + expect(hero).toEqual(expectedHero, 'should return expected hero'), + fail + ); + + // One request to POST hero from expected URL + const req = httpTestingController.expectOne( + r => r.method === 'POST' && r.url === heroUrl + ); + + expect(req.request.body).toEqual(heroData, 'should send entity data'); + + // Respond with the expected hero + req.flush(expectedHero); + }); + + it('should throw when no entity given', () => { + service.add(undefined as any).subscribe( + heroes => fail('add succeeded when expected it to fail'), + err => { + expect(err.error).toMatch(/No "Hero" entity/); + } + ); + }); + }); + + describe('#delete', () => { + const heroUrlId1 = heroUrl + '1'; + + it('should delete by hero id', () => { + service + .delete(1) + .subscribe( + result => + expect(result).toEqual(1, 'should return the deleted entity id'), + fail + ); + + // One request to DELETE hero from expected URL + const req = httpTestingController.expectOne( + r => r.method === 'DELETE' && r.url === heroUrlId1 + ); + + expect(req.request.body).toBeNull('should not send data'); + + // Respond with empty nonsense object + req.flush({}); + }); + + it('should return successfully when id not found and delete404OK is true (default)', () => { + service + .delete(1) + .subscribe( + result => + expect(result).toEqual(1, 'should return the deleted entity id'), + fail + ); + + // One request to DELETE hero from expected URL + const req = httpTestingController.expectOne( + r => r.method === 'DELETE' && r.url === heroUrlId1 + ); + + // Respond with empty nonsense object + req.flush({}); + }); + + it('should return 404 when id not found and delete404OK is false', () => { + service = new DefaultDataService('Hero', httpClient, httpUrlGenerator, { + delete404OK: false, + }); + service.delete(1).subscribe( + heroes => fail('delete succeeded when expected it to fail with a 404'), + err => { + expect(err instanceof DataServiceError).toBe(true); + } + ); + + const req = httpTestingController.expectOne(heroUrlId1); + const errorEvent = { message: 'boom!' } as ErrorEvent; + req.error(errorEvent, { status: 404, statusText: 'Not Found' }); + }); + + it('should throw when no id given', () => { + service.delete(undefined as any).subscribe( + heroes => fail('delete succeeded when expected it to fail'), + err => { + expect(err.error).toMatch(/No "Hero" key/); + } + ); + }); + }); + + describe('#update', () => { + const heroUrlId1 = heroUrl + '1'; + + it('should return expected hero with id', () => { + // Call service.update with an Update arg + const updateArg: Update = { + id: 1, + changes: { id: 1, name: 'B' }, + }; + + // The server makes the update AND updates the version concurrency property. + const expectedHero: Hero = { id: 1, name: 'B', version: 2 }; + + service + .update(updateArg) + .subscribe( + updated => + expect(updated).toEqual( + expectedHero, + 'should return the expected hero' + ), + fail + ); + + // One request to PUT hero from expected URL + const req = httpTestingController.expectOne( + r => r.method === 'PUT' && r.url === heroUrlId1 + ); + + expect(req.request.body).toEqual( + updateArg.changes, + 'should send update entity data' + ); + + // Respond with the expected hero + req.flush(expectedHero); + }); + + it('should return 404 when id not found', () => { + service.update({ id: 1, changes: { id: 1, name: 'B' } }).subscribe( + update => fail('update succeeded when expected it to fail with a 404'), + err => { + expect(err instanceof DataServiceError).toBe(true); + } + ); + + const req = httpTestingController.expectOne(heroUrlId1); + const errorEvent = { message: 'boom!' } as ErrorEvent; + req.error(errorEvent, { status: 404, statusText: 'Not Found' }); + }); + + it('should throw when no update given', () => { + service.update(undefined as any).subscribe( + heroes => fail('update succeeded when expected it to fail'), + err => { + expect(err.error).toMatch(/No "Hero" update data/); + } + ); + }); + }); + + describe('#upsert', () => { + let expectedHero: Hero; + + it('should return expected hero with id', () => { + expectedHero = { id: 42, name: 'A' }; + const heroData: Hero = { id: undefined, name: 'A' } as any; + + service + .upsert(heroData) + .subscribe( + hero => + expect(hero).toEqual(expectedHero, 'should return expected hero'), + fail + ); + + // One request to POST hero from expected URL + const req = httpTestingController.expectOne( + r => r.method === 'POST' && r.url === heroUrl + ); + + expect(req.request.body).toEqual(heroData, 'should send entity data'); + + // Respond with the expected hero + req.flush(expectedHero); + }); + + it('should throw when no entity given', () => { + service.upsert(undefined as any).subscribe( + heroes => fail('add succeeded when expected it to fail'), + err => { + expect(err.error).toMatch(/No "Hero" entity/); + } + ); + }); + }); +}); + +describe('DefaultDataServiceFactory', () => { + const heroUrl = 'api/hero'; + const heroesUrl = 'api/heroes'; + + let http: any; + let httpUrlGenerator: HttpUrlGenerator; + + beforeEach(() => { + httpUrlGenerator = new DefaultHttpUrlGenerator(null as any); + httpUrlGenerator.registerHttpResourceUrls({ + Hero: { + entityResourceUrl: heroUrl, + collectionResourceUrl: heroesUrl, + }, + }); + http = jasmine.createSpyObj('HttpClient', ['get', 'delete', 'post', 'put']); + http.get.and.returnValue(of([])); + }); + + describe('(no config)', () => { + it('can create factory', () => { + const factory = new DefaultDataServiceFactory(http, httpUrlGenerator); + const heroDS = factory.create('Hero'); + expect(heroDS.name).toBe('Hero DefaultDataService'); + }); + + it('should produce hero data service that gets all heroes with expected URL', () => { + const factory = new DefaultDataServiceFactory(http, httpUrlGenerator); + const heroDS = factory.create('Hero'); + heroDS.getAll(); + expect(http.get).toHaveBeenCalledWith('api/heroes', undefined); + }); + }); + + describe('(with config)', () => { + it('can create factory', () => { + const config: DefaultDataServiceConfig = { root: 'api' }; + const factory = new DefaultDataServiceFactory( + http, + httpUrlGenerator, + config + ); + const heroDS = factory.create('Hero'); + expect(heroDS.name).toBe('Hero DefaultDataService'); + }); + + it('should produce hero data service that gets heroes via hero HttpResourceUrls', () => { + const newHeroesUrl = 'some/other/api/heroes'; + const config: DefaultDataServiceConfig = { + root: 'api', + entityHttpResourceUrls: { + Hero: { + entityResourceUrl: heroUrl, + collectionResourceUrl: newHeroesUrl, + }, + }, + }; + const factory = new DefaultDataServiceFactory( + http, + httpUrlGenerator, + config + ); + const heroDS = factory.create('Hero'); + heroDS.getAll(); + expect(http.get).toHaveBeenCalledWith(newHeroesUrl, undefined); + }); + }); +}); diff --git a/modules/data/spec/dataservices/entity-data.service.spec.ts b/modules/data/spec/dataservices/entity-data.service.spec.ts new file mode 100644 index 0000000000..e7a43773a2 --- /dev/null +++ b/modules/data/spec/dataservices/entity-data.service.spec.ts @@ -0,0 +1,180 @@ +import { NgModule, Optional } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { HttpClient } from '@angular/common/http'; + +import { Observable } from 'rxjs'; + +import { Update } from '@ngrx/entity'; + +import { + createEntityDefinition, + EntityDefinition, + EntityMetadata, + EntityMetadataMap, + ENTITY_METADATA_TOKEN, + DefaultDataService, + DefaultDataServiceFactory, + HttpUrlGenerator, + EntityHttpResourceUrls, + EntityDataService, + EntityCollectionDataService, + QueryParams, +} from '../../'; + +// region Test Helpers +///// Test Helpers ///// + +export class CustomDataService { + name: string; + constructor(name: string) { + this.name = name + ' CustomDataService'; + } +} + +export class Bazinga { + id: number; + wow: string; +} + +export class BazingaDataService + implements EntityCollectionDataService { + name: string; + + // TestBed bug requires `@Optional` even though http is always provided. + constructor(@Optional() private http: HttpClient) { + if (!http) { + throw new Error('Where is HttpClient?'); + } + this.name = 'Bazinga custom data service'; + } + + add(entity: Bazinga): Observable { + return this.bazinga(); + } + delete(id: any): Observable { + return this.bazinga(); + } + getAll(): Observable { + return this.bazinga(); + } + getById(id: any): Observable { + return this.bazinga(); + } + getWithQuery(params: string | QueryParams): Observable { + return this.bazinga(); + } + update(update: Update): Observable { + return this.bazinga(); + } + upsert(entity: Bazinga): Observable { + return this.bazinga(); + } + + private bazinga(): any { + bazingaFail(); + return undefined; + } +} + +@NgModule({ + providers: [BazingaDataService], +}) +export class CustomDataServiceModule { + constructor( + entityDataService: EntityDataService, + bazingaService: BazingaDataService + ) { + entityDataService.registerService('Bazinga', bazingaService); + } +} + +function bazingaFail() { + throw new Error('Bazinga! This method is not implemented.'); +} + +/** Test version always returns canned Hero resource base URLs */ +class TestHttpUrlGenerator implements HttpUrlGenerator { + entityResource(entityName: string, root: string): string { + return 'api/hero/'; + } + collectionResource(entityName: string, root: string): string { + return 'api/heroes/'; + } + registerHttpResourceUrls( + entityHttpResourceUrls: EntityHttpResourceUrls + ): void {} +} + +// endregion + +///// Tests begin //// +describe('EntityDataService', () => { + const nullHttp = {}; + let entityDataService: EntityDataService; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [CustomDataServiceModule], + providers: [ + DefaultDataServiceFactory, + EntityDataService, + { provide: HttpClient, useValue: nullHttp }, + { provide: HttpUrlGenerator, useClass: TestHttpUrlGenerator }, + ], + }); + entityDataService = TestBed.get(EntityDataService); + }); + + describe('#getService', () => { + it('can create a data service for "Hero" entity', () => { + const service = entityDataService.getService('Hero'); + expect(service).toBeDefined(); + }); + + it('data service should be a DefaultDataService by default', () => { + const service = entityDataService.getService('Hero'); + expect(service instanceof DefaultDataService).toBe(true); + }); + + it('gets the same service every time you ask for it', () => { + const service1 = entityDataService.getService('Hero'); + const service2 = entityDataService.getService('Hero'); + expect(service1).toBe(service2); + }); + }); + + describe('#register...', () => { + it('can register a custom service for "Hero"', () => { + const customService: any = new CustomDataService('Hero'); + entityDataService.registerService('Hero', customService); + + const service = entityDataService.getService('Hero'); + expect(service).toBe(customService); + }); + + it('can register multiple custom services at the same time', () => { + const customHeroService: any = new CustomDataService('Hero'); + const customVillainService: any = new CustomDataService('Villain'); + entityDataService.registerServices({ + Hero: customHeroService, + Villain: customVillainService, + }); + + let service = entityDataService.getService('Hero'); + expect(service).toBe(customHeroService, 'custom Hero data service'); + expect(service.name).toBe('Hero CustomDataService'); + + service = entityDataService.getService('Villain'); + expect(service).toBe(customVillainService, 'custom Villain data service'); + + // Other services are still DefaultDataServices + service = entityDataService.getService('Foo'); + expect(service.name).toBe('Foo DefaultDataService'); + }); + + it('can register a custom service using a module import', () => { + const service = entityDataService.getService('Bazinga'); + expect(service instanceof BazingaDataService).toBe(true); + }); + }); +}); diff --git a/modules/data/spec/dispatchers/entity-dispatcher.spec.ts b/modules/data/spec/dispatchers/entity-dispatcher.spec.ts new file mode 100644 index 0000000000..b539d033da --- /dev/null +++ b/modules/data/spec/dispatchers/entity-dispatcher.spec.ts @@ -0,0 +1,507 @@ +import { Action } from '@ngrx/store'; +import { Update } from '@ngrx/entity'; + +import { Subject } from 'rxjs'; + +import { + EntityDispatcherDefaultOptions, + CorrelationIdGenerator, + EntityActionFactory, + createEntityCacheSelector, + defaultSelectId, + EntityDispatcherBase, + EntityDispatcher, + EntityAction, + EntityOp, + MergeStrategy, +} from '../..'; + +class Hero { + id: number; + name: string; + saying?: string; +} + +/** Store stub */ +class TestStore { + // only interested in calls to store.dispatch() + dispatch() {} + select() {} +} + +const defaultDispatcherOptions = new EntityDispatcherDefaultOptions(); + +describe('EntityDispatcher', () => { + commandDispatchTest(entityDispatcherSetup); + + function entityDispatcherSetup() { + const correlationIdGenerator = new CorrelationIdGenerator(); + const entityActionFactory = new EntityActionFactory(); + const entityCacheSelector = createEntityCacheSelector(); + const scannedActions$ = new Subject(); + const selectId = defaultSelectId; + const store: any = new TestStore(); + + const dispatcher = new EntityDispatcherBase( + 'Hero', + entityActionFactory, + store, + selectId, + defaultDispatcherOptions, + scannedActions$, // scannedActions$ not used in these tests + entityCacheSelector, // entityCacheSelector not used in these tests + correlationIdGenerator + ); + return { dispatcher, store }; + } +}); + +///// Tests ///// + +/** + * Test that implementer of EntityCommands dispatches properly + * @param setup Function that sets up the EntityDispatcher before each test (called in a BeforeEach()). + */ +export function commandDispatchTest( + setup: () => { dispatcher: EntityDispatcher; store: any } +) { + let dispatcher: EntityDispatcher; + let testStore: { dispatch: jasmine.Spy }; + + function dispatchedAction() { + return testStore.dispatch.calls.argsFor(0)[0]; + } + + beforeEach(() => { + const s = setup(); + spyOn(s.store, 'dispatch').and.callThrough(); + dispatcher = s.dispatcher; + testStore = s.store; + }); + + it('#entityName is the expected name of the entity type', () => { + expect(dispatcher.entityName).toBe('Hero'); + }); + + it('#cancel(correlationId) can dispatch CANCEL_PERSIST', () => { + dispatcher.cancel('CRID007', 'Test cancel'); + const { entityOp, correlationId, data } = dispatchedAction().payload; + expect(entityOp).toBe(EntityOp.CANCEL_PERSIST); + expect(correlationId).toBe('CRID007'); + expect(data).toBe('Test cancel'); + }); + + describe('Save actions', () => { + // By default add and update are pessimistic and delete is optimistic. + // Tests override in the dispatcher method calls as necessary. + + describe('(optimistic)', () => { + it('#add(hero) can dispatch SAVE_ADD_ONE optimistically', () => { + const hero: Hero = { id: 42, name: 'test' }; + dispatcher.add(hero, { isOptimistic: true }); + const { entityOp, isOptimistic, data } = dispatchedAction().payload; + expect(entityOp).toBe(EntityOp.SAVE_ADD_ONE); + expect(isOptimistic).toBe(true); + expect(data).toBe(hero); + }); + + it('#delete(42) dispatches SAVE_DELETE_ONE optimistically for the id:42', () => { + dispatcher.delete(42); // optimistic by default + const { entityOp, isOptimistic, data } = dispatchedAction().payload; + expect(entityOp).toBe(EntityOp.SAVE_DELETE_ONE); + expect(isOptimistic).toBe(true); + expect(data).toBe(42); + }); + + it('#delete(hero) dispatches SAVE_DELETE_ONE optimistically for the hero.id', () => { + const id = 42; + const hero: Hero = { id, name: 'test' }; + dispatcher.delete(hero); // optimistic by default + const { entityOp, isOptimistic, data } = dispatchedAction().payload; + expect(entityOp).toBe(EntityOp.SAVE_DELETE_ONE); + expect(isOptimistic).toBe(true); + expect(data).toBe(42); + }); + + it('#update(hero) can dispatch SAVE_UPDATE_ONE optimistically with an update payload', () => { + const hero: Hero = { id: 42, name: 'test' }; + const expectedUpdate: Update = { id: 42, changes: hero }; + + dispatcher.update(hero, { isOptimistic: true }); + const { entityOp, isOptimistic, data } = dispatchedAction().payload; + expect(entityOp).toBe(EntityOp.SAVE_UPDATE_ONE); + expect(isOptimistic).toBe(true); + expect(data).toEqual(expectedUpdate); + }); + }); + + describe('(pessimistic)', () => { + it('#add(hero) dispatches SAVE_ADD pessimistically', () => { + const hero: Hero = { id: 42, name: 'test' }; + dispatcher.add(hero); // pessimistic by default + const { entityOp, isOptimistic, data } = dispatchedAction().payload; + expect(entityOp).toBe(EntityOp.SAVE_ADD_ONE); + expect(isOptimistic).toBe(false); + expect(data).toBe(hero); + }); + + it('#delete(42) can dispatch SAVE_DELETE pessimistically for the id:42', () => { + dispatcher.delete(42, { isOptimistic: false }); // optimistic by default + const { entityOp, isOptimistic, data } = dispatchedAction().payload; + expect(entityOp).toBe(EntityOp.SAVE_DELETE_ONE); + expect(isOptimistic).toBe(false); + expect(data).toBe(42); + }); + + it('#delete(hero) can dispatch SAVE_DELETE pessimistically for the hero.id', () => { + const id = 42; + const hero: Hero = { id, name: 'test' }; + + dispatcher.delete(hero, { isOptimistic: false }); // optimistic by default + const { entityOp, isOptimistic, data } = dispatchedAction().payload; + expect(entityOp).toBe(EntityOp.SAVE_DELETE_ONE); + expect(isOptimistic).toBe(false); + expect(data).toBe(42); + }); + + it('#update(hero) dispatches SAVE_UPDATE with an update payload', () => { + const hero: Hero = { id: 42, name: 'test' }; + const expectedUpdate: Update = { id: 42, changes: hero }; + + dispatcher.update(hero); // pessimistic by default + const { entityOp, isOptimistic, data } = dispatchedAction().payload; + expect(entityOp).toBe(EntityOp.SAVE_UPDATE_ONE); + expect(isOptimistic).toBe(false); + expect(data).toEqual(expectedUpdate); + }); + }); + }); + + describe('Query actions', () => { + it('#getAll() dispatches QUERY_ALL', () => { + dispatcher.getAll(); + + const { + entityOp, + entityName, + mergeStrategy, + } = dispatchedAction().payload; + expect(entityOp).toBe(EntityOp.QUERY_ALL); + expect(entityName).toBe('Hero'); + expect(mergeStrategy).toBeUndefined('no MergeStrategy'); + }); + + it('#getAll({mergeStrategy}) dispatches QUERY_ALL with a MergeStrategy', () => { + dispatcher.getAll({ mergeStrategy: MergeStrategy.PreserveChanges }); + + const { + entityOp, + entityName, + mergeStrategy, + } = dispatchedAction().payload; + expect(entityOp).toBe(EntityOp.QUERY_ALL); + expect(entityName).toBe('Hero'); + expect(mergeStrategy).toBe(MergeStrategy.PreserveChanges); + }); + + it('#getByKey(42) dispatches QUERY_BY_KEY for the id:42', () => { + dispatcher.getByKey(42); + + const { entityOp, data, mergeStrategy } = dispatchedAction().payload; + expect(entityOp).toBe(EntityOp.QUERY_BY_KEY); + expect(data).toBe(42); + expect(mergeStrategy).toBeUndefined('no MergeStrategy'); + }); + + it('#getByKey(42, {mergeStrategy}) dispatches QUERY_BY_KEY with a MergeStrategy', () => { + dispatcher.getByKey(42, { + mergeStrategy: MergeStrategy.OverwriteChanges, + }); + + const { entityOp, data, mergeStrategy } = dispatchedAction().payload; + expect(entityOp).toBe(EntityOp.QUERY_BY_KEY); + expect(data).toBe(42); + expect(mergeStrategy).toBe(MergeStrategy.OverwriteChanges); + }); + + it('#getWithQuery(QueryParams) dispatches QUERY_MANY', () => { + dispatcher.getWithQuery({ name: 'B' }); + + const { + entityOp, + data, + entityName, + mergeStrategy, + } = dispatchedAction().payload; + expect(entityOp).toBe(EntityOp.QUERY_MANY); + expect(entityName).toBe('Hero'); + expect(data).toEqual({ name: 'B' }, 'params'); + expect(mergeStrategy).toBeUndefined('no MergeStrategy'); + }); + + it('#getWithQuery(string) dispatches QUERY_MANY', () => { + dispatcher.getWithQuery('name=B'); + + const { + entityOp, + data, + entityName, + mergeStrategy, + } = dispatchedAction().payload; + expect(entityOp).toBe(EntityOp.QUERY_MANY); + expect(entityName).toBe('Hero'); + expect(data).toEqual('name=B', 'params'); + expect(mergeStrategy).toBeUndefined('no MergeStrategy'); + }); + + it('#getWithQuery(string) dispatches QUERY_MANY with a MergeStrategy', () => { + dispatcher.getWithQuery('name=B', { + mergeStrategy: MergeStrategy.PreserveChanges, + }); + + const { + entityOp, + data, + entityName, + mergeStrategy, + } = dispatchedAction().payload; + expect(entityOp).toBe(EntityOp.QUERY_MANY); + expect(entityName).toBe('Hero'); + expect(data).toEqual('name=B', 'params'); + expect(mergeStrategy).toBe(MergeStrategy.PreserveChanges); + }); + + it('#load() dispatches QUERY_LOAD', () => { + dispatcher.load(); + + const { + entityOp, + entityName, + mergeStrategy, + } = dispatchedAction().payload; + expect(entityOp).toBe(EntityOp.QUERY_LOAD); + expect(entityName).toBe('Hero'); + expect(mergeStrategy).toBeUndefined('no MergeStrategy'); + }); + }); + + /*** Cache-only operations ***/ + describe('Cache-only actions', () => { + it('#addAllToCache dispatches ADD_ALL', () => { + const heroes: Hero[] = [ + { id: 42, name: 'test 42' }, + { id: 84, name: 'test 84', saying: 'howdy' }, + ]; + dispatcher.addAllToCache(heroes); + const { entityOp, data } = dispatchedAction().payload; + expect(entityOp).toBe(EntityOp.ADD_ALL); + expect(data).toBe(heroes); + }); + + it('#addOneToCache dispatches ADD_ONE', () => { + const hero: Hero = { id: 42, name: 'test' }; + dispatcher.addOneToCache(hero); + const { entityOp, data, mergeStrategy } = dispatchedAction().payload; + expect(entityOp).toBe(EntityOp.ADD_ONE); + expect(data).toBe(hero); + expect(mergeStrategy).toBeUndefined('no MergeStrategy'); + }); + + it('#addOneToCache can dispatch ADD_ONE and MergeStrategy.IgnoreChanges', () => { + const hero: Hero = { id: 42, name: 'test' }; + dispatcher.addOneToCache(hero, { + mergeStrategy: MergeStrategy.IgnoreChanges, + }); + const { entityOp, mergeStrategy } = dispatchedAction().payload; + expect(entityOp).toBe(EntityOp.ADD_ONE); + expect(mergeStrategy).toBe(MergeStrategy.IgnoreChanges); + }); + + it('#addManyToCache dispatches ADD_MANY', () => { + const heroes: Hero[] = [ + { id: 42, name: 'test 42' }, + { id: 84, name: 'test 84', saying: 'howdy' }, + ]; + dispatcher.addManyToCache(heroes); + const { entityOp, data } = dispatchedAction().payload; + expect(entityOp).toBe(EntityOp.ADD_MANY); + expect(data).toBe(heroes); + }); + + it('#addManyToCache can dispatch ADD_MANY and MergeStrategy.IgnoreChanges', () => { + const heroes: Hero[] = [ + { id: 42, name: 'test 42' }, + { id: 84, name: 'test 84', saying: 'howdy' }, + ]; + dispatcher.addManyToCache(heroes, { + mergeStrategy: MergeStrategy.IgnoreChanges, + }); + const { entityOp, mergeStrategy } = dispatchedAction().payload; + expect(entityOp).toBe(EntityOp.ADD_MANY); + expect(mergeStrategy).toBe(MergeStrategy.IgnoreChanges); + }); + + it('#clearCache() dispatches REMOVE_ALL for the Hero collection', () => { + dispatcher.clearCache(); + const { entityOp, entityName } = dispatchedAction().payload; + expect(entityOp).toBe(EntityOp.REMOVE_ALL); + expect(entityName).toBe('Hero'); + }); + + it('#clearCache() can dispatch REMOVE_ALL with options', () => { + dispatcher.clearCache({ mergeStrategy: MergeStrategy.IgnoreChanges }); + const { entityOp, mergeStrategy } = dispatchedAction().payload; + expect(entityOp).toBe(EntityOp.REMOVE_ALL); + expect(mergeStrategy).toBe(MergeStrategy.IgnoreChanges); + }); + + it('#removeOneFromCache(key) dispatches REMOVE_ONE', () => { + const id = 42; + dispatcher.removeOneFromCache(id); + const { entityOp, data } = dispatchedAction().payload; + expect(entityOp).toBe(EntityOp.REMOVE_ONE); + expect(data).toBe(id); + }); + + it('#removeOneFromCache(key) can dispatch REMOVE_ONE and MergeStrategy.IgnoreChanges', () => { + const id = 42; + dispatcher.removeOneFromCache(id, { + mergeStrategy: MergeStrategy.IgnoreChanges, + }); + const { entityOp, mergeStrategy } = dispatchedAction().payload; + expect(entityOp).toBe(EntityOp.REMOVE_ONE); + expect(mergeStrategy).toBe(MergeStrategy.IgnoreChanges); + }); + + it('#removeManyFromCache(keys) dispatches REMOVE_MANY', () => { + const keys = [42, 84]; + dispatcher.removeManyFromCache(keys); + const { entityOp, data } = dispatchedAction().payload; + expect(entityOp).toBe(EntityOp.REMOVE_MANY); + expect(data).toBe(keys); + }); + + it('#removeManyFromCache(keys) can dispatch REMOVE_MANY and MergeStrategy.IgnoreChanges', () => { + const keys = [42, 84]; + dispatcher.removeManyFromCache(keys, { + mergeStrategy: MergeStrategy.IgnoreChanges, + }); + const { entityOp, mergeStrategy } = dispatchedAction().payload; + expect(entityOp).toBe(EntityOp.REMOVE_MANY); + expect(mergeStrategy).toBe(MergeStrategy.IgnoreChanges); + }); + + it('#removeManyFromCache(entities) dispatches REMOVE_MANY', () => { + const heroes: Hero[] = [ + { id: 42, name: 'test 42' }, + { id: 84, name: 'test 84', saying: 'howdy' }, + ]; + const keys = heroes.map(h => h.id); + dispatcher.removeManyFromCache(heroes); + const { entityOp, data } = dispatchedAction().payload; + expect(entityOp).toBe(EntityOp.REMOVE_MANY); + expect(data).toEqual(keys); + }); + + it('#toUpdate() helper method creates Update', () => { + const hero: Partial = { id: 42, name: 'test' }; + const expected = { id: 42, changes: hero }; + const update = dispatcher.toUpdate(hero); + expect(update).toEqual(expected); + }); + + it('#updateOneInCache dispatches UPDATE_ONE', () => { + const hero: Partial = { id: 42, name: 'test' }; + const update = { id: 42, changes: hero }; + dispatcher.updateOneInCache(hero); + const { entityOp, data } = dispatchedAction().payload; + expect(entityOp).toBe(EntityOp.UPDATE_ONE); + expect(data).toEqual(update); + }); + + it('#updateOneInCache can dispatch UPDATE_ONE and MergeStrategy.IgnoreChanges', () => { + const hero: Partial = { id: 42, name: 'test' }; + const update = { id: 42, changes: hero }; + dispatcher.updateOneInCache(hero, { + mergeStrategy: MergeStrategy.IgnoreChanges, + }); + const { entityOp, mergeStrategy } = dispatchedAction().payload; + expect(entityOp).toBe(EntityOp.UPDATE_ONE); + expect(mergeStrategy).toBe(MergeStrategy.IgnoreChanges); + }); + + it('#updateManyInCache dispatches UPDATE_MANY', () => { + const heroes: Partial[] = [ + { id: 42, name: 'test 42' }, + { id: 84, saying: 'ho ho ho' }, + ]; + const updates = [ + { id: 42, changes: heroes[0] }, + { id: 84, changes: heroes[1] }, + ]; + dispatcher.updateManyInCache(heroes); + const { entityOp, data } = dispatchedAction().payload; + expect(entityOp).toBe(EntityOp.UPDATE_MANY); + expect(data).toEqual(updates); + }); + + it('#updateManyInCache can dispatch UPDATE_MANY and MergeStrategy.IgnoreChanges', () => { + const heroes: Partial[] = [ + { id: 42, name: 'test 42' }, + { id: 84, saying: 'ho ho ho' }, + ]; + const updates = [ + { id: 42, changes: heroes[0] }, + { id: 84, changes: heroes[1] }, + ]; + dispatcher.updateManyInCache(heroes, { + mergeStrategy: MergeStrategy.IgnoreChanges, + }); + const { entityOp, mergeStrategy } = dispatchedAction().payload; + expect(entityOp).toBe(EntityOp.UPDATE_MANY); + expect(mergeStrategy).toBe(MergeStrategy.IgnoreChanges); + }); + + it('#upsertOneInCache dispatches UPSERT_ONE', () => { + const hero = { id: 42, name: 'test' }; + dispatcher.upsertOneInCache(hero); + const { entityOp, data } = dispatchedAction().payload; + expect(entityOp).toBe(EntityOp.UPSERT_ONE); + expect(data).toEqual(hero); + }); + + it('#upsertOneInCache can dispatch UPSERT_ONE and MergeStrategy.IgnoreChanges', () => { + const hero = { id: 42, name: 'test' }; + dispatcher.upsertOneInCache(hero, { + mergeStrategy: MergeStrategy.IgnoreChanges, + }); + const { entityOp, mergeStrategy } = dispatchedAction().payload; + expect(entityOp).toBe(EntityOp.UPSERT_ONE); + expect(mergeStrategy).toBe(MergeStrategy.IgnoreChanges); + }); + + it('#upsertManyInCache dispatches UPSERT_MANY', () => { + const heroes = [ + { id: 42, name: 'test 42' }, + { id: 84, saying: 'ho ho ho' }, + ]; + dispatcher.upsertManyInCache(heroes); + const { entityOp, data } = dispatchedAction().payload; + expect(entityOp).toBe(EntityOp.UPSERT_MANY); + expect(data).toEqual(heroes); + }); + + it('#upsertManyInCache can dispatch UPSERT_MANY and MergeStrategy.IgnoreChanges', () => { + const heroes = [ + { id: 42, name: 'test 42' }, + { id: 84, saying: 'ho ho ho' }, + ]; + dispatcher.upsertManyInCache(heroes, { + mergeStrategy: MergeStrategy.IgnoreChanges, + }); + const { entityOp, mergeStrategy } = dispatchedAction().payload; + expect(entityOp).toBe(EntityOp.UPSERT_MANY); + expect(mergeStrategy).toBe(MergeStrategy.IgnoreChanges); + }); + }); +} diff --git a/modules/data/spec/effects/entity-cache-effects.spec.ts b/modules/data/spec/effects/entity-cache-effects.spec.ts new file mode 100644 index 0000000000..7a24186597 --- /dev/null +++ b/modules/data/spec/effects/entity-cache-effects.spec.ts @@ -0,0 +1,220 @@ +// Not using marble testing +import { TestBed } from '@angular/core/testing'; +import { Action } from '@ngrx/store'; +import { Actions } from '@ngrx/effects'; + +import { + asapScheduler, + Observable, + of, + merge, + ReplaySubject, + Subject, + throwError, +} from 'rxjs'; +import { first, mergeMap, observeOn, tap } from 'rxjs/operators'; + +import { + EntityCacheEffects, + EntityActionFactory, + EntityCacheDataService, + SaveEntities, + SaveEntitiesSuccess, + SaveEntitiesCancel, + SaveEntitiesCanceled, + SaveEntitiesError, + HttpMethods, + DataServiceError, + ChangeSet, + ChangeSetItem, + ChangeSetOperation, + Logger, + MergeStrategy, +} from '../..'; + +describe('EntityCacheEffects (normal testing)', () => { + let actions$: ReplaySubject; + let correlationId: string; + let dataService: TestEntityCacheDataService; + let effects: EntityCacheEffects; + let logger: Logger; + let mergeStrategy: MergeStrategy | undefined; + let options: { + correlationId: typeof correlationId; + mergeStrategy: typeof mergeStrategy; + }; + + function expectCompletion(completion: any, done: DoneFn) { + effects.saveEntities$.subscribe(result => { + expect(result).toEqual(completion); + done(); + }, fail); + } + + beforeEach(() => { + actions$ = new ReplaySubject(1); + correlationId = 'CORID42'; + logger = jasmine.createSpyObj('Logger', ['error', 'log', 'warn']); + mergeStrategy = undefined; + options = { correlationId, mergeStrategy }; + + const eaFactory = new EntityActionFactory(); // doesn't change. + + TestBed.configureTestingModule({ + providers: [ + EntityCacheEffects, + { provide: EntityActionFactory, useValue: eaFactory }, + { provide: Actions, useValue: actions$ }, + /* tslint:disable-next-line:no-use-before-declare */ + { + provide: EntityCacheDataService, + useClass: TestEntityCacheDataService, + }, + { provide: Logger, useValue: logger }, + ], + }); + + actions$ = TestBed.get(Actions); + effects = TestBed.get(EntityCacheEffects); + dataService = TestBed.get(EntityCacheDataService); + }); + + it('should return a SAVE_ENTITIES_SUCCESS with the expected ChangeSet on success', (done: DoneFn) => { + const cs = createChangeSet(); + const action = new SaveEntities(cs, 'test/save', options); + const completion = new SaveEntitiesSuccess(cs, 'test/save', options); + + expectCompletion(completion, done); + + actions$.next(action); + dataService.setResponse(cs); + }); + + it('should not emit SAVE_ENTITIES_SUCCESS if cancel arrives in time', (done: DoneFn) => { + const cs = createChangeSet(); + const action = new SaveEntities(cs, 'test/save', options); + const cancel = new SaveEntitiesCancel(correlationId, 'Test Cancel'); + + effects.saveEntities$.subscribe(result => { + expect(result instanceof SaveEntitiesSuccess).toBe(false); + expect(result instanceof SaveEntitiesCanceled).toBe(true); // instead + done(); + }, done.fail); + + actions$.next(action); + actions$.next(cancel); + dataService.setResponse(cs); + }); + + it('should emit SAVE_ENTITIES_SUCCESS if cancel arrives too late', (done: DoneFn) => { + const cs = createChangeSet(); + const action = new SaveEntities(cs, 'test/save', options); + const cancel = new SaveEntitiesCancel(correlationId, 'Test Cancel'); + + effects.saveEntities$.subscribe(result => { + expect(result instanceof SaveEntitiesSuccess).toBe(true); + done(); + }, done.fail); + + actions$.next(action); + dataService.setResponse(cs); + setTimeout(() => actions$.next(cancel), 1); + }); + + it('should emit SAVE_ENTITIES_SUCCESS immediately if no changes to save', (done: DoneFn) => { + const action = new SaveEntities({ changes: [] }, 'test/save', options); + effects.saveEntities$.subscribe(result => { + expect(result instanceof SaveEntitiesSuccess).toBe(true); + expect(dataService.saveEntities).not.toHaveBeenCalled(); + done(); + }, done.fail); + actions$.next(action); + }); + + xit('should return a SAVE_ENTITIES_ERROR when data service fails', (done: DoneFn) => { + const cs = createChangeSet(); + const action = new SaveEntities(cs, 'test/save', options); + const httpError = { error: new Error('Test Failure'), status: 501 }; + const error = makeDataServiceError('POST', httpError); + const completion = new SaveEntitiesError(error, action); + + expectCompletion(completion, done); + + actions$.next(action); + dataService.setErrorResponse(error); + }); +}); + +// #region test helpers +export class TestEntityCacheDataService { + response$ = new Subject(); + + saveEntities = jasmine + .createSpy('saveEntities') + .and.returnValue(this.response$.pipe(observeOn(asapScheduler))); + + setResponse(data: any) { + this.response$.next(data); + } + + setErrorResponse(error: any) { + this.response$.error(error); + } +} + +/** make error produced by the EntityDataService */ +function makeDataServiceError( + /** Http method for that action */ + method: HttpMethods, + /** Http error from the web api */ + httpError?: any, + /** Options sent with the request */ + options?: any +) { + let url = 'test/save'; + if (httpError) { + url = httpError.url || url; + } else { + httpError = { error: new Error('Test error'), status: 500, url }; + } + return new DataServiceError(httpError, { method, url, options }); +} + +function createChangeSet(): ChangeSet { + const changes: ChangeSetItem[] = [ + { + op: ChangeSetOperation.Add, + entityName: 'Hero', + entities: [{ id: 1, name: 'A1 Add' }], + }, + { + op: ChangeSetOperation.Delete, + entityName: 'Hero', + entities: [2, 3], + }, + { + op: ChangeSetOperation.Update, + entityName: 'Villain', + entities: [ + { id: 4, changes: { id: 4, name: 'V4 Update' } }, + { id: 5, changes: { id: 5, name: 'V5 Update' } }, + { id: 6, changes: { id: 6, name: 'V6 Update' } }, + ], + }, + { + op: ChangeSetOperation.Upsert, + entityName: 'Villain', + entities: [ + { id: 7, name: 'V7 Upsert new' }, + { id: 4, name: 'V4 Upsert existing' }, + ], + }, + ]; + + return { + changes, + extras: { foo: 'anything' }, + tag: 'Test', + }; +} +// #endregion test helpers diff --git a/modules/data/spec/effects/entity-effects.marbles.spec.ts b/modules/data/spec/effects/entity-effects.marbles.spec.ts new file mode 100644 index 0000000000..3bb238bcf4 --- /dev/null +++ b/modules/data/spec/effects/entity-effects.marbles.spec.ts @@ -0,0 +1,520 @@ +// Using marble testing +import { TestBed } from '@angular/core/testing'; + +import { cold, hot, getTestScheduler } from 'jasmine-marbles'; +import { Observable, of, Subject } from 'rxjs'; + +import { Actions } from '@ngrx/effects'; +import { Update } from '@ngrx/entity'; +import { provideMockActions } from '@ngrx/effects/testing'; +import { + EntityEffects, + EntityActionFactory, + EntityDataService, + PersistenceResultHandler, + DefaultPersistenceResultHandler, + EntityOp, + HttpMethods, + DataServiceError, + EntityAction, + makeErrorOp, + EntityActionDataServiceError, + Logger, +} from '../..'; +import { ENTITY_EFFECTS_SCHEDULER } from '../../src/effects/entity-effects-scheduler'; + +//////// Tests begin //////// +describe('EntityEffects (marble testing)', () => { + let effects: EntityEffects; + let entityActionFactory: EntityActionFactory; + let dataService: TestDataService; + let actions: Observable; + let logger: Logger; + + beforeEach(() => { + logger = jasmine.createSpyObj('Logger', ['error', 'log', 'warn']); + + TestBed.configureTestingModule({ + providers: [ + EntityEffects, + provideMockActions(() => actions), + EntityActionFactory, + // See https://github.com/ReactiveX/rxjs/blob/master/doc/marble-testing.md + { provide: ENTITY_EFFECTS_SCHEDULER, useFactory: getTestScheduler }, + /* tslint:disable-next-line:no-use-before-declare */ + { provide: EntityDataService, useClass: TestDataService }, + { provide: Logger, useValue: logger }, + { + provide: PersistenceResultHandler, + useClass: DefaultPersistenceResultHandler, + }, + ], + }); + actions = TestBed.get(Actions); + dataService = TestBed.get(EntityDataService); + entityActionFactory = TestBed.get(EntityActionFactory); + effects = TestBed.get(EntityEffects); + }); + + it('should return a QUERY_ALL_SUCCESS with the heroes on success', () => { + const hero1 = { id: 1, name: 'A' } as Hero; + const hero2 = { id: 2, name: 'B' } as Hero; + const heroes = [hero1, hero2]; + + const action = entityActionFactory.create('Hero', EntityOp.QUERY_ALL); + const completion = entityActionFactory.create( + 'Hero', + EntityOp.QUERY_ALL_SUCCESS, + heroes + ); + + const x = hot('-a---', { a: action }); + actions = hot('-a---', { a: action }); + // delay the response 3 frames + const response = cold('---a|', { a: heroes }); + const expected = cold('----b', { b: completion }); + dataService.getAll.and.returnValue(response); + + expect(effects.persist$).toBeObservable(expected); + }); + + it('should return a QUERY_ALL_ERROR when data service fails', () => { + const action = entityActionFactory.create('Hero', EntityOp.QUERY_ALL); + const httpError = { error: new Error('Test Failure'), status: 501 }; + const error = makeDataServiceError('GET', httpError); + const completion = makeEntityErrorCompletion(action, error); + + actions = hot('-a---', { a: action }); + const response = cold('----#|', {}, error); + const expected = cold('------b', { b: completion }); + dataService.getAll.and.returnValue(response); + + expect(effects.persist$).toBeObservable(expected); + expect(completion.payload.entityOp).toEqual(EntityOp.QUERY_ALL_ERROR); + }); + + it('should return a QUERY_BY_KEY_SUCCESS with a hero on success', () => { + const hero = { id: 1, name: 'A' } as Hero; + const action = entityActionFactory.create('Hero', EntityOp.QUERY_BY_KEY, 1); + const completion = entityActionFactory.create( + 'Hero', + EntityOp.QUERY_BY_KEY_SUCCESS, + hero + ); + + actions = hot('-a---', { a: action }); + // delay the response 3 frames + const response = cold('---a|', { a: hero }); + const expected = cold('----b', { b: completion }); + dataService.getById.and.returnValue(response); + + expect(effects.persist$).toBeObservable(expected); + }); + + it('should return a QUERY_BY_KEY_ERROR when data service fails', () => { + const action = entityActionFactory.create( + 'Hero', + EntityOp.QUERY_BY_KEY, + 42 + ); + const httpError = { error: new Error('Entity not found'), status: 404 }; + const error = makeDataServiceError('GET', httpError); + const completion = makeEntityErrorCompletion(action, error); + + actions = hot('-a---', { a: action }); + const response = cold('----#|', {}, error); + const expected = cold('------b', { b: completion }); + dataService.getById.and.returnValue(response); + + expect(effects.persist$).toBeObservable(expected); + }); + + it('should return a QUERY_MANY_SUCCESS with selected heroes on success', () => { + const hero1 = { id: 1, name: 'BA' } as Hero; + const hero2 = { id: 2, name: 'BB' } as Hero; + const heroes = [hero1, hero2]; + + const action = entityActionFactory.create('Hero', EntityOp.QUERY_MANY, { + name: 'B', + }); + const completion = entityActionFactory.create( + 'Hero', + EntityOp.QUERY_MANY_SUCCESS, + heroes + ); + + actions = hot('-a---', { a: action }); + // delay the response 3 frames + const response = cold('---a|', { a: heroes }); + const expected = cold('----b', { b: completion }); + dataService.getWithQuery.and.returnValue(response); + + expect(effects.persist$).toBeObservable(expected); + }); + + it('should return a QUERY_MANY_ERROR when data service fails', () => { + const action = entityActionFactory.create('Hero', EntityOp.QUERY_MANY, { + name: 'B', + }); + const httpError = { error: new Error('Resource not found'), status: 404 }; + const error = makeDataServiceError('GET', httpError, { + name: 'B', + }); + const completion = makeEntityErrorCompletion(action, error); + + actions = hot('-a---', { a: action }); + const response = cold('----#|', {}, error); + const expected = cold('------b', { b: completion }); + dataService.getWithQuery.and.returnValue(response); + + expect(effects.persist$).toBeObservable(expected); + expect(completion.payload.entityOp).toEqual(EntityOp.QUERY_MANY_ERROR); + }); + + it('should return a SAVE_ADD_ONE_SUCCESS (optimistic) with the hero on success', () => { + const hero = { id: 1, name: 'A' } as Hero; + + const action = entityActionFactory.create( + 'Hero', + EntityOp.SAVE_ADD_ONE, + hero, + { isOptimistic: true } + ); + const completion = entityActionFactory.create( + 'Hero', + EntityOp.SAVE_ADD_ONE_SUCCESS, + hero, + { isOptimistic: true } + ); + + actions = hot('-a---', { a: action }); + // delay the response 3 frames + const response = cold('---a|', { a: hero }); + const expected = cold('----b', { b: completion }); + dataService.add.and.returnValue(response); + + expect(effects.persist$).toBeObservable(expected); + }); + + it('should return a SAVE_ADD_ONE_SUCCESS (pessimistic) with the hero on success', () => { + const hero = { id: 1, name: 'A' } as Hero; + + const action = entityActionFactory.create( + 'Hero', + EntityOp.SAVE_ADD_ONE, + hero + ); + const completion = entityActionFactory.create( + 'Hero', + EntityOp.SAVE_ADD_ONE_SUCCESS, + hero + ); + + actions = hot('-a---', { a: action }); + // delay the response 3 frames + const response = cold('---a|', { a: hero }); + const expected = cold('----b', { b: completion }); + dataService.add.and.returnValue(response); + + expect(effects.persist$).toBeObservable(expected); + }); + + it('should return a SAVE_ADD_ONE_ERROR when data service fails', () => { + const hero = { id: 1, name: 'A' } as Hero; + const action = entityActionFactory.create( + 'Hero', + EntityOp.SAVE_ADD_ONE, + hero + ); + const httpError = { error: new Error('Test Failure'), status: 501 }; + const error = makeDataServiceError('PUT', httpError); + const completion = makeEntityErrorCompletion(action, error); + + actions = hot('-a---', { a: action }); + const response = cold('----#|', {}, error); + const expected = cold('------b', { b: completion }); + dataService.add.and.returnValue(response); + + expect(effects.persist$).toBeObservable(expected); + }); + + it('should return a SAVE_DELETE_ONE_SUCCESS (Optimistic) with the delete id on success', () => { + const action = entityActionFactory.create( + 'Hero', + EntityOp.SAVE_DELETE_ONE, + 42, + { isOptimistic: true } + ); + const completion = entityActionFactory.create( + 'Hero', + EntityOp.SAVE_DELETE_ONE_SUCCESS, + 42, + { isOptimistic: true } + ); + + actions = hot('-a---', { a: action }); + // delay the response 3 frames + const response = cold('---a|', { a: 42 }); + const expected = cold('----b', { b: completion }); + dataService.delete.and.returnValue(response); + + expect(effects.persist$).toBeObservable(expected); + }); + + it('should return a SAVE_DELETE_ONE_SUCCESS (Pessimistic) on success', () => { + const action = entityActionFactory.create( + 'Hero', + EntityOp.SAVE_DELETE_ONE, + 42 + ); + const completion = entityActionFactory.create( + 'Hero', + EntityOp.SAVE_DELETE_ONE_SUCCESS, + 42 + ); + + actions = hot('-a---', { a: action }); + // delay the response 3 frames + const response = cold('---a|', { a: 42 }); + const expected = cold('----b', { b: completion }); + dataService.delete.and.returnValue(response); + + expect(effects.persist$).toBeObservable(expected); + }); + + it('should return a SAVE_DELETE_ONE_ERROR when data service fails', () => { + const action = entityActionFactory.create( + 'Hero', + EntityOp.SAVE_DELETE_ONE, + 42 + ); + const httpError = { error: new Error('Test Failure'), status: 501 }; + const error = makeDataServiceError('DELETE', httpError); + const completion = makeEntityErrorCompletion(action, error); + + actions = hot('-a---', { a: action }); + const response = cold('----#|', {}, error); + const expected = cold('------b', { b: completion }); + dataService.delete.and.returnValue(response); + + expect(effects.persist$).toBeObservable(expected); + }); + + it('should return a SAVE_UPDATE_ONE_SUCCESS (Optimistic) with the hero on success', () => { + const updateEntity = { id: 1, name: 'A' }; + const update = { id: 1, changes: updateEntity } as Update; + const updateResponse = { ...update, changed: true }; + + const action = entityActionFactory.create( + 'Hero', + EntityOp.SAVE_UPDATE_ONE, + update, + { isOptimistic: true } + ); + const completion = entityActionFactory.create( + 'Hero', + EntityOp.SAVE_UPDATE_ONE_SUCCESS, + updateResponse, + { isOptimistic: true } + ); + + actions = hot('-a---', { a: action }); + // delay the response 3 frames + const response = cold('---a|', { a: updateEntity }); + const expected = cold('----b', { b: completion }); + dataService.update.and.returnValue(response); + + expect(effects.persist$).toBeObservable(expected); + }); + + it('should return a SAVE_UPDATE_ONE_SUCCESS (Pessimistic) with the hero on success', () => { + const updateEntity = { id: 1, name: 'A' }; + const update = { id: 1, changes: updateEntity } as Update; + const updateResponse = { ...update, changed: true }; + + const action = entityActionFactory.create( + 'Hero', + EntityOp.SAVE_UPDATE_ONE, + update + ); + const completion = entityActionFactory.create( + 'Hero', + EntityOp.SAVE_UPDATE_ONE_SUCCESS, + updateResponse + ); + + actions = hot('-a---', { a: action }); + // delay the response 3 frames + const response = cold('---a|', { a: updateEntity }); + const expected = cold('----b', { b: completion }); + dataService.update.and.returnValue(response); + + expect(effects.persist$).toBeObservable(expected); + }); + + it('should return a SAVE_UPDATE_ONE_ERROR when data service fails', () => { + const update = { id: 1, changes: { id: 1, name: 'A' } } as Update; + const action = entityActionFactory.create( + 'Hero', + EntityOp.SAVE_UPDATE_ONE, + update + ); + const httpError = { error: new Error('Test Failure'), status: 501 }; + const error = makeDataServiceError('PUT', httpError); + const completion = makeEntityErrorCompletion(action, error); + + actions = hot('-a---', { a: action }); + const response = cold('----#|', {}, error); + const expected = cold('------b', { b: completion }); + dataService.update.and.returnValue(response); + + expect(effects.persist$).toBeObservable(expected); + }); + + it('should return a SAVE_UPSERT_ONE_SUCCESS (optimistic) with the hero on success', () => { + const hero = { id: 1, name: 'A' } as Hero; + + const action = entityActionFactory.create( + 'Hero', + EntityOp.SAVE_UPSERT_ONE, + hero, + { isOptimistic: true } + ); + const completion = entityActionFactory.create( + 'Hero', + EntityOp.SAVE_UPSERT_ONE_SUCCESS, + hero, + { isOptimistic: true } + ); + + actions = hot('-a---', { a: action }); + // delay the response 3 frames + const response = cold('---a|', { a: hero }); + const expected = cold('----b', { b: completion }); + dataService.upsert.and.returnValue(response); + + expect(effects.persist$).toBeObservable(expected); + }); + + it('should return a SAVE_UPSERT_ONE_SUCCESS (pessimistic) with the hero on success', () => { + const hero = { id: 1, name: 'A' } as Hero; + + const action = entityActionFactory.create( + 'Hero', + EntityOp.SAVE_UPSERT_ONE, + hero + ); + const completion = entityActionFactory.create( + 'Hero', + EntityOp.SAVE_UPSERT_ONE_SUCCESS, + hero + ); + + actions = hot('-a---', { a: action }); + // delay the response 3 frames + const response = cold('---a|', { a: hero }); + const expected = cold('----b', { b: completion }); + dataService.upsert.and.returnValue(response); + + expect(effects.persist$).toBeObservable(expected); + }); + + it('should return a SAVE_UPSERT_ONE_ERROR when data service fails', () => { + const hero = { id: 1, name: 'A' } as Hero; + const action = entityActionFactory.create( + 'Hero', + EntityOp.SAVE_UPSERT_ONE, + hero + ); + const httpError = { error: new Error('Test Failure'), status: 501 }; + const error = makeDataServiceError('POST', httpError); + const completion = makeEntityErrorCompletion(action, error); + + actions = hot('-a---', { a: action }); + const response = cold('----#|', {}, error); + const expected = cold('------b', { b: completion }); + dataService.upsert.and.returnValue(response); + + expect(effects.persist$).toBeObservable(expected); + }); + + it(`should not do anything with an irrelevant action`, () => { + // Would clear the cached collection + const action = entityActionFactory.create('Hero', EntityOp.REMOVE_ALL); + + actions = hot('-a---', { a: action }); + const expected = cold('---'); + + expect(effects.persist$).toBeObservable(expected); + }); +}); + +// #region test helpers +export class Hero { + id: number; + name: string; +} + +/** make error produced by the EntityDataService */ +function makeDataServiceError( + /** Http method for that action */ + method: HttpMethods, + /** Http error from the web api */ + httpError?: any, + /** Options sent with the request */ + options?: any +) { + let url = 'api/heroes'; + if (httpError) { + url = httpError.url || url; + } else { + httpError = { error: new Error('Test error'), status: 500, url }; + } + return new DataServiceError(httpError, { method, url, options }); +} + +/** Make an EntityDataService error */ +function makeEntityErrorCompletion( + /** The action that initiated the data service call */ + originalAction: EntityAction, + /** error produced by the EntityDataService */ + error: DataServiceError +) { + const errOp = makeErrorOp(originalAction.payload.entityOp); + + // Entity Error Action + const eaFactory = new EntityActionFactory(); + return eaFactory.create('Hero', errOp, { + originalAction, + error, + }); +} + +export interface TestDataServiceMethod { + add: jasmine.Spy; + delete: jasmine.Spy; + getAll: jasmine.Spy; + getById: jasmine.Spy; + getWithQuery: jasmine.Spy; + update: jasmine.Spy; + upsert: jasmine.Spy; +} + +export class TestDataService { + add = jasmine.createSpy('add'); + delete = jasmine.createSpy('delete'); + getAll = jasmine.createSpy('getAll'); + getById = jasmine.createSpy('getById'); + getWithQuery = jasmine.createSpy('getWithQuery'); + update = jasmine.createSpy('update'); + upsert = jasmine.createSpy('upsert'); + + getService(): TestDataServiceMethod { + return this; + } + + setResponse(methodName: keyof TestDataServiceMethod, data$: Observable) { + this[methodName].and.returnValue(data$); + } +} +// #endregion test helpers diff --git a/modules/data/spec/effects/entity-effects.spec.ts b/modules/data/spec/effects/entity-effects.spec.ts new file mode 100644 index 0000000000..aebee4e4de --- /dev/null +++ b/modules/data/spec/effects/entity-effects.spec.ts @@ -0,0 +1,559 @@ +// Not using marble testing +import { TestBed } from '@angular/core/testing'; +import { Action } from '@ngrx/store'; +import { Actions } from '@ngrx/effects'; +import { Update } from '@ngrx/entity'; + +import { Observable, of, merge, ReplaySubject, throwError, timer } from 'rxjs'; +import { delay, first, mergeMap } from 'rxjs/operators'; + +import { + EntityActionFactory, + EntityEffects, + EntityAction, + EntityDataService, + PersistenceResultHandler, + DefaultPersistenceResultHandler, + EntityOp, + HttpMethods, + DataServiceError, + makeErrorOp, + EntityActionDataServiceError, + Logger, +} from '../..'; + +describe('EntityEffects (normal testing)', () => { + // factory never changes in these tests + const entityActionFactory = new EntityActionFactory(); + + let actions$: ReplaySubject; + let effects: EntityEffects; + let logger: Logger; + let dataService: TestDataService; + + function expectCompletion(completion: EntityAction, done: DoneFn) { + effects.persist$.subscribe( + result => { + expect(result).toEqual(completion); + done(); + }, + error => { + fail(error); + } + ); + } + + beforeEach(() => { + logger = jasmine.createSpyObj('Logger', ['error', 'log', 'warn']); + actions$ = new ReplaySubject(1); + + TestBed.configureTestingModule({ + providers: [ + EntityEffects, + { provide: Actions, useValue: actions$ }, + { provide: EntityActionFactory, useValue: entityActionFactory }, + /* tslint:disable-next-line:no-use-before-declare */ + { provide: EntityDataService, useClass: TestDataService }, + { provide: Logger, useValue: logger }, + { + provide: PersistenceResultHandler, + useClass: DefaultPersistenceResultHandler, + }, + ], + }); + + actions$ = TestBed.get(Actions); + effects = TestBed.get(EntityEffects); + dataService = TestBed.get(EntityDataService); + }); + + it('cancel$ should emit correlation id for CANCEL_PERSIST', (done: DoneFn) => { + const action = entityActionFactory.create( + 'Hero', + EntityOp.CANCEL_PERSIST, + undefined, + { correlationId: 42 } + ); + effects.cancel$.subscribe((crid: any) => { + expect(crid).toBe(42); + done(); + }); + actions$.next(action); + }); + + it('should return a QUERY_ALL_SUCCESS with the heroes on success', (done: DoneFn) => { + const hero1 = { id: 1, name: 'A' } as Hero; + const hero2 = { id: 2, name: 'B' } as Hero; + const heroes = [hero1, hero2]; + dataService.setResponse('getAll', heroes); + + const action = entityActionFactory.create('Hero', EntityOp.QUERY_ALL); + const completion = entityActionFactory.create( + 'Hero', + EntityOp.QUERY_ALL_SUCCESS, + heroes + ); + + actions$.next(action); + expectCompletion(completion, done); + }); + + it('should perform QUERY_ALL when dispatch custom tagged action', (done: DoneFn) => { + const hero1 = { id: 1, name: 'A' } as Hero; + const hero2 = { id: 2, name: 'B' } as Hero; + const heroes = [hero1, hero2]; + dataService.setResponse('getAll', heroes); + + const action = entityActionFactory.create({ + entityName: 'Hero', + entityOp: EntityOp.QUERY_ALL, + tag: 'Custom Hero Tag', + }); + + const completion = entityActionFactory.createFromAction(action, { + entityOp: EntityOp.QUERY_ALL_SUCCESS, + data: heroes, + }); + + actions$.next(action); + expectCompletion(completion, done); + }); + + it('should perform QUERY_ALL when dispatch custom action w/ that entityOp', (done: DoneFn) => { + const hero1 = { id: 1, name: 'A' } as Hero; + const hero2 = { id: 2, name: 'B' } as Hero; + const heroes = [hero1, hero2]; + dataService.setResponse('getAll', heroes); + + const action = { + type: 'some/arbitrary/type/text', + payload: { + entityName: 'Hero', + entityOp: EntityOp.QUERY_ALL, + }, + }; + + const completion = entityActionFactory.createFromAction(action, { + entityOp: EntityOp.QUERY_ALL_SUCCESS, + data: heroes, + }); + + actions$.next(action); + expectCompletion(completion, done); + }); + + it('should return a QUERY_ALL_ERROR when data service fails', (done: DoneFn) => { + const action = entityActionFactory.create('Hero', EntityOp.QUERY_ALL); + const httpError = { error: new Error('Test Failure'), status: 501 }; + const error = makeDataServiceError('GET', httpError); + const completion = makeEntityErrorCompletion(action, error); + + actions$.next(action); + dataService.setErrorResponse('getAll', error); + + expectCompletion(completion, done); + expect(completion.payload.entityOp).toEqual(EntityOp.QUERY_ALL_ERROR); + }); + + it('should return a QUERY_BY_KEY_SUCCESS with a hero on success', (done: DoneFn) => { + const hero = { id: 1, name: 'A' } as Hero; + const action = entityActionFactory.create('Hero', EntityOp.QUERY_BY_KEY, 1); + const completion = entityActionFactory.create( + 'Hero', + EntityOp.QUERY_BY_KEY_SUCCESS, + hero + ); + + actions$.next(action); + dataService.setResponse('getById', hero); + + expectCompletion(completion, done); + }); + + it('should return a QUERY_BY_KEY_ERROR when data service fails', (done: DoneFn) => { + const action = entityActionFactory.create( + 'Hero', + EntityOp.QUERY_BY_KEY, + 42 + ); + const httpError = { error: new Error('Entity not found'), status: 404 }; + const error = makeDataServiceError('GET', httpError); + const completion = makeEntityErrorCompletion(action, error); + + actions$.next(action); + dataService.setErrorResponse('getById', error); + + expectCompletion(completion, done); + }); + + it('should return a QUERY_MANY_SUCCESS with selected heroes on success', (done: DoneFn) => { + const hero1 = { id: 1, name: 'BA' } as Hero; + const hero2 = { id: 2, name: 'BB' } as Hero; + const heroes = [hero1, hero2]; + + const action = entityActionFactory.create('Hero', EntityOp.QUERY_MANY, { + name: 'B', + }); + const completion = entityActionFactory.create( + 'Hero', + EntityOp.QUERY_MANY_SUCCESS, + heroes + ); + + actions$.next(action); + dataService.setResponse('getWithQuery', heroes); + + expectCompletion(completion, done); + }); + + it('should return a QUERY_MANY_ERROR when data service fails', (done: DoneFn) => { + const action = entityActionFactory.create('Hero', EntityOp.QUERY_MANY, { + name: 'B', + }); + const httpError = { error: new Error('Resource not found'), status: 404 }; + const error = makeDataServiceError('GET', httpError, { + name: 'B', + }); + const completion = makeEntityErrorCompletion(action, error); + + actions$.next(action); + dataService.setErrorResponse('getWithQuery', error); + + expectCompletion(completion, done); + }); + + it('should return a SAVE_ADD_ONE_SUCCESS (Optimistic) with the hero on success', (done: DoneFn) => { + const hero = { id: 1, name: 'A' } as Hero; + + const action = entityActionFactory.create( + 'Hero', + EntityOp.SAVE_ADD_ONE, + hero, + { isOptimistic: true } + ); + const completion = entityActionFactory.create( + 'Hero', + EntityOp.SAVE_ADD_ONE_SUCCESS, + hero, + { isOptimistic: true } + ); + + actions$.next(action); + dataService.setResponse('add', hero); + + expectCompletion(completion, done); + }); + + it('should return a SAVE_ADD_ONE_SUCCESS (Pessimistic) with the hero on success', (done: DoneFn) => { + const hero = { id: 1, name: 'A' } as Hero; + + const action = entityActionFactory.create( + 'Hero', + EntityOp.SAVE_ADD_ONE, + hero + ); + const completion = entityActionFactory.create( + 'Hero', + EntityOp.SAVE_ADD_ONE_SUCCESS, + hero + ); + + actions$.next(action); + dataService.setResponse('add', hero); + + expectCompletion(completion, done); + }); + + it('should return a SAVE_ADD_ONE_ERROR when data service fails', (done: DoneFn) => { + const hero = { id: 1, name: 'A' } as Hero; + const action = entityActionFactory.create( + 'Hero', + EntityOp.SAVE_ADD_ONE, + hero + ); + const httpError = { error: new Error('Test Failure'), status: 501 }; + const error = makeDataServiceError('PUT', httpError); + const completion = makeEntityErrorCompletion(action, error); + + actions$.next(action); + dataService.setErrorResponse('add', error); + + expectCompletion(completion, done); + }); + + it('should return a SAVE_DELETE_ONE_SUCCESS (Optimistic) on success with delete id', (done: DoneFn) => { + const action = entityActionFactory.create( + 'Hero', + EntityOp.SAVE_DELETE_ONE, + 42, + { isOptimistic: true } + ); + const completion = entityActionFactory.create( + 'Hero', + EntityOp.SAVE_DELETE_ONE_SUCCESS, + 42, + { isOptimistic: true } + ); + + actions$.next(action); + dataService.setResponse('delete', 42); + + expectCompletion(completion, done); + }); + + it('should return a SAVE_DELETE_ONE_SUCCESS (Pessimistic) on success', (done: DoneFn) => { + const action = entityActionFactory.create( + 'Hero', + EntityOp.SAVE_DELETE_ONE, + 42 + ); + const completion = entityActionFactory.create( + 'Hero', + EntityOp.SAVE_DELETE_ONE_SUCCESS, + 42 + ); + + actions$.next(action); + dataService.setResponse('delete', 42); + + expectCompletion(completion, done); + }); + + it('should return a SAVE_DELETE_ONE_ERROR when data service fails', (done: DoneFn) => { + const action = entityActionFactory.create( + 'Hero', + EntityOp.SAVE_DELETE_ONE, + 42 + ); + const httpError = { error: new Error('Test Failure'), status: 501 }; + const error = makeDataServiceError('DELETE', httpError); + const completion = makeEntityErrorCompletion(action, error); + + actions$.next(action); + dataService.setErrorResponse('delete', error); + + expectCompletion(completion, done); + }); + + it('should return a SAVE_UPDATE_ONE_SUCCESS (Optimistic) with the hero on success', (done: DoneFn) => { + const updateEntity = { id: 1, name: 'A' }; + const update = { id: 1, changes: updateEntity } as Update; + const updateResponse = { ...update, changed: true }; + + const action = entityActionFactory.create( + 'Hero', + EntityOp.SAVE_UPDATE_ONE, + update, + { isOptimistic: true } + ); + const completion = entityActionFactory.create( + 'Hero', + EntityOp.SAVE_UPDATE_ONE_SUCCESS, + updateResponse, + { isOptimistic: true } + ); + + actions$.next(action); + dataService.setResponse('update', updateEntity); + + expectCompletion(completion, done); + }); + + it('should return a SAVE_UPDATE_ONE_SUCCESS (Pessimistic) with the hero on success', (done: DoneFn) => { + const updateEntity = { id: 1, name: 'A' }; + const update = { id: 1, changes: updateEntity } as Update; + const updateResponse = { ...update, changed: true }; + + const action = entityActionFactory.create( + 'Hero', + EntityOp.SAVE_UPDATE_ONE, + update + ); + const completion = entityActionFactory.create( + 'Hero', + EntityOp.SAVE_UPDATE_ONE_SUCCESS, + updateResponse + ); + + actions$.next(action); + dataService.setResponse('update', updateEntity); + + expectCompletion(completion, done); + }); + + it('should return a SAVE_UPDATE_ONE_ERROR when data service fails', (done: DoneFn) => { + const update = { id: 1, changes: { id: 1, name: 'A' } } as Update; + const action = entityActionFactory.create( + 'Hero', + EntityOp.SAVE_UPDATE_ONE, + update + ); + const httpError = { error: new Error('Test Failure'), status: 501 }; + const error = makeDataServiceError('PUT', httpError); + const completion = makeEntityErrorCompletion(action, error); + + actions$.next(action); + dataService.setErrorResponse('update', error); + + expectCompletion(completion, done); + }); + + it('should return a SAVE_UPSERT_ONE_SUCCESS (Optimistic) with the hero on success', (done: DoneFn) => { + const hero = { id: 1, name: 'A' } as Hero; + + const action = entityActionFactory.create( + 'Hero', + EntityOp.SAVE_UPSERT_ONE, + hero, + { isOptimistic: true } + ); + const completion = entityActionFactory.create( + 'Hero', + EntityOp.SAVE_UPSERT_ONE_SUCCESS, + hero, + { isOptimistic: true } + ); + + actions$.next(action); + dataService.setResponse('upsert', hero); + + expectCompletion(completion, done); + }); + + it('should return a SAVE_UPSERT_ONE_SUCCESS (Pessimistic) with the hero on success', (done: DoneFn) => { + const hero = { id: 1, name: 'A' } as Hero; + + const action = entityActionFactory.create( + 'Hero', + EntityOp.SAVE_UPSERT_ONE, + hero + ); + const completion = entityActionFactory.create( + 'Hero', + EntityOp.SAVE_UPSERT_ONE_SUCCESS, + hero + ); + + actions$.next(action); + dataService.setResponse('upsert', hero); + + expectCompletion(completion, done); + }); + + it('should return a SAVE_UPSERT_ONE_ERROR when data service fails', (done: DoneFn) => { + const hero = { id: 1, name: 'A' } as Hero; + const action = entityActionFactory.create( + 'Hero', + EntityOp.SAVE_UPSERT_ONE, + hero + ); + const httpError = { error: new Error('Test Failure'), status: 501 }; + const error = makeDataServiceError('POST', httpError); + const completion = makeEntityErrorCompletion(action, error); + + actions$.next(action); + dataService.setErrorResponse('upsert', error); + + expectCompletion(completion, done); + }); + it(`should not do anything with an irrelevant action`, (done: DoneFn) => { + // Would clear the cached collection + const action = entityActionFactory.create('Hero', EntityOp.REMOVE_ALL); + + actions$.next(action); + const sentinel = 'no persist$ effect'; + + merge( + effects.persist$, + of(sentinel).pipe(delay(1)) + // of(entityActionFactory.create('Hero', EntityOp.QUERY_ALL)) // will cause test to fail + ) + .pipe(first()) + .subscribe( + result => expect(result).toEqual(sentinel), + err => { + fail(err); + done(); + }, + done + ); + }); +}); + +// #region test helpers +export class Hero { + id: number; + name: string; +} + +/** make error produced by the EntityDataService */ +function makeDataServiceError( + /** Http method for that action */ + method: HttpMethods, + /** Http error from the web api */ + httpError?: any, + /** Options sent with the request */ + options?: any +) { + let url = 'api/heroes'; + if (httpError) { + url = httpError.url || url; + } else { + httpError = { error: new Error('Test error'), status: 500, url }; + } + return new DataServiceError(httpError, { method, url, options }); +} + +/** Make an EntityDataService error */ +function makeEntityErrorCompletion( + /** The action that initiated the data service call */ + originalAction: EntityAction, + /** error produced by the EntityDataService */ + error: DataServiceError +) { + const errOp = makeErrorOp(originalAction.payload.entityOp); + + // Entity Error Action + const eaFactory = new EntityActionFactory(); + return eaFactory.create('Hero', errOp, { + originalAction, + error, + }); +} + +export interface TestDataServiceMethod { + add: jasmine.Spy; + delete: jasmine.Spy; + getAll: jasmine.Spy; + getById: jasmine.Spy; + getWithQuery: jasmine.Spy; + update: jasmine.Spy; + upsert: jasmine.Spy; +} +export class TestDataService { + add = jasmine.createSpy('add'); + delete = jasmine.createSpy('delete'); + getAll = jasmine.createSpy('getAll'); + getById = jasmine.createSpy('getById'); + getWithQuery = jasmine.createSpy('getWithQuery'); + update = jasmine.createSpy('update'); + upsert = jasmine.createSpy('upsert'); + + getService(): TestDataServiceMethod { + return this; + } + + setResponse(methodName: keyof TestDataServiceMethod, data: any) { + this[methodName].and.returnValue(of(data).pipe(delay(1))); + } + + setErrorResponse(methodName: keyof TestDataServiceMethod, error: any) { + // Following won't quite work because delay does not appear to delay an error + // this[methodName].and.returnValue(throwError(error).pipe(delay(1))); + // Use timer instead + this[methodName].and.returnValue( + timer(1).pipe(mergeMap(() => throwError(error))) + ); + } +} +// #endregion test helpers diff --git a/modules/data/spec/entity-metadata/entity-definition.service.spec.ts b/modules/data/spec/entity-metadata/entity-definition.service.spec.ts new file mode 100644 index 0000000000..cfc90b7698 --- /dev/null +++ b/modules/data/spec/entity-metadata/entity-definition.service.spec.ts @@ -0,0 +1,144 @@ +import { NgModule } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; + +import { + createEntityDefinition, + EntityDefinitionService, + EntityMetadataMap, + ENTITY_METADATA_TOKEN, +} from '../..'; + +@NgModule({}) +class LazyModule { + lazyMetadataMap = { + Sidekick: {}, + }; + + constructor(entityDefinitionService: EntityDefinitionService) { + entityDefinitionService.registerMetadataMap(this.lazyMetadataMap); + } +} + +describe('EntityDefinitionService', () => { + let service: EntityDefinitionService; + let metadataMap: EntityMetadataMap; + + beforeEach(() => { + metadataMap = { + Hero: {}, + Villain: {}, + }; + + TestBed.configureTestingModule({ + // Not actually lazy but demonstrates a module that registers metadata + imports: [LazyModule], + providers: [ + EntityDefinitionService, + { provide: ENTITY_METADATA_TOKEN, multi: true, useValue: metadataMap }, + ], + }); + service = TestBed.get(EntityDefinitionService); + }); + + describe('#getDefinition', () => { + it('returns definition for known entity', () => { + const def = service.getDefinition('Hero'); + expect(def).toBeDefined(); + }); + + it('throws if request definition for unknown entity', () => { + expect(() => service.getDefinition('Foo')).toThrowError(/no entity/i); + }); + + it('returns undefined if request definition for unknown entity and `shouldThrow` is false', () => { + const def = service.getDefinition('foo', /* shouldThrow */ false); + expect(def).not.toBeDefined(); + }); + }); + + describe('#registerMetadata(Map)', () => { + it('can register a new definition by metadata', () => { + service.registerMetadata({ entityName: 'Foo' }); + + let def = service.getDefinition('Foo'); + expect(def).toBeDefined(); + // Hero is still defined after registering Foo + def = service.getDefinition('Hero'); + expect(def).toBeDefined('Hero still defined'); + }); + + it('can register new definitions by metadata map', () => { + service.registerMetadataMap({ + Foo: {}, + Bar: {}, + }); + + let def = service.getDefinition('Foo'); + expect(def).toBeDefined('Foo'); + def = service.getDefinition('Bar'); + expect(def).toBeDefined('Bar'); + def = service.getDefinition('Hero'); + expect(def).toBeDefined('Hero still defined'); + }); + + it('entityName property should trump map key', () => { + service.registerMetadataMap({ + 1: { entityName: 'Foo' }, // key and entityName differ + }); + + let def = service.getDefinition('Foo'); + expect(def).toBeDefined('Foo'); + def = service.getDefinition('Hero'); + expect(def).toBeDefined('Hero still defined'); + }); + + it('a (lazy-loaded) module can register metadata with its constructor', () => { + // The `Sidekick` metadata are registered by LazyModule's ctor + // Although LazyModule is actually eagerly-loaded in this test, + // the registration technique is the important thing. + const def = service.getDefinition('Sidekick'); + expect(def).toBeDefined('Sidekick'); + }); + }); + + describe('#registerDefinition(s)', () => { + it('can register a new definition', () => { + const newDef = createEntityDefinition({ entityName: 'Foo' }); + service.registerDefinition(newDef); + + let def = service.getDefinition('Foo'); + expect(def).toBeDefined(); + // Hero is still defined after registering Foo + def = service.getDefinition('Hero'); + expect(def).toBeDefined('Hero still defined'); + }); + + it('can register a map of several definitions', () => { + const newDefMap = { + Foo: createEntityDefinition({ entityName: 'Foo' }), + Bar: createEntityDefinition({ entityName: 'Bar' }), + }; + service.registerDefinitions(newDefMap); + + let def = service.getDefinition('Foo'); + expect(def).toBeDefined('Foo'); + def = service.getDefinition('Bar'); + expect(def).toBeDefined('Bar'); + def = service.getDefinition('Hero'); + expect(def).toBeDefined('Hero still defined'); + }); + + it('can re-register an existing definition', () => { + const testSelectId = (entity: any) => 'test-id'; + const newDef = createEntityDefinition({ + entityName: 'Hero', + selectId: testSelectId, + }); + service.registerDefinition(newDef); + + const def = service.getDefinition('Hero'); + expect(def).toBeDefined('Hero still defined'); + expect(def.selectId).toBe(testSelectId, 'updated w/ new selectId'); + }); + }); +}); diff --git a/modules/data/spec/entity-metadata/entity-definition.spec.ts b/modules/data/spec/entity-metadata/entity-definition.spec.ts new file mode 100644 index 0000000000..189dc5560d --- /dev/null +++ b/modules/data/spec/entity-metadata/entity-definition.spec.ts @@ -0,0 +1,107 @@ +import { EntityMetadata, createEntityDefinition } from '../..'; + +interface Hero { + id: number; + name: string; +} + +interface NonIdClass { + key: string; + something: any; +} + +const sorter = (a: T, b: T) => 'foo'; + +const filter = (entities: T[], pattern?: any) => entities; + +const selectIdForNonId = (entity: any) => entity.key; + +const HERO_METADATA: EntityMetadata = { + entityName: 'Hero', + sortComparer: sorter, + filterFn: filter, +}; + +describe('EntityDefinition', () => { + let heroMetadata: EntityMetadata; + + describe('#createEntityDefinition', () => { + beforeEach(() => { + heroMetadata = { ...HERO_METADATA }; + }); + + it('generates expected `initialState`', () => { + const def = createEntityDefinition(heroMetadata); + const initialState = def.initialState; + expect(initialState).toEqual({ + entityName: 'Hero', + ids: [], + entities: {}, + filter: '', + loaded: false, + loading: false, + changeState: {}, + }); + }); + + it('generates expected `initialState` when `additionalCollectionState`', () => { + // extend Hero collection metadata with more collection state + const metadata = { + ...heroMetadata, + additionalCollectionState: { foo: 'foo' }, + }; + const def = createEntityDefinition(metadata); + const initialState = def.initialState; + expect(initialState).toEqual({ + entityName: 'Hero', + ids: [], + entities: {}, + filter: '', + loaded: false, + loading: false, + changeState: {}, + foo: 'foo', + }); + }); + + it('creates default `selectId` on the definition when no metadata.selectId', () => { + const def = createEntityDefinition(heroMetadata); + expect(def.selectId({ id: 42 } as Hero)).toBe(42); + }); + + it('creates expected `selectId` on the definition when metadata.selectId exists', () => { + const metadata: EntityMetadata = { + entityName: 'NonIdClass', + selectId: selectIdForNonId, + }; + const def = createEntityDefinition(metadata); + expect(def.selectId({ key: 'foo' })).toBe('foo'); + }); + + it('sets `sortComparer` to false if not in metadata', () => { + delete heroMetadata.sortComparer; + const def = createEntityDefinition(heroMetadata); + expect(def.metadata.sortComparer).toBe(false); + }); + + it('sets `entityDispatchOptions to {} if not in metadata', () => { + const def = createEntityDefinition(heroMetadata); + expect(def.entityDispatcherOptions).toEqual({}); + }); + + it('passes `metadata.entityDispatchOptions` thru', () => { + const options = { + optimisticAdd: false, + optimisticUpdate: false, + }; + heroMetadata.entityDispatcherOptions = options; + const def = createEntityDefinition(heroMetadata); + expect(def.entityDispatcherOptions).toBe(options); + }); + + it('throws error if missing `entityName`', () => { + const metadata: EntityMetadata = {}; + expect(() => createEntityDefinition(metadata)).toThrowError(/entityName/); + }); + }); +}); diff --git a/modules/data/spec/entity-metadata/entity-filters.spec.ts b/modules/data/spec/entity-metadata/entity-filters.spec.ts new file mode 100644 index 0000000000..462858abe0 --- /dev/null +++ b/modules/data/spec/entity-metadata/entity-filters.spec.ts @@ -0,0 +1,52 @@ +import { PropsFilterFnFactory } from '../..'; + +class Hero { + id: number; + name: string; + saying?: string; +} + +describe('EntityFilterFn - PropsFilter', () => { + it('can match entity on full text of a target prop', () => { + const entity1: Hero = { id: 42, name: 'Foo' }; + const entity2: Hero = { id: 21, name: 'Bar' }; + const entities: Hero[] = [entity1, entity2]; + const filter = PropsFilterFnFactory(['name']); + expect(filter(entities, 'Foo')).toEqual([entity1]); + }); + + it('can match entity on regex of a target prop', () => { + const entity1: Hero = { id: 42, name: 'Foo' }; + const entity2: Hero = { id: 21, name: 'Bar' }; + const entities: Hero[] = [entity1, entity2]; + const filter = PropsFilterFnFactory(['name']); + expect(filter(entities, /fo/i)).toEqual([entity1]); + }); + + it('can match entity on regex of two target props', () => { + const entity1: Hero = { id: 42, name: 'Foo' }; + const entity2: Hero = { id: 21, name: 'Bar', saying: 'Foo is not Bar' }; + const entities: Hero[] = [entity1, entity2]; + const filter = PropsFilterFnFactory(['name', 'saying']); + expect(filter(entities, /fo/i)).toEqual([entity1, entity2]); + }); + + it('returns empty array when no matches', () => { + const entity1: Hero = { id: 42, name: 'Foo' }; + const entity2: Hero = { id: 21, name: 'Bar' }; + const entities: Hero[] = [entity1, entity2]; + const filter = PropsFilterFnFactory(['name']); + expect(filter(entities, 'Baz')).toEqual([]); + }); + + it('returns empty array for empty input entities array', () => { + const entities: Hero[] = []; + const filter = PropsFilterFnFactory(['name']); + expect(filter(entities, 'Foo')).toEqual([]); + }); + + it('returns empty array for null input entities array', () => { + const filter = PropsFilterFnFactory(['name']); + expect(filter(null as any, 'Foo')).toEqual([]); + }); +}); diff --git a/modules/data/spec/entity-services/entity-collection-service.spec.ts b/modules/data/spec/entity-services/entity-collection-service.spec.ts new file mode 100644 index 0000000000..ecce6dfd06 --- /dev/null +++ b/modules/data/spec/entity-services/entity-collection-service.spec.ts @@ -0,0 +1,635 @@ +import { Injectable } from '@angular/core'; +import { ComponentFixture, inject, TestBed } from '@angular/core/testing'; +import { HttpErrorResponse } from '@angular/common/http'; +import { Action, StoreModule, Store } from '@ngrx/store'; +import { Actions, EffectsModule } from '@ngrx/effects'; + +import { Observable, of, ReplaySubject, throwError, timer } from 'rxjs'; +import { + delay, + filter, + first, + mergeMap, + skip, + tap, + withLatestFrom, +} from 'rxjs/operators'; + +import { commandDispatchTest } from '../dispatchers/entity-dispatcher.spec'; +import { + EntityCollectionService, + EntityActionOptions, + PersistanceCanceled, + EntityDispatcherDefaultOptions, + EntityAction, + EntityActionFactory, + EntityCache, + EntityCollection, + EntityOp, + EntityMetadataMap, + NgrxDataModule, + EntityCacheEffects, + EntityDataService, + EntityDispatcherFactory, + EntityServices, + OP_SUCCESS, + HttpMethods, + DataServiceError, + Logger, +} from '../..'; + +describe('EntityCollectionService', () => { + describe('Command dispatching', () => { + // Borrowing the dispatcher tests from entity-dispatcher.spec. + // The critical difference: those test didn't invoke the reducers; they do when run here. + commandDispatchTest(getDispatcher); + + function getDispatcher() { + const { heroCollectionService, store } = entityServicesSetup(); + const dispatcher = heroCollectionService.dispatcher; + return { dispatcher, store }; + } + }); + + // TODO: test the effect of MergeStrategy when there are entities in cache with changes + // This concern is largely met by EntityChangeTracker tests but integration tests would be reassuring. + describe('queries', () => { + let heroCollectionService: EntityCollectionService; + let dataService: TestDataService; + let reducedActions$Snoop: () => void; + + beforeEach(() => { + ({ + heroCollectionService, + reducedActions$Snoop, + dataService, + } = entityServicesSetup()); + }); + + // Compare to next test which subscribes to getAll() result + it('can use loading$ to learn when getAll() succeeds', (done: DoneFn) => { + const hero1 = { id: 1, name: 'A' } as Hero; + const hero2 = { id: 2, name: 'B' } as Hero; + const heroes = [hero1, hero2]; + dataService.setResponse('getAll', heroes); + heroCollectionService.getAll(); + + // N.B.: This technique does not detect errors + heroCollectionService.loading$ + .pipe( + filter(loading => !loading), + withLatestFrom(heroCollectionService.entities$) + ) + .subscribe(([loading, data]) => { + expect(data).toEqual(heroes); + done(); + }); + }); + + // Compare to previous test the waits for loading$ flag to flip + it('getAll observable should emit heroes on success', (done: DoneFn) => { + const hero1 = { id: 1, name: 'A' } as Hero; + const hero2 = { id: 2, name: 'B' } as Hero; + const heroes = [hero1, hero2]; + dataService.setResponse('getAll', heroes); + heroCollectionService.getAll().subscribe(expectDataToBe(heroes, done)); + + // reducedActions$Snoop(); // diagnostic + }); + + it('getAll observable should emit expected error when data service fails', (done: DoneFn) => { + const httpError = { error: new Error('Test Failure'), status: 501 }; + const error = makeDataServiceError('GET', httpError); + dataService.setErrorResponse('getAll', error); + heroCollectionService.getAll().subscribe(expectErrorToBe(error, done)); + }); + + it('getByKey observable should emit a hero on success', (done: DoneFn) => { + const hero = { id: 1, name: 'A' } as Hero; + dataService.setResponse('getById', hero); + heroCollectionService.getByKey(1).subscribe(expectDataToBe(hero, done)); + }); + + it('getByKey observable should emit expected error when data service fails', (done: DoneFn) => { + // Simulate HTTP 'Not Found' response + const httpError = new HttpErrorResponse({ + error: 'Entity not found', + status: 404, + statusText: 'Not Found', + url: 'bad/location', + }); + + // For test purposes, the following would have been effectively the same thing + // const httpError = { error: new Error('Entity not found'), status: 404 }; + + const error = makeDataServiceError('GET', httpError); + dataService.setErrorResponse('getById', error); + heroCollectionService + .getByKey(42) + .subscribe(expectErrorToBe(error, done)); + }); + + it('getWithQuery observable should emit heroes on success', (done: DoneFn) => { + const hero1 = { id: 1, name: 'A' } as Hero; + const hero2 = { id: 2, name: 'B' } as Hero; + const heroes = [hero1, hero2]; + dataService.setResponse('getWithQuery', heroes); + heroCollectionService + .getWithQuery({ name: 'foo' }) + .subscribe(expectDataToBe(heroes, done)); + + // reducedActions$Snoop(); // diagnostic + }); + + it('getWithQuery observable should emit expected error when data service fails', (done: DoneFn) => { + const httpError = { error: new Error('Test Failure'), status: 501 }; + const error = makeDataServiceError('GET', httpError); + dataService.setErrorResponse('getWithQuery', error); + heroCollectionService + .getWithQuery({ name: 'foo' }) + .subscribe(expectErrorToBe(error, done)); + }); + + it('load observable should emit heroes on success', (done: DoneFn) => { + const hero1 = { id: 1, name: 'A' } as Hero; + const hero2 = { id: 2, name: 'B' } as Hero; + const heroes = [hero1, hero2]; + dataService.setResponse('getAll', heroes); + heroCollectionService.load().subscribe(expectDataToBe(heroes, done)); + }); + + it('load observable should emit expected error when data service fails', (done: DoneFn) => { + const httpError = { error: new Error('Test Failure'), status: 501 }; + const error = makeDataServiceError('GET', httpError); + dataService.setErrorResponse('getAll', error); + heroCollectionService.load().subscribe(expectErrorToBe(error, done)); + }); + }); + + describe('cancel', () => { + const hero1 = { id: 1, name: 'A' } as Hero; + const hero2 = { id: 2, name: 'B' } as Hero; + const heroes = [hero1, hero2]; + + let heroCollectionService: EntityCollectionService; + let dataService: TestDataService; + let reducedActions$Snoop: () => void; + + beforeEach(() => { + ({ + dataService, + heroCollectionService, + reducedActions$Snoop, + } = entityServicesSetup()); + }); + + it('can cancel a long running query', (done: DoneFn) => { + const responseDelay = 4; + dataService['getAll'].and.returnValue( + of(heroes).pipe(delay(responseDelay)) + ); + + // Create the correlation id yourself to know which action to cancel. + const correlationId = 'CRID007'; + const options: EntityActionOptions = { correlationId }; + heroCollectionService.getAll(options).subscribe( + data => fail('should not have data but got data'), + error => { + expect(error instanceof PersistanceCanceled).toBe( + true, + 'is PersistanceCanceled' + ); + expect(error.message).toBe('Test cancel'); + done(); + } + ); + + heroCollectionService.cancel(correlationId, 'Test cancel'); + }); + + it('has no effect on action with different correlationId', (done: DoneFn) => { + const responseDelay = 4; + dataService['getAll'].and.returnValue( + of(heroes).pipe(delay(responseDelay)) + ); + + const correlationId = 'CRID007'; + const options: EntityActionOptions = { correlationId }; + heroCollectionService.getAll(options).subscribe(data => { + expect(data).toEqual(heroes); + done(); + }, fail); + + heroCollectionService.cancel('not-the-crid'); + }); + + it('has no effect when too late', (done: DoneFn) => { + const responseDelay = 4; + dataService['getAll'].and.returnValue( + of(heroes).pipe(delay(responseDelay)) + ); + + const correlationId = 'CRID007'; + const options: EntityActionOptions = { correlationId }; + heroCollectionService + .getAll(options) + .subscribe(data => expect(data).toEqual(heroes), fail); + + setTimeout( + () => heroCollectionService.cancel(correlationId), + responseDelay + 2 + ); + setTimeout(done, responseDelay + 4); // wait for all to complete + }); + }); + + xdescribe('saves (optimistic)', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + /* tslint:disable-next-line:no-use-before-declare */ + providers: [ + { + provide: EntityDispatcherDefaultOptions, + useClass: OptimisticDispatcherDefaultOptions, + }, + ], + }); + }); + + combinedSaveTests(true); + }); + + xdescribe('saves (pessimistic)', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + /* tslint:disable-next-line:no-use-before-declare */ + providers: [ + { + provide: EntityDispatcherDefaultOptions, + useClass: PessimisticDispatcherDefaultOptions, + }, + ], + }); + }); + + combinedSaveTests(false); + }); + + /** Save tests to be run both optimistically and pessimistically */ + function combinedSaveTests(isOptimistic: boolean) { + let heroCollectionService: EntityCollectionService; + let dataService: TestDataService; + let expectOptimisticSuccess: (expect: boolean) => () => void; + let reducedActions$Snoop: () => void; + let successActions$: Observable; + + beforeEach(() => { + ({ + dataService, + expectOptimisticSuccess, + heroCollectionService, + reducedActions$Snoop, + successActions$, + } = entityServicesSetup()); + }); + + it('add() should save a new entity and return it', (done: DoneFn) => { + const extra = expectOptimisticSuccess(isOptimistic); + const hero = { id: 1, name: 'A' } as Hero; + dataService.setResponse('add', hero); + heroCollectionService + .add(hero) + .subscribe(expectDataToBe(hero, done, undefined, extra)); + }); + + it('add() observable should emit expected error when data service fails', (done: DoneFn) => { + const hero = { id: 1, name: 'A' } as Hero; + const httpError = { error: new Error('Test Failure'), status: 501 }; + const error = makeDataServiceError('PUT', httpError); + dataService.setErrorResponse('add', error); + heroCollectionService.add(hero).subscribe(expectErrorToBe(error, done)); + }); + + it('delete() should send delete for entity not in cache and return its id', (done: DoneFn) => { + const extra = expectOptimisticSuccess(isOptimistic); + dataService.setResponse('delete', 42); + heroCollectionService + .delete(42) + .subscribe(expectDataToBe(42, done, undefined, extra)); + }); + + it('delete() should skip delete for added entity cache', (done: DoneFn) => { + // reducedActions$Snoop(); + let wasSkipped: boolean; + successActions$.subscribe( + (act: EntityAction) => (wasSkipped = act.payload.skip === true) + ); + const extra = () => + expect(wasSkipped).toBe(true, 'expected to be skipped'); + + const hero = { id: 1, name: 'A' } as Hero; + heroCollectionService.addOneToCache(hero); + dataService.setResponse('delete', 1); + heroCollectionService + .delete(1) + .subscribe(expectDataToBe(1, done, undefined, extra)); + }); + + it('delete() observable should emit expected error when data service fails', (done: DoneFn) => { + const httpError = { error: new Error('Test Failure'), status: 501 }; + const error = makeDataServiceError('DELETE', httpError); + dataService.setErrorResponse('delete', error); + heroCollectionService.delete(42).subscribe(expectErrorToBe(error, done)); + }); + + it('update() should save updated entity and return it', (done: DoneFn) => { + const extra = expectOptimisticSuccess(isOptimistic); + const preUpdate = { id: 1, name: 'A' } as Hero; + heroCollectionService.addAllToCache([preUpdate]); // populate cache + const update = { ...preUpdate, name: 'Updated A' }; + dataService.setResponse('update', null); // server returns nothing after update + heroCollectionService + .update(update) + .subscribe(expectDataToBe(update, done, undefined, extra)); + }); + + it('update() should save updated entity and return server-changed version', (done: DoneFn) => { + const extra = expectOptimisticSuccess(isOptimistic); + const preUpdate = { id: 1, name: 'A' } as Hero; + heroCollectionService.addAllToCache([preUpdate]); // populate cache + const update = { ...preUpdate, name: 'Updated A' }; + const postUpdate = { + ...preUpdate, + name: 'Updated A', + saying: 'Server set this', + }; + dataService.setResponse('update', postUpdate); // server returns entity with server-side changes + heroCollectionService + .update(update) + .subscribe(expectDataToBe(postUpdate, done, undefined, extra)); + }); + + it('update() observable should emit expected error when data service fails', (done: DoneFn) => { + const preUpdate = { id: 1, name: 'A' } as Hero; + heroCollectionService.addAllToCache([preUpdate]); // populate cache + const update = { ...preUpdate, name: 'Updated A' }; + const httpError = { error: new Error('Test Failure'), status: 501 }; + const error = makeDataServiceError('PUT', httpError); + dataService.setErrorResponse('update', error); + heroCollectionService + .update(update) + .subscribe(expectErrorToBe(error, done)); + }); + + it('can handle out-of-order save results', (done: DoneFn) => { + const hero1 = { id: 1, name: 'A' } as Hero; + const hero2 = { id: 2, name: 'B' } as Hero; + let successActionCount = 0; + const delayMs = 5; + let responseDelay = delayMs; + const savedHeroes: Hero[] = []; + + successActions$.pipe(delay(1)).subscribe(act => { + successActionCount += 1; + if (successActionCount === 2) { + // Confirm hero2 actually saved before hero1 + expect(savedHeroes).toEqual([hero2, hero1], 'savedHeroes'); + done(); + } + }); + + // dataService.add returns odd responses later than even responses + // so add of hero2 should complete before add of hero1 + dataService['add'].and.callFake((data: Hero) => { + const result = of(data).pipe( + delay(responseDelay), + tap(h => savedHeroes.push(h)) + ); + responseDelay = delayMs === responseDelay ? 1 : responseDelay; + return result; + }); + + // Save hero1 before hero2 + // Confirm that each add returns with its own hero + heroCollectionService + .add(hero1) + .subscribe(data => expect(data).toEqual(hero1, 'first hero')); + + heroCollectionService + .add(hero2) + .subscribe(data => expect(data).toEqual(hero2, 'second hero')); + }); + } + + describe('selectors$', () => { + let entityActionFactory: EntityActionFactory; + let heroCollectionService: EntityCollectionService; + let store: Store; + + function dispatchedAction() { + return (store.dispatch).calls.argsFor(0)[0]; + } + + beforeEach(() => { + const setup = entityServicesSetup(); + ({ entityActionFactory, heroCollectionService, store } = setup); + spyOn(store, 'dispatch').and.callThrough(); + }); + + it('can get collection from collection$', () => { + const action = entityActionFactory.create('Hero', EntityOp.ADD_ALL, [ + { id: 1, name: 'A' }, + ]); + store.dispatch(action); + heroCollectionService.collection$.subscribe(collection => { + expect(collection.ids).toEqual([1]); + }); + }); + }); +}); + +// #region test helpers +class Hero { + id: number; + name: string; + saying?: string; +} +class Villain { + key: string; + name: string; +} + +const entityMetadata: EntityMetadataMap = { + Hero: {}, + Villain: { selectId: villain => villain.key }, +}; + +function entityServicesSetup() { + const logger = jasmine.createSpyObj('Logger', ['error', 'log', 'warn']); + + TestBed.configureTestingModule({ + imports: [ + StoreModule.forRoot({}), + EffectsModule.forRoot([]), + NgrxDataModule.forRoot({ + entityMetadata: entityMetadata, + }), + ], + providers: [ + { provide: EntityCacheEffects, useValue: {} }, + /* tslint:disable-next-line:no-use-before-declare */ + { provide: EntityDataService, useClass: TestDataService }, + { provide: Logger, useValue: logger }, + ], + }); + + const actions$: Observable = TestBed.get(Actions); + const dataService: TestDataService = TestBed.get(EntityDataService); + const entityActionFactory: EntityActionFactory = TestBed.get( + EntityActionFactory + ); + const entityDispatcherFactory: EntityDispatcherFactory = TestBed.get( + EntityDispatcherFactory + ); + const entityServices: EntityServices = TestBed.get(EntityServices); + const heroCollectionService = entityServices.getEntityCollectionService( + 'Hero' + ); + const reducedActions$: Observable = + entityDispatcherFactory.reducedActions$; + const store: Store = TestBed.get(Store); + const successActions$: Observable = reducedActions$.pipe( + filter( + (act: any) => act.payload && act.payload.entityOp.endsWith(OP_SUCCESS) + ) + ); + + /** Returns fn that confirms EntityAction was (or was not Optimistic) after success */ + function expectOptimisticSuccess(expected: boolean) { + let wasOptimistic: boolean; + const msg = `${expected ? 'Optimistic' : 'Pessimistic'} save `; + successActions$.subscribe( + (act: EntityAction) => (wasOptimistic = act.payload.isOptimistic === true) + ); + return () => expect(wasOptimistic).toBe(expected, msg); + } + + /** Snoop on reducedActions$ while debugging a test */ + function reducedActions$Snoop() { + reducedActions$.subscribe(act => { + console.log('scannedActions$', act); + }); + } + + return { + actions$, + dataService, + entityActionFactory, + entityServices, + expectOptimisticSuccess, + heroCollectionService, + reducedActions$, + reducedActions$Snoop, + store, + successActions$, + }; +} + +function expectDataToBe( + expected: any, + done: DoneFn, + message?: string, + extra?: () => void +) { + return { + next: (data: any) => { + expect(data).toEqual(expected, message); + if (extra) { + extra(); // extra expectations before done + } + done(); + }, + error: fail, + }; +} + +function expectErrorToBe(expected: any, done: DoneFn, message?: string) { + return { + next: (data: any) => { + fail(`Expected error response but got data: '${JSON.stringify(data)}'`); + done(); + }, + error: (error: any) => { + expect(error).toEqual(expected, message); + done(); + }, + }; +} + +/** make error produced by the EntityDataService */ +function makeDataServiceError( + /** Http method for that action */ + method: HttpMethods, + /** Http error from the web api */ + httpError?: any, + /** Options sent with the request */ + options?: any +) { + let url = 'api/heroes'; + if (httpError) { + url = httpError.url || url; + } else { + httpError = { error: new Error('Test error'), status: 500, url }; + } + return new DataServiceError(httpError, { method, url, options }); +} + +@Injectable() +export class OptimisticDispatcherDefaultOptions { + optimisticAdd = true; + optimisticDelete = true; + optimisticUpdate = true; +} + +@Injectable() +export class PessimisticDispatcherDefaultOptions { + optimisticAdd = false; + optimisticDelete = false; + optimisticUpdate = false; +} + +export interface TestDataServiceMethod { + add: jasmine.Spy; + delete: jasmine.Spy; + getAll: jasmine.Spy; + getById: jasmine.Spy; + getWithQuery: jasmine.Spy; + update: jasmine.Spy; +} + +export class TestDataService { + add = jasmine.createSpy('add'); + delete = jasmine.createSpy('delete'); + getAll = jasmine.createSpy('getAll'); + getById = jasmine.createSpy('getById'); + getWithQuery = jasmine.createSpy('getWithQuery'); + update = jasmine.createSpy('update'); + + getService(): TestDataServiceMethod { + return this; + } + + setResponse(methodName: keyof TestDataServiceMethod, data: any) { + this[methodName].and.returnValue(of(data).pipe(delay(1))); + } + + setErrorResponse(methodName: keyof TestDataServiceMethod, error: any) { + // Following won't quite work because delay does not appear to delay an error + // this[methodName].and.returnValue(throwError(error).pipe(delay(1))); + // Use timer instead + this[methodName].and.returnValue( + timer(1).pipe(mergeMap(() => throwError(error))) + ); + } +} +// #endregion test helpers diff --git a/modules/data/spec/entity-services/entity-services.spec.ts b/modules/data/spec/entity-services/entity-services.spec.ts new file mode 100644 index 0000000000..274c4bbf18 --- /dev/null +++ b/modules/data/spec/entity-services/entity-services.spec.ts @@ -0,0 +1,252 @@ +import { Injectable } from '@angular/core'; +import { ComponentFixture, inject, TestBed } from '@angular/core/testing'; +import { HttpErrorResponse } from '@angular/common/http'; +import { Action, StoreModule, Store } from '@ngrx/store'; +import { Actions, EffectsModule } from '@ngrx/effects'; + +import { Observable, of, ReplaySubject, throwError, timer } from 'rxjs'; +import { + delay, + filter, + first, + mergeMap, + skip, + tap, + withLatestFrom, +} from 'rxjs/operators'; + +import { + EntityAction, + EntityOp, + EntityCacheQuerySet, + MergeQuerySet, + EntityMetadataMap, + NgrxDataModule, + EntityCacheEffects, + EntityDataService, + EntityActionFactory, + EntityDispatcherFactory, + EntityServices, + EntityCache, + HttpMethods, + DataServiceError, + Logger, +} from '../..'; + +describe('EntityServices', () => { + describe('entityActionErrors$', () => { + it('should emit EntityAction errors for multiple entity types', () => { + const errors: EntityAction[] = []; + const { entityActionFactory, entityServices } = entityServicesSetup(); + entityServices.entityActionErrors$.subscribe(error => errors.push(error)); + + entityServices.dispatch({ type: 'not-an-entity-action' }); + entityServices.dispatch( + entityActionFactory.create('Hero', EntityOp.QUERY_ALL) + ); // not an error + entityServices.dispatch( + entityActionFactory.create( + 'Hero', + EntityOp.QUERY_ALL_ERROR, + makeDataServiceError('GET', new Error('Bad hero news')) + ) + ); + entityServices.dispatch( + entityActionFactory.create('Villain', EntityOp.QUERY_ALL) + ); // not an error + entityServices.dispatch( + entityActionFactory.create( + 'Villain', + EntityOp.SAVE_ADD_ONE_ERROR, + makeDataServiceError('PUT', new Error('Bad villain news')) + ) + ); + + expect(errors.length).toBe(2); + }); + }); + + describe('entityCache$', () => { + it('should observe the entire entity cache', () => { + const entityCacheValues: any = []; + + const { + entityActionFactory, + entityServices, + store, + } = entityServicesSetup(); + + // entityCache$.subscribe() callback invoked immediately. The cache is empty at first. + entityServices.entityCache$.subscribe(ec => entityCacheValues.push(ec)); + + // This first action to go through the Hero's EntityCollectionReducer + // creates the collection in the EntityCache as a side-effect, + // triggering the second entityCache$.subscribe() callback + const heroAction = entityActionFactory.create( + 'Hero', + EntityOp.SET_FILTER, + 'test' + ); + store.dispatch(heroAction); + + expect(entityCacheValues.length).toEqual( + 2, + 'entityCache$ callback twice' + ); + expect(entityCacheValues[0]).toEqual({}, 'empty at first'); + expect(entityCacheValues[1].Hero).toBeDefined('has Hero collection'); + }); + }); + + describe('dispatch(MergeQuerySet)', () => { + // using async test to guard against false test pass. + it('should update entityCache$ twice after merging two individual collections', (done: DoneFn) => { + const hero1 = { id: 1, name: 'A' } as Hero; + const hero2 = { id: 2, name: 'B' } as Hero; + const heroes = [hero1, hero2]; + + const villain = { key: 'DE', name: 'Dr. Evil' } as Villain; + + const { entityServices } = entityServicesSetup(); + const heroCollectionService = entityServices.getEntityCollectionService< + Hero + >('Hero'); + const villainCollectionService = entityServices.getEntityCollectionService< + Villain + >('Villain'); + + const entityCacheValues: any = []; + entityServices.entityCache$.subscribe(cache => { + entityCacheValues.push(cache); + if (entityCacheValues.length === 3) { + expect(entityCacheValues[0]).toEqual({}, '#1 empty at first'); + expect(entityCacheValues[1]['Hero'].ids).toEqual( + [1, 2], + '#2 has heroes' + ); + expect(entityCacheValues[1]['Villain']).toBeUndefined( + '#2 does not have Villain collection' + ); + expect(entityCacheValues[2]['Villain'].entities['DE']).toEqual( + villain, + '#3 has villain' + ); + done(); + } + }); + + // Emulate what would happen if had queried collections separately + heroCollectionService.createAndDispatch( + EntityOp.QUERY_MANY_SUCCESS, + heroes + ); + villainCollectionService.createAndDispatch( + EntityOp.QUERY_BY_KEY_SUCCESS, + villain + ); + }); + + // using async test to guard against false test pass. + it('should update entityCache$ once when MergeQuerySet multiple collections', (done: DoneFn) => { + const hero1 = { id: 1, name: 'A' } as Hero; + const hero2 = { id: 2, name: 'B' } as Hero; + const heroes = [hero1, hero2]; + const villain = { key: 'DE', name: 'Dr. Evil' } as Villain; + const querySet: EntityCacheQuerySet = { + Hero: heroes, + Villain: [villain], + }; + const action = new MergeQuerySet(querySet); + + const { entityServices } = entityServicesSetup(); + + // Skip initial value. Want the first one after merge is dispatched + entityServices.entityCache$ + .pipe( + skip(1), + first() + ) + .subscribe(cache => { + expect(cache['Hero'].ids).toEqual([1, 2], 'has merged heroes'); + expect(cache['Villain'].entities['DE']).toEqual( + villain, + 'has merged villain' + ); + done(); + }); + entityServices.dispatch(action); + }); + }); +}); + +// #region test helpers +class Hero { + id: number; + name: string; + saying?: string; +} +class Villain { + key: string; + name: string; +} + +const entityMetadata: EntityMetadataMap = { + Hero: {}, + Villain: { selectId: villain => villain.key }, +}; + +function entityServicesSetup() { + const logger = jasmine.createSpyObj('Logger', ['error', 'log', 'warn']); + + TestBed.configureTestingModule({ + imports: [ + StoreModule.forRoot({}), + EffectsModule.forRoot([]), + NgrxDataModule.forRoot({ + entityMetadata: entityMetadata, + }), + ], + /* tslint:disable-next-line:no-use-before-declare */ + providers: [ + { provide: EntityCacheEffects, useValue: {} }, + { provide: EntityDataService, useValue: null }, + { provide: Logger, useValue: logger }, + ], + }); + + const actions$: Observable = TestBed.get(Actions); + const entityActionFactory: EntityActionFactory = TestBed.get( + EntityActionFactory + ); + const entityDispatcherFactory: EntityDispatcherFactory = TestBed.get( + EntityDispatcherFactory + ); + const entityServices: EntityServices = TestBed.get(EntityServices); + const store: Store = TestBed.get(Store); + + return { + actions$, + entityActionFactory, + entityServices, + store, + }; +} + +/** make error produced by the EntityDataService */ +function makeDataServiceError( + /** Http method for that action */ + method: HttpMethods, + /** Http error from the web api */ + httpError?: any, + /** Options sent with the request */ + options?: any +) { + let url = 'api/heroes'; + if (httpError) { + url = httpError.url || url; + } else { + httpError = { error: new Error('Test error'), status: 500, url }; + } + return new DataServiceError(httpError, { method, url, options }); +} +// #endregion test helpers diff --git a/modules/data/spec/ngrx-data.module.spec.ts b/modules/data/spec/ngrx-data.module.spec.ts new file mode 100644 index 0000000000..7aeda6f2c4 --- /dev/null +++ b/modules/data/spec/ngrx-data.module.spec.ts @@ -0,0 +1,265 @@ +import { Injectable, InjectionToken } from '@angular/core'; +import { + Action, + ActionReducer, + MetaReducer, + Store, + StoreModule, +} from '@ngrx/store'; +import { Actions, Effect, EffectsModule } from '@ngrx/effects'; + +// Not using marble testing +import { TestBed } from '@angular/core/testing'; + +import { Observable, of, Subject } from 'rxjs'; +import { map, skip, tap } from 'rxjs/operators'; + +import { + EntityCache, + ofEntityOp, + persistOps, + EntityAction, + EntityActionFactory, + NgrxDataModule, + EntityCacheEffects, + EntityEffects, + EntityOp, + EntityCollectionCreator, + EntityCollection, +} from '..'; + +const TEST_ACTION = 'test/get-everything-succeeded'; +const EC_METAREDUCER_TOKEN = new InjectionToken< + MetaReducer +>('EC MetaReducer'); + +@Injectable() +class TestEntityEffects { + @Effect() + test$: Observable = this.actions.pipe( + // tap(action => console.log('test$ effect', action)), + ofEntityOp(persistOps), + map(this.testHook) + ); + + testHook(action: EntityAction) { + return { + type: 'test-action', + payload: action, // the incoming action + entityName: action.payload.entityName, + }; + } + + constructor(private actions: Actions) {} +} + +class Hero { + id: number; + name: string; + power?: string; +} + +class Villain { + id: string; + name: string; +} + +const entityMetadata = { + Hero: {}, + Villain: {}, +}; + +//////// Tests begin //////// + +describe('NgrxDataModule', () => { + describe('with replaced EntityEffects', () => { + // factory never changes in these tests + const entityActionFactory = new EntityActionFactory(); + + let actions$: Actions; + let store: Store; + let testEffects: TestEntityEffects; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ + StoreModule.forRoot({}), + EffectsModule.forRoot([]), + NgrxDataModule.forRoot({ + entityMetadata: entityMetadata, + }), + ], + providers: [ + { provide: EntityCacheEffects, useValue: {} }, + { provide: EntityEffects, useClass: TestEntityEffects }, + ], + }); + + actions$ = TestBed.get(Actions); + store = TestBed.get(Store); + + testEffects = TestBed.get(EntityEffects); + spyOn(testEffects, 'testHook').and.callThrough(); + }); + + it('should invoke test effect with an EntityAction', () => { + const actions: Action[] = []; + + // listen for actions after the next dispatched action + actions$ + .pipe( + // tap(act => console.log('test action', act)), + skip(1) // Skip QUERY_ALL + ) + .subscribe(act => actions.push(act)); + + const action = entityActionFactory.create('Hero', EntityOp.QUERY_ALL); + store.dispatch(action); + expect(actions.length).toBe(1, 'expect one effect action'); + expect(actions[0].type).toBe('test-action'); + }); + + it('should not invoke test effect with non-EntityAction', () => { + const actions: Action[] = []; + + // listen for actions after the next dispatched action + actions$.pipe(skip(1)).subscribe(act => actions.push(act)); + + store.dispatch({ type: 'not-an-entity-action' }); + expect(actions.length).toBe(0); + }); + }); + + describe('with EntityCacheMetaReducer', () => { + let cacheSelector$: Observable; + let eaFactory: EntityActionFactory; + let metaReducerLog: string[]; + let store: Store<{ entityCache: EntityCache }>; + + function loggingEntityCacheMetaReducer( + reducer: ActionReducer + ): ActionReducer { + return (state, action) => { + metaReducerLog.push(`MetaReducer saw "${action.type}"`); + return reducer(state, action); + }; + } + + beforeEach(() => { + metaReducerLog = []; + + TestBed.configureTestingModule({ + imports: [ + StoreModule.forRoot({}), + EffectsModule.forRoot([]), + NgrxDataModule.forRoot({ + entityMetadata: entityMetadata, + entityCacheMetaReducers: [ + loggingEntityCacheMetaReducer, + EC_METAREDUCER_TOKEN, + ], + }), + ], + providers: [ + { provide: EntityCacheEffects, useValue: {} }, + { provide: EntityEffects, useValue: {} }, + { + // Here's how you add an EntityCache metareducer with an injected service + provide: EC_METAREDUCER_TOKEN, + useFactory: entityCacheMetaReducerFactory, + deps: [EntityCollectionCreator], + }, + ], + }); + + store = TestBed.get(Store); + cacheSelector$ = store.select(state => state.entityCache); + eaFactory = TestBed.get(EntityActionFactory); + }); + + it('should log an ordinary entity action', () => { + const action = eaFactory.create('Hero', EntityOp.SET_LOADING); + store.dispatch(action); + expect(metaReducerLog.join('|')).toContain( + EntityOp.SET_LOADING, + 'logged entity action' + ); + }); + + it('should respond to action handled by custom EntityCacheMetaReducer', () => { + const data = { + Hero: [ + { id: 2, name: 'B', power: 'Fast' }, + { id: 1, name: 'A', power: 'invisible' }, + ], + Villain: [{ id: 30, name: 'Dr. Evil' }], + }; + const action = { + type: TEST_ACTION, + payload: data, + }; + store.dispatch(action); + cacheSelector$.subscribe(cache => { + try { + expect(cache.Hero.entities[1]).toEqual( + data.Hero[1], + 'has expected hero' + ); + expect(cache.Villain.entities[30]).toEqual( + data.Villain[0], + 'has expected hero' + ); + expect(metaReducerLog.join('|')).toContain( + TEST_ACTION, + 'logged test action' + ); + } catch (error) { + fail(error); + } + }, fail); + }); + }); +}); + +// #region helpers + +/** Create the test entityCacheMetaReducer, injected in tests */ +function entityCacheMetaReducerFactory( + collectionCreator: EntityCollectionCreator +) { + return (reducer: ActionReducer) => { + return (state: EntityCache, action: { type: string; payload?: any }) => { + switch (action.type) { + case TEST_ACTION: { + const mergeState = { + Hero: createCollection('Hero', action.payload['Hero'] || []), + Villain: createCollection( + 'Villain', + action.payload['Villain'] || [] + ), + }; + return { ...state, ...mergeState }; + } + } + return reducer(state, action); + }; + }; + + function createCollection( + entityName: string, + data: T[] + ) { + return { + ...collectionCreator.create(entityName), + ids: data.map(e => e.id), + entities: data.reduce( + (acc, e) => { + acc[e.id] = e; + return acc; + }, + {} as any + ), + } as EntityCollection; + } +} +// #endregion diff --git a/modules/data/spec/reducers/entity-cache-reducer.spec.ts b/modules/data/spec/reducers/entity-cache-reducer.spec.ts new file mode 100644 index 0000000000..f66ec95975 --- /dev/null +++ b/modules/data/spec/reducers/entity-cache-reducer.spec.ts @@ -0,0 +1,703 @@ +import { TestBed } from '@angular/core/testing'; +import { Action, ActionReducer } from '@ngrx/store'; +import { IdSelector } from '@ngrx/entity'; + +import { + EntityMetadataMap, + EntityCollectionCreator, + EntityActionFactory, + EntityCache, + EntityCacheReducerFactory, + EntityCollectionReducerMethodsFactory, + EntityCollectionReducerFactory, + EntityCollectionReducerRegistry, + EntityDefinitionService, + ENTITY_METADATA_TOKEN, + EntityOp, + ClearCollections, + EntityCacheQuerySet, + LoadCollections, + MergeQuerySet, + SetEntityCache, + SaveEntities, + SaveEntitiesCancel, + SaveEntitiesSuccess, + DataServiceError, + SaveEntitiesError, + EntityCollection, + ChangeSet, + ChangeSetOperation, + Logger, + EntityMetadata, +} from '../..'; + +class Hero { + id: number; + name: string; + power?: string; +} +class Villain { + key: string; + name: string; +} + +const metadata: EntityMetadataMap = { + Fool: {}, + Hero: {}, + Knave: {}, + Villain: { selectId: villain => villain.key }, +}; + +describe('EntityCacheReducer', () => { + let collectionCreator: EntityCollectionCreator; + let entityActionFactory: EntityActionFactory; + let entityCacheReducer: ActionReducer; + + beforeEach(() => { + entityActionFactory = new EntityActionFactory(); + const logger = jasmine.createSpyObj('Logger', ['error', 'log', 'warn']); + + TestBed.configureTestingModule({ + providers: [ + EntityCacheReducerFactory, + EntityCollectionCreator, + { + provide: EntityCollectionReducerMethodsFactory, + useClass: EntityCollectionReducerMethodsFactory, + }, + EntityCollectionReducerFactory, + EntityCollectionReducerRegistry, + EntityDefinitionService, + { provide: ENTITY_METADATA_TOKEN, multi: true, useValue: metadata }, + { provide: Logger, useValue: logger }, + ], + }); + + collectionCreator = TestBed.get(EntityCollectionCreator); + const entityCacheReducerFactory = TestBed.get( + EntityCacheReducerFactory + ) as EntityCacheReducerFactory; + entityCacheReducer = entityCacheReducerFactory.create(); + }); + + describe('#create', () => { + it('creates a default hero reducer when QUERY_ALL for hero', () => { + const hero: Hero = { id: 42, name: 'Bobby' }; + const action = entityActionFactory.create( + 'Hero', + EntityOp.ADD_ONE, + hero + ); + + const state = entityCacheReducer({}, action); + const collection = state['Hero']; + expect(collection.ids.length).toBe(1, 'should have added one'); + expect(collection.entities[42]).toEqual(hero, 'should be added hero'); + }); + + it('throws when ask for reducer of unknown entity type', () => { + const action = entityActionFactory.create('Foo', EntityOp.QUERY_ALL); + expect(() => entityCacheReducer({}, action)).toThrowError( + /no EntityDefinition/i + ); + }); + }); + + /** + * Test the EntityCache-level actions, SET and MERGE, which can + * be used to restore the entity cache from a know state such as + * re-hydrating from browser storage. + * Useful for an offline-capable app. + */ + describe('EntityCache-level actions', () => { + let initialHeroes: Hero[]; + let initialCache: EntityCache; + + beforeEach(() => { + initialHeroes = [ + { id: 2, name: 'B', power: 'Fast' }, + { id: 1, name: 'A', power: 'invisible' }, + ]; + initialCache = createInitialCache({ Hero: initialHeroes }); + }); + + describe('CLEAR_COLLECTIONS', () => { + beforeEach(() => { + const heroes = [ + { id: 2, name: 'B', power: 'Fast' }, + { id: 1, name: 'A', power: 'invisible' }, + ]; + const villains = [{ key: 'DE', name: 'Dr. Evil' }]; + const fools = [{ id: 66, name: 'Fool 66' }]; + + initialCache = createInitialCache({ + Hero: heroes, + Villain: villains, + Fool: fools, + }); + }); + + it('should clear an existing cached collection', () => { + const collections = ['Hero']; + const action = new ClearCollections(collections); + const state = entityCacheReducer(initialCache, action); + expect(state['Hero'].ids).toEqual([], 'empty Hero collection'); + expect(state['Fool'].ids.length).toBeGreaterThan(0, 'Fools remain'); + expect(state['Villain'].ids.length).toBeGreaterThan( + 0, + 'Villains remain' + ); + }); + + it('should clear multiple existing cached collections', () => { + const collections = ['Hero', 'Villain']; + const action = new ClearCollections(collections); + const state = entityCacheReducer(initialCache, action); + expect(state['Hero'].ids).toEqual([], 'empty Hero collection'); + expect(state['Villain'].ids).toEqual([], 'empty Villain collection'); + expect(state['Fool'].ids.length).toBeGreaterThan(0, 'Fools remain'); + }); + + it('should initialize an empty cache with the collections', () => { + // because ANY call to a reducer creates the collection! + const collections = ['Hero', 'Villain']; + const action = new ClearCollections(collections); + + const state = entityCacheReducer({}, action); + expect(Object.keys(state)).toEqual( + ['Hero', 'Villain'], + 'created collections' + ); + expect(state['Villain'].ids).toEqual([], 'Hero id'); + expect(state['Hero'].ids).toEqual([], 'Villain ids'); + }); + + it('should return cache matching existing cache for empty collections array', () => { + const collections: string[] = []; + const action = new ClearCollections(collections); + const state = entityCacheReducer(initialCache, action); + expect(state).toEqual(initialCache); + }); + + it('should clear every collection in an existing cache when collections is falsy', () => { + const action = new ClearCollections(undefined); + const state = entityCacheReducer(initialCache, action); + expect(Object.keys(state).sort()).toEqual( + ['Fool', 'Hero', 'Villain'], + 'collections still exist' + ); + expect(state['Fool'].ids).toEqual([], 'no Fool ids'); + expect(state['Hero'].ids).toEqual([], 'no Villain ids'); + expect(state['Villain'].ids).toEqual([], 'no Hero id'); + }); + }); + + describe('LOAD_COLLECTIONS', () => { + function shouldHaveExpectedHeroes(entityCache: EntityCache) { + const heroCollection = entityCache['Hero']; + expect(heroCollection.ids).toEqual([2, 1], 'Hero ids'); + expect(heroCollection.entities).toEqual({ + 1: initialHeroes[1], + 2: initialHeroes[0], + }); + expect(heroCollection.loaded).toBe(true, 'Heroes loaded'); + } + + it('should initialize an empty cache with the collections', () => { + const collections: EntityCacheQuerySet = { + Hero: initialHeroes, + Villain: [{ key: 'DE', name: 'Dr. Evil' }], + }; + + const action = new LoadCollections(collections); + + const state = entityCacheReducer({}, action); + shouldHaveExpectedHeroes(state); + expect(state['Villain'].ids).toEqual(['DE'], 'Villain ids'); + expect(state['Villain'].loaded).toBe(true, 'Villains loaded'); + }); + + it('should return cache matching existing cache when collections set is empty', () => { + const action = new LoadCollections({}); + const state = entityCacheReducer(initialCache, action); + expect(state).toEqual(initialCache); + }); + + it('should add a new collection to existing cache', () => { + const collections: EntityCacheQuerySet = { + Knave: [{ id: 96, name: 'Sneaky Pete' }], + }; + const action = new LoadCollections(collections); + const state = entityCacheReducer(initialCache, action); + expect(state['Knave'].ids).toEqual([96], 'Knave ids'); + expect(state['Knave'].loaded).toBe(true, 'Knave loaded'); + }); + + it('should replace an existing cached collection', () => { + const collections: EntityCacheQuerySet = { + Hero: [{ id: 42, name: 'Bobby' }], + }; + const action = new LoadCollections(collections); + const state = entityCacheReducer(initialCache, action); + const heroCollection = state['Hero']; + expect(heroCollection.ids).toEqual([42], 'only the loaded hero'); + expect(heroCollection.entities[42]).toEqual( + { id: 42, name: 'Bobby' }, + 'loaded hero' + ); + }); + }); + + describe('MERGE_QUERY_SET', () => { + function shouldHaveExpectedHeroes(entityCache: EntityCache) { + expect(entityCache['Hero'].ids).toEqual([2, 1], 'Hero ids'); + expect(entityCache['Hero'].entities).toEqual({ + 1: initialHeroes[1], + 2: initialHeroes[0], + }); + } + + it('should initialize an empty cache with query set', () => { + const querySet: EntityCacheQuerySet = { + Hero: initialHeroes, + Villain: [{ key: 'DE', name: 'Dr. Evil' }], + }; + + const action = new MergeQuerySet(querySet); + + const state = entityCacheReducer({}, action); + shouldHaveExpectedHeroes(state); + expect(state['Villain'].ids).toEqual(['DE'], 'Villain ids'); + }); + + it('should return cache matching existing cache when query set is empty', () => { + const action = new MergeQuerySet({}); + const state = entityCacheReducer(initialCache, action); + shouldHaveExpectedHeroes(state); + }); + + it('should add a new collection to existing cache', () => { + const querySet: EntityCacheQuerySet = { + Villain: [{ key: 'DE', name: 'Dr. Evil' }], + }; + const action = new MergeQuerySet(querySet); + const state = entityCacheReducer(initialCache, action); + shouldHaveExpectedHeroes(state); + expect(state['Villain'].ids).toEqual(['DE'], 'Villain ids'); + }); + + it('should merge into an existing cached collection', () => { + const querySet: EntityCacheQuerySet = { + Hero: [{ id: 42, name: 'Bobby' }], + }; + const action = new MergeQuerySet(querySet); + const state = entityCacheReducer(initialCache, action); + const heroCollection = state['Hero']; + const expectedIds = initialHeroes.map(h => h.id).concat(42); + expect(heroCollection.ids).toEqual(expectedIds, 'merged ids'); + expect(heroCollection.entities[42]).toEqual( + { id: 42, name: 'Bobby' }, + 'merged hero' + ); + }); + }); + + describe('SET_ENTITY_CACHE', () => { + it('should initialize cache', () => { + const cache = createInitialCache({ + Hero: initialHeroes, + Villain: [{ key: 'DE', name: 'Dr. Evil' }], + }); + + const action = new SetEntityCache(cache); + // const action = { // equivalent + // type: SET_ENTITY_CACHE, + // payload: cache + // }; + + const state = entityCacheReducer(cache, action); + expect(state['Hero'].ids).toEqual([2, 1], 'Hero ids'); + expect(state['Hero'].entities).toEqual({ + 1: initialHeroes[1], + 2: initialHeroes[0], + }); + expect(state['Villain'].ids).toEqual(['DE'], 'Villain ids'); + }); + + it('should clear the cache when set with empty object', () => { + const action = new SetEntityCache({}); + const state = entityCacheReducer(initialCache, action); + expect(Object.keys(state)).toEqual([]); + }); + + it('should replace prior cache with new cache', () => { + const priorCache = createInitialCache({ + Hero: initialHeroes, + Villain: [{ key: 'DE', name: 'Dr. Evil' }], + }); + + const newHeroes = [{ id: 42, name: 'Bobby' }]; + const newCache = createInitialCache({ Hero: newHeroes }); + + const action = new SetEntityCache(newCache); + const state = entityCacheReducer(priorCache, action); + expect(state['Villain']).toBeUndefined('No villains'); + + const heroCollection = state['Hero']; + expect(heroCollection.ids).toEqual([42], 'hero ids'); + expect(heroCollection.entities[42]).toEqual(newHeroes[0], 'heroes'); + }); + }); + + describe('SAVE_ENTITIES', () => { + it('should turn on loading flags for affected collections and nothing more when pessimistic', () => { + const changeSet = createTestChangeSet(); + const action = new SaveEntities(changeSet, 'api/save', { + isOptimistic: false, + }); + + const entityCache = entityCacheReducer({}, action); + + expect(entityCache['Fool'].ids).toEqual([], 'Fool ids'); + expect(entityCache['Hero'].ids).toEqual([], 'Hero ids'); + expect(entityCache['Knave'].ids).toEqual([], 'Knave ids'); + expect(entityCache['Villain'].ids).toEqual([], 'Villain ids'); + expectLoadingFlags(entityCache, true); + }); + + it('should initialize an empty cache with entities when optimistic and turn on loading flags', () => { + const changeSet = createTestChangeSet(); + const action = new SaveEntities(changeSet, 'api/save', { + isOptimistic: true, + }); + + const entityCache = entityCacheReducer({}, action); + + expect(entityCache['Fool'].ids).toEqual([], 'Fool ids'); + expect(entityCache['Hero'].ids).toEqual([42, 43], 'added Hero ids'); + expect(entityCache['Knave'].ids).toEqual([6, 66], 'Knave ids'); + expect(entityCache['Villain'].ids).toEqual( + ['44', '45'], + 'added Villain ids' + ); + expectLoadingFlags(entityCache, true); + }); + + it('should modify existing cache with entities when optimistic and turn on loading flags', () => { + const initialEntities = createInitialSaveTestEntities(); + let entityCache = createInitialCache(initialEntities); + + const changeSet = createTestChangeSet(); + const action = new SaveEntities(changeSet, 'api/save', { + isOptimistic: true, + }); + + entityCache = entityCacheReducer(entityCache, action); + + expect(entityCache['Fool'].ids).toEqual([1, 2], 'Fool ids'); + expect(entityCache['Fool'].entities[1].skill).toEqual( + 'Updated Skill 1' + ); + + expect(entityCache['Hero'].ids).toEqual( + [4, 42, 43], + 'Hero ids - 2 new, {3,5} deleted' + ); + + expect(entityCache['Knave'].ids).toEqual([6, 66], 'Knave ids'); + expect(entityCache['Knave'].entities[6].name).toEqual( + 'Upsert Update Knave 6' + ); + expect(entityCache['Knave'].entities[66].name).toEqual( + 'Upsert Add Knave 66' + ); + + expect(entityCache['Villain'].ids).toEqual( + ['7', '8', '10', '44', '45'], + 'Villain ids' + ); + + expectLoadingFlags(entityCache, true); + }); + }); + + describe('SAVE_ENTITIES_CANCEL', () => { + const corid = 'CORID42'; + + it('should not turn off loading flags if you do not specify collections', () => { + const changeSet = createTestChangeSet(); + let action: Action = new SaveEntities(changeSet, 'api/save', { + correlationId: corid, + isOptimistic: false, + }); + + // Pessimistic save turns on loading flags + let entityCache = entityCacheReducer({}, action); + expectLoadingFlags(entityCache, true); + + action = new SaveEntitiesCancel(corid, 'Test Cancel'); // no names so no flags turned off. + entityCache = entityCacheReducer(entityCache, action); + expectLoadingFlags(entityCache, true); + }); + + it('should turn off loading flags for collections that you specify', () => { + const changeSet = createTestChangeSet(); + let action: Action = new SaveEntities(changeSet, 'api/save', { + correlationId: corid, + isOptimistic: false, + }); + + // Pessimistic save turns on loading flags + let entityCache = entityCacheReducer({}, action); + expectLoadingFlags(entityCache, true); + + action = new SaveEntitiesCancel(corid, 'Test Cancel', ['Hero', 'Fool']); + entityCache = entityCacheReducer(entityCache, action); + expectLoadingFlags(entityCache, false, ['Hero', 'Fool']); + expectLoadingFlags(entityCache, true, ['Knave', 'Villain']); + }); + }); + + describe('SAVE_ENTITIES_SUCCESS', () => { + it('should initialize an empty cache with entities when pessimistic', () => { + const changeSet = createTestChangeSet(); + const action = new SaveEntitiesSuccess(changeSet, 'api/save', { + isOptimistic: false, + }); + + const entityCache = entityCacheReducer({}, action); + + expect(entityCache['Fool'].ids).toEqual([], 'Fool ids'); + expect(entityCache['Hero'].ids).toEqual([42, 43], 'added Hero ids'); + expect(entityCache['Knave'].ids).toEqual([6, 66], 'Knave ids'); + expect(entityCache['Villain'].ids).toEqual( + ['44', '45'], + 'added Villain ids' + ); + expectLoadingFlags(entityCache, false); + }); + + it('should modify existing cache with entities when pessimistic', () => { + const initialEntities = createInitialSaveTestEntities(); + let entityCache = createInitialCache(initialEntities); + + const changeSet = createTestChangeSet(); + const action = new SaveEntitiesSuccess(changeSet, 'api/save', { + isOptimistic: false, + }); + + entityCache = entityCacheReducer(entityCache, action); + + expect(entityCache['Fool'].ids).toEqual([1, 2], 'Fool ids'); + expect(entityCache['Fool'].entities[1].skill).toEqual( + 'Updated Skill 1' + ); + + expect(entityCache['Hero'].ids).toEqual( + [4, 42, 43], + 'Hero ids - 2 new, {3,5} deleted' + ); + + expect(entityCache['Knave'].ids).toEqual([6, 66], 'Knave ids'); + expect(entityCache['Knave'].entities[6].name).toEqual( + 'Upsert Update Knave 6' + ); + expect(entityCache['Knave'].entities[66].name).toEqual( + 'Upsert Add Knave 66' + ); + + expect(entityCache['Villain'].ids).toEqual( + ['7', '8', '10', '44', '45'], + 'Villain ids' + ); + + expectLoadingFlags(entityCache, false); + }); + + it('should modify existing cache with entities when optimistic', () => { + const initialEntities = createInitialSaveTestEntities(); + let entityCache = createInitialCache(initialEntities); + + const changeSet = createTestChangeSet(); + const action = new SaveEntitiesSuccess(changeSet, 'api/save', { + isOptimistic: true, + }); + + entityCache = entityCacheReducer(entityCache, action); + + expect(entityCache['Fool'].ids).toEqual([1, 2], 'Fool ids'); + expect(entityCache['Fool'].entities[1].skill).toEqual( + 'Updated Skill 1' + ); + + expect(entityCache['Hero'].ids).toEqual( + [4, 42, 43], + 'Hero ids - 2 new, {3,5} deleted' + ); + + expect(entityCache['Knave'].ids).toEqual([6, 66], 'Knave ids'); + expect(entityCache['Knave'].entities[6].name).toEqual( + 'Upsert Update Knave 6' + ); + expect(entityCache['Knave'].entities[66].name).toEqual( + 'Upsert Add Knave 66' + ); + + expect(entityCache['Villain'].ids).toEqual( + ['7', '8', '10', '44', '45'], + 'Villain ids' + ); + + expectLoadingFlags(entityCache, false); + }); + }); + + describe('SAVE_ENTITIES_ERROR', () => { + it('should turn loading flags off', () => { + // Begin as if saving optimistically + const changeSet = createTestChangeSet(); + const saveAction = new SaveEntities(changeSet, 'api/save', { + isOptimistic: true, + }); + let entityCache = entityCacheReducer({}, saveAction); + + expectLoadingFlags(entityCache, true); + + const dsError = new DataServiceError(new Error('Test Error'), { + url: 'api/save', + } as any); + const errorAction = new SaveEntitiesError(dsError, saveAction); + entityCache = entityCacheReducer(entityCache, errorAction); + + expectLoadingFlags(entityCache, false); + + // Added entities remain in cache (if not on the server), with pending changeState + expect(entityCache['Hero'].ids).toEqual([42, 43], 'added Hero ids'); + const heroChangeState = entityCache['Hero'].changeState; + expect(heroChangeState[42]).toBeDefined('Hero [42] has changeState'); + expect(heroChangeState[43]).toBeDefined('Hero [43] has changeState'); + }); + }); + }); + + // #region helpers + function createCollection( + entityName: string, + data: T[], + selectId: IdSelector + ) { + return { + ...collectionCreator.create(entityName), + ids: data.map(e => selectId(e)) as string[] | number[], + entities: data.reduce( + (acc, e) => { + acc[selectId(e)] = e; + return acc; + }, + {} as any + ), + } as EntityCollection; + } + + function createInitialCache(entityMap: { [entityName: string]: any[] }) { + const cache: EntityCache = {}; + // tslint:disable-next-line:forin + for (const entityName in entityMap) { + const selectId = + metadata[entityName].selectId || ((entity: any) => entity.id); + cache[entityName] = createCollection( + entityName, + entityMap[entityName], + selectId + ); + } + + return cache; + } + + function createInitialSaveTestEntities() { + const entities: { [entityName: string]: any[] } = { + Fool: [ + { id: 1, name: 'Fool 1', skill: 'Skill 1' }, + { id: 2, name: 'Fool 2', skill: 'Skill 2' }, + ], + Hero: [ + { id: 3, name: 'Hero 3', power: 'Power 3' }, + { id: 4, name: 'Hero 4', power: 'Power 4' }, + { id: 5, name: 'Hero 5', power: 'Power 5' }, + ], + Knave: [{ id: 6, name: 'Knave 1', weakness: 'Weakness 6' }], + Villain: [ + { key: '7', name: 'Villain 7', sin: 'Sin 7' }, + { key: '8', name: 'Villain 8', sin: 'Sin 8' }, + { key: '9', name: 'Villain 9', sin: 'Sin 9' }, + { key: '10', name: 'Villain 10', sin: 'Sin 10' }, + ], + }; + return entities; + } + + function createTestChangeSet() { + const changeSet: ChangeSet = { + changes: [ + { + entityName: 'Hero', + op: ChangeSetOperation.Add, + entities: [ + { id: 42, name: 'Hero 42' }, + { id: 43, name: 'Hero 43', power: 'Power 43' }, + ] as Hero[], + }, + { entityName: 'Hero', op: ChangeSetOperation.Delete, entities: [3, 5] }, + { + entityName: 'Villain', + op: ChangeSetOperation.Delete, + entities: ['9'], + }, + { + entityName: 'Villain', + op: ChangeSetOperation.Add, + entities: [ + { key: '44', name: 'Villain 44' }, + { key: '45', name: 'Villain 45', sin: 'Sin 45' }, + ] as Villain[], + }, + { + entityName: 'Fool', + op: ChangeSetOperation.Update, + entities: [{ id: 1, changes: { id: 1, skill: 'Updated Skill 1' } }], + }, + { + entityName: 'Knave', + op: ChangeSetOperation.Upsert, + entities: [ + { id: 6, name: 'Upsert Update Knave 6' }, + { id: 66, name: 'Upsert Add Knave 66' }, + ], + }, + ], + }; + return changeSet; + } + + /** + * Expect the loading flags of the named EntityCache collections to be in the `flag` state. + * @param entityCache cache to check + * @param flag True if should be loading; false if should not be loading + * @param entityNames names of collections to check; if undefined, check all collections + */ + function expectLoadingFlags( + entityCache: EntityCache, + flag: boolean, + entityNames?: string[] + ) { + entityNames = entityNames ? [] : Object.keys(entityCache); + entityNames.forEach(name => { + expect(entityCache[name].loading).toBe( + flag, + `${name}${flag ? '' : ' not'} loading` + ); + }); + } + // #endregion helpers +}); diff --git a/modules/data/spec/reducers/entity-change-tracker-base.spec.ts b/modules/data/spec/reducers/entity-change-tracker-base.spec.ts new file mode 100644 index 0000000000..4ce76ee004 --- /dev/null +++ b/modules/data/spec/reducers/entity-change-tracker-base.spec.ts @@ -0,0 +1,806 @@ +import { EntityAdapter, createEntityAdapter } from '@ngrx/entity'; +import { + EntityCollection, + EntityChangeTracker, + createEmptyEntityCollection, + EntityChangeTrackerBase, + defaultSelectId, + ChangeType, + ChangeState, + MergeStrategy, +} from '../..'; + +interface Hero { + id: number; + name: string; + power?: string; +} + +function sortByName(a: { name: string }, b: { name: string }): number { + return a.name.localeCompare(b.name); +} + +/** Test version of toUpdate that assumes entity has key named 'id' */ +function toUpdate(entity: any) { + return { id: entity.id, changes: entity }; +} + +const adapter: EntityAdapter = createEntityAdapter({ + sortComparer: sortByName, +}); + +describe('EntityChangeTrackerBase', () => { + let origCollection: EntityCollection; + let tracker: EntityChangeTracker; + + beforeEach(() => { + origCollection = createEmptyEntityCollection('Hero'); + origCollection.entities = { + 1: { id: 1, name: 'Alice', power: 'Strong' }, + 2: { id: 2, name: 'Gail', power: 'Loud' }, + 7: { id: 7, name: 'Bob', power: 'Swift' }, + }; + origCollection.ids = [1, 7, 2]; + tracker = new EntityChangeTrackerBase(adapter, defaultSelectId); + }); + + describe('#commitAll', () => { + it('should clear all tracked changes', () => { + let { collection } = createTestTrackedEntities(); + expect(Object.keys(collection.changeState).length).toBe( + 3, + 'tracking 3 entities' + ); + + collection = tracker.commitAll(collection); + expect(Object.keys(collection.changeState).length).toBe( + 0, + 'tracking zero entities' + ); + }); + }); + + describe('#commitOne', () => { + it('should clear current tracking of the given entity', () => { + // tslint:disable-next-line:prefer-const + let { + collection, + deletedEntity, + addedEntity, + updatedEntity, + } = createTestTrackedEntities(); + collection = tracker.commitMany([updatedEntity], collection); + expect(collection.changeState[updatedEntity.id]).toBeUndefined( + 'no changes tracked for updated entity' + ); + expect(collection.changeState[deletedEntity.id]).toBeDefined( + 'still tracking deleted entity' + ); + expect(collection.changeState[addedEntity.id]).toBeDefined( + 'still tracking added entity' + ); + }); + }); + + describe('#commitMany', () => { + it('should clear current tracking of the given entities', () => { + // tslint:disable-next-line:prefer-const + let { + collection, + deletedEntity, + addedEntity, + updatedEntity, + } = createTestTrackedEntities(); + collection = tracker.commitMany([addedEntity, updatedEntity], collection); + expect(collection.changeState[addedEntity.id]).toBeUndefined( + 'no changes tracked for added entity' + ); + expect(collection.changeState[updatedEntity.id]).toBeUndefined( + 'no changes tracked for updated entity' + ); + expect(collection.changeState[deletedEntity.id]).toBeDefined( + 'still tracking deleted entity' + ); + }); + }); + + describe('#trackAddOne', () => { + it('should return a new collection with tracked new entity', () => { + const addedEntity = { id: 42, name: 'Ted', power: 'Chatty' }; + const collection = tracker.trackAddOne(addedEntity, origCollection); + + expect(collection).not.toBe(origCollection); + const change = collection.changeState[addedEntity.id]; + expect(change).toBeDefined('tracking the entity'); + expectChangeType(change, ChangeType.Added); + expect(change.originalValue).toBeUndefined( + 'no original value for a new entity' + ); + }); + + it('should leave added entity tracked as added when entity is updated', () => { + const addedEntity = { id: 42, name: 'Ted', power: 'Chatty' }; + let collection = tracker.trackAddOne(addedEntity, origCollection); + + const updatedEntity = { ...addedEntity, name: 'Double Test' }; + collection = tracker.trackUpdateOne(toUpdate(updatedEntity), collection); + // simulate the collection update + collection.entities[addedEntity.id] = updatedEntity; + + const change = collection.changeState[updatedEntity.id]; + expect(change).toBeDefined('is still tracked as an added entity'); + expectChangeType(change, ChangeType.Added); + expect(change.originalValue).toBeUndefined( + 'still no original value for added entity' + ); + }); + + it('should return same collection if called with null entity', () => { + const collection = tracker.trackAddOne(null as any, origCollection); + expect(collection).toBe(origCollection); + }); + + it('should return the same collection if MergeStrategy.IgnoreChanges', () => { + const addedEntity = { id: 42, name: 'Ted', power: 'Chatty' }; + const collection = tracker.trackAddOne( + addedEntity, + origCollection, + MergeStrategy.IgnoreChanges + ); + + expect(collection).toBe(origCollection); + const change = collection.changeState[addedEntity.id]; + expect(change).toBeUndefined('not tracking the entity'); + }); + }); + + describe('#trackAddMany', () => { + const newEntities = [ + { id: 42, name: 'Ted', power: 'Chatty' }, + { id: 84, name: 'Sally', power: 'Laughter' }, + ]; + + it('should return a new collection with tracked new entities', () => { + const collection = tracker.trackAddMany(newEntities, origCollection); + expect(collection).not.toBe(origCollection); + const trackKeys = Object.keys(collection.changeState); + expect(trackKeys).toEqual(['42', '84'], 'tracking new entities'); + + trackKeys.forEach((key, ix) => { + const change = collection.changeState[key]; + expect(change).toBeDefined(`tracking the entity ${key}`); + expectChangeType( + change, + ChangeType.Added, + `tracking ${key} as a new entity` + ); + expect(change.originalValue).toBeUndefined( + `no original value for new entity ${key}` + ); + }); + }); + + it('should return same collection if called with empty array', () => { + const collection = tracker.trackAddMany([] as any, origCollection); + expect(collection).toBe(origCollection); + }); + }); + + describe('#trackDeleteOne', () => { + it('should return a new collection with tracked "deleted" entity', () => { + const existingEntity = getFirstExistingEntity(); + const collection = tracker.trackDeleteOne( + existingEntity.id, + origCollection + ); + expect(collection).not.toBe(origCollection); + const change = collection.changeState[existingEntity.id]; + expect(change).toBeDefined('tracking the entity'); + expectChangeType(change, ChangeType.Deleted); + expect(change.originalValue).toBe( + existingEntity, + 'originalValue is the existing entity' + ); + }); + + it('should return a new collection with tracked "deleted" entity, deleted by key', () => { + const existingEntity = getFirstExistingEntity(); + const collection = tracker.trackDeleteOne( + existingEntity.id, + origCollection + ); + expect(collection).not.toBe(origCollection); + const change = collection.changeState[existingEntity.id]; + expect(change).toBeDefined('tracking the entity'); + expectChangeType(change, ChangeType.Deleted); + expect(change.originalValue).toBe( + existingEntity, + 'originalValue is the existing entity' + ); + }); + + it('should untrack (commit) an added entity when it is removed', () => { + const addedEntity = { id: 42, name: 'Ted', power: 'Chatty' }; + let collection = tracker.trackAddOne(addedEntity, origCollection); + + // Add it to the collection as the reducer would + collection = { + ...collection, + entities: { ...collection.entities, 42: addedEntity }, + ids: (collection.ids as number[]).concat(42), + }; + + let change = collection.changeState[addedEntity.id]; + expect(change).toBeDefined('tracking the new entity'); + + collection = tracker.trackDeleteOne(addedEntity.id, collection); + change = collection.changeState[addedEntity.id]; + expect(change).not.toBeDefined('is no longer tracking the new entity'); + }); + + it('should switch an updated entity to a deleted entity when it is removed', () => { + const existingEntity = getFirstExistingEntity(); + const updatedEntity = toUpdate({ + ...existingEntity, + name: 'test update', + }); + + let collection = tracker.trackUpdateOne( + toUpdate(updatedEntity), + origCollection + ); + + let change = collection.changeState[updatedEntity.id]; + expect(change).toBeDefined('tracking the updated existing entity'); + expectChangeType(change, ChangeType.Updated, 'updated at first'); + + collection = tracker.trackDeleteOne(updatedEntity.id, collection); + change = collection.changeState[updatedEntity.id]; + expect(change).toBeDefined('tracking the deleted, updated entity'); + expectChangeType(change, ChangeType.Deleted, 'after delete'); + expect(change.originalValue).toEqual( + existingEntity, + 'tracking original value' + ); + }); + + it('should leave deleted entity tracked as deleted when try to update', () => { + const existingEntity = getFirstExistingEntity(); + let collection = tracker.trackDeleteOne( + existingEntity.id, + origCollection + ); + + let change = collection.changeState[existingEntity.id]; + expect(change).toBeDefined('tracking the deleted entity'); + expectChangeType(change, ChangeType.Deleted); + + // This shouldn't be possible but let's try it. + const updatedEntity = { ...existingEntity, name: 'Double Test' }; + collection.entities[existingEntity.id] = updatedEntity; + + collection = tracker.trackUpdateOne(toUpdate(updatedEntity), collection); + change = collection.changeState[updatedEntity.id]; + expect(change).toBeDefined('is still tracked as a deleted entity'); + expectChangeType(change, ChangeType.Deleted); + expect(change.originalValue).toEqual( + existingEntity, + 'still tracking original value' + ); + }); + + it('should return same collection if called with null entity', () => { + const collection = tracker.trackDeleteOne(null as any, origCollection); + expect(collection).toBe(origCollection); + }); + + it('should return same collection if called with a key not found', () => { + const collection = tracker.trackDeleteOne('1234', origCollection); + expect(collection).toBe(origCollection); + }); + + it('should return same collection if MergeStrategy.IgnoreChanges', () => { + const existingEntity = getFirstExistingEntity(); + const collection = tracker.trackDeleteOne( + existingEntity.id, + origCollection, + MergeStrategy.IgnoreChanges + ); + expect(collection).toBe(origCollection); + const change = collection.changeState[existingEntity.id]; + expect(change).toBeUndefined('not tracking the entity'); + }); + }); + + describe('#trackDeleteMany', () => { + it('should return a new collection with tracked "deleted" entities', () => { + const existingEntities = getSomeExistingEntities(2); + const collection = tracker.trackDeleteMany( + existingEntities.map(e => e.id), + origCollection + ); + expect(collection).not.toBe(origCollection); + existingEntities.forEach((entity, ix) => { + const change = collection.changeState[existingEntities[ix].id]; + expect(change).toBeDefined(`tracking entity #${ix}`); + expectChangeType(change, ChangeType.Deleted, `entity #${ix}`); + expect(change.originalValue).toBe( + existingEntities[ix], + `entity #${ix} originalValue` + ); + }); + }); + + it('should return same collection if called with empty array', () => { + const collection = tracker.trackDeleteMany([], origCollection); + expect(collection).toBe(origCollection); + }); + + it('should return same collection if called with a key not found', () => { + const collection = tracker.trackDeleteMany(['1234', 456], origCollection); + expect(collection).toBe(origCollection); + }); + }); + + describe('#trackUpdateOne', () => { + it('should return a new collection with tracked updated entity', () => { + const existingEntity = getFirstExistingEntity(); + const updatedEntity = toUpdate({ + ...existingEntity, + name: 'test update', + }); + const collection = tracker.trackUpdateOne(updatedEntity, origCollection); + expect(collection).not.toBe(origCollection); + const change = collection.changeState[existingEntity.id]; + expect(change).toBeDefined('tracking the entity'); + expectChangeType(change, ChangeType.Updated); + expect(change.originalValue).toBe( + existingEntity, + 'originalValue is the existing entity' + ); + }); + + it('should return a new collection with tracked updated entity, updated by key', () => { + const existingEntity = getFirstExistingEntity(); + const updatedEntity = toUpdate({ + ...existingEntity, + name: 'test update', + }); + const collection = tracker.trackUpdateOne(updatedEntity, origCollection); + expect(collection).not.toBe(origCollection); + const change = collection.changeState[existingEntity.id]; + expect(change).toBeDefined('tracking the entity'); + expectChangeType(change, ChangeType.Updated); + expect(change.originalValue).toBe( + existingEntity, + 'originalValue is the existing entity' + ); + }); + + it('should leave updated entity tracked as updated if try to add', () => { + const existingEntity = getFirstExistingEntity(); + const updatedEntity = toUpdate({ + ...existingEntity, + name: 'test update', + }); + let collection = tracker.trackUpdateOne(updatedEntity, origCollection); + + let change = collection.changeState[existingEntity.id]; + expect(change).toBeDefined('tracking the updated entity'); + expectChangeType(change, ChangeType.Updated); + + // This shouldn't be possible but let's try it. + const addedEntity = { ...existingEntity, name: 'Double Test' }; + collection.entities[existingEntity.id] = addedEntity; + + collection = tracker.trackAddOne(addedEntity, collection); + change = collection.changeState[addedEntity.id]; + expect(change).toBeDefined('is still tracked as an updated entity'); + expectChangeType(change, ChangeType.Updated); + expect(change.originalValue).toEqual( + existingEntity, + 'still tracking original value' + ); + }); + + it('should return same collection if called with null entity', () => { + const collection = tracker.trackUpdateOne(null as any, origCollection); + expect(collection).toBe(origCollection); + }); + + it('should return same collection if called with a key not found', () => { + const updateEntity = toUpdate({ id: '1234', name: 'Mr. 404' }); + const collection = tracker.trackUpdateOne(updateEntity, origCollection); + expect(collection).toBe(origCollection); + }); + + it('should return same collection if MergeStrategy.IgnoreChanges', () => { + const existingEntity = getFirstExistingEntity(); + const updatedEntity = toUpdate({ + ...existingEntity, + name: 'test update', + }); + const collection = tracker.trackUpdateOne( + updatedEntity, + origCollection, + MergeStrategy.IgnoreChanges + ); + expect(collection).toBe(origCollection); + const change = collection.changeState[existingEntity.id]; + expect(change).toBeUndefined('not tracking the entity'); + }); + }); + + describe('#trackUpdateMany', () => { + it('should return a new collection with tracked updated entities', () => { + const existingEntities = getSomeExistingEntities(2); + const updateEntities = existingEntities.map(e => + toUpdate({ ...e, name: e.name + ' updated' }) + ); + const collection = tracker.trackUpdateMany( + updateEntities, + origCollection + ); + expect(collection).not.toBe(origCollection); + existingEntities.forEach((entity, ix) => { + const change = collection.changeState[existingEntities[ix].id]; + expect(change).toBeDefined(`tracking entity #${ix}`); + expectChangeType(change, ChangeType.Updated, `entity #${ix}`); + expect(change.originalValue).toBe( + existingEntities[ix], + `entity #${ix} originalValue` + ); + }); + }); + + it('should return same collection if called with empty array', () => { + const collection = tracker.trackUpdateMany([], origCollection); + expect(collection).toBe(origCollection); + }); + + it('should return same collection if called with entities whose keys are not found', () => { + const updateEntities = [ + toUpdate({ id: '1234', name: 'Mr. 404' }), + toUpdate({ id: 456, name: 'Ms. 404' }), + ]; + const collection = tracker.trackUpdateMany( + updateEntities, + origCollection + ); + expect(collection).toBe(origCollection); + }); + }); + + describe('#trackUpsertOne', () => { + it('should return a new collection with tracked added entity', () => { + const addedEntity = { id: 42, name: 'Ted', power: 'Chatty' }; + const collection = tracker.trackUpsertOne(addedEntity, origCollection); + expect(collection).not.toBe(origCollection); + const change = collection.changeState[addedEntity.id]; + expect(change).toBeDefined('tracking the entity'); + expectChangeType(change, ChangeType.Added); + expect(change.originalValue).toBeUndefined( + 'no originalValue for added entity' + ); + }); + + it('should return a new collection with tracked updated entity', () => { + const existingEntity = getFirstExistingEntity(); + const collection = tracker.trackUpsertOne(existingEntity, origCollection); + expect(collection).not.toBe(origCollection); + const change = collection.changeState[existingEntity.id]; + expect(change).toBeDefined('tracking the entity'); + expectChangeType(change, ChangeType.Updated); + expect(change.originalValue).toBe( + existingEntity, + 'originalValue is the existing entity' + ); + }); + + it('should not change orig value of updated entity that is updated again', () => { + const existingEntity = getFirstExistingEntity(); + let collection = tracker.trackUpsertOne(existingEntity, origCollection); + + let change = collection.changeState[existingEntity.id]; + expect(change).toBeDefined('tracking the updated entity'); + expectChangeType(change, ChangeType.Updated, 'first updated'); + + const updatedAgainEntity = { ...existingEntity, name: 'Double Test' }; + + collection = tracker.trackUpsertOne(updatedAgainEntity, collection); + change = collection.changeState[updatedAgainEntity.id]; + expect(change).toBeDefined('is still tracked as an updated entity'); + expectChangeType( + change, + ChangeType.Updated, + 'still updated after attempted add' + ); + expect(change.originalValue).toEqual( + existingEntity, + 'still tracking original value' + ); + }); + + it('should return same collection if called with null entity', () => { + const collection = tracker.trackUpsertOne(null as any, origCollection); + expect(collection).toBe(origCollection); + }); + + it('should return same collection if MergeStrategy.IgnoreChanges', () => { + const existingEntity = getFirstExistingEntity(); + const updatedEntity = { ...existingEntity, name: 'test update' }; + const collection = tracker.trackUpsertOne( + updatedEntity, + origCollection, + MergeStrategy.IgnoreChanges + ); + expect(collection).toBe(origCollection); + const change = collection.changeState[existingEntity.id]; + expect(change).toBeUndefined('not tracking the entity'); + }); + }); + + describe('#trackUpsertMany', () => { + it('should return a new collection with tracked upserted entities', () => { + const addedEntity = { id: 42, name: 'Ted', power: 'Chatty' }; + const exitingEntities = getSomeExistingEntities(2); + const updatedEntities = exitingEntities.map(e => ({ + ...e, + name: e.name + 'test', + })); + const upsertEntities = updatedEntities.concat(addedEntity); + const collection = tracker.trackUpsertMany( + upsertEntities, + origCollection + ); + expect(collection).not.toBe(origCollection); + updatedEntities.forEach((entity, ix) => { + const change = collection.changeState[updatedEntities[ix].id]; + expect(change).toBeDefined(`tracking entity #${ix}`); + // first two should be updated, the 3rd is added + expectChangeType( + change, + ix === 2 ? ChangeType.Added : ChangeType.Updated, + `entity #${ix}` + ); + if (change.changeType === ChangeType.Updated) { + expect(change.originalValue).toBe( + exitingEntities[ix], + `entity #${ix} originalValue` + ); + } else { + expect(change.originalValue).toBeUndefined( + `no originalValue for added entity #${ix}` + ); + } + }); + }); + + it('should return same collection if called with empty array', () => { + const collection = tracker.trackUpsertMany([], origCollection); + expect(collection).toBe(origCollection); + }); + }); + + describe('#undoAll', () => { + it('should clear all tracked changes', () => { + let { collection } = createTestTrackedEntities(); + expect(Object.keys(collection.changeState).length).toBe( + 3, + 'tracking 3 entities' + ); + + collection = tracker.undoAll(collection); + expect(Object.keys(collection.changeState).length).toBe( + 0, + 'tracking zero entities' + ); + }); + + it('should restore the collection to the pre-change state', () => { + // tslint:disable-next-line:prefer-const + let { + collection, + addedEntity, + deletedEntity, + preUpdatedEntity, + updatedEntity, + } = createTestTrackedEntities(); + + // Before undo + expect(collection.entities[addedEntity.id]).toBeDefined( + 'added entity should be present' + ); + expect(collection.entities[deletedEntity.id]).toBeUndefined( + 'deleted entity should be missing' + ); + expect(updatedEntity.name).not.toEqual( + preUpdatedEntity.name, + 'updated entity should be changed' + ); + + collection = tracker.undoAll(collection); + + // After undo + expect(collection.entities[addedEntity.id]).toBeUndefined( + 'added entity should be removed' + ); + expect(collection.entities[deletedEntity.id]).toBeDefined( + 'deleted entity should be restored' + ); + const revertedUpdate = collection.entities[updatedEntity.id]; + expect(revertedUpdate.name).toEqual( + preUpdatedEntity.name, + 'updated entity should be restored' + ); + }); + }); + + describe('#undoOne', () => { + it('should restore the collection to the pre-change state for the given entity', () => { + // tslint:disable-next-line:prefer-const + let { + collection, + addedEntity, + deletedEntity, + preUpdatedEntity, + updatedEntity, + } = createTestTrackedEntities(); + + collection = tracker.undoOne(deletedEntity, collection); + + expect(collection.entities[deletedEntity.id]).toBeDefined( + 'deleted entity should be restored' + ); + expect(collection.entities[addedEntity.id]).toBeDefined( + 'added entity should still be present' + ); + expect(updatedEntity.name).not.toEqual( + preUpdatedEntity.name, + 'updated entity should be changed' + ); + }); + + it('should do nothing when the given entity is null', () => { + // tslint:disable-next-line:prefer-const + let { + collection, + addedEntity, + deletedEntity, + preUpdatedEntity, + updatedEntity, + } = createTestTrackedEntities(); + + collection = tracker.undoOne(null as any, collection); + expect(collection.entities[addedEntity.id]).toBeDefined( + 'added entity should be present' + ); + expect(collection.entities[deletedEntity.id]).toBeUndefined( + 'deleted entity should be missing' + ); + expect(updatedEntity.name).not.toEqual( + preUpdatedEntity.name, + 'updated entity should be changed' + ); + }); + }); + + describe('#undoMany', () => { + it('should restore the collection to the pre-change state for the given entities', () => { + // tslint:disable-next-line:prefer-const + let { + collection, + addedEntity, + deletedEntity, + preUpdatedEntity, + updatedEntity, + } = createTestTrackedEntities(); + + collection = tracker.undoMany( + [addedEntity, deletedEntity, updatedEntity], + collection + ); + expect(collection.entities[addedEntity.id]).toBeUndefined( + 'added entity should be removed' + ); + expect(collection.entities[deletedEntity.id]).toBeDefined( + 'deleted entity should be restored' + ); + const revertedUpdate = collection.entities[updatedEntity.id]; + expect(revertedUpdate.name).toEqual( + preUpdatedEntity.name, + 'updated entity should be restored' + ); + }); + + it('should do nothing when there are no entities to undo', () => { + // tslint:disable-next-line:prefer-const + let { + collection, + addedEntity, + deletedEntity, + preUpdatedEntity, + updatedEntity, + } = createTestTrackedEntities(); + + collection = tracker.undoMany([], collection); + expect(collection.entities[addedEntity.id]).toBeDefined( + 'added entity should be present' + ); + expect(collection.entities[deletedEntity.id]).toBeUndefined( + 'deleted entity should be missing' + ); + expect(updatedEntity.name).not.toEqual( + preUpdatedEntity.name, + 'updated entity should be changed' + ); + }); + }); + + /// helpers /// + + /** Simulate the state of the collection after some test changes */ + function createTestTrackedEntities() { + const addedEntity = { id: 42, name: 'Ted', power: 'Chatty' }; + const [deletedEntity, preUpdatedEntity] = getSomeExistingEntities(2); + const updatedEntity = { ...preUpdatedEntity, name: 'Test Me' }; + + let collection = tracker.trackAddOne(addedEntity, origCollection); + collection = tracker.trackDeleteOne(deletedEntity.id, collection); + collection = tracker.trackUpdateOne(toUpdate(updatedEntity), collection); + + // Make the collection match these changes + collection.ids = (collection.ids.slice( + 1, + collection.ids.length + ) as number[]).concat(42); + const entities: { [id: number]: Hero } = { + ...collection.entities, + 42: addedEntity, + [updatedEntity.id]: updatedEntity, + }; + delete entities[deletedEntity.id]; + collection.entities = entities; + return { + collection, + addedEntity, + deletedEntity, + preUpdatedEntity, + updatedEntity, + }; + } + + /** Test for ChangeState with expected ChangeType */ + function expectChangeType( + change: ChangeState, + expectedChangeType: ChangeType, + msg?: string + ) { + expect(ChangeType[change.changeType]).toEqual( + ChangeType[expectedChangeType], + msg + ); + } + + /** Get the first entity in `originalCollection` */ + function getFirstExistingEntity() { + return getExistingEntityById(origCollection.ids[0]); + } + + /** + * Get the first 'n' existing entities from `originalCollection` + * @param n Number of them to get + */ + function getSomeExistingEntities(n: number) { + const ids = (origCollection.ids as string[]).slice(0, n); + return getExistingEntitiesById(ids); + } + + function getExistingEntityById(id: number | string) { + return getExistingEntitiesById([id as string])[0]; + } + + function getExistingEntitiesById(ids: string[]) { + return ids.map(id => origCollection.entities[id]); + } +}); diff --git a/modules/data/spec/reducers/entity-collection-creator.spec.ts b/modules/data/spec/reducers/entity-collection-creator.spec.ts new file mode 100644 index 0000000000..a59eaff58f --- /dev/null +++ b/modules/data/spec/reducers/entity-collection-creator.spec.ts @@ -0,0 +1,63 @@ +import { + EntityMetadata, + EntityCollectionCreator, + EntityDefinitionService, + createEntityDefinition, + EntityCollection, +} from '../..'; + +/** HeroMetadata identifies extra collection state properties */ +const heroMetadata: EntityMetadata = { + entityName: 'Hero', + additionalCollectionState: { + foo: 'Foo', + bar: 3.14, + }, +}; + +describe('EntityCollectionCreator', () => { + let creator: EntityCollectionCreator; + let eds: EntityDefinitionService; + + beforeEach(() => { + eds = new EntityDefinitionService(null as any); + const hdef = createEntityDefinition(heroMetadata); + hdef.initialState.filter = 'super'; + eds.registerDefinition(hdef); + + creator = new EntityCollectionCreator(eds); + }); + + it('should create collection with the definitions initial state', () => { + const collection = creator.create('Hero'); + expect(collection.foo).toBe('Foo'); + expect(collection.filter).toBe('super'); + }); + + it('should create empty collection even when no initial state', () => { + const hdef = eds.getDefinition('Hero'); + hdef.initialState = undefined as any; // ZAP! + const collection = creator.create('Hero'); + expect(collection.foo).toBeUndefined('foo'); + expect(collection.ids).toBeDefined('ids'); + }); + + it('should create empty collection even when no def for entity type', () => { + const collection = creator.create('Bazinga'); + expect(collection.ids).toBeDefined('ids'); + }); +}); + +/////// Test values and helpers ///////// + +/// Hero +interface Hero { + id: number; + name: string; +} + +/** HeroCollection is EntityCollection with extra collection properties */ +interface HeroCollection extends EntityCollection { + foo: string; + bar: number; +} diff --git a/modules/data/spec/reducers/entity-collection-reducer-registry.spec.ts b/modules/data/spec/reducers/entity-collection-reducer-registry.spec.ts new file mode 100644 index 0000000000..abf3f535a2 --- /dev/null +++ b/modules/data/spec/reducers/entity-collection-reducer-registry.spec.ts @@ -0,0 +1,347 @@ +import { TestBed } from '@angular/core/testing'; +import { Action, ActionReducer, MetaReducer } from '@ngrx/store'; +import { IdSelector } from '@ngrx/entity'; + +import { + EntityMetadataMap, + EntityCollectionCreator, + EntityActionFactory, + EntityCache, + EntityCollectionReducerRegistry, + EntityCacheReducerFactory, + EntityCollectionReducerMethodsFactory, + EntityCollectionReducerFactory, + EntityDefinitionService, + ENTITY_METADATA_TOKEN, + EntityOp, + EntityCollectionReducers, + EntityCollection, + EntityAction, + ENTITY_COLLECTION_META_REDUCERS, + Logger, +} from '../..'; + +class Bar { + id: number; + bar: string; +} +class Foo { + id: string; + foo: string; +} +class Hero { + id: number; + name: string; + power?: string; +} +class Villain { + key: string; + name: string; +} + +const metadata: EntityMetadataMap = { + Hero: {}, + Villain: { selectId: villain => villain.key }, +}; +describe('EntityCollectionReducerRegistry', () => { + let collectionCreator: EntityCollectionCreator; + let entityActionFactory: EntityActionFactory; + let entityCacheReducer: ActionReducer; + let entityCollectionReducerRegistry: EntityCollectionReducerRegistry; + let logger: jasmine.Spy; + + beforeEach(() => { + entityActionFactory = new EntityActionFactory(); + logger = jasmine.createSpyObj('Logger', ['error', 'log', 'warn']); + + TestBed.configureTestingModule({ + providers: [ + EntityCacheReducerFactory, + EntityCollectionCreator, + { + provide: EntityCollectionReducerMethodsFactory, + useClass: EntityCollectionReducerMethodsFactory, + }, + EntityCollectionReducerFactory, + EntityCollectionReducerRegistry, + EntityDefinitionService, + { provide: ENTITY_METADATA_TOKEN, multi: true, useValue: metadata }, + { provide: Logger, useValue: logger }, + ], + }); + }); + + /** Sets the test variables with injected values. Closes TestBed configuration. */ + function setup() { + collectionCreator = TestBed.get(EntityCollectionCreator); + const entityCacheReducerFactory = TestBed.get( + EntityCacheReducerFactory + ) as EntityCacheReducerFactory; + entityCacheReducer = entityCacheReducerFactory.create(); + entityCollectionReducerRegistry = TestBed.get( + EntityCollectionReducerRegistry + ); + } + + describe('#registerReducer', () => { + beforeEach(setup); + + it('can register a new reducer', () => { + const reducer = createNoopReducer(); + entityCollectionReducerRegistry.registerReducer('Foo', reducer); + const action = entityActionFactory.create('Foo', EntityOp.ADD_ONE, { + id: 'forty-two', + foo: 'fooz', + }); + // Must initialize the state by hand + const state = entityCacheReducer({}, action); + const collection = state['Foo']; + expect(collection.ids.length).toBe(0, 'ADD_ONE should not add'); + }); + + it('can replace existing reducer by registering with same name', () => { + // Just like ADD_ONE test above with default reducer + // but this time should not add the hero. + const hero: Hero = { id: 42, name: 'Bobby' }; + const reducer = createNoopReducer(); + entityCollectionReducerRegistry.registerReducer('Hero', reducer); + const action = entityActionFactory.create( + 'Hero', + EntityOp.ADD_ONE, + hero + ); + const state = entityCacheReducer({}, action); + const collection = state['Hero']; + expect(collection.ids.length).toBe(0, 'ADD_ONE should not add'); + }); + }); + + describe('#registerReducers', () => { + beforeEach(setup); + + it('can register several reducers at the same time.', () => { + const reducer = createNoopReducer(); + const reducers: EntityCollectionReducers = { + Foo: reducer, + Bar: reducer, + }; + entityCollectionReducerRegistry.registerReducers(reducers); + + const fooAction = entityActionFactory.create( + 'Foo', + EntityOp.ADD_ONE, + { id: 'forty-two', foo: 'fooz' } + ); + const barAction = entityActionFactory.create( + 'Bar', + EntityOp.ADD_ONE, + { id: 84, bar: 'baz' } + ); + + let state = entityCacheReducer({}, fooAction); + state = entityCacheReducer(state, barAction); + + expect(state['Foo'].ids.length).toBe(0, 'ADD_ONE Foo should not add'); + expect(state['Bar'].ids.length).toBe(0, 'ADD_ONE Bar should not add'); + }); + + it('can register several reducers that may override.', () => { + const reducer = createNoopReducer(); + const reducers: EntityCollectionReducers = { + Foo: reducer, + Hero: reducer, + }; + entityCollectionReducerRegistry.registerReducers(reducers); + + const fooAction = entityActionFactory.create( + 'Foo', + EntityOp.ADD_ONE, + { id: 'forty-two', foo: 'fooz' } + ); + const heroAction = entityActionFactory.create( + 'Hero', + EntityOp.ADD_ONE, + { id: 84, name: 'Alex' } + ); + + let state = entityCacheReducer({}, fooAction); + state = entityCacheReducer(state, heroAction); + + expect(state['Foo'].ids.length).toBe(0, 'ADD_ONE Foo should not add'); + expect(state['Hero'].ids.length).toBe(0, 'ADD_ONE Hero should not add'); + }); + }); + + describe('with EntityCollectionMetadataReducers', () => { + let metaReducerA: MetaReducer; + let metaReducerB: MetaReducer; + let metaReducerOutput: any[]; + + // Create MetaReducer that reports how it was called on the way in and out + function testMetadataReducerFactory(name: string) { + // Return the MetaReducer + return (r: ActionReducer) => { + // Return the wrapped reducer + return (state: EntityCollection, action: EntityAction) => { + // entered + metaReducerOutput.push({ metaReducer: name, inOut: 'in', action }); + // called reducer + const newState = r(state, action); + // exited + metaReducerOutput.push({ metaReducer: name, inOut: 'out', action }); + return newState; + }; + }; + } + + let addOneAction: EntityAction; + let hero: Hero; + + beforeEach(() => { + metaReducerOutput = []; + metaReducerA = jasmine + .createSpy('metaReducerA') + .and.callFake(testMetadataReducerFactory('A')); + metaReducerB = jasmine + .createSpy('metaReducerA') + .and.callFake(testMetadataReducerFactory('B')); + const metaReducers = [metaReducerA, metaReducerB]; + + TestBed.configureTestingModule({ + providers: [ + EntityCacheReducerFactory, + EntityCollectionCreator, + { + provide: EntityCollectionReducerMethodsFactory, + useClass: EntityCollectionReducerMethodsFactory, + }, + EntityCollectionReducerFactory, + EntityCollectionReducerRegistry, + EntityDefinitionService, + { provide: ENTITY_METADATA_TOKEN, multi: true, useValue: metadata }, + { provide: ENTITY_COLLECTION_META_REDUCERS, useValue: metaReducers }, + { provide: Logger, useValue: logger }, + ], + }); + + setup(); + + hero = { id: 42, name: 'Bobby' }; + addOneAction = entityActionFactory.create( + 'Hero', + EntityOp.ADD_ONE, + hero + ); + }); + + it('should run inner default reducer as expected', () => { + const state = entityCacheReducer({}, addOneAction); + + // inner default reducer worked as expected + const collection = state['Hero']; + expect(collection.ids.length).toBe(1, 'should have added one'); + expect(collection.entities[42]).toEqual(hero, 'should be added hero'); + }); + + it('should call meta reducers for inner default reducer as expected', () => { + const expected = [ + { metaReducer: 'A', inOut: 'in', action: addOneAction }, + { metaReducer: 'B', inOut: 'in', action: addOneAction }, + { metaReducer: 'B', inOut: 'out', action: addOneAction }, + { metaReducer: 'A', inOut: 'out', action: addOneAction }, + ]; + + const state = entityCacheReducer({}, addOneAction); + expect(metaReducerA).toHaveBeenCalled(); + expect(metaReducerB).toHaveBeenCalled(); + expect(metaReducerOutput).toEqual(expected); + }); + + it('should call meta reducers for custom registered reducer', () => { + const reducer = createNoopReducer(); + entityCollectionReducerRegistry.registerReducer('Foo', reducer); + const action = entityActionFactory.create('Foo', EntityOp.ADD_ONE, { + id: 'forty-two', + foo: 'fooz', + }); + + const state = entityCacheReducer({}, action); + expect(metaReducerA).toHaveBeenCalled(); + expect(metaReducerB).toHaveBeenCalled(); + }); + + it('should call meta reducers for multiple registered reducers', () => { + const reducer = createNoopReducer(); + const reducers: EntityCollectionReducers = { + Foo: reducer, + Hero: reducer, + }; + entityCollectionReducerRegistry.registerReducers(reducers); + + const fooAction = entityActionFactory.create( + 'Foo', + EntityOp.ADD_ONE, + { id: 'forty-two', foo: 'fooz' } + ); + + entityCacheReducer({}, fooAction); + expect(metaReducerA).toHaveBeenCalled(); + expect(metaReducerB).toHaveBeenCalled(); + + const heroAction = entityActionFactory.create( + 'Hero', + EntityOp.ADD_ONE, + { id: 84, name: 'Alex' } + ); + + entityCacheReducer({}, heroAction); + expect(metaReducerA).toHaveBeenCalledTimes(2); + expect(metaReducerB).toHaveBeenCalledTimes(2); + }); + }); + + // #region helpers + function createCollection( + entityName: string, + data: T[], + selectId: IdSelector + ) { + return { + ...collectionCreator.create(entityName), + ids: data.map(e => selectId(e)) as string[] | number[], + entities: data.reduce( + (acc, e) => { + acc[selectId(e)] = e; + return acc; + }, + {} as any + ), + } as EntityCollection; + } + + function createInitialCache(entityMap: { [entityName: string]: any[] }) { + const cache: EntityCache = {}; + // tslint:disable-next-line:forin + for (const entityName in entityMap) { + const selectId = + metadata[entityName].selectId || ((entity: any) => entity.id); + cache[entityName] = createCollection( + entityName, + entityMap[entityName], + selectId + ); + } + + return cache; + } + + function createNoopReducer() { + return function NoopReducer( + collection: EntityCollection, + action: EntityAction + ): EntityCollection { + return collection; + }; + } + // #endregion helpers +}); diff --git a/modules/data/spec/reducers/entity-collection-reducer.spec.ts b/modules/data/spec/reducers/entity-collection-reducer.spec.ts new file mode 100644 index 0000000000..3d5a4d485a --- /dev/null +++ b/modules/data/spec/reducers/entity-collection-reducer.spec.ts @@ -0,0 +1,2810 @@ +// EntityCollectionReducer tests - tests of reducers for entity collections in the entity cache +// Tests for EntityCache-level reducers (e.g., SET_ENTITY_CACHE) are in `entity-cache-reducer.spec.ts` +import { Action } from '@ngrx/store'; +import { EntityAdapter, Update, IdSelector } from '@ngrx/entity'; + +import { + EntityMetadataMap, + EntityActionFactory, + EntityOp, + EntityActionOptions, + EntityAction, + toUpdateFactory, + EntityCollectionReducerRegistry, + EntityCache, + EntityCollectionCreator, + EntityDefinitionService, + EntityCollectionReducerMethodsFactory, + EntityCollectionReducerFactory, + EntityCacheReducerFactory, + EntityCollection, + ChangeStateMap, + EntityActionDataServiceError, + DataServiceError, + ChangeType, + ChangeState, + Logger, +} from '../../'; + +class Foo { + id: string; + foo: string; +} +class Hero { + id: number; + name: string; + power?: string; +} +class Villain { + key: string; + name: string; +} + +const metadata: EntityMetadataMap = { + Hero: {}, + Villain: { selectId: villain => villain.key }, +}; + +describe('EntityCollectionReducer', () => { + // action factory never changes in these tests + const entityActionFactory = new EntityActionFactory(); + const createAction: ( + entityName: string, + op: EntityOp, + data?: any, + options?: EntityActionOptions + ) => EntityAction = entityActionFactory.create.bind(entityActionFactory); + + const toHeroUpdate = toUpdateFactory(); + + let entityReducerRegistry: EntityCollectionReducerRegistry; + let entityReducer: (state: EntityCache, action: Action) => EntityCache; + + let initialHeroes: Hero[]; + let initialCache: EntityCache; + let logger: Logger; + let collectionCreator: EntityCollectionCreator; + + beforeEach(() => { + const eds = new EntityDefinitionService([metadata]); + collectionCreator = new EntityCollectionCreator(eds); + const collectionReducerMethodsFactory = new EntityCollectionReducerMethodsFactory( + eds + ); + const collectionReducerFactory = new EntityCollectionReducerFactory( + collectionReducerMethodsFactory + ); + logger = jasmine.createSpyObj('Logger', ['error', 'log', 'warn']); + + entityReducerRegistry = new EntityCollectionReducerRegistry( + collectionReducerFactory + ); + const entityCacheReducerFactory = new EntityCacheReducerFactory( + collectionCreator, + entityReducerRegistry, + logger + ); + entityReducer = entityCacheReducerFactory.create(); + + initialHeroes = [ + { id: 2, name: 'B', power: 'Fast' }, + { id: 1, name: 'A', power: 'Invisible' }, + ]; + initialCache = createInitialCache({ Hero: initialHeroes }); + }); + + it('should ignore an action without an EntityOp', () => { + // should not throw + const action = { + type: 'does-not-matter', + payload: { + entityName: 'Hero', + entityOp: undefined, + }, + }; + const newCache = entityReducer(initialCache, action); + expect(newCache).toBe(initialCache, 'cache unchanged'); + }); + + // #region queries + describe('QUERY_ALL', () => { + const queryAction = createAction('Hero', EntityOp.QUERY_ALL); + + it('QUERY_ALL sets loading flag but does not fill collection', () => { + const state = entityReducer({}, queryAction); + const collection = state['Hero']; + expect(collection.ids.length).toBe(0, 'should be empty collection'); + expect(collection.loaded).toBe(false, 'should not be loaded'); + expect(collection.loading).toBe(true, 'should be loading'); + }); + + it('QUERY_ALL_SUCCESS can create the initial collection', () => { + let state = entityReducer({}, queryAction); + const heroes: Hero[] = [{ id: 2, name: 'B' }, { id: 1, name: 'A' }]; + const action = createAction('Hero', EntityOp.QUERY_ALL_SUCCESS, heroes); + state = entityReducer(state, action); + const collection = state['Hero']; + expect(collection.ids).toEqual( + [2, 1], + 'should have expected ids in load order' + ); + expect(collection.entities['1']).toBe(heroes[1], 'hero with id:1'); + expect(collection.entities['2']).toBe(heroes[0], 'hero with id:2'); + }); + + it('QUERY_ALL_SUCCESS sets the loaded flag and clears loading flag', () => { + let state = entityReducer({}, queryAction); + const heroes: Hero[] = [{ id: 2, name: 'B' }, { id: 1, name: 'A' }]; + const action = createAction('Hero', EntityOp.QUERY_ALL_SUCCESS, heroes); + state = entityReducer(state, action); + const collection = state['Hero']; + expect(collection.loaded).toBe(true, 'should be loaded'); + expect(collection.loading).toBe(false, 'should not be loading'); + }); + + it('QUERY_ALL_ERROR clears loading flag and does not fill collection', () => { + let state = entityReducer({}, queryAction); + const action = createAction('Hero', EntityOp.QUERY_ALL_ERROR); + state = entityReducer(state, action); + const collection = state['Hero']; + expect(collection.loading).toBe(false, 'should not be loading'); + expect(collection.loaded).toBe(false, 'should not be loaded'); + expect(collection.ids.length).toBe(0, 'should be empty collection'); + }); + + it('QUERY_ALL_SUCCESS works for "Villain" entity with non-id primary key', () => { + let state = entityReducer({}, queryAction); + const villains: Villain[] = [ + { key: '2', name: 'B' }, + { key: '1', name: 'A' }, + ]; + const action = createAction( + 'Villain', + EntityOp.QUERY_ALL_SUCCESS, + villains + ); + state = entityReducer(state, action); + const collection = state['Villain']; + expect(collection.ids).toEqual( + ['2', '1'], + 'should have expected ids in load order' + ); + expect(collection.entities['1']).toBe(villains[1], 'villain with key:1'); + expect(collection.entities['2']).toBe(villains[0], 'villain with key:2'); + expect(collection.loaded).toBe(true, 'should be loaded'); + expect(collection.loading).toBe(false, 'should not be loading'); + }); + + it('QUERY_ALL_SUCCESS can add to existing collection', () => { + let state = entityReducer(initialCache, queryAction); + const heroes: Hero[] = [{ id: 3, name: 'C' }]; + const action = createAction('Hero', EntityOp.QUERY_ALL_SUCCESS, heroes); + state = entityReducer(state, action); + const collection = state['Hero']; + + expect(collection.ids).toEqual( + [2, 1, 3], + 'should have expected ids in load order' + ); + }); + + it('QUERY_ALL_SUCCESS can update existing collection', () => { + let state = entityReducer(initialCache, queryAction); + const heroes: Hero[] = [{ id: 1, name: 'A+' }]; + const action = createAction('Hero', EntityOp.QUERY_ALL_SUCCESS, heroes); + state = entityReducer(state, action); + const collection = state['Hero']; + + expect(collection.ids).toEqual( + [2, 1], + 'should have expected ids in load order' + ); + expect(collection.entities['1'].name).toBe('A+', 'should update hero:1'); + }); + + it('QUERY_ALL_SUCCESS can add and update existing collection', () => { + let state = entityReducer(initialCache, queryAction); + const heroes: Hero[] = [{ id: 3, name: 'C' }, { id: 1, name: 'A+' }]; + const action = createAction('Hero', EntityOp.QUERY_ALL_SUCCESS, heroes); + state = entityReducer(state, action); + const collection = state['Hero']; + + expect(collection.ids).toEqual( + [2, 1, 3], + 'should have expected ids in load order' + ); + expect(collection.entities['1'].name).toBe('A+', 'should update hero:1'); + }); + + it('QUERY_ALL_SUCCESS overwrites changeState.originalValue for updated entity', () => { + const { + entityCache, + preUpdatedEntity, + updatedEntity, + } = createTestTrackedEntities(); + const queriedUpdate = { ...updatedEntity, name: 'Queried update' }; + + // a new entity and yet another version of the entity that is currently updated but not saved. + const queryResults: Hero[] = [{ id: 100, name: 'X' }, queriedUpdate]; + const action = createAction( + 'Hero', + EntityOp.QUERY_ALL_SUCCESS, + queryResults + ); + const collection = entityReducer(entityCache, action)['Hero']; + const originalValue = + collection.changeState[updatedEntity.id].originalValue; + + expect(collection.entities[updatedEntity.id]).toEqual( + updatedEntity, + 'current value still the update' + ); + expect(originalValue).toBeDefined('entity still in changeState'); + expect(originalValue).not.toEqual( + preUpdatedEntity, + 'no longer the initial entity' + ); + expect(originalValue).not.toEqual( + updatedEntity, + 'not the updated entity either' + ); + expect(originalValue).toEqual( + queriedUpdate, + 'originalValue is now the queried entity' + ); + }); + + it('QUERY_ALL_SUCCESS works when the query results are empty', () => { + let state = entityReducer(initialCache, queryAction); + const action = createAction('Hero', EntityOp.QUERY_ALL_SUCCESS, []); + state = entityReducer(state, action); + const collection = state['Hero']; + + expect(collection.entities).toBe( + initialCache['Hero'].entities, + 'collection.entities should be untouched' + ); + expect(collection.ids).toBe( + initialCache['Hero'].ids, + 'collection.entities should be untouched' + ); + expect(collection.ids).toEqual([2, 1], 'ids were not mutated'); + expect(collection).not.toBe( + initialCache['Hero'], + 'collection changed by loading flag' + ); + }); + }); + + describe('QUERY_BY_KEY', () => { + const queryAction = createAction('Hero', EntityOp.QUERY_BY_KEY); + + it('QUERY_BY_KEY sets loading flag but does not touch the collection', () => { + const state = entityReducer({}, queryAction); + const collection = state['Hero']; + expect(collection.ids.length).toBe(0, 'should be empty collection'); + expect(collection.loaded).toBe(false, 'should not be loaded'); + expect(collection.loading).toBe(true, 'should be loading'); + }); + + it('QUERY_BY_KEY_SUCCESS can create the initial collection', () => { + let state = entityReducer({}, queryAction); + const hero: Hero = { id: 3, name: 'C' }; + const action = createAction('Hero', EntityOp.QUERY_BY_KEY_SUCCESS, hero); + state = entityReducer(state, action); + const collection = state['Hero']; + + expect(collection.ids).toEqual( + [3], + 'should have expected ids in load order' + ); + expect(collection.loaded).toBe(false, 'should not be loaded'); + expect(collection.loading).toBe(false, 'should not be loading'); + }); + + it('QUERY_BY_KEY_SUCCESS can add to existing collection', () => { + let state = entityReducer(initialCache, queryAction); + const hero: Hero = { id: 3, name: 'C' }; + const action = createAction('Hero', EntityOp.QUERY_BY_KEY_SUCCESS, hero); + state = entityReducer(state, action); + const collection = state['Hero']; + + expect(collection.ids).toEqual( + [2, 1, 3], + 'should have expected ids in load order' + ); + }); + + it('QUERY_BY_KEY_SUCCESS can update existing collection', () => { + let state = entityReducer(initialCache, queryAction); + const hero: Hero = { id: 1, name: 'A+' }; + const action = createAction('Hero', EntityOp.QUERY_BY_KEY_SUCCESS, hero); + state = entityReducer(state, action); + const collection = state['Hero']; + + expect(collection.ids).toEqual( + [2, 1], + 'should have expected ids in load order' + ); + expect(collection.entities['1'].name).toBe('A+', 'should update hero:1'); + }); + + it('QUERY_BY_KEY_SUCCESS updates the originalValue of a pending update', () => { + const { + entityCache, + preUpdatedEntity, + updatedEntity, + } = createTestTrackedEntities(); + const queriedUpdate = { ...updatedEntity, name: 'Queried update' }; + const action = createAction( + 'Hero', + EntityOp.QUERY_BY_KEY_SUCCESS, + queriedUpdate + ); + const collection = entityReducer(entityCache, action)['Hero']; + const originalValue = + collection.changeState[updatedEntity.id].originalValue; + + expect(collection.entities[updatedEntity.id]).toEqual( + updatedEntity, + 'current value still the update' + ); + expect(originalValue).toBeDefined('entity still in changeState'); + expect(originalValue).not.toEqual( + preUpdatedEntity, + 'no longer the initial entity' + ); + expect(originalValue).not.toEqual( + updatedEntity, + 'not the updated entity either' + ); + expect(originalValue).toEqual( + queriedUpdate, + 'originalValue is now the queried entity' + ); + }); + + // Normally would 404 but maybe this API just returns an empty result. + it('QUERY_BY_KEY_SUCCESS works when the query results are empty', () => { + let state = entityReducer(initialCache, queryAction); + const action = createAction( + 'Hero', + EntityOp.QUERY_BY_KEY_SUCCESS, + undefined + ); + state = entityReducer(state, action); + const collection = state['Hero']; + + expect(collection.entities).toBe( + initialCache['Hero'].entities, + 'collection.entities should be untouched' + ); + expect(collection.ids).toBe( + initialCache['Hero'].ids, + 'collection.entities should be untouched' + ); + expect(collection.ids).toEqual([2, 1], 'ids were not mutated'); + }); + }); + + describe('QUERY_MANY', () => { + const queryAction = createAction('Hero', EntityOp.QUERY_MANY); + + it('QUERY_MANY sets loading flag but does not touch the collection', () => { + const state = entityReducer({}, queryAction); + const collection = state['Hero']; + expect(collection.loaded).toBe(false, 'should not be loaded'); + expect(collection.loading).toBe(true, 'should be loading'); + expect(collection.ids.length).toBe(0, 'should be empty collection'); + }); + + it('QUERY_MANY_SUCCESS can create the initial collection', () => { + let state = entityReducer({}, queryAction); + const heroes: Hero[] = [{ id: 3, name: 'C' }]; + const action = createAction('Hero', EntityOp.QUERY_MANY_SUCCESS, heroes); + state = entityReducer(state, action); + const collection = state['Hero']; + + expect(collection.ids).toEqual( + [3], + 'should have expected ids in load order' + ); + expect(collection.loaded).toBe(false, 'should not be loaded'); + expect(collection.loading).toBe(false, 'should not be loading'); + }); + + it('QUERY_MANY_SUCCESS can add to existing collection', () => { + let state = entityReducer(initialCache, queryAction); + const heroes: Hero[] = [{ id: 3, name: 'C' }]; + const action = createAction('Hero', EntityOp.QUERY_MANY_SUCCESS, heroes); + state = entityReducer(state, action); + const collection = state['Hero']; + + expect(collection.ids).toEqual( + [2, 1, 3], + 'should have expected ids in load order' + ); + }); + + it('QUERY_MANY_SUCCESS can update existing collection', () => { + let state = entityReducer(initialCache, queryAction); + const heroes: Hero[] = [{ id: 1, name: 'A+' }]; + const action = createAction('Hero', EntityOp.QUERY_MANY_SUCCESS, heroes); + state = entityReducer(state, action); + const collection = state['Hero']; + + expect(collection.ids).toEqual( + [2, 1], + 'should have expected ids in load order' + ); + expect(collection.entities['1'].name).toBe('A+', 'should update hero:1'); + }); + + it('QUERY_MANY_SUCCESS can add and update existing collection', () => { + let state = entityReducer(initialCache, queryAction); + const heroes: Hero[] = [{ id: 3, name: 'C' }, { id: 1, name: 'A+' }]; + const action = createAction('Hero', EntityOp.QUERY_MANY_SUCCESS, heroes); + state = entityReducer(state, action); + const collection = state['Hero']; + + expect(collection.ids).toEqual( + [2, 1, 3], + 'should have expected ids in load order' + ); + expect(collection.entities['1'].name).toBe('A+', 'should update hero:1'); + }); + + it('QUERY_MANY_SUCCESS overwrites changeState.originalValue for updated entity', () => { + const { + entityCache, + preUpdatedEntity, + updatedEntity, + } = createTestTrackedEntities(); + const queriedUpdate = { ...updatedEntity, name: 'Queried update' }; + + // a new entity and yet another version of the entity that is currently updated but not saved. + const queryResults: Hero[] = [{ id: 100, name: 'X' }, queriedUpdate]; + const action = createAction( + 'Hero', + EntityOp.QUERY_MANY_SUCCESS, + queryResults + ); + const collection = entityReducer(entityCache, action)['Hero']; + const originalValue = + collection.changeState[updatedEntity.id].originalValue; + + expect(collection.entities[updatedEntity.id]).toEqual( + updatedEntity, + 'current value still the update' + ); + expect(originalValue).toBeDefined('entity still in changeState'); + expect(originalValue).not.toEqual( + preUpdatedEntity, + 'no longer the initial entity' + ); + expect(originalValue).not.toEqual( + updatedEntity, + 'not the updated entity either' + ); + expect(originalValue).toEqual( + queriedUpdate, + 'originalValue is now the queried entity' + ); + }); + + it('QUERY_MANY_SUCCESS works when the query results are empty', () => { + let state = entityReducer(initialCache, queryAction); + const action = createAction('Hero', EntityOp.QUERY_MANY_SUCCESS, []); + state = entityReducer(state, action); + const collection = state['Hero']; + + expect(collection.entities).toBe( + initialCache['Hero'].entities, + 'collection.entities should be untouched' + ); + expect(collection.ids).toBe( + initialCache['Hero'].ids, + 'collection.entities should be untouched' + ); + expect(collection.ids).toEqual([2, 1], 'ids were not mutated'); + expect(collection).not.toBe( + initialCache['Hero'], + 'collection changed by loading flag' + ); + }); + }); + + describe('CANCEL_PERSIST', () => { + it('should only clear the loading flag', () => { + const { entityCache } = createTestTrackedEntities(); + let cache = entityReducer( + entityCache, + createAction('Hero', EntityOp.SET_LOADING, true) + ); + expect(cache['Hero'].loading).toBe(true, 'loading flag on at start'); + cache = entityReducer( + cache, + createAction('Hero', EntityOp.CANCEL_PERSIST, undefined, { + correlationId: 42, + }) + ); + expect(cache['Hero'].loading).toBe(false, 'loading flag on at start'); + expect(cache).toEqual(entityCache, 'the rest of the cache is untouched'); + }); + }); + + describe('QUERY_LOAD', () => { + const queryAction = createAction('Hero', EntityOp.QUERY_LOAD); + + it('QUERY_LOAD sets loading flag but does not fill collection', () => { + const state = entityReducer({}, queryAction); + const collection = state['Hero']; + expect(collection.ids.length).toBe(0, 'should be empty collection'); + expect(collection.loaded).toBe(false, 'should not be loaded'); + expect(collection.loading).toBe(true, 'should be loading'); + }); + + it('QUERY_LOAD_SUCCESS fills collection, clears loading flag, and sets loaded flag', () => { + let state = entityReducer({}, queryAction); + const heroes: Hero[] = [{ id: 2, name: 'B' }, { id: 1, name: 'A' }]; + const action = createAction('Hero', EntityOp.QUERY_LOAD_SUCCESS, heroes); + state = entityReducer(state, action); + const collection = state['Hero']; + expect(collection.ids).toEqual( + [2, 1], + 'should have expected ids in load order' + ); + expect(collection.entities['1']).toBe(heroes[1], 'hero with id:1'); + expect(collection.entities['2']).toBe(heroes[0], 'hero with id:2'); + expect(collection.loaded).toBe(true, 'should be loaded'); + expect(collection.loading).toBe(false, 'should not be loading'); + }); + + it('QUERY_LOAD_SUCCESS clears changeState', () => { + const { + entityCache, + preUpdatedEntity, + updatedEntity, + } = createTestTrackedEntities(); + + // Completely replaces existing Hero entities + const heroes: Hero[] = [ + { id: 1000, name: 'X' }, + { ...updatedEntity, name: 'Queried update' }, + ]; + const action = createAction('Hero', EntityOp.QUERY_LOAD_SUCCESS, heroes); + const collection: EntityCollection = entityReducer( + entityCache, + action + )['Hero']; + const { ids, changeState } = collection; + expect(changeState).toEqual({} as ChangeStateMap); + expect(ids).toEqual([1000, updatedEntity.id]); // no sort so in load order + }); + + it('QUERY_LOAD_SUCCESS replaces collection contents with queried entities', () => { + let state: EntityCache = { + Hero: { + entityName: 'Hero', + ids: [42], + entities: { 42: { id: 42, name: 'Fribit' } }, + filter: 'xxx', + loaded: true, + loading: false, + changeState: {}, + }, + }; + state = entityReducer(state, queryAction); + const heroes: Hero[] = [{ id: 2, name: 'B' }, { id: 1, name: 'A' }]; + const action = createAction('Hero', EntityOp.QUERY_LOAD_SUCCESS, heroes); + state = entityReducer(state, action); + const collection = state['Hero']; + expect(collection.ids).toEqual( + [2, 1], + 'should have expected ids in load order' + ); + expect(collection.entities['1']).toBe(heroes[1], 'hero with id:1'); + expect(collection.entities['2']).toBe(heroes[0], 'hero with id:2'); + }); + + it('QUERY_LOAD_ERROR clears loading flag and does not fill collection', () => { + let state = entityReducer({}, queryAction); + const action = createAction('Hero', EntityOp.QUERY_LOAD_ERROR); + state = entityReducer(state, action); + const collection = state['Hero']; + expect(collection.loading).toBe(false, 'should not be loading'); + expect(collection.loaded).toBe(false, 'should not be loaded'); + expect(collection.ids.length).toBe(0, 'should be empty collection'); + }); + + it('QUERY_LOAD_SUCCESS works for "Villain" entity with non-id primary key', () => { + let state = entityReducer({}, queryAction); + const villains: Villain[] = [ + { key: '2', name: 'B' }, + { key: '1', name: 'A' }, + ]; + const action = createAction( + 'Villain', + EntityOp.QUERY_LOAD_SUCCESS, + villains + ); + state = entityReducer(state, action); + const collection = state['Villain']; + expect(collection.ids).toEqual( + ['2', '1'], + 'should have expected ids in load order' + ); + expect(collection.entities['1']).toBe(villains[1], 'villain with key:1'); + expect(collection.entities['2']).toBe(villains[0], 'villain with key:2'); + expect(collection.loaded).toBe(true, 'should be loaded'); + expect(collection.loading).toBe(false, 'should not be loading'); + }); + }); + // #endregion queries + + // #region saves + describe('SAVE_ADD_ONE (Optimistic)', () => { + function createTestAction(hero: Hero) { + return createAction('Hero', EntityOp.SAVE_ADD_ONE, hero, { + isOptimistic: true, + }); + } + + it('should add a new hero to collection', () => { + const hero: Hero = { id: 13, name: 'New One', power: 'Strong' }; + const action = createTestAction(hero); + const state = entityReducer(initialCache, action); + const collection = state['Hero']; + + expect(collection.ids).toEqual([2, 1, 13], 'should have new hero'); + }); + + it('should error if new hero lacks its pkey', () => { + const hero = { name: 'New One', power: 'Strong' }; + // bad add, no id. + const action = createTestAction(hero); + const state = entityReducer(initialCache, action); + expect(state).toBe(initialCache); + expect(action.payload.error!.message).toMatch( + /missing or invalid entity key/ + ); + }); + + it('should NOT update an existing entity in collection', () => { + const hero: Hero = { id: 2, name: 'B+' }; + const action = createTestAction(hero); + const state = entityReducer(initialCache, action); + const collection = state['Hero']; + + expect(collection.ids).toEqual([2, 1], 'ids are the same'); + expect(collection.entities[2].name).toBe('B', 'same old name'); + // unmentioned property stays the same + expect(collection.entities[2].power).toBe('Fast', 'power'); + }); + }); + + describe('SAVE_ADD_ONE (Pessimistic)', () => { + it('should only set the loading flag', () => { + const addedEntity = { id: 42, name: 'New Guy' }; + const action = createAction('Hero', EntityOp.SAVE_ADD_ONE, addedEntity); + expectOnlySetLoadingFlag(action, initialCache); + }); + }); + + describe('SAVE_ADD_ONE_SUCCESS (Optimistic)', () => { + function createTestAction(hero: Hero) { + return createAction('Hero', EntityOp.SAVE_ADD_ONE_SUCCESS, hero, { + isOptimistic: true, + }); + } + + // server returned a hero with different id; not good + it('should NOT add a new hero to collection', () => { + const hero: Hero = { id: 13, name: 'New One', power: 'Strong' }; + const action = createTestAction(hero); + const state = entityReducer(initialCache, action); + const collection = state['Hero']; + + expect(collection.ids).toEqual( + [2, 1], + 'should have same ids, no added hero' + ); + }); + + // The hero was already added to the collection by SAVE_ADD_ONE + // You cannot change the key with SAVE_ADD_ONE_SUCCESS + // You'd have to do it with SAVE_UPDATE_ONE... + it('should NOT change the id of a newly added hero', () => { + // pretend this hero was added by SAVE_ADD_ONE and returned by server with new ID + const hero = initialHeroes[0]; + hero.id = 13; + + const action = createTestAction(hero); + const state = entityReducer(initialCache, action); + const collection = state['Hero']; + + expect(collection.ids).toEqual([2, 1], 'should have same ids'); + }); + + it('should error if new hero lacks its pkey', () => { + const hero = { name: 'New One', power: 'Strong' }; + // bad add, no id. + const action = createTestAction(hero); + const state = entityReducer(initialCache, action); + expect(state).toBe(initialCache); + expect(action.payload.error!.message).toMatch( + /missing or invalid entity key/ + ); + }); + + // because the hero was already added to the collection by SAVE_ADD_ONE + // should update values (but not id) if the server changed them + // as it might with a concurrency property. + it('should update an existing entity with that ID in collection', () => { + // This example simulates the server updating the name and power + const hero: Hero = { id: 2, name: 'Updated Name', power: 'Test Power' }; + const action = createTestAction(hero); + const state = entityReducer(initialCache, action); + const collection = state['Hero']; + + expect(collection.ids).toEqual([2, 1], 'ids are the same'); + expect(collection.entities[2].name).toBe('Updated Name'); + // unmentioned property updated too + expect(collection.entities[2].power).toBe('Test Power'); + }); + }); + + describe('SAVE_ADD_ONE_SUCCESS (Pessimistic)', () => { + function createTestAction(hero: Hero) { + return createAction('Hero', EntityOp.SAVE_ADD_ONE_SUCCESS, hero); + } + + it('should add a new hero to collection', () => { + const hero: Hero = { id: 13, name: 'New One', power: 'Strong' }; + const action = createTestAction(hero); + const collection = entityReducer(initialCache, action)['Hero']; + expect(collection.ids).toEqual([2, 1, 13], 'added new hero'); + }); + + it('should error if new hero lacks its pkey', () => { + const hero = { name: 'New One', power: 'Strong' }; + // bad add, no id. + const action = createTestAction(hero); + const state = entityReducer(initialCache, action); + expect(state).toBe(initialCache); + expect(action.payload.error!.message).toMatch( + /missing or invalid entity key/ + ); + }); + + it('should update an existing entity in collection', () => { + // ... because reducer calls mergeServerUpserts() + const hero: Hero = { id: 2, name: 'B+' }; + const action = createTestAction(hero); + const collection = entityReducer(initialCache, action)['Hero']; + + expect(collection.ids).toEqual([2, 1], 'ids are the same'); + expect(collection.entities[2].name).toBe('B+', 'same old name'); + // unmentioned property stays the same + expect(collection.entities[2].power).toBe('Fast', 'power'); + }); + }); + + describe('SAVE_ADD_ONE_ERROR', () => { + it('should only clear the loading flag', () => { + const { entityCache, addedEntity } = createTestTrackedEntities(); + const originalAction = createAction( + 'Hero', + EntityOp.SAVE_ADD_ONE, + addedEntity + ); + const error: EntityActionDataServiceError = { + error: new DataServiceError(new Error('Test Error'), { + method: 'POST', + url: 'foo', + }), + originalAction, + }; + const action = createAction('Hero', EntityOp.SAVE_ADD_MANY_ERROR, error); + expectOnlySetLoadingFlag(action, entityCache); + }); + }); + + describe('SAVE_ADD_MANY (Optimistic)', () => { + function createTestAction(heroes: Hero[]) { + return createAction('Hero', EntityOp.SAVE_ADD_MANY, heroes, { + isOptimistic: true, + }); + } + + it('should add new heroes to collection', () => { + const heroes: Hero[] = [ + { id: 13, name: 'New A', power: 'Strong' }, + { id: 14, name: 'New B', power: 'Swift' }, + ]; + const action = createTestAction(heroes); + const state = entityReducer(initialCache, action); + const collection = state['Hero']; + + expect(collection.ids).toEqual([2, 1, 13, 14], 'should have new hero'); + }); + + it('should error if one of new heroes lacks its pkey', () => { + const heroes: Hero[] = [ + { id: 13, name: 'New A', power: 'Strong' }, + { id: undefined as any, name: 'New B', power: 'Swift' }, // missing its id + ]; + const action = createTestAction(heroes); + const state = entityReducer(initialCache, action); + expect(state).toBe(initialCache); + expect(action.payload.error!.message).toMatch( + /does not have a valid entity key/ + ); + }); + + it('should NOT update an existing entity in collection', () => { + const heroes: Hero[] = [ + { id: 13, name: 'New A', power: 'Strong' }, + { id: 2, name: 'B+' }, + { id: 14, name: 'New B', power: 'Swift' }, // missing its id + ]; + const action = createTestAction(heroes); + const state = entityReducer(initialCache, action); + const collection = state['Hero']; + + expect(collection.ids).toEqual([2, 1, 13, 14], 'ids are the same'); + expect(collection.entities[2].name).toBe('B', 'same old name'); + // unmentioned property stays the same + expect(collection.entities[2].power).toBe('Fast', 'power'); + }); + }); + + describe('SAVE_ADD_MANY (Pessimistic)', () => { + it('should only set the loading flag', () => { + const heroes: Hero[] = [ + { id: 13, name: 'New A', power: 'Strong' }, + { id: 14, name: 'New B', power: 'Swift' }, + ]; + const action = createAction('Hero', EntityOp.SAVE_ADD_MANY, heroes); + expectOnlySetLoadingFlag(action, initialCache); + }); + }); + + describe('SAVE_ADD_MANY_SUCCESS (Optimistic)', () => { + function createTestAction(heroes: Hero[]) { + return createAction('Hero', EntityOp.SAVE_ADD_MANY_SUCCESS, heroes, { + isOptimistic: true, + }); + } + + // Server returned heroes with ids that are new and were not sent to the server. + // This could be correct or it could be bad (e.g. server changed the id of a new entity) + // Regardless, SAVE_ADD_MANY_SUCCESS (optimistic) will add them because it upserts. + it('should add heroes that were not previously in the collection', () => { + const heroes: Hero[] = [ + { id: 13, name: 'New A', power: 'Strong' }, + { id: 14, name: 'New B', power: 'Swift' }, + ]; + const action = createTestAction(heroes); + const state = entityReducer(initialCache, action); + const collection = state['Hero']; + + expect(collection.ids).toEqual([2, 1, 13, 14], 'adds heroes'); + }); + + it('should error if new hero lacks its pkey', () => { + const heroes: Hero[] = [ + { id: undefined as any, name: 'New A', power: 'Strong' }, // missing its id + { id: 14, name: 'New B', power: 'Swift' }, + ]; + const action = createTestAction(heroes); + const state = entityReducer(initialCache, action); + expect(state).toBe(initialCache); + expect(action.payload.error!.message).toMatch( + /does not have a valid entity key/ + ); + }); + + // because the hero was already added to the collection by SAVE_ADD_MANY + // should update values (but not id) if the server changed them + // as it might with a concurrency property. + it('should update an existing entity with that ID in collection', () => { + // This example simulates the server updating the name and power + const heroes: Hero[] = [ + { id: 1, name: 'Updated name A', power: 'Test Power A' }, + { id: 2, name: 'Updated name B', power: 'Test Power B' }, + ]; + const action = createTestAction(heroes); + const state = entityReducer(initialCache, action); + const collection = state['Hero']; + + expect(collection.ids).toEqual([2, 1], 'ids are the same'); + expect(collection.entities[1].name).toBe('Updated name A'); + expect(collection.entities[2].name).toBe('Updated name B'); + // unmentioned property updated too + expect(collection.entities[1].power).toBe('Test Power A'); + expect(collection.entities[2].power).toBe('Test Power B'); + }); + }); + + describe('SAVE_ADD_MANY_SUCCESS (Pessimistic)', () => { + function createTestAction(heroes: Hero[]) { + return createAction('Hero', EntityOp.SAVE_ADD_MANY_SUCCESS, heroes, { + isOptimistic: false, + }); + } + + it('should add new heroes to collection', () => { + const heroes: Hero[] = [ + { id: 13, name: 'New A', power: 'Strong' }, + { id: 14, name: 'New B', power: 'Swift' }, + ]; + const action = createTestAction(heroes); + const collection = entityReducer(initialCache, action)['Hero']; + expect(collection.ids).toEqual([2, 1, 13, 14], 'added new heroes'); + }); + + it('should error if new hero lacks its pkey', () => { + const heroes: Hero[] = [ + { id: undefined as any, name: 'New A', power: 'Strong' }, // missing id + { id: 14, name: 'New B', power: 'Swift' }, + ]; + const action = createTestAction(heroes); + const state = entityReducer(initialCache, action); + expect(state).toBe(initialCache); + expect(action.payload!.error!.message).toMatch( + /does not have a valid entity key/ + ); + }); + + it('should update an existing entity in collection', () => { + // This example simulates the server updating the name and power + const heroes: Hero[] = [ + { id: 1, name: 'Updated name A' }, + { id: 2, name: 'Updated name B' }, + ]; + const action = createTestAction(heroes); + const collection = entityReducer(initialCache, action)['Hero']; + + expect(collection.ids).toEqual([2, 1], 'ids are the same'); + expect(collection.entities[1].name).toBe('Updated name A'); + expect(collection.entities[2].name).toBe('Updated name B'); + // unmentioned property stays the same + expect(collection.entities[1].power).toBe( + initialHeroes[1].power, + 'power A' + ); + expect(collection.entities[2].power).toBe( + initialHeroes[0].power, + 'power B' + ); + }); + }); + + describe('SAVE_ADD_MANY_ERROR', () => { + it('should only clear the loading flag', () => { + const { entityCache, addedEntity } = createTestTrackedEntities(); + const originalAction = createAction('Hero', EntityOp.SAVE_ADD_MANY, [ + addedEntity, + ]); + const error: EntityActionDataServiceError = { + error: new DataServiceError(new Error('Test Error'), { + method: 'POST', + url: 'foo', + }), + originalAction, + }; + const action = createAction('Hero', EntityOp.SAVE_ADD_MANY_ERROR, error); + expectOnlySetLoadingFlag(action, entityCache); + }); + }); + + describe('SAVE_DELETE_ONE (Optimistic)', () => { + it('should immediately remove the existing hero', () => { + const hero = initialHeroes[0]; + expect(initialCache['Hero'].entities[hero.id]).toBe( + hero, + 'exists before delete' + ); + + const action = createAction('Hero', EntityOp.SAVE_DELETE_ONE, hero, { + isOptimistic: true, + }); + + const collection = entityReducer(initialCache, action)['Hero']; + expect(collection.entities[hero.id]).toBeUndefined('hero removed'); + expect(collection.loading).toBe(true, 'loading on'); + }); + + it('should immediately remove the hero by id ', () => { + const hero = initialHeroes[0]; + expect(initialCache['Hero'].entities[hero.id]).toBe( + hero, + 'exists before delete' + ); + + const action = createAction('Hero', EntityOp.SAVE_DELETE_ONE, hero.id, { + isOptimistic: true, + }); + + const collection = entityReducer(initialCache, action)['Hero']; + expect(collection.entities[hero.id]).toBeUndefined('hero removed'); + expect(collection.loading).toBe(true, 'loading on'); + }); + + it('should immediately remove an unsaved added hero', () => { + const { entityCache, addedEntity } = createTestTrackedEntities(); + const id = addedEntity.id; + const action = createAction('Hero', EntityOp.SAVE_DELETE_ONE, id, { + isOptimistic: true, + }); + const { entities, changeState } = entityReducer(entityCache, action)[ + 'Hero' + ]; + expect(entities[id]).toBeUndefined('added entity removed'); + expect(changeState[id]).toBeUndefined('no longer tracked'); + expect(action.payload.skip).toBe(true, 'should skip save'); + }); + + it('should reclassify change of an unsaved updated hero to "deleted"', () => { + const { entityCache, updatedEntity } = createTestTrackedEntities(); + const id = updatedEntity.id; + const action = createAction('Hero', EntityOp.SAVE_DELETE_ONE, id, { + isOptimistic: true, + }); + const collection = entityReducer(entityCache, action)['Hero']; + + expect(collection.entities[id]).toBeUndefined( + 'updated entity removed from collection' + ); + const entityChangeState = collection.changeState[id]; + expect(entityChangeState).toBeDefined('updated entity still tracked'); + expect(entityChangeState.changeType).toBe(ChangeType.Deleted); + }); + + it('should be ok when the id is not in the collection', () => { + expect(initialCache['Hero'].entities[1000]).toBeUndefined( + 'should not exist' + ); + + const action = createAction( + 'Hero', + EntityOp.SAVE_DELETE_ONE, + 1000, // id of entity that is not in the collection + { isOptimistic: true } + ); + + const collection = entityReducer(initialCache, action)['Hero']; + expect(collection.entities[1000]).toBeUndefined('hero removed'); + expect(collection.loading).toBe(true, 'loading on'); + }); + }); + + describe('SAVE_DELETE_ONE (Pessimistic)', () => { + it('should NOT remove the existing hero', () => { + const hero = initialHeroes[0]; + const action = createAction('Hero', EntityOp.SAVE_DELETE_ONE, hero); + + const state = entityReducer(initialCache, action); + const collection = state['Hero']; + expect(collection.entities[hero.id]).toBe(hero, 'hero still there'); + expect(collection.loading).toBe(true, 'loading on'); + }); + + it('should immediately remove an unsaved added hero', () => { + const { entityCache, addedEntity } = createTestTrackedEntities(); + const id = addedEntity.id; + const action = createAction('Hero', EntityOp.SAVE_DELETE_ONE, id); + const { entities, changeState } = entityReducer(entityCache, action)[ + 'Hero' + ]; + expect(entities[id]).toBeUndefined('added entity removed'); + expect(changeState[id]).toBeUndefined('no longer tracked'); + expect(action.payload.skip).toBe(true, 'should skip save'); + }); + + it('should reclassify change of an unsaved updated hero to "deleted"', () => { + const { entityCache, updatedEntity } = createTestTrackedEntities(); + const id = updatedEntity.id; + const action = createAction('Hero', EntityOp.SAVE_DELETE_ONE, id); + const collection = entityReducer(entityCache, action)['Hero']; + + expect(collection.entities[id]).toBeDefined( + 'updated entity still in collection' + ); + const entityChangeState = collection.changeState[id]; + expect(entityChangeState).toBeDefined('updated entity still tracked'); + expect(entityChangeState.changeType).toBe(ChangeType.Deleted); + }); + }); + + describe('SAVE_DELETE_ONE_SUCCESS (Optimistic)', () => { + it('should turn loading flag off and clear change tracking for existing entity', () => { + const { entityCache, removedEntity } = createTestTrackedEntities(); + + // the action that would have saved the delete + const saveAction = createAction( + 'Hero', + EntityOp.SAVE_DELETE_ONE, + removedEntity.id, + { isOptimistic: true } + ); + + const { + entities: initialEntities, + changeState: initialChangeState, + } = entityCache['Hero']; + expect(initialChangeState[removedEntity.id]).toBeDefined( + 'removed is tracked before save success' + ); + + const action = createAction( + 'Hero', + EntityOp.SAVE_DELETE_ONE_SUCCESS, + removedEntity.id, // Pretend optimistically deleted this hero + { isOptimistic: true } + ); + + const collection = entityReducer(entityCache, action)['Hero']; + expect(collection.entities).toBe(initialEntities, 'entities untouched'); + expect(collection.loading).toBe(false, 'loading off'); + expect(collection.changeState[removedEntity.id]).toBeUndefined( + 'removed no longer tracked' + ); + }); + + it('should be ok when the id is not in the collection', () => { + expect(initialCache['Hero'].entities[1000]).toBeUndefined( + 'should not exist' + ); + + const action = createAction( + 'Hero', + EntityOp.SAVE_DELETE_ONE_SUCCESS, + 1000, // id of entity that is not in the collection + { isOptimistic: true } + ); + + const state = entityReducer(initialCache, action); + const collection = state['Hero']; + + expect(collection.entities[1000]).toBeUndefined('hero removed'); + expect(collection.loading).toBe(false, 'loading off'); + }); + }); + + describe('SAVE_DELETE_ONE_SUCCESS (Pessimistic)', () => { + it('should remove the hero by id', () => { + const hero = initialHeroes[0]; + expect(initialCache['Hero'].entities[hero.id]).toBe( + hero, + 'exists before delete' + ); + + const action = createAction( + 'Hero', + EntityOp.SAVE_DELETE_ONE_SUCCESS, + hero.id + ); + + const collection = entityReducer(initialCache, action)['Hero']; + + expect(collection.entities[hero.id]).toBeUndefined('hero removed'); + expect(collection.loading).toBe(false, 'loading off'); + }); + + it('should be ok when the id is not in the collection', () => { + expect(initialCache['Hero'].entities[1000]).toBeUndefined( + 'should not exist' + ); + + const action = createAction( + 'Hero', + EntityOp.SAVE_DELETE_ONE_SUCCESS, + 1000 + ); + + const collection = entityReducer(initialCache, action)['Hero']; + + expect(collection.entities[1000]).toBeUndefined('hero removed'); + expect(collection.loading).toBe(false, 'loading off'); + }); + }); + + describe('SAVE_DELETE_ONE_ERROR', () => { + it('should only clear the loading flag', () => { + const { entityCache, removedEntity } = createTestTrackedEntities(); + const originalAction = createAction( + 'Hero', + EntityOp.SAVE_DELETE_ONE, + removedEntity.id + ); + const error: EntityActionDataServiceError = { + error: new DataServiceError(new Error('Test Error'), { + method: 'DELETE', + url: 'foo', + }), + originalAction, + }; + const action = createAction( + 'Hero', + EntityOp.SAVE_DELETE_ONE_ERROR, + error + ); + expectOnlySetLoadingFlag(action, entityCache); + }); + + // No compensating action on error (yet) + it('should NOT restore the hero after optimistic save', () => { + const initialEntities = initialCache['Hero'].entities; + const action = createAction( + 'Hero', + EntityOp.SAVE_DELETE_ONE_ERROR, + { id: 13, name: 'Deleted' }, // Pretend optimistically deleted this hero + { isOptimistic: true } + ); + + const state = entityReducer(initialCache, action); + const collection = state['Hero']; + expect(collection.entities).toBe(initialEntities, 'entities untouched'); + expect(collection.loading).toBe(false, 'loading off'); + }); + + it('should NOT remove the hero', () => { + const hero = initialHeroes[0]; + const action = createAction('Hero', EntityOp.SAVE_DELETE_ONE_ERROR, hero); + + const state = entityReducer(initialCache, action); + const collection = state['Hero']; + expect(collection.entities[hero.id]).toBe(hero, 'hero still there'); + expect(collection.loading).toBe(false, 'loading off'); + }); + }); + + describe('SAVE_DELETE_MANY (Optimistic)', () => { + it('should immediately remove the heroes by id ', () => { + const ids = initialHeroes.map(h => h.id); + expect(initialCache['Hero'].entities[ids[0]]).toBe( + initialHeroes[0], + 'heroes[0] exists before delete' + ); + expect(initialCache['Hero'].entities[ids[1]]).toBe( + initialHeroes[1], + 'heroes[1] exists before delete' + ); + + const action = createAction('Hero', EntityOp.SAVE_DELETE_MANY, ids, { + isOptimistic: true, + }); + + const collection = entityReducer(initialCache, action)['Hero']; + expect(collection.entities[ids[0]]).toBeUndefined('heroes[0] removed'); + expect(collection.entities[ids[1]]).toBeUndefined('heroes[1] removed'); + expect(collection.loading).toBe(true, 'loading on'); + }); + + it('should immediately remove an unsaved added hero', () => { + const { entityCache, addedEntity } = createTestTrackedEntities(); + const id = addedEntity.id; + const action = createAction('Hero', EntityOp.SAVE_DELETE_MANY, [id], { + isOptimistic: true, + }); + const { entities, changeState } = entityReducer(entityCache, action)[ + 'Hero' + ]; + expect(entities[id]).toBeUndefined('added entity removed'); + expect(changeState[id]).toBeUndefined('no longer tracked'); + }); + + it('should reclassify change of an unsaved updated hero to "deleted"', () => { + const { entityCache, updatedEntity } = createTestTrackedEntities(); + const id = updatedEntity.id; + const action = createAction('Hero', EntityOp.SAVE_DELETE_MANY, [id], { + isOptimistic: true, + }); + const collection = entityReducer(entityCache, action)['Hero']; + + expect(collection.entities[id]).toBeUndefined( + 'updated entity removed from collection' + ); + const entityChangeState = collection.changeState[id]; + expect(entityChangeState).toBeDefined('updated entity still tracked'); + expect(entityChangeState.changeType).toBe(ChangeType.Deleted); + }); + + it('should be ok when the id is not in the collection', () => { + expect(initialCache['Hero'].entities[1000]).toBeUndefined( + 'should not exist' + ); + + const action = createAction( + 'Hero', + EntityOp.SAVE_DELETE_MANY, + [1000], // id of entity that is not in the collection + { isOptimistic: true } + ); + + const collection = entityReducer(initialCache, action)['Hero']; + expect(collection.entities[1000]).toBeUndefined('hero removed'); + expect(collection.loading).toBe(true, 'loading on'); + }); + }); + + describe('SAVE_DELETE_MANY (Pessimistic)', () => { + it('should NOT remove the existing hero', () => { + const hero = initialHeroes[0]; + const action = createAction('Hero', EntityOp.SAVE_DELETE_ONE, hero); + + const state = entityReducer(initialCache, action); + const collection = state['Hero']; + expect(collection.entities[hero.id]).toBe(hero, 'hero still there'); + expect(collection.loading).toBe(true, 'loading on'); + }); + + it('should immediately remove an unsaved added hero', () => { + const { entityCache, addedEntity } = createTestTrackedEntities(); + const id = addedEntity.id; + const action = createAction('Hero', EntityOp.SAVE_DELETE_ONE, id); + const { entities, changeState } = entityReducer(entityCache, action)[ + 'Hero' + ]; + expect(entities[id]).toBeUndefined('added entity removed'); + expect(changeState[id]).toBeUndefined('no longer tracked'); + expect(action.payload.skip).toBe(true, 'should skip save'); + }); + + it('should reclassify change of an unsaved updated hero to "deleted"', () => { + const { entityCache, updatedEntity } = createTestTrackedEntities(); + const id = updatedEntity.id; + const action = createAction('Hero', EntityOp.SAVE_DELETE_ONE, id); + const collection = entityReducer(entityCache, action)['Hero']; + + expect(collection.entities[id]).toBeDefined( + 'updated entity still in collection' + ); + const entityChangeState = collection.changeState[id]; + expect(entityChangeState).toBeDefined('updated entity still tracked'); + expect(entityChangeState.changeType).toBe(ChangeType.Deleted); + }); + }); + + describe('SAVE_DELETE_MANY_SUCCESS (Optimistic)', () => { + it('should turn loading flag off and clear change tracking for existing entities', () => { + const { + entityCache, + removedEntity, + updatedEntity, + } = createTestTrackedEntities(); + const ids = [removedEntity.id, updatedEntity.id]; + + let action = createAction('Hero', EntityOp.SAVE_DELETE_MANY, ids, { + isOptimistic: true, + }); + + let collection = entityReducer(entityCache, action)['Hero']; + let changeState = collection.changeState; + expect(collection.loading).toBe(true, 'loading on'); + expect(changeState[ids[0]]).toBeDefined( + '[0] removed is tracked before save success' + ); + expect(changeState[ids[1]]).toBeDefined( + '[1] removed is tracked before save success' + ); + + action = createAction( + 'Hero', + EntityOp.SAVE_DELETE_MANY_SUCCESS, + ids, // After optimistically deleted this hero + { isOptimistic: true } + ); + + collection = entityReducer(entityCache, action)['Hero']; + changeState = collection.changeState; + expect(collection.loading).toBe(false, 'loading off'); + expect(changeState[ids[0]]).toBeUndefined( + '[0] removed no longer tracked' + ); + expect(changeState[ids[1]]).toBeUndefined( + '[1] removed no longer tracked' + ); + }); + + it('should be ok when the id is not in the collection', () => { + expect(initialCache['Hero'].entities[1000]).toBeUndefined( + 'should not exist' + ); + + const action = createAction( + 'Hero', + EntityOp.SAVE_DELETE_MANY_SUCCESS, + [1000], // id of entity that is not in the collection + { isOptimistic: true } + ); + + const state = entityReducer(initialCache, action); + const collection = state['Hero']; + + expect(collection.entities[1000]).toBeUndefined('hero removed'); + expect(collection.loading).toBe(false, 'loading off'); + }); + }); + + describe('SAVE_DELETE_MANY_SUCCESS (Pessimistic)', () => { + it('should remove heroes by id', () => { + const heroes = initialHeroes; + const ids = heroes.map(h => h.id); + + expect(initialCache['Hero'].entities[ids[0]]).toBe( + heroes[0], + 'hero 0 exists before delete success' + ); + expect(initialCache['Hero'].entities[ids[1]]).toBe( + heroes[1], + 'hero 1 exists before delete success' + ); + + const action = createAction( + 'Hero', + EntityOp.SAVE_DELETE_MANY_SUCCESS, + ids, + { isOptimistic: false } + ); + + const collection = entityReducer(initialCache, action)['Hero']; + + expect(collection.entities[ids[0]]).toBeUndefined('heroes[0] gone'); + expect(collection.entities[ids[1]]).toBeUndefined('heroes[1] gone'); + expect(collection.loading).toBe(false, 'loading off'); + }); + + it('should be ok when an id is not in the collection', () => { + const ids = [initialHeroes[0].id, 1000]; + expect(initialCache['Hero'].entities[1000]).toBeUndefined( + 'should not exist' + ); + + const action = createAction( + 'Hero', + EntityOp.SAVE_DELETE_MANY_SUCCESS, + ids, + { isOptimistic: false } + ); + + const collection = entityReducer(initialCache, action)['Hero']; + + expect(collection.entities[ids[0]]).toBeUndefined('heroes[0] gone'); + expect(collection.entities[1000]).toBeUndefined( + 'hero[1000] not there now either' + ); + expect(collection.loading).toBe(false, 'loading off'); + }); + }); + + describe('SAVE_DELETE_MANY_ERROR', () => { + it('should only clear the loading flag', () => { + const { entityCache, removedEntity } = createTestTrackedEntities(); + const originalAction = createAction( + 'Hero', + EntityOp.SAVE_DELETE_MANY, + removedEntity.id + ); + const error: EntityActionDataServiceError = { + error: new DataServiceError(new Error('Test Error'), { + method: 'DELETE', + url: 'foo', + }), + originalAction, + }; + const action = createAction( + 'Hero', + EntityOp.SAVE_DELETE_MANY_ERROR, + error + ); + expectOnlySetLoadingFlag(action, entityCache); + }); + + // No compensating action on error (yet) + it('should NOT restore the hero after optimistic save', () => { + const initialEntities = initialCache['Hero'].entities; + const action = createAction( + 'Hero', + EntityOp.SAVE_DELETE_MANY_ERROR, + { id: 13, name: 'Deleted' }, // Pretend optimistically deleted this hero + { isOptimistic: true } + ); + + const state = entityReducer(initialCache, action); + const collection = state['Hero']; + expect(collection.entities).toBe(initialEntities, 'entities untouched'); + expect(collection.loading).toBe(false, 'loading off'); + }); + + it('should NOT remove the hero', () => { + const hero = initialHeroes[0]; + const action = createAction( + 'Hero', + EntityOp.SAVE_DELETE_MANY_ERROR, + hero + ); + + const state = entityReducer(initialCache, action); + const collection = state['Hero']; + expect(collection.entities[hero.id]).toBe(hero, 'hero still there'); + expect(collection.loading).toBe(false, 'loading off'); + }); + }); + + describe('SAVE_UPDATE_ONE (Optimistic)', () => { + function createTestAction(hero: Update) { + return createAction('Hero', EntityOp.SAVE_UPDATE_ONE, hero, { + isOptimistic: true, + }); + } + + it('should update existing entity in collection', () => { + const hero: Hero = { id: 2, name: 'B+' }; + const action = createTestAction(toHeroUpdate(hero)); + const state = entityReducer(initialCache, action); + const collection = state['Hero']; + + expect(collection.ids).toEqual([2, 1], 'ids are the same'); + expect(collection.entities[2].name).toBe('B+', 'name'); + // unmentioned property stays the same + expect(collection.entities[2].power).toBe('Fast', 'power'); + }); + + it('can update existing entity key in collection', () => { + // Change the pkey (id) and the name of former hero:2 + const hero: Hero = { id: 42, name: 'Super' }; + const update = { id: 2, changes: hero }; + const action = createTestAction(update); + const state = entityReducer(initialCache, action); + const collection = state['Hero']; + + expect(collection.ids).toEqual([42, 1], 'ids are the same'); + expect(collection.entities[42].name).toBe('Super', 'name'); + // unmentioned property stays the same + expect(collection.entities[42].power).toBe('Fast', 'power'); + }); + + // Changed in v6. It used to add a new entity. + it('should NOT add new hero to collection', () => { + const hero: Hero = { id: 13, name: 'New One', power: 'Strong' }; + const action = createTestAction(toHeroUpdate(hero)); + const state = entityReducer(initialCache, action); + const collection = state['Hero']; + + expect(collection.ids).toEqual([2, 1], 'no new hero:13'); + }); + }); + + describe('SAVE_UPDATE_ONE (Pessimistic)', () => { + it('should only set the loading flag', () => { + const updatedEntity = { ...initialHeroes[0], name: 'Updated' }; + const update = { id: updatedEntity.id, changes: updatedEntity }; + const action = createAction('Hero', EntityOp.SAVE_UPDATE_ONE, update); + expectOnlySetLoadingFlag(action, initialCache); + }); + }); + + describe('SAVE_UPDATE_ONE_SUCCESS (Optimistic)', () => { + function createTestAction(update: Update, changed: boolean) { + return createAction( + 'Hero', + EntityOp.SAVE_UPDATE_ONE_SUCCESS, + { ...update, changed }, + { isOptimistic: true } + ); + } + + it('should leave updated entity alone if server did not change the update (changed: false)', () => { + const { entityCache, updatedEntity } = createTestTrackedEntities(); + const ids = entityCache['Hero'].ids; + const id = updatedEntity.id; + const action = createTestAction(toHeroUpdate(updatedEntity), false); + const collection = entityReducer(entityCache, action)['Hero']; + + expect(collection.ids).toEqual(ids, 'ids are the same'); + expect(collection.entities[id].name).toBe(updatedEntity.name, 'name'); + expect(collection.entities[id].power).toBe(updatedEntity.power, 'power'); + }); + + it('should update existing entity when server adds its own changes (changed: true)', () => { + const { entityCache, updatedEntity } = createTestTrackedEntities(); + const ids = entityCache['Hero'].ids; + const id = updatedEntity.id; + // Server changed the name + const serverEntity = { id: updatedEntity.id, name: 'Server Update Name' }; + const action = createTestAction(toHeroUpdate(serverEntity), true); + const collection = entityReducer(entityCache, action)['Hero']; + + expect(collection.ids).toEqual(ids, 'ids are the same'); + expect(collection.entities[id].name).toBe(serverEntity.name, 'name'); + // unmentioned property stays the same + expect(collection.entities[id].power).toBe(updatedEntity.power, 'power'); + }); + + it('can update existing entity key', () => { + const { entityCache, updatedEntity } = createTestTrackedEntities(); + const ids = entityCache['Hero'].ids as number[]; + const id = updatedEntity.id; + // Server changed the pkey (id) and the name + const serverEntity = { id: 13, name: 'Server Update Name' }; + const update = { id: updatedEntity.id, changes: serverEntity }; + const action = createTestAction(update, true); + const collection = entityReducer(entityCache, action)['Hero']; + + // Should have replaced updatedEntity.id with 13 + const newIds = ids.map(i => (i === id ? 13 : i)); + + expect(collection.ids).toEqual(newIds, 'server-changed id in the ids'); + expect(collection.entities[13].name).toBe(serverEntity.name, 'name'); + // unmentioned property stays the same + expect(collection.entities[13].power).toBe(updatedEntity.power, 'power'); + }); + + // Changed in v6. It used to add a new entity. + it('should NOT add new hero to collection', () => { + const { entityCache } = createTestTrackedEntities(); + const ids = entityCache['Hero'].ids; + const hero: Hero = { id: 13, name: 'New One', power: 'Strong' }; + const action = createTestAction(toHeroUpdate(hero), true); + const collection = entityReducer(entityCache, action)['Hero']; + + expect(collection.ids).toEqual(ids, 'no new hero:13'); + }); + }); + + describe('SAVE_UPDATE_ONE_SUCCESS (Pessimistic)', () => { + function createTestAction(update: Update, changed: boolean) { + return createAction('Hero', EntityOp.SAVE_UPDATE_ONE_SUCCESS, { + ...update, + changed, + }); + } + + it('should update existing entity in collection', () => { + const hero: Hero = { id: 2, name: 'B+' }; + const action = createTestAction(toHeroUpdate(hero), false); + const state = entityReducer(initialCache, action); + const collection = state['Hero']; + + expect(collection.ids).toEqual([2, 1], 'ids are the same'); + expect(collection.entities[2].name).toBe('B+', 'name'); + // unmentioned property stays the same + expect(collection.entities[2].power).toBe('Fast', 'power'); + }); + + it('can update existing entity key in collection', () => { + // Change the pkey (id) and the name of former hero:2 + const hero: Hero = { id: 42, name: 'Super' }; + const update = { id: 2, changes: hero }; + const action = createTestAction(update, true); + const state = entityReducer(initialCache, action); + const collection = state['Hero']; + + expect(collection.ids).toEqual([42, 1], 'ids are the same'); + expect(collection.entities[42].name).toBe('Super', 'name'); + // unmentioned property stays the same + expect(collection.entities[42].power).toBe('Fast', 'power'); + }); + + // Changed in v6. It used to add a new entity. + it('should NOT add new hero to collection', () => { + const hero: Hero = { id: 13, name: 'New One', power: 'Strong' }; + const action = createTestAction(toHeroUpdate(hero), false); + const state = entityReducer(initialCache, action); + const collection = state['Hero']; + + expect(collection.ids).toEqual([2, 1], 'no new hero:13'); + }); + }); + + describe('SAVE_UPDATE_ONE_ERROR', () => { + it('should only clear the loading flag', () => { + const { entityCache, updatedEntity } = createTestTrackedEntities(); + const originalAction = createAction( + 'Hero', + EntityOp.SAVE_UPDATE_ONE, + updatedEntity + ); + const error: EntityActionDataServiceError = { + error: new DataServiceError(new Error('Test Error'), { + method: 'PUT', + url: 'foo', + }), + originalAction, + }; + const action = createAction( + 'Hero', + EntityOp.SAVE_UPDATE_ONE_ERROR, + error + ); + expectOnlySetLoadingFlag(action, entityCache); + }); + }); + + describe('SAVE_UPDATE_MANY (Optimistic)', () => { + function createTestAction(heroes: Update[]) { + return createAction('Hero', EntityOp.SAVE_UPDATE_MANY, heroes, { + isOptimistic: true, + }); + } + + it('should update existing entities in collection', () => { + const heroes: Partial[] = [ + { id: 2, name: 'B+' }, + { id: 1, power: 'Updated Power' }, + ]; + const action = createTestAction(heroes.map(h => toHeroUpdate(h))); + const state = entityReducer(initialCache, action); + const collection = state['Hero']; + + expect(collection.ids).toEqual([2, 1], 'ids are the same'); + expect(collection.entities[1].name).toBe('A', '1 name unchanged'); + expect(collection.entities[1].power).toBe( + 'Updated Power', + '2 power updated' + ); + expect(collection.entities[2].name).toBe('B+', '2 name updated'); + expect(collection.entities[2].power).toBe('Fast', '2 power unchanged'); + }); + + it('can update existing entity key in collection', () => { + // Change the pkey (id) and the name of former hero:2 + const hero: Hero = { id: 42, name: 'Super' }; + const update = { id: 2, changes: hero }; + const action = createTestAction([update]); + const state = entityReducer(initialCache, action); + const collection = state['Hero']; + + expect(collection.ids).toEqual([42, 1], 'ids are the same'); + expect(collection.entities[42].name).toBe('Super', 'name'); + // unmentioned property stays the same + expect(collection.entities[42].power).toBe('Fast', 'power'); + }); + + // Changed in v6. It used to add a new entity. + it('should NOT add new hero to collection', () => { + const hero: Hero = { id: 13, name: 'New One', power: 'Strong' }; + const action = createTestAction([toHeroUpdate(hero)]); + const state = entityReducer(initialCache, action); + const collection = state['Hero']; + + expect(collection.ids).toEqual([2, 1], 'no new hero:13'); + }); + }); + + describe('SAVE_UPDATE_MANY (Pessimistic)', () => { + it('should only set the loading flag', () => { + const updatedEntity = { ...initialHeroes[0], name: 'Updated' }; + const update = { id: updatedEntity.id, changes: updatedEntity }; + const action = createAction('Hero', EntityOp.SAVE_UPDATE_MANY, [update]); + expectOnlySetLoadingFlag(action, initialCache); + }); + }); + + describe('SAVE_UPDATE_MANY_SUCCESS (Optimistic)', () => { + function createInitialAction(updates: Update[]) { + return createAction('Hero', EntityOp.SAVE_UPDATE_MANY, updates, { + isOptimistic: true, + }); + } + function createTestAction(updates: Update[]) { + return createAction('Hero', EntityOp.SAVE_UPDATE_MANY_SUCCESS, updates, { + isOptimistic: true, + }); + } + + it('should update existing entities when server adds its own changes', () => { + const updates = initialHeroes.map(h => { + return { id: h.id, changes: { ...h, name: 'Updated ' + h.name } }; + }); + + let action = createInitialAction(updates); + let entityCache = entityReducer(initialCache, action); + let collection = entityCache['Hero']; + + let name0 = updates[0].changes.name; + expect(name0).toContain('Updated', 'name updated before MANY_SUCCESS'); + + const id0 = updates[0].id; + name0 = 'Re-' + name0; // server's own change + updates[0] = { id: id0, changes: { ...updates[0].changes, name: name0 } }; + action = createTestAction(updates); + entityCache = entityReducer(entityCache, action); + collection = entityCache['Hero']; + + expect(collection.entities[id0].name).toBe(name0); + }); + + it('can update existing entity key', () => { + const { entityCache, updatedEntity } = createTestTrackedEntities(); + const ids = entityCache['Hero'].ids as number[]; + const id = updatedEntity.id; + // Server changed the pkey (id) and the name + const serverEntity = { id: 13, name: 'Server Update Name' }; + const update = { id: updatedEntity.id, changes: serverEntity }; + const action = createTestAction([update]); + const collection = entityReducer(entityCache, action)['Hero']; + + // Should have replaced updatedEntity.id with 13 + const newIds = ids.map(i => (i === id ? 13 : i)); + + expect(collection.ids).toEqual(newIds, 'server-changed id in the ids'); + expect(collection.entities[13].name).toBe(serverEntity.name, 'name'); + // unmentioned property stays the same + expect(collection.entities[13].power).toBe(updatedEntity.power, 'power'); + }); + + // Changed in v6. It used to add a new entity. + it('should NOT add new hero to collection', () => { + const { entityCache } = createTestTrackedEntities(); + const ids = entityCache['Hero'].ids; + const hero: Hero = { id: 13, name: 'New One', power: 'Strong' }; + const action = createTestAction([toHeroUpdate(hero)]); + const collection = entityReducer(entityCache, action)['Hero']; + + expect(collection.ids).toEqual(ids, 'no new hero:13'); + }); + }); + + describe('SAVE_UPDATE_MANY_SUCCESS (Pessimistic)', () => { + function createTestAction(updates: Update[]) { + return createAction('Hero', EntityOp.SAVE_UPDATE_MANY_SUCCESS, updates, { + isOptimistic: false, + }); + } + + it('should update existing entities in collection', () => { + const updates = initialHeroes.map(h => { + return { id: h.id, changes: { ...h, name: 'Updated ' + h.name } }; + }); + + const action = createTestAction(updates); + const collection = entityReducer(initialCache, action)['Hero']; + + expect(collection.ids).toEqual([2, 1], 'ids are the same'); + expect(collection.entities[1].name).toContain('Updated', '[1] name'); + expect(collection.entities[2].name).toContain('Updated', '[2] name'); + // unmentioned property stays the same + expect(collection.entities[2].power).toBe('Fast', 'power'); + }); + + it('can update existing entity key in collection', () => { + // Change the pkey (id) and the name of former hero:2 + const hero: Hero = { id: 42, name: 'Super' }; + const update = { id: 2, changes: hero }; + const action = createTestAction([update]); + const state = entityReducer(initialCache, action); + const collection = state['Hero']; + + expect(collection.ids).toEqual([42, 1], 'ids are the same'); + expect(collection.entities[42].name).toBe('Super', 'name'); + // unmentioned property stays the same + expect(collection.entities[42].power).toBe('Fast', 'power'); + }); + + // Changed in v6. It used to add a new entity. + it('should NOT add new hero to collection', () => { + const hero: Hero = { id: 13, name: 'New One', power: 'Strong' }; + const action = createTestAction([toHeroUpdate(hero)]); + const state = entityReducer(initialCache, action); + const collection = state['Hero']; + + expect(collection.ids).toEqual([2, 1], 'no new hero:13'); + }); + }); + + describe('SAVE_UPDATE_MANY_ERROR', () => { + it('should only clear the loading flag', () => { + const { entityCache, updatedEntity } = createTestTrackedEntities(); + const originalAction = createAction('Hero', EntityOp.SAVE_UPDATE_MANY, [ + updatedEntity, + ]); + const error: EntityActionDataServiceError = { + error: new DataServiceError(new Error('Test Error'), { + method: 'PUT', + url: 'foo', + }), + originalAction, + }; + const action = createAction( + 'Hero', + EntityOp.SAVE_UPDATE_MANY_ERROR, + error + ); + expectOnlySetLoadingFlag(action, entityCache); + }); + }); + + describe('SAVE_UPSERT_ONE (Optimistic)', () => { + function createTestAction(hero: Hero) { + return createAction('Hero', EntityOp.SAVE_UPSERT_ONE, hero, { + isOptimistic: true, + }); + } + + it('should add a new hero to collection', () => { + const hero: Hero = { id: 13, name: 'New One', power: 'Strong' }; + const action = createTestAction(hero); + const state = entityReducer(initialCache, action); + const collection = state['Hero']; + + expect(collection.ids).toEqual([2, 1, 13], 'should have new hero'); + }); + + it('should error if new hero lacks its pkey', () => { + const hero = { name: 'New One', power: 'Strong' }; + // bad add, no id. + const action = createTestAction(hero); + const state = entityReducer(initialCache, action); + expect(state).toBe(initialCache); + expect(action.payload.error!.message).toMatch( + /missing or invalid entity key/ + ); + }); + + it('should update an existing entity in collection', () => { + const hero: Hero = { id: 2, name: 'B+' }; + const action = createTestAction(hero); + const state = entityReducer(initialCache, action); + const collection = state['Hero']; + + expect(collection.ids).toEqual([2, 1], 'ids are the same'); + expect(collection.entities[2].name).toBe('B+', 'updated name'); + // unmentioned property stays the same + expect(collection.entities[2].power).toBe('Fast', 'power'); + }); + }); + + describe('SAVE_UPSERT_ONE (Pessimistic)', () => { + it('should only set the loading flag', () => { + const addedEntity = { id: 42, name: 'New Guy' }; + const action = createAction( + 'Hero', + EntityOp.SAVE_UPSERT_ONE, + addedEntity + ); + expectOnlySetLoadingFlag(action, initialCache); + }); + }); + + describe('SAVE_UPSERT_ONE_SUCCESS (Optimistic)', () => { + function createTestAction(hero: Hero) { + return createAction('Hero', EntityOp.SAVE_UPSERT_ONE_SUCCESS, hero, { + isOptimistic: true, + }); + } + + it('should add a new hero to collection, even if it was not among the saved upserted entities', () => { + // pretend this new hero was returned by the server instead of the one added by SAVE_UPSERT_ONE + const hero: Hero = { id: 13, name: 'Different New One', power: 'Strong' }; + const action = createTestAction(hero); + const state = entityReducer(initialCache, action); + const collection = state['Hero']; + + expect(collection.ids).toEqual([2, 1, 13]); + }); + + it('should error if new hero lacks its pkey', () => { + const hero = { name: 'New One', power: 'Strong' }; + // bad add, no id. + const action = createTestAction(hero); + const state = entityReducer(initialCache, action); + expect(state).toBe(initialCache); + expect(action.payload.error!.message).toMatch( + /missing or invalid entity key/ + ); + }); + + // because the hero was already upserted to the collection by SAVE_UPSERT_ONE + // should update values (but not id) if the server changed them + // as it might with a concurrency property. + it('should update an existing entity with that ID in collection', () => { + // This example simulates the server updating the name and power + const hero: Hero = { id: 2, name: 'Updated Name', power: 'Test Power' }; + const action = createTestAction(hero); + const state = entityReducer(initialCache, action); + const collection = state['Hero']; + + expect(collection.ids).toEqual([2, 1], 'ids are the same'); + expect(collection.entities[2].name).toBe('Updated Name'); + // unmentioned property updated too + expect(collection.entities[2].power).toBe('Test Power'); + }); + + // You cannot change the key with SAVE_UPSERT_MANY_SUCCESS + // You'd have to do it with SAVE_UPDATE_ONE... + it('should NOT change the id of an existing entity hero (will add instead)', () => { + const hero = initialHeroes[0]; + hero.id = 13; + + const action = createTestAction(hero); + const state = entityReducer(initialCache, action); + const collection = state['Hero']; + + expect(collection.ids).toEqual([2, 1, 13], 'should add the entity'); + expect(collection.entities[13].name).toEqual( + collection.entities[2].name, + 'copied name' + ); + }); + }); + + describe('SAVE_UPSERT_ONE_SUCCESS (Pessimistic)', () => { + function createTestAction(heroes: Hero) { + return createAction('Hero', EntityOp.SAVE_UPSERT_ONE_SUCCESS, heroes, { + isOptimistic: false, + }); + } + + it('should add new hero to collection', () => { + const hero: Hero = { id: 13, name: 'New A', power: 'Strong' }; + const action = createTestAction(hero); + const collection = entityReducer(initialCache, action)['Hero']; + expect(collection.ids).toEqual([2, 1, 13], 'added new hero'); + }); + + it('should error if new hero lacks its pkey', () => { + const hero: Hero = { + id: undefined as any, + name: 'New A', + power: 'Strong', + }; // missing id + const action = createTestAction(hero); + const state = entityReducer(initialCache, action); + expect(state).toBe(initialCache); + expect(action.payload.error!.message).toMatch( + /missing or invalid entity key/ + ); + }); + + it('should update an existing entity in collection', () => { + // This example simulates the server updating the name and power + const hero: Hero = { + id: 1, + name: 'Updated name A', + power: 'Updated power A', + }; + const action = createTestAction(hero); + const collection = entityReducer(initialCache, action)['Hero']; + + expect(collection.ids).toEqual([2, 1], 'ids are the same'); + expect(collection.entities[1].name).toBe('Updated name A'); + expect(collection.entities[1].power).toBe('Updated power A'); + }); + }); + + describe('SAVE_UPSERT_ONE_ERROR', () => { + it('should only clear the loading flag', () => { + const { entityCache, addedEntity } = createTestTrackedEntities(); + const originalAction = createAction( + 'Hero', + EntityOp.SAVE_UPSERT_ONE, + addedEntity + ); + const error: EntityActionDataServiceError = { + error: new DataServiceError(new Error('Test Error'), { + method: 'POST', + url: 'foo', + }), + originalAction, + }; + const action = createAction( + 'Hero', + EntityOp.SAVE_UPSERT_ONE_ERROR, + error + ); + expectOnlySetLoadingFlag(action, entityCache); + }); + }); + + describe('SAVE_UPSERT_MANY (Optimistic)', () => { + function createTestAction(heroes: Hero[]) { + return createAction('Hero', EntityOp.SAVE_UPSERT_MANY, heroes, { + isOptimistic: true, + }); + } + + it('should add new heroes to collection', () => { + const heroes: Hero[] = [ + { id: 13, name: 'New A', power: 'Strong' }, + { id: 14, name: 'New B', power: 'Swift' }, + ]; + const action = createTestAction(heroes); + const state = entityReducer(initialCache, action); + const collection = state['Hero']; + + expect(collection.ids).toEqual([2, 1, 13, 14], 'should have new hero'); + }); + + it('should error if one of new heroes lacks its pkey', () => { + const heroes: Hero[] = [ + { id: 13, name: 'New A', power: 'Strong' }, + { id: undefined as any, name: 'New B', power: 'Swift' }, // missing its id + ]; + const action = createTestAction(heroes); + const state = entityReducer(initialCache, action); + expect(state).toBe(initialCache); + expect(action.payload.error!.message).toMatch( + /does not have a valid entity key/ + ); + }); + + it('should update an existing entity in collection while adding new ones', () => { + const heroes: Hero[] = [ + { id: 13, name: 'New A', power: 'Strong' }, + { id: 2, name: 'B+' }, + { id: 14, name: 'New C', power: 'Swift' }, + ]; + const action = createTestAction(heroes); + const state = entityReducer(initialCache, action); + const collection = state['Hero']; + + expect(collection.ids).toEqual([2, 1, 13, 14], 'ids are the same'); + expect(collection.entities[2].name).toBe('B+', 'updated name'); + // unmentioned property stays the same + expect(collection.entities[2].power).toBe('Fast', 'power'); + }); + }); + + describe('SAVE_UPSERT_MANY (Pessimistic)', () => { + it('should only set the loading flag', () => { + const heroes: Hero[] = [ + { id: 13, name: 'New A', power: 'Strong' }, + { id: 14, name: 'New B', power: 'Swift' }, + ]; + const action = createAction('Hero', EntityOp.SAVE_UPSERT_MANY, heroes); + expectOnlySetLoadingFlag(action, initialCache); + }); + }); + + describe('SAVE_UPSERT_MANY_SUCCESS (Optimistic)', () => { + function createTestAction(heroes: Hero[]) { + return createAction('Hero', EntityOp.SAVE_UPSERT_MANY_SUCCESS, heroes, { + isOptimistic: true, + }); + } + + // server returned additional heroes + it('should add new heroes to collection, even if they were not among the saved upserted entities', () => { + const heroes: Hero[] = [ + { id: 13, name: 'New A', power: 'Strong' }, + { id: 2, name: 'Updated name' }, + { id: 14, name: 'New C', power: 'Swift' }, + ]; + const action = createTestAction(heroes); + const state = entityReducer(initialCache, action); + const collection = state['Hero']; + + expect(collection.ids).toEqual( + [2, 1, 13, 14], + 'should have include added heroes' + ); + }); + + // You cannot change the key with SAVE_UPSERT_MANY_SUCCESS + // You'd have to do it with SAVE_UPDATE_ONE... + it('should NOT change the id of an existing entity hero (will add instead)', () => { + const hero = initialHeroes[0]; + hero.id = 13; + + const action = createTestAction([hero]); + const state = entityReducer(initialCache, action); + const collection = state['Hero']; + + expect(collection.ids).toEqual([2, 1, 13], 'should add the entity'); + expect(collection.entities[13].name).toEqual( + collection.entities[2].name, + 'copied name' + ); + }); + + it('should error if new hero lacks its pkey', () => { + const heroes: Hero[] = [ + { id: undefined as any, name: 'New A', power: 'Strong' }, // missing its id + { id: 14, name: 'New B', power: 'Swift' }, + ]; + const action = createTestAction(heroes); + const state = entityReducer(initialCache, action); + expect(state).toBe(initialCache); + expect(action.payload.error!.message).toMatch( + /does not have a valid entity key/ + ); + }); + + // because the hero was already added to the collection by SAVE_UPSERT_MANY + // should update values (but not id) if the server changed them + // as it might with a concurrency property. + it('should update an existing entity with that ID in collection', () => { + const heroes: Hero[] = [ + { id: 13, name: 'New A', power: 'Strong' }, + { id: 2, name: 'Updated name', power: 'Updated power' }, + { id: 14, name: 'New C', power: 'Swift' }, + ]; + const action = createTestAction(heroes); + const state = entityReducer(initialCache, action); + const collection = state['Hero']; + + expect(collection.ids).toEqual([2, 1, 13, 14], 'ids'); + expect(collection.entities[2].name).toBe('Updated name'); + expect(collection.entities[2].power).toBe('Updated power'); + }); + }); + + describe('SAVE_UPSERT_MANY_SUCCESS (Pessimistic)', () => { + function createTestAction(heroes: Hero[]) { + return createAction('Hero', EntityOp.SAVE_UPSERT_MANY_SUCCESS, heroes, { + isOptimistic: false, + }); + } + + it('should add new heroes to collection', () => { + const heroes: Hero[] = [ + { id: 13, name: 'New A', power: 'Strong' }, + { id: 14, name: 'New B', power: 'Swift' }, + ]; + const action = createTestAction(heroes); + const collection = entityReducer(initialCache, action)['Hero']; + expect(collection.ids).toEqual([2, 1, 13, 14], 'added new heroes'); + }); + + it('should error if new hero lacks its pkey', () => { + const heroes: Hero[] = [ + { id: undefined as any, name: 'New A', power: 'Strong' }, // missing id + { id: 14, name: 'New B', power: 'Swift' }, + ]; + const action = createTestAction(heroes); + const state = entityReducer(initialCache, action); + expect(state).toBe(initialCache); + expect(action.payload.error!.message).toMatch( + /does not have a valid entity key/ + ); + }); + + it('should update an existing entity in collection', () => { + const heroes: Hero[] = [ + { id: 13, name: 'New A', power: 'Strong' }, + { id: 2, name: 'Updated name', power: 'Updated power' }, + { id: 14, name: 'New C', power: 'Swift' }, + ]; + const action = createTestAction(heroes); + const state = entityReducer(initialCache, action); + const collection = state['Hero']; + + expect(collection.ids).toEqual([2, 1, 13, 14], 'ids'); + expect(collection.entities[2].name).toBe('Updated name'); + expect(collection.entities[2].power).toBe('Updated power'); + }); + }); + + describe('SAVE_UPSERT_MANY_ERROR', () => { + it('should only clear the loading flag', () => { + const { + entityCache, + addedEntity, + updatedEntity, + } = createTestTrackedEntities(); + const originalAction = createAction('Hero', EntityOp.SAVE_UPSERT_MANY, [ + addedEntity, + updatedEntity, + ]); + const error: EntityActionDataServiceError = { + error: new DataServiceError(new Error('Test Error'), { + method: 'POST', + url: 'foo', + }), + originalAction, + }; + const action = createAction( + 'Hero', + EntityOp.SAVE_UPSERT_MANY_ERROR, + error + ); + expectOnlySetLoadingFlag(action, entityCache); + }); + }); + + // #endregion saves + + // #region cache-only + describe('ADD_ONE', () => { + function createTestAction(hero: Hero) { + return createAction('Hero', EntityOp.ADD_ONE, hero); + } + + it('should add a new hero to collection', () => { + const hero: Hero = { id: 13, name: 'New One', power: 'Strong' }; + const action = createTestAction(hero); + const state = entityReducer(initialCache, action); + const collection = state['Hero']; + + expect(collection.ids).toEqual([2, 1, 13], 'no new hero'); + }); + + it('should error if new hero lacks its pkey', () => { + const hero = { name: 'New One', power: 'Strong' }; + // bad add, no id. + const action = createTestAction(hero); + const state = entityReducer(initialCache, action); + expect(state).toBe(initialCache); + expect(action.payload.error!.message).toMatch( + /missing or invalid entity key/ + ); + }); + + it('should NOT update an existing entity in collection', () => { + const hero: Hero = { id: 2, name: 'B+' }; + const action = createTestAction(hero); + const state = entityReducer(initialCache, action); + const collection = state['Hero']; + + expect(collection.ids).toEqual([2, 1], 'ids are the same'); + expect(collection.entities[2].name).toBe('B', 'same old name'); + // unmentioned property stays the same + expect(collection.entities[2].power).toBe('Fast', 'power'); + }); + }); + + describe('UPDATE_MANY', () => { + function createTestAction(heroes: Update[]) { + return createAction('Hero', EntityOp.UPDATE_MANY, heroes); + } + + it('should not add new hero to collection', () => { + const heroes: Hero[] = [{ id: 3, name: 'New One' }]; + const updates = heroes.map(h => toHeroUpdate(h)); + const action = createTestAction(updates); + const state = entityReducer(initialCache, action); + const collection = state['Hero']; + + expect(collection.ids).toEqual([2, 1], 'no id:3'); + }); + + it('should update existing entity in collection', () => { + const heroes: Hero[] = [{ id: 2, name: 'B+' }]; + const updates = heroes.map(h => toHeroUpdate(h)); + const action = createTestAction(updates); + const state = entityReducer(initialCache, action); + const collection = state['Hero']; + + expect(collection.ids).toEqual([2, 1], 'ids are the same'); + expect(collection.entities[2].name).toBe('B+', 'name'); + // unmentioned property stays the same + expect(collection.entities[2].power).toBe('Fast', 'power'); + }); + + it('should update multiple existing entities in collection', () => { + const heroes: Hero[] = [ + { id: 1, name: 'A+' }, + { id: 2, name: 'B+' }, + { id: 3, name: 'New One' }, + ]; + const updates = heroes.map(h => toHeroUpdate(h)); + const action = createTestAction(updates); + const state = entityReducer(initialCache, action); + const collection = state['Hero']; + + // Did not add the 'New One' + expect(collection.ids).toEqual([2, 1], 'ids are the same'); + expect(collection.entities[1].name).toBe('A+', 'name'); + expect(collection.entities[2].name).toBe('B+', 'name'); + expect(collection.entities[2].power).toBe('Fast', 'power'); + }); + + it('can update existing entity key in collection', () => { + // Change the pkey (id) and the name of former hero:2 + const heroes: Hero[] = [{ id: 42, name: 'Super' }]; + const updates = [{ id: 2, changes: heroes[0] }]; + const action = createTestAction(updates); + const state = entityReducer(initialCache, action); + const collection = state['Hero']; + + expect(collection.ids).toEqual([42, 1], 'ids are the same'); + expect(collection.entities[42].name).toBe('Super', 'name'); + // unmentioned property stays the same + expect(collection.entities[42].power).toBe('Fast', 'power'); + }); + }); + + describe('UPDATE_ONE', () => { + function createTestAction(hero: Update) { + return createAction('Hero', EntityOp.UPDATE_ONE, hero); + } + + it('should not add a new hero to collection', () => { + const hero: Hero = { id: 13, name: 'New One', power: 'Strong' }; + const action = createTestAction(toHeroUpdate(hero)); + const state = entityReducer(initialCache, action); + const collection = state['Hero']; + + expect(collection.ids).toEqual([2, 1], 'no new hero'); + }); + + it('should error if new hero lacks its pkey', () => { + const hero = { name: 'New One', power: 'Strong' }; + // bad update: not an Update + const action = createTestAction(hero); + const state = entityReducer(initialCache, action); + expect(state).toBe(initialCache); + expect(action.payload.error!.message).toMatch( + /missing or invalid entity key/ + ); + }); + + it('should update existing entity in collection', () => { + const hero: Hero = { id: 2, name: 'B+' }; + const action = createTestAction(toHeroUpdate(hero)); + const state = entityReducer(initialCache, action); + const collection = state['Hero']; + + expect(collection.ids).toEqual([2, 1], 'ids are the same'); + expect(collection.entities[2].name).toBe('B+', 'name'); + // unmentioned property stays the same + expect(collection.entities[2].power).toBe('Fast', 'power'); + }); + + it('can update existing entity key in collection', () => { + // Change the pkey (id) and the name of former hero:2 + const hero: Hero = { id: 42, name: 'Super' }; + const update = { id: 2, changes: hero }; + const action = createTestAction(update); + const state = entityReducer(initialCache, action); + const collection = state['Hero']; + + expect(collection.ids).toEqual([42, 1], 'ids are the same'); + expect(collection.entities[42].name).toBe('Super', 'name'); + // unmentioned property stays the same + expect(collection.entities[42].power).toBe('Fast', 'power'); + }); + }); + + describe('UPSERT_MANY', () => { + function createTestAction(heroes: Hero[]) { + return createAction('Hero', EntityOp.UPSERT_MANY, heroes); + } + + it('should add new hero to collection', () => { + const updates: Hero[] = [{ id: 13, name: 'New One', power: 'Strong' }]; + const action = createTestAction(updates); + const state = entityReducer(initialCache, action); + const collection = state['Hero']; + + expect(collection.ids).toEqual([2, 1, 13], 'new hero:13'); + expect(collection.entities[13].name).toBe('New One', 'name'); + expect(collection.entities[13].power).toBe('Strong', 'power'); + }); + + it('should update existing entity in collection', () => { + const updates: Hero[] = [{ id: 2, name: 'B+' }]; + const action = createTestAction(updates); + const state = entityReducer(initialCache, action); + const collection = state['Hero']; + + expect(collection.ids).toEqual([2, 1], 'ids are the same'); + expect(collection.entities[2].name).toBe('B+', 'name'); + // unmentioned property stays the same + expect(collection.entities[2].power).toBe('Fast', 'power'); + }); + + it('should update multiple existing entities in collection', () => { + const updates: Hero[] = [ + { id: 1, name: 'A+' }, + { id: 2, name: 'B+' }, + { id: 13, name: 'New One', power: 'Strong' }, + ]; + const action = createTestAction(updates); + const state = entityReducer(initialCache, action); + const collection = state['Hero']; + + // Did not add the 'New One' + expect(collection.ids).toEqual([2, 1, 13], 'ids are the same'); + expect(collection.entities[1].name).toBe('A+', 'name'); + expect(collection.entities[2].name).toBe('B+', 'name'); + expect(collection.entities[2].power).toBe('Fast', 'power'); + expect(collection.entities[13].name).toBe('New One', 'name'); + expect(collection.entities[13].power).toBe('Strong', 'power'); + }); + }); + + describe('UPSERT_ONE', () => { + function createTestAction(hero: Hero) { + return createAction('Hero', EntityOp.UPSERT_ONE, hero); + } + + it('should add new hero to collection', () => { + const hero: Hero = { id: 13, name: 'New One', power: 'Strong' }; + const action = createTestAction(hero); + const state = entityReducer(initialCache, action); + const collection = state['Hero']; + + expect(collection.ids).toEqual([2, 1, 13], 'new hero:13'); + expect(collection.entities[13].name).toBe('New One', 'name'); + expect(collection.entities[13].power).toBe('Strong', 'power'); + }); + + it('should update existing entity in collection', () => { + const hero: Hero = { id: 2, name: 'B+' }; + const action = createTestAction(hero); + const state = entityReducer(initialCache, action); + const collection = state['Hero']; + + expect(collection.ids).toEqual([2, 1], 'ids are the same'); + expect(collection.entities[2].name).toBe('B+', 'name'); + // unmentioned property stays the same + expect(collection.entities[2].power).toBe('Fast', 'power'); + }); + }); + + describe('SET FLAGS', () => { + it('should set filter value with SET_FILTER', () => { + const action = createAction( + 'Hero', + EntityOp.SET_FILTER, + 'test filter value' + ); + const state = entityReducer(initialCache, action); + const collection = state['Hero']; + + expect(collection.filter).toEqual('test filter value'); + }); + + it('should set loaded flag with SET_LOADED', () => { + const beforeLoaded = initialCache['Hero'].loaded; + const expectedLoaded = !beforeLoaded; + const action = createAction('Hero', EntityOp.SET_LOADED, expectedLoaded); + const state = entityReducer(initialCache, action); + const collection = state['Hero']; + + expect(collection.loaded).toEqual(expectedLoaded, 'loaded flag'); + }); + + it('should set loading flag with SET_LOADING', () => { + const beforeLoading = initialCache['Hero'].loading; + const expectedLoading = !beforeLoading; + const action = createAction( + 'Hero', + EntityOp.SET_LOADING, + expectedLoading + ); + const state = entityReducer(initialCache, action); + const collection = state['Hero']; + + expect(collection.loading).toEqual(expectedLoading, 'loading flag'); + }); + }); + // #endregion cache-only + + /** TODO: TEST REMAINING ACTIONS **/ + + /*** + * Todo: test all other reducer actions + * Not a high priority because these other EntityReducer methods delegate to the + * @ngrx/entity EntityAdapter reducer methods which are presumed to be well tested. + ***/ + + describe('reducer override', () => { + const queryLoadAction = createAction('Hero', EntityOp.QUERY_LOAD); + + beforeEach(() => { + const eds = new EntityDefinitionService([metadata]); + const def = eds.getDefinition('Hero'); + const reducer = createReadOnlyHeroReducer(def.entityAdapter); + // override regular Hero reducer + entityReducerRegistry.registerReducer('Hero', reducer); + }); + + // Make sure read-only reducer doesn't change QUERY_ALL behavior + it('QUERY_LOAD_SUCCESS —clears loading flag and fills collection', () => { + let state = entityReducer({}, queryLoadAction); + let collection = state['Hero']; + expect(collection.loaded).toBe(false, 'should not be loaded at first'); + expect(collection.loading).toBe(true, 'should be loading at first'); + + const heroes: Hero[] = [{ id: 2, name: 'B' }, { id: 1, name: 'A' }]; + const action = createAction('Hero', EntityOp.QUERY_LOAD_SUCCESS, heroes); + state = entityReducer(state, action); + collection = state['Hero']; + expect(collection.ids).toEqual( + [2, 1], + 'should have expected ids in load order' + ); + expect(collection.entities['1']).toBe(heroes[1], 'hero with id:1'); + expect(collection.entities['2']).toBe(heroes[0], 'hero with id:2'); + expect(collection.loaded).toBe(true, 'should be loaded '); + expect(collection.loading).toBe(false, 'should not be loading'); + }); + + it('QUERY_LOAD_ERROR clears loading flag and does not fill collection', () => { + let state = entityReducer({}, queryLoadAction); + const action = createAction('Hero', EntityOp.QUERY_LOAD_ERROR); + state = entityReducer(state, action); + const collection = state['Hero']; + expect(collection.loading).toBe(false, 'should not be loading'); + expect(collection.ids.length).toBe(0, 'should be empty collection'); + }); + + it('QUERY_LOAD_SUCCESS works for "Villain" entity with non-id primary key', () => { + let state = entityReducer({}, queryLoadAction); + const villains: Villain[] = [ + { key: '2', name: 'B' }, + { key: '1', name: 'A' }, + ]; + const action = createAction( + 'Villain', + EntityOp.QUERY_LOAD_SUCCESS, + villains + ); + state = entityReducer(state, action); + const collection = state['Villain']; + expect(collection.loading).toBe(false, 'should not be loading'); + expect(collection.ids).toEqual( + ['2', '1'], + 'should have expected ids in load order' + ); + expect(collection.entities['1']).toBe(villains[1], 'villain with key:1'); + expect(collection.entities['2']).toBe(villains[0], 'villain with key:2'); + }); + + it('QUERY_MANY is illegal for "Hero" collection', () => { + const initialState = entityReducer({}, queryLoadAction); + + const action = createAction('Hero', EntityOp.QUERY_MANY); + const state = entityReducer(initialState, action); + + // Expect override reducer to throw error and for + // EntityReducer to catch it and set the `EntityAction.payload.error` + expect(action.payload.error!.message).toMatch( + /illegal operation for the "Hero" collection/ + ); + expect(state).toBe(initialState); + }); + + it('QUERY_MANY still works for "Villain" collection', () => { + const action = createAction('Villain', EntityOp.QUERY_MANY); + const state = entityReducer({}, action); + const collection = state['Villain']; + expect(collection.loading).toBe(true, 'should be loading'); + }); + + /** Make Hero collection readonly except for QUERY_LOAD */ + function createReadOnlyHeroReducer(adapter: EntityAdapter) { + return function heroReducer( + collection: EntityCollection, + action: EntityAction + ): EntityCollection { + switch (action.payload.entityOp) { + case EntityOp.QUERY_LOAD: + return collection.loading + ? collection + : { ...collection, loading: true }; + + case EntityOp.QUERY_LOAD_SUCCESS: + return { + ...adapter.addAll(action.payload.data, collection), + loaded: true, + loading: false, + changeState: {}, + }; + + case EntityOp.QUERY_LOAD_ERROR: { + return collection.loading + ? { ...collection, loading: false } + : collection; + } + + default: + throw new Error( + `${ + action.payload.entityOp + } is an illegal operation for the "Hero" collection` + ); + } + }; + } + }); + + // #region helpers + function createCollection( + entityName: string, + data: T[], + selectId: IdSelector + ) { + return { + ...collectionCreator.create(entityName), + ids: data.map(e => selectId(e)) as string[] | number[], + entities: data.reduce( + (acc, e) => { + acc[selectId(e)] = e; + return acc; + }, + {} as any + ), + } as EntityCollection; + } + + function createInitialCache(entityMap: { [entityName: string]: any[] }) { + const cache: EntityCache = {}; + // tslint:disable-next-line:forin + for (const entityName in entityMap) { + const selectId = + metadata[entityName].selectId || ((entity: any) => entity.id); + cache[entityName] = createCollection( + entityName, + entityMap[entityName], + selectId + ); + } + + return cache; + } + + /** + * Prepare the state of the collection with some test data. + * Assumes that ADD_ALL, ADD_ONE, REMOVE_ONE, and UPDATE_ONE are working + */ + function createTestTrackedEntities() { + const startingHeroes = [ + { id: 2, name: 'B', power: 'Fast' }, + { id: 1, name: 'A', power: 'Invisible' }, + { id: 3, name: 'C', power: 'Strong' }, + ]; + + const [removedEntity, preUpdatedEntity] = startingHeroes; + let action = createAction('Hero', EntityOp.ADD_ALL, startingHeroes); + let entityCache = entityReducer({}, action); + + const addedEntity = { id: 42, name: 'E', power: 'Smart' }; + action = createAction('Hero', EntityOp.ADD_ONE, addedEntity); + entityCache = entityReducer(entityCache, action); + + action = createAction('Hero', EntityOp.REMOVE_ONE, removedEntity.id); + entityCache = entityReducer(entityCache, action); + + const updatedEntity = { ...preUpdatedEntity, name: 'A Updated' }; + action = createAction('Hero', EntityOp.UPDATE_ONE, { + id: updatedEntity.id, + changes: updatedEntity, + }); + entityCache = entityReducer(entityCache, action); + + return { + entityCache, + addedEntity, + removedEntity, + preUpdatedEntity, + startingHeroes, + updatedEntity, + }; + } + + /** Test for ChangeState with expected ChangeType */ + function expectChangeType( + change: ChangeState, + expectedChangeType: ChangeType, + msg?: string + ) { + expect(ChangeType[change.changeType]).toEqual( + ChangeType[expectedChangeType], + msg + ); + } + + /** Test that loading flag changed in expected way and the rest of the collection stayed the same. */ + function expectOnlySetLoadingFlag( + action: EntityAction, + entityCache: EntityCache + ) { + // Flag should be true when op starts, false after error or success + const expectedLoadingFlag = !/error|success/i.test(action.payload.entityOp); + const initialCollection = entityCache['Hero']; + const newCollection = entityReducer(entityCache, action)['Hero']; + expect(newCollection.loading).toBe(expectedLoadingFlag, 'loading flag'); + expect({ + ...newCollection, + loading: initialCollection.loading, // revert flag for test + }).toEqual(initialCollection); + } + // #endregion helpers +}); diff --git a/modules/data/spec/selectors/entity-selectors$.spec.ts b/modules/data/spec/selectors/entity-selectors$.spec.ts new file mode 100644 index 0000000000..9d29971534 --- /dev/null +++ b/modules/data/spec/selectors/entity-selectors$.spec.ts @@ -0,0 +1,318 @@ +import { Action, MemoizedSelector, Store } from '@ngrx/store'; + +import { BehaviorSubject, Observable, Subject } from 'rxjs'; +import { + EntityMetadata, + EntityCache, + EntitySelectors$Factory, + EntitySelectorsFactory, + createEntityCacheSelector, + ENTITY_CACHE_NAME, + EntityCollection, + EntityActionFactory, + EntityOp, + createEmptyEntityCollection, + PropsFilterFnFactory, + EntitySelectors$, + EntitySelectors, +} from '../../'; + +describe('EntitySelectors$', () => { + /** HeroMetadata identifies extra collection state properties */ + const heroMetadata: EntityMetadata = { + entityName: 'Hero', + filterFn: nameFilter, + additionalCollectionState: { + foo: 'Foo', + bar: 3.14, + }, + }; + + /** As entityAdapter.initialState would create it */ + const emptyHeroCollection = createHeroState({ foo: 'foo', bar: 3.14 }); + + const villainMetadata: EntityMetadata = { + entityName: 'Villain', + selectId: villain => villain.key, + }; + + // Hero has a super-set of EntitySelectors$ + describe('EntitySelectors$Factory.create (Hero)', () => { + // Some immutable cache states + const emptyCache: EntityCache = {}; + + const initializedHeroCache: EntityCache = { + // The state of the HeroCollection in this test suite + // as the EntityReducer might initialize it. + Hero: emptyHeroCollection, + }; + + let collectionCreator: any; + + let bar: number; + let collection: HeroCollection; + let foo: string; + let heroes: Hero[]; + let loaded: boolean; + let loading: boolean; + + // The store during tests will be the entity cache + let store: Store<{ entityCache: EntityCache }>; + + // Observable of state changes, which these tests simulate + let state$: BehaviorSubject<{ entityCache: EntityCache }>; + + let actions$: Subject; + + const nextCacheState = (cache: EntityCache) => + state$.next({ entityCache: cache }); + + let heroCollectionSelectors: HeroSelectors; + + let factory: EntitySelectors$Factory; + + beforeEach(() => { + actions$ = new Subject(); + state$ = new BehaviorSubject({ entityCache: emptyCache }); + store = new Store<{ entityCache: EntityCache }>( + state$, + null as any, + null as any + ); + + // EntitySelectors + collectionCreator = jasmine.createSpyObj('entityCollectionCreator', [ + 'create', + ]); + collectionCreator.create.and.returnValue(emptyHeroCollection); + const entitySelectorsFactory = new EntitySelectorsFactory( + collectionCreator + ); + heroCollectionSelectors = entitySelectorsFactory.create< + Hero, + HeroSelectors + >(heroMetadata); + + // EntitySelectorFactory + factory = new EntitySelectors$Factory( + store, + actions$ as any, + createEntityCacheSelector(ENTITY_CACHE_NAME) + ); + + // listen for changes to the hero collection + store + .select(ENTITY_CACHE_NAME, 'Hero') + .subscribe((c: HeroCollection) => (collection = c)); + }); + + function subscribeToSelectors(selectors$: HeroSelectors$) { + selectors$.entities$.subscribe(h => (heroes = h)); + selectors$.loaded$.subscribe(l => (loaded = l)); + selectors$.loading$.subscribe(l => (loading = l)); + selectors$.foo$.subscribe(f => (foo = f)); + selectors$.bar$.subscribe(b => (bar = b)); + } + + it('can select$ the default empty collection when store collection is undefined ', () => { + const selectors$ = factory.create( + 'Hero', + heroCollectionSelectors + ); + let selectorCollection: EntityCollection; + selectors$.collection$.subscribe(c => (selectorCollection = c)); + expect(selectorCollection!).toBeDefined('selector collection'); + expect(selectorCollection!.entities).toEqual({}, 'entities'); + + // Important: the selector is returning these values; + // They are not actually in the store's entity cache collection! + expect(collection).toBeUndefined( + 'no collection until reducer creates it.' + ); + }); + + it('selectors$ emit default empty values when collection is undefined', () => { + const selectors$ = factory.create( + 'Hero', + heroCollectionSelectors + ); + + subscribeToSelectors(selectors$); + + expect(heroes).toEqual([], 'no heroes by default'); + expect(loaded).toBe(false, 'loaded is false by default'); + expect(loading).toBe(false, 'loading is false by default'); + expect(foo).toBe('foo', 'default foo value is "foo"'); + expect(bar).toBe(3.14, 'no default bar value is 3.14'); + }); + + it('selectors$ emit updated hero values', () => { + const selectors$ = factory.create( + 'Hero', + heroCollectionSelectors + ); + + subscribeToSelectors(selectors$); + + // prime the store for Hero first use as the EntityReducer would + nextCacheState(initializedHeroCache); + + // set foo and add an entity as the reducer would + collection = { + ...collection, + ...{ + foo: 'FooDoo', + ids: [42], + loaded: true, + entities: { 42: { id: 42, name: 'Bob' } }, + }, + }; + + // update the store as a reducer would + nextCacheState({ ...emptyCache, Hero: collection }); + + // Selectors$ should have emitted the updated values. + expect(heroes).toEqual([{ id: 42, name: 'Bob' }], 'added a hero'); + expect(loaded).toBe(true, 'loaded'); // as if had QueryAll + expect(loading).toBe(false, 'loading'); // didn't change + expect(foo).toEqual('FooDoo', 'updated foo value'); + expect(bar).toEqual(3.14, 'still the initial value'); // didn't change + }); + + it('selectors$ emit supplied defaultCollectionState when collection is undefined', () => { + // N.B. This is an absurd default state, suitable for test purposes only. + // The default state feature exists to prevent selectors$ subscriptions + // from bombing before the collection is initialized or + // during time-travel debugging. + const defaultHeroState = createHeroState({ + ids: [1], + entities: { 1: { id: 1, name: 'A' } }, + loaded: true, + foo: 'foo foo', + bar: 42, + }); + + collectionCreator.create.and.returnValue(defaultHeroState); + const selectors$ = factory.create( + 'Hero', + heroCollectionSelectors + ); // <- override default state + + subscribeToSelectors(selectors$); + + expect(heroes).toEqual([{ id: 1, name: 'A' }], 'default state heroes'); + expect(foo).toEqual('foo foo', 'has default foo'); + expect(bar).toEqual(42, 'has default bar'); + + // Important: the selector is returning these values; + // They are not actually in the store's entity cache collection! + expect(collection).toBeUndefined( + 'no collection until reducer creates it.' + ); + }); + + it('`entityCache$` should observe the entire entity cache', () => { + const entityCacheValues: any = []; + factory.entityCache$.subscribe(ec => entityCacheValues.push(ec)); + + // prime the store for Hero first use as the EntityReducer would + nextCacheState(initializedHeroCache); + + expect(entityCacheValues.length).toEqual(2, 'set the cache twice'); + expect(entityCacheValues[0]).toEqual({}, 'empty at first'); + expect(entityCacheValues[1].Hero).toBeDefined('has Hero collection'); + }); + + it('`actions$` emits hero collection EntityActions and no other actions', () => { + const actionsReceived: Action[] = []; + const selectors$ = factory.create( + 'Hero', + heroCollectionSelectors + ); + const entityActions$ = selectors$.entityActions$; + entityActions$.subscribe(action => actionsReceived.push(action)); + + const eaFactory = new EntityActionFactory(); + actions$.next({ type: 'Generic action' }); + // EntityAction but not for heroes + actions$.next(eaFactory.create('Villain', EntityOp.QUERY_ALL)); + // Hero EntityAction + const heroAction = eaFactory.create('Hero', EntityOp.QUERY_ALL); + actions$.next(heroAction); + + expect(actionsReceived.length).toBe(1, 'only one hero action'); + expect(actionsReceived[0]).toBe(heroAction, 'expected hero action'); + }); + + it('`errors$` emits hero collection EntityAction errors and no other actions', () => { + const actionsReceived: Action[] = []; + const selectors$ = factory.create( + 'Hero', + heroCollectionSelectors + ); + const errors$ = selectors$.errors$; + errors$.subscribe(action => actionsReceived.push(action)); + + const eaFactory = new EntityActionFactory(); + actions$.next({ type: 'Generic action' }); + // EntityAction error but not for heroes + actions$.next(eaFactory.create('Villain', EntityOp.QUERY_ALL_ERROR)); + // Hero EntityAction (but not an error) + actions$.next(eaFactory.create('Hero', EntityOp.QUERY_ALL)); + // Hero EntityAction Error + const heroErrorAction = eaFactory.create( + 'Hero', + EntityOp.QUERY_ALL_ERROR + ); + actions$.next(heroErrorAction); + expect(actionsReceived.length).toBe(1, 'only one hero action'); + expect(actionsReceived[0]).toBe( + heroErrorAction, + 'expected error hero action' + ); + }); + }); +}); + +/////// Test values and helpers ///////// + +function createHeroState(state: Partial): HeroCollection { + return { + ...createEmptyEntityCollection('Hero'), + ...state, + } as HeroCollection; +} + +function nameFilter(entities: T[], pattern: string) { + return PropsFilterFnFactory(['name'])(entities, pattern); +} + +/// Hero +interface Hero { + id: number; + name: string; +} + +/** HeroCollection is EntityCollection with extra collection properties */ +interface HeroCollection extends EntityCollection { + foo: string; + bar: number; +} + +/** HeroSelectors identifies the extra selectors for the extra collection properties */ +interface HeroSelectors extends EntitySelectors { + selectFoo: MemoizedSelector; + selectBar: MemoizedSelector; +} + +/** HeroSelectors identifies the extra selectors for the extra collection properties */ +interface HeroSelectors$ extends EntitySelectors$ { + foo$: Observable | Store; + bar$: Observable | Store; +} + +/// Villain +interface Villain { + key: string; + name: string; +} diff --git a/modules/data/spec/selectors/entity-selectors.spec.ts b/modules/data/spec/selectors/entity-selectors.spec.ts new file mode 100644 index 0000000000..eccf00e108 --- /dev/null +++ b/modules/data/spec/selectors/entity-selectors.spec.ts @@ -0,0 +1,230 @@ +import { MemoizedSelector } from '@ngrx/store'; +import { + EntityMetadata, + EntitySelectorsFactory, + EntityCollection, + createEmptyEntityCollection, + PropsFilterFnFactory, + EntitySelectors, +} from '../../'; + +describe('EntitySelectors', () => { + /** HeroMetadata identifies the extra collection state properties */ + const heroMetadata: EntityMetadata = { + entityName: 'Hero', + filterFn: nameFilter, + additionalCollectionState: { + foo: 'Foo', + bar: 3.14, + }, + }; + + const villainMetadata: EntityMetadata = { + entityName: 'Villain', + selectId: villain => villain.key, + }; + + let collectionCreator: any; + let entitySelectorsFactory: EntitySelectorsFactory; + + beforeEach(() => { + collectionCreator = jasmine.createSpyObj('entityCollectionCreator', [ + 'create', + ]); + entitySelectorsFactory = new EntitySelectorsFactory(collectionCreator); + }); + + describe('#createCollectionSelector', () => { + const initialState = createHeroState({ + ids: [1], + entities: { 1: { id: 1, name: 'A' } }, + foo: 'foo foo', + bar: 42, + }); + + it('creates collection selector that defaults to initial state', () => { + collectionCreator.create.and.returnValue(initialState); + const selectors = entitySelectorsFactory.createCollectionSelector< + Hero, + HeroCollection + >('Hero'); + const state = { entityCache: {} }; // ngrx store with empty cache + const collection = selectors(state); + expect(collection.entities).toEqual(initialState.entities, 'entities'); + expect(collection.foo).toEqual('foo foo', 'foo'); + expect(collectionCreator.create).toHaveBeenCalled(); + }); + + it('collection selector should return cached collection when it exists', () => { + // must specify type-args when initialState isn't available for type inference + const selectors = entitySelectorsFactory.createCollectionSelector< + Hero, + HeroCollection + >('Hero'); + + // ngrx store with populated Hero collection + const state = { + entityCache: { + Hero: { + ids: [42], + entities: { 42: { id: 42, name: 'The Answer' } }, + filter: '', + loading: true, + foo: 'towel', + bar: 0, + }, + }, + }; + + const collection = selectors(state); + expect(collection.entities[42]).toEqual( + { id: 42, name: 'The Answer' }, + 'entities' + ); + expect(collection.foo).toBe('towel', 'foo'); + expect(collectionCreator.create).not.toHaveBeenCalled(); + }); + }); + + describe('#createEntitySelectors', () => { + let heroCollection: HeroCollection; + let heroEntities: Hero[]; + + beforeEach(() => { + heroEntities = [{ id: 42, name: 'A' }, { id: 48, name: 'B' }]; + + heroCollection = ({ + ids: [42, 48], + entities: { + 42: heroEntities[0], + 48: heroEntities[1], + }, + filter: 'B', + foo: 'Foo', + }); + }); + + it('should have expected Hero selectors (a super-set of EntitySelectors)', () => { + const store = { entityCache: { Hero: heroCollection } }; + + const selectors = entitySelectorsFactory.create( + heroMetadata + ); + + expect(selectors.selectEntities).toBeDefined('selectEntities'); + expect(selectors.selectEntities(store)).toEqual( + heroEntities, + 'selectEntities' + ); + + expect(selectors.selectFilteredEntities(store)).toEqual( + heroEntities.filter(h => h.name === 'B'), + 'filtered B heroes' + ); + + expect(selectors.selectFoo).toBeDefined('selectFoo exists'); + expect(selectors.selectFoo(store)).toBe('Foo', 'execute `selectFoo`'); + }); + + it('should have all Hero when create EntitySelectorFactory directly', () => { + const store = { entityCache: { Hero: heroCollection } }; + + // Create EntitySelectorFactory directly rather than injecting it! + // Works ONLY if have not changed the name of the EntityCache. + // In this case, where also not supplying the EntityCollectionCreator + // selector for additional collection properties might fail, + // but doesn't in this test because the additional Foo property is in the store. + + const eaFactory = new EntitySelectorsFactory(); + const selectors = eaFactory.create(heroMetadata); + + expect(selectors.selectEntities).toBeDefined('selectEntities'); + expect(selectors.selectEntities(store)).toEqual( + heroEntities, + 'selectEntities' + ); + + expect(selectors.selectFilteredEntities(store)).toEqual( + heroEntities.filter(h => h.name === 'B'), + 'filtered B heroes' + ); + + expect(selectors.selectFoo).toBeDefined('selectFoo exists'); + expect(selectors.selectFoo(store)).toBe('Foo', 'execute `selectFoo`'); + }); + + it('should create default selectors (no filter, no extras) when create with "Hero" instead of hero metadata', () => { + const store = { entityCache: { Hero: heroCollection } }; + + // const selectors = entitySelectorsFactory.create('Hero'); + // There won't be extra selectors so type selectors for Hero collection only + const selectors = entitySelectorsFactory.create('Hero'); + expect(selectors.selectEntities).toBeDefined('selectEntities'); + expect(selectors.selectFoo).not.toBeDefined('selectFoo should not exist'); + expect(selectors.selectFilteredEntities(store)).toEqual( + heroEntities, + 'filtered same as all hero entities' + ); + }); + + it('should have expected Villain selectors', () => { + const collection = >({ + ids: [24], + entities: { 24: { key: 'evil', name: 'A' } }, + filter: 'B', // doesn't matter because no filter function + }); + const store = { entityCache: { Villain: collection } }; + + const selectors = entitySelectorsFactory.create(villainMetadata); + const expectedEntities: Villain[] = [{ key: 'evil', name: 'A' }]; + + expect(selectors.selectEntities).toBeDefined('selectAll'); + expect(selectors.selectEntities(store)).toEqual( + expectedEntities, + 'try selectAll' + ); + + expect(selectors.selectFilteredEntities(store)).toEqual( + expectedEntities, + 'all villains because no filter fn' + ); + }); + }); +}); + +/////// Test values and helpers ///////// + +function createHeroState(state: Partial): HeroCollection { + return { + ...createEmptyEntityCollection('Hero'), + ...state, + } as HeroCollection; +} + +function nameFilter(entities: T[], pattern: string) { + return PropsFilterFnFactory(['name'])(entities, pattern); +} + +/// Hero +interface Hero { + id: number; + name: string; +} + +/** HeroCollection is EntityCollection with extra collection properties */ +interface HeroCollection extends EntityCollection { + foo: string; + bar: number; +} + +/** HeroSelectors identifies the extra selectors for the extra collection properties */ +interface HeroSelectors extends EntitySelectors { + selectFoo: MemoizedSelector; + selectBar: MemoizedSelector; +} + +/// Villain +interface Villain { + key: string; + name: string; +} diff --git a/modules/data/spec/selectors/related-entity-selectors.spec.ts b/modules/data/spec/selectors/related-entity-selectors.spec.ts new file mode 100644 index 0000000000..fc5c6b7fab --- /dev/null +++ b/modules/data/spec/selectors/related-entity-selectors.spec.ts @@ -0,0 +1,493 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { + Action, + createSelector, + Selector, + StoreModule, + Store, +} from '@ngrx/store'; +import { Actions } from '@ngrx/effects'; +import { Update } from '@ngrx/entity'; + +import { Observable, Subject } from 'rxjs'; +import { skip } from 'rxjs/operators'; + +import { + EntityMetadataMap, + EntityActionFactory, + EntitySelectorsFactory, + EntityCache, + NgrxDataModuleWithoutEffects, + ENTITY_METADATA_TOKEN, + EntityOp, + EntityAction, +} from '../../'; + +const entityMetadataMap: EntityMetadataMap = { + Battle: {}, + Hero: {}, + HeroPowerMap: {}, + Power: { + sortComparer: sortByName, + }, + Sidekick: {}, +}; + +describe('Related-entity Selectors', () => { + // #region setup + let eaFactory: EntityActionFactory; + let entitySelectorsFactory: EntitySelectorsFactory; + let store: Store; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [StoreModule.forRoot({}), NgrxDataModuleWithoutEffects], + providers: [ + // required by NgrxData but not used in these tests + { provide: Actions, useValue: null }, + { + provide: ENTITY_METADATA_TOKEN, + multi: true, + useValue: entityMetadataMap, + }, + ], + }); + + store = TestBed.get(Store); + eaFactory = TestBed.get(EntityActionFactory); + entitySelectorsFactory = TestBed.get(EntitySelectorsFactory); + initializeCache(eaFactory, store); + }); + + // #endregion setup + + describe('hero -> sidekick (1-1)', () => { + function setCollectionSelectors() { + const heroSelectors = entitySelectorsFactory.create('Hero'); + const selectHeroMap = heroSelectors.selectEntityMap; + + const sidekickSelectors = entitySelectorsFactory.create( + 'Sidekick' + ); + const selectSidekickMap = sidekickSelectors.selectEntityMap; + + return { + selectHeroMap, + selectSidekickMap, + }; + } + + function createHeroSidekickSelector$(heroId: number): Observable { + const { selectHeroMap, selectSidekickMap } = setCollectionSelectors(); + const selectHero = createSelector( + selectHeroMap, + heroes => heroes[heroId] + ); + const selectSideKick = createSelector( + selectHero, + selectSidekickMap, + (hero, sidekicks) => { + const sidekickId = hero && hero.sidekickFk!; + return sidekicks[sidekickId]; + } + ); + return store.select(selectSideKick); + } + + // Note: async done() callback ensures test passes only if subscribe(successCallback()) called. + + it('should get Alpha Hero sidekick', (done: DoneFn) => { + createHeroSidekickSelector$(1).subscribe(sk => { + expect(sk.name).toBe('Bob'); + done(); + }); + }); + + it('should get Alpha Hero updated sidekick', (done: DoneFn) => { + // Skip the initial sidekick and check the one after update + createHeroSidekickSelector$(1) + .pipe(skip(1)) + .subscribe(sk => { + expect(sk.name).toBe('Robert'); + done(); + }); + + // update the related sidekick + const action = eaFactory.create>( + 'Sidekick', + EntityOp.UPDATE_ONE, + { id: 1, changes: { id: 1, name: 'Robert' } } + ); + store.dispatch(action); + }); + + it('should get Alpha Hero changed sidekick', (done: DoneFn) => { + // Skip the initial sidekick and check the one after update + createHeroSidekickSelector$(1) + .pipe(skip(1)) + .subscribe(sk => { + expect(sk.name).toBe('Sally'); + done(); + }); + + // update the hero's sidekick from fk=1 to fk=2 + const action = eaFactory.create>( + 'Hero', + EntityOp.UPDATE_ONE, + { id: 1, changes: { id: 1, sidekickFk: 2 } } // Sally + ); + store.dispatch(action); + }); + + it('changing a different hero should NOT trigger first hero selector', (done: DoneFn) => { + let alphaCount = 0; + + createHeroSidekickSelector$(1).subscribe(sk => { + alphaCount += 1; + }); + + // update a different hero's sidekick from fk=2 (Sally) to fk=1 (Bob) + createHeroSidekickSelector$(2) + .pipe(skip(1)) + .subscribe(sk => { + expect(sk.name).toBe('Bob'); + expect(alphaCount).toEqual( + 1, + 'should only callback for Hero #1 once' + ); + done(); + }); + + const action = eaFactory.create>( + 'Hero', + EntityOp.UPDATE_ONE, + { id: 2, changes: { id: 2, sidekickFk: 1 } } // Bob + ); + store.dispatch(action); + }); + + it('should get undefined sidekick if hero not found', (done: DoneFn) => { + createHeroSidekickSelector$(1234).subscribe(sk => { + expect(sk).toBeUndefined(); + done(); + }); + }); + + it('should get undefined sidekick from Gamma because it has no sidekickFk', (done: DoneFn) => { + createHeroSidekickSelector$(3).subscribe(sk => { + expect(sk).toBeUndefined(); + done(); + }); + }); + + it('should get Gamma sidekick after creating and assigning one', (done: DoneFn) => { + // Skip(1), the initial state in which Gamma has no sidekick + // Note that BOTH dispatches complete synchronously, before the selector updates + // so we only have to skip one. + createHeroSidekickSelector$(3) + .pipe(skip(1)) + .subscribe(sk => { + expect(sk.name).toBe('Robin'); + done(); + }); + + // create a new sidekick + let action: EntityAction = eaFactory.create( + 'Sidekick', + EntityOp.ADD_ONE, + { + id: 42, + name: 'Robin', + } + ); + store.dispatch(action); + + // assign new sidekick to Gamma + action = eaFactory.create>('Hero', EntityOp.UPDATE_ONE, { + id: 3, + changes: { id: 3, sidekickFk: 42 }, + }); + store.dispatch(action); + }); + }); + + describe('hero -> battles (1-m)', () => { + function setCollectionSelectors() { + const heroSelectors = entitySelectorsFactory.create('Hero'); + const selectHeroMap = heroSelectors.selectEntityMap; + + const battleSelectors = entitySelectorsFactory.create('Battle'); + const selectBattleEntities = battleSelectors.selectEntities; + + const selectHeroBattleMap = createSelector( + selectBattleEntities, + battles => + battles.reduce( + (acc, battle) => { + const hid = battle.heroFk; + if (hid) { + const hbs = acc[hid]; + if (hbs) { + hbs.push(battle); + } else { + acc[hid] = [battle]; + } + } + return acc; + }, + {} as { [heroId: number]: Battle[] } + ) + ); + + return { + selectHeroMap, + selectHeroBattleMap, + }; + } + + function createHeroBattlesSelector$(heroId: number): Observable { + const { selectHeroMap, selectHeroBattleMap } = setCollectionSelectors(); + + const selectHero = createSelector( + selectHeroMap, + heroes => heroes[heroId] + ); + + const selectHeroBattles = createSelector( + selectHero, + selectHeroBattleMap, + (hero, heroBattleMap) => { + const hid = hero && hero.id; + return heroBattleMap[hid] || []; + } + ); + return store.select(selectHeroBattles); + } + + // TODO: more tests + // Note: async done() callback ensures test passes only if subscribe(successCallback()) called. + + it('should get Alpha Hero battles', (done: DoneFn) => { + createHeroBattlesSelector$(1).subscribe(battles => { + expect(battles.length).toBe(3, 'Alpha should have 3 battles'); + done(); + }); + }); + + it('should get Alpha Hero battles again after updating one of its battles', (done: DoneFn) => { + // Skip the initial sidekick and check the one after update + createHeroBattlesSelector$(1) + .pipe(skip(1)) + .subscribe(battles => { + expect(battles[0].name).toBe('Scalliwag'); + done(); + }); + + // update the first of the related battles + const action = eaFactory.create>( + 'Battle', + EntityOp.UPDATE_ONE, + { id: 100, changes: { id: 100, name: 'Scalliwag' } } + ); + store.dispatch(action); + }); + + it('Gamma Hero should have no battles', (done: DoneFn) => { + createHeroBattlesSelector$(3).subscribe(battles => { + expect(battles.length).toBe(0, 'Gamma should have no battles'); + done(); + }); + }); + }); + + describe('hero -> heropower <- power (m-m)', () => { + function setCollectionSelectors() { + const heroSelectors = entitySelectorsFactory.create('Hero'); + const selectHeroMap = heroSelectors.selectEntityMap; + + const powerSelectors = entitySelectorsFactory.create('Power'); + const selectPowerMap = powerSelectors.selectEntityMap; + + const heroPowerMapSelectors = entitySelectorsFactory.create( + 'HeroPowerMap' + ); + const selectHeroPowerMapEntities = heroPowerMapSelectors.selectEntities; + + const selectHeroPowerIds = createSelector( + selectHeroPowerMapEntities, + hpMaps => + hpMaps.reduce( + (acc, hpMap) => { + const hid = hpMap.heroFk; + if (hid) { + const hpIds = acc[hid]; + if (hpIds) { + hpIds.push(hpMap.powerFk); + } else { + acc[hid] = [hpMap.powerFk]; + } + } + return acc; + }, + {} as { [heroId: number]: number[] } + ) + ); + + return { + selectHeroMap, + selectHeroPowerIds, + selectPowerMap, + }; + } + + function createHeroPowersSelector$(heroId: number): Observable { + const { + selectHeroMap, + selectHeroPowerIds, + selectPowerMap, + } = setCollectionSelectors(); + + const selectHero = createSelector( + selectHeroMap, + heroes => heroes[heroId] + ); + + const selectHeroPowers = createSelector( + selectHero, + selectHeroPowerIds, + selectPowerMap, + (hero, heroPowerIds, powerMap) => { + const hid = hero && hero.id; + const pids = heroPowerIds[hid] || []; + const powers = pids.map(id => powerMap[id]).filter(power => power); + return powers; + } + ); + return store.select(selectHeroPowers); + } + + // TODO: more tests + // Note: async done() callback ensures test passes only if subscribe(successCallback()) called. + + it('should get Alpha Hero powers', (done: DoneFn) => { + createHeroPowersSelector$(1).subscribe(powers => { + expect(powers.length).toBe(3, 'Alpha should have 3 powers'); + done(); + }); + }); + + it('should get Beta Hero power', (done: DoneFn) => { + createHeroPowersSelector$(2).subscribe(powers => { + expect(powers.length).toBe(1, 'Beta should have 1 power'); + expect(powers[0].name).toBe('Invisibility'); + done(); + }); + }); + + it('Beta Hero should have no powers after delete', (done: DoneFn) => { + createHeroPowersSelector$(2) + .pipe(skip(1)) + .subscribe(powers => { + expect(powers.length).toBe(0, 'Beta should have no powers'); + done(); + }); + + // delete Beta's one power via the HeroPowerMap + const action: EntityAction = eaFactory.create( + 'HeroPowerMap', + EntityOp.REMOVE_ONE, + 96 + ); + store.dispatch(action); + }); + + it('Gamma Hero should have no powers', (done: DoneFn) => { + createHeroPowersSelector$(3).subscribe(powers => { + expect(powers.length).toBe(0, 'Gamma should have no powers'); + done(); + }); + }); + }); +}); + +// #region Test support + +interface Hero { + id: number; + name: string; + saying?: string; + sidekickFk?: number; +} + +interface Battle { + id: number; + name: string; + heroFk: number; + won: boolean; +} + +interface HeroPowerMap { + id: number; + heroFk: number; + powerFk: number; +} + +interface Power { + id: number; + name: string; +} + +interface Sidekick { + id: number; + name: string; +} + +/** Sort Comparer to sort the entity collection by its name property */ +export function sortByName(a: { name: string }, b: { name: string }): number { + return a.name.localeCompare(b.name); +} + +function initializeCache( + eaFactory: EntityActionFactory, + store: Store +) { + let action: EntityAction; + + action = eaFactory.create('Sidekick', EntityOp.ADD_ALL, [ + { id: 1, name: 'Bob' }, + { id: 2, name: 'Sally' }, + ]); + store.dispatch(action); + + action = eaFactory.create('Hero', EntityOp.ADD_ALL, [ + { id: 1, name: 'Alpha', sidekickFk: 1 }, + { id: 2, name: 'Beta', sidekickFk: 2 }, + { id: 3, name: 'Gamma' }, // no sidekick + ]); + store.dispatch(action); + + action = eaFactory.create('Battle', EntityOp.ADD_ALL, [ + { id: 100, heroFk: 1, name: 'Plains of Yon', won: true }, + { id: 200, heroFk: 1, name: 'Yippee-kai-eh', won: false }, + { id: 300, heroFk: 1, name: 'Yada Yada', won: true }, + { id: 400, heroFk: 2, name: 'Tally-hoo', won: true }, + ]); + store.dispatch(action); + + action = eaFactory.create('Power', EntityOp.ADD_ALL, [ + { id: 10, name: 'Speed' }, + { id: 20, name: 'Strength' }, + { id: 30, name: 'Invisibility' }, + ]); + store.dispatch(action); + + action = eaFactory.create('HeroPowerMap', EntityOp.ADD_ALL, [ + { id: 99, heroFk: 1, powerFk: 10 }, + { id: 98, heroFk: 1, powerFk: 20 }, + { id: 97, heroFk: 1, powerFk: 30 }, + { id: 96, heroFk: 2, powerFk: 30 }, + // Gamma has no powers + ]); + store.dispatch(action); +} +// #endregion Test support diff --git a/modules/data/spec/utils/default-pluralizer.spec.ts b/modules/data/spec/utils/default-pluralizer.spec.ts new file mode 100644 index 0000000000..5544694b1b --- /dev/null +++ b/modules/data/spec/utils/default-pluralizer.spec.ts @@ -0,0 +1,96 @@ +import { TestBed } from '@angular/core/testing'; + +import { DefaultPluralizer, Pluralizer, PLURAL_NAMES_TOKEN } from '../../'; + +describe('DefaultPluralizer', () => { + let pluralizer: Pluralizer; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [{ provide: Pluralizer, useClass: DefaultPluralizer }], + }); + }); + describe('without plural names', () => { + it('should turn "Hero" to "Heros" because no plural names map', () => { + pluralizer = TestBed.get(Pluralizer); + // No map so 'Hero' gets default pluralization + expect(pluralizer.pluralize('Hero')).toBe('Heros'); + }); + + it('should pluralize "Villain" which is not in plural names', () => { + // default pluralization with 's' + expect(pluralizer.pluralize('Villain')).toBe('Villains'); + }); + + it('should pluralize "consonant + y" with "-ies"', () => { + expect(pluralizer.pluralize('Company')).toBe('Companies'); + }); + + it('should pluralize "vowel + y" with "-es"', () => { + expect(pluralizer.pluralize('Cowboy')).toBe('Cowboys'); + }); + + it('should pluralize "Information" as "Information ', () => { + // known "uncoumtables" + expect(pluralizer.pluralize('Information')).toBe('Information'); + }); + + it('should pluralize "SkyBox" which is not in plural names', () => { + // default pluralization of word ending in 'x' + expect(pluralizer.pluralize('SkyBox')).toBe('SkyBoxes'); + }); + }); + + describe('with injected plural names', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + // Demonstrate multi-provider + // Default would turn "Hero" into "Heros". Fix it. + { + provide: PLURAL_NAMES_TOKEN, + multi: true, + useValue: { Hero: 'Heroes' }, + }, + // "Foots" is deliberately wrong. Count on override in next provider + { + provide: PLURAL_NAMES_TOKEN, + multi: true, + useValue: { Foot: 'Foots' }, + }, + // Demonstrate overwrite of 'Foot' while setting multiple names + { + provide: PLURAL_NAMES_TOKEN, + multi: true, + useValue: { Foot: 'Feet', Person: 'People' }, + }, + { provide: Pluralizer, useClass: DefaultPluralizer }, + ], + }); + + pluralizer = TestBed.get(Pluralizer); + }); + + it('should pluralize "Villain" which is not in plural names', () => { + // default pluralization with 's' + expect(pluralizer.pluralize('Villain')).toBe('Villains'); + }); + + it('should pluralize "Hero" using plural names', () => { + expect(pluralizer.pluralize('Hero')).toBe('Heroes'); + }); + + it('should be case sensitive when using map', () => { + // uses default pluralization rule, not the names map + expect(pluralizer.pluralize('hero')).toBe('heros'); + }); + + it('should pluralize "Person" using a plural name added to the map later', () => { + expect(pluralizer.pluralize('Person')).toBe('People'); + }); + + it('later plural name map replaces earlier one', () => { + expect(pluralizer.pluralize('Foot')).toBe('Feet'); + }); + }); +}); diff --git a/modules/data/spec/utils/utils.spec.ts b/modules/data/spec/utils/utils.spec.ts new file mode 100644 index 0000000000..5119033a51 --- /dev/null +++ b/modules/data/spec/utils/utils.spec.ts @@ -0,0 +1,32 @@ +import { CorrelationIdGenerator } from '../../'; + +describe('Utilities (utils)', () => { + describe('CorrelationIdGenerator', () => { + const prefix = 'CRID'; + + it('generates a non-zero integer id', () => { + const generator = new CorrelationIdGenerator(); + const id = generator.next(); + expect(id).toBe(prefix + 1); + }); + + it('generates successive integer ids', () => { + const generator = new CorrelationIdGenerator(); + const id1 = generator.next(); + const id2 = generator.next(); + expect(id1).toBe(prefix + 1); + expect(id2).toBe(prefix + 2); + }); + + it('new instance of the service has its own ids', () => { + const generator1 = new CorrelationIdGenerator(); + const generator2 = new CorrelationIdGenerator(); + const id1 = generator1.next(); + const id2 = generator1.next(); + const id3 = generator2.next(); + expect(id1).toBe(prefix + 1, 'gen1 first'); + expect(id2).toBe(prefix + 2, 'gen1 second'); + expect(id3).toBe(prefix + 1, 'gen2 first'); + }); + }); +}); diff --git a/modules/data/src/actions/entity-action-factory.ts b/modules/data/src/actions/entity-action-factory.ts new file mode 100644 index 0000000000..b2b838ca57 --- /dev/null +++ b/modules/data/src/actions/entity-action-factory.ts @@ -0,0 +1,86 @@ +import { Injectable } from '@angular/core'; +import { Action } from '@ngrx/store'; + +import { EntityOp } from './entity-op'; +import { + EntityAction, + EntityActionOptions, + EntityActionPayload, +} from './entity-action'; +@Injectable() +export class EntityActionFactory { + /** + * Create an EntityAction to perform an operation (op) for a particular entity type + * (entityName) with optional data and other optional flags + * @param entityName Name of the entity type + * @param entityOp Operation to perform (EntityOp) + * @param [data] data for the operation + * @param [options] additional options + */ + create

( + entityName: string, + entityOp: EntityOp, + data?: P, + options?: EntityActionOptions + ): EntityAction

; + + /** + * Create an EntityAction to perform an operation (op) for a particular entity type + * (entityName) with optional data and other optional flags + * @param payload Defines the EntityAction and its options + */ + create

(payload: EntityActionPayload

): EntityAction

; + + // polymorphic create for the two signatures + create

( + nameOrPayload: EntityActionPayload

| string, + entityOp?: EntityOp, + data?: P, + options?: EntityActionOptions + ): EntityAction

{ + const payload: EntityActionPayload

= + typeof nameOrPayload === 'string' + ? ({ + ...(options || {}), + entityName: nameOrPayload, + entityOp, + data, + } as EntityActionPayload

) + : nameOrPayload; + return this.createCore(payload); + } + + /** + * Create an EntityAction to perform an operation (op) for a particular entity type + * (entityName) with optional data and other optional flags + * @param payload Defines the EntityAction and its options + */ + protected createCore

(payload: EntityActionPayload

) { + const { entityName, entityOp, tag } = payload; + if (!entityName) { + throw new Error('Missing entity name for new action'); + } + if (entityOp == null) { + throw new Error('Missing EntityOp for new action'); + } + const type = this.formatActionType(entityOp, tag || entityName); + return { type, payload }; + } + + /** + * Create an EntityAction from another EntityAction, replacing properties with those from newPayload; + * @param from Source action that is the base for the new action + * @param newProperties New EntityAction properties that replace the source action properties + */ + createFromAction

( + from: EntityAction, + newProperties: Partial> + ): EntityAction

{ + return this.create({ ...from.payload, ...newProperties }); + } + + formatActionType(op: string, tag: string) { + return `[${tag}] ${op}`; + // return `${op} [${tag}]`.toUpperCase(); // example of an alternative + } +} diff --git a/modules/data/src/actions/entity-action-guard.ts b/modules/data/src/actions/entity-action-guard.ts new file mode 100644 index 0000000000..cf3e20b1c9 --- /dev/null +++ b/modules/data/src/actions/entity-action-guard.ts @@ -0,0 +1,157 @@ +import { IdSelector, Update } from '@ngrx/entity'; + +import { EntityAction } from './entity-action'; +import { UpdateResponseData } from '../actions/update-response-data'; + +/** + * Guard methods that ensure EntityAction payload is as expected. + * Each method returns that payload if it passes the guard or + * throws an error. + */ +export class EntityActionGuard { + constructor(private entityName: string, private selectId: IdSelector) {} + + /** Throw if the action payload is not an entity with a valid key */ + mustBeEntity(action: EntityAction): T { + const data = this.extractData(action); + if (!data) { + return this.throwError(action, `should have a single entity.`); + } + const id = this.selectId(data); + if (this.isNotKeyType(id)) { + this.throwError(action, `has a missing or invalid entity key (id)`); + } + return data as T; + } + + /** Throw if the action payload is not an array of entities with valid keys */ + mustBeEntities(action: EntityAction): T[] { + const data = this.extractData(action); + if (!Array.isArray(data)) { + return this.throwError(action, `should be an array of entities`); + } + data.forEach((entity, i) => { + const id = this.selectId(entity); + if (this.isNotKeyType(id)) { + const msg = `, item ${i + 1}, does not have a valid entity key (id)`; + this.throwError(action, msg); + } + }); + return data; + } + + /** Throw if the action payload is not a single, valid key */ + mustBeKey(action: EntityAction): string | number | never { + const data = this.extractData(action); + if (!data) { + throw new Error(`should be a single entity key`); + } + if (this.isNotKeyType(data)) { + throw new Error(`is not a valid key (id)`); + } + return data; + } + + /** Throw if the action payload is not an array of valid keys */ + mustBeKeys(action: EntityAction<(string | number)[]>): (string | number)[] { + const data = this.extractData(action); + if (!Array.isArray(data)) { + return this.throwError(action, `should be an array of entity keys (id)`); + } + data.forEach((id, i) => { + if (this.isNotKeyType(id)) { + const msg = `${this.entityName} ', item ${i + + 1}, is not a valid entity key (id)`; + this.throwError(action, msg); + } + }); + return data; + } + + /** Throw if the action payload is not an update with a valid key (id) */ + mustBeUpdate(action: EntityAction>): Update { + const data = this.extractData(action); + if (!data) { + return this.throwError(action, `should be a single entity update`); + } + const { id, changes } = data; + const id2 = this.selectId(changes as T); + if (this.isNotKeyType(id) || this.isNotKeyType(id2)) { + this.throwError(action, `has a missing or invalid entity key (id)`); + } + return data; + } + + /** Throw if the action payload is not an array of updates with valid keys (ids) */ + mustBeUpdates(action: EntityAction[]>): Update[] { + const data = this.extractData(action); + if (!Array.isArray(data)) { + return this.throwError(action, `should be an array of entity updates`); + } + data.forEach((item, i) => { + const { id, changes } = item; + const id2 = this.selectId(changes as T); + if (this.isNotKeyType(id) || this.isNotKeyType(id2)) { + this.throwError( + action, + `, item ${i + 1}, has a missing or invalid entity key (id)` + ); + } + }); + return data; + } + + /** Throw if the action payload is not an update response with a valid key (id) */ + mustBeUpdateResponse( + action: EntityAction> + ): UpdateResponseData { + const data = this.extractData(action); + if (!data) { + return this.throwError(action, `should be a single entity update`); + } + const { id, changes } = data; + const id2 = this.selectId(changes as T); + if (this.isNotKeyType(id) || this.isNotKeyType(id2)) { + this.throwError(action, `has a missing or invalid entity key (id)`); + } + return data; + } + + /** Throw if the action payload is not an array of update responses with valid keys (ids) */ + mustBeUpdateResponses( + action: EntityAction[]> + ): UpdateResponseData[] { + const data = this.extractData(action); + if (!Array.isArray(data)) { + return this.throwError(action, `should be an array of entity updates`); + } + data.forEach((item, i) => { + const { id, changes } = item; + const id2 = this.selectId(changes as T); + if (this.isNotKeyType(id) || this.isNotKeyType(id2)) { + this.throwError( + action, + `, item ${i + 1}, has a missing or invalid entity key (id)` + ); + } + }); + return data; + } + + private extractData(action: EntityAction) { + return action.payload && action.payload.data; + } + + /** Return true if this key (id) is invalid */ + private isNotKeyType(id: any) { + return typeof id !== 'string' && typeof id !== 'number'; + } + + private throwError(action: EntityAction, msg: string): never { + throw new Error( + `${this.entityName} EntityAction guard for "${ + action.type + }": payload ${msg}` + ); + } +} diff --git a/modules/data/src/actions/entity-action-operators.ts b/modules/data/src/actions/entity-action-operators.ts new file mode 100644 index 0000000000..82adeab936 --- /dev/null +++ b/modules/data/src/actions/entity-action-operators.ts @@ -0,0 +1,96 @@ +import { Action } from '@ngrx/store'; +import { Actions } from '@ngrx/effects'; + +import { Observable, OperatorFunction } from 'rxjs'; +import { filter } from 'rxjs/operators'; + +import { EntityAction } from './entity-action'; +import { EntityOp } from './entity-op'; +import { flattenArgs } from '../utils/utilities'; + +/** + * Select actions concerning one of the allowed Entity operations + * @param allowedEntityOps Entity operations (e.g, EntityOp.QUERY_ALL) whose actions should be selected + * Example: + * ``` + * this.actions.pipe(ofEntityOp(EntityOp.QUERY_ALL, EntityOp.QUERY_MANY), ...) + * this.actions.pipe(ofEntityOp(...queryOps), ...) + * this.actions.pipe(ofEntityOp(queryOps), ...) + * this.actions.pipe(ofEntityOp(), ...) // any action with a defined `entityOp` property + * ``` + */ +export function ofEntityOp( + allowedOps: string[] | EntityOp[] +): OperatorFunction; +export function ofEntityOp( + ...allowedOps: (string | EntityOp)[] +): OperatorFunction; +export function ofEntityOp( + ...allowedEntityOps: any[] +): OperatorFunction { + const ops: string[] = flattenArgs(allowedEntityOps); + switch (ops.length) { + case 0: + return filter( + (action: EntityAction): action is T => + action.payload && action.payload.entityOp != null + ); + case 1: + const op = ops[0]; + return filter( + (action: EntityAction): action is T => + action.payload && op === action.payload.entityOp + ); + default: + return filter( + (action: EntityAction): action is T => { + const entityOp = action.payload && action.payload.entityOp; + return entityOp && ops.some(o => o === entityOp); + } + ); + } +} + +/** + * Select actions concerning one of the allowed Entity types + * @param allowedEntityNames Entity-type names (e.g, 'Hero') whose actions should be selected + * Example: + * ``` + * this.actions.pipe(ofEntityType(), ...) // ayn EntityAction with a defined entity type property + * this.actions.pipe(ofEntityType('Hero'), ...) // EntityActions for the Hero entity + * this.actions.pipe(ofEntityType('Hero', 'Villain', 'Sidekick'), ...) + * this.actions.pipe(ofEntityType(...theChosen), ...) + * this.actions.pipe(ofEntityType(theChosen), ...) + * ``` + */ +export function ofEntityType( + allowedEntityNames?: string[] +): OperatorFunction; +export function ofEntityType( + ...allowedEntityNames: string[] +): OperatorFunction; +export function ofEntityType( + ...allowedEntityNames: any[] +): OperatorFunction { + const names: string[] = flattenArgs(allowedEntityNames); + switch (names.length) { + case 0: + return filter( + (action: EntityAction): action is T => + action.payload && action.payload.entityName != null + ); + case 1: + const name = names[0]; + return filter( + (action: EntityAction): action is T => + action.payload && name === action.payload.entityName + ); + default: + return filter( + (action: EntityAction): action is T => { + const entityName = action.payload && action.payload.entityName; + return !!entityName && names.some(n => n === entityName); + } + ); + } +} diff --git a/modules/data/src/actions/entity-action.ts b/modules/data/src/actions/entity-action.ts new file mode 100644 index 0000000000..f658e4f680 --- /dev/null +++ b/modules/data/src/actions/entity-action.ts @@ -0,0 +1,45 @@ +import { Injectable } from '@angular/core'; +import { Action } from '@ngrx/store'; + +import { EntityOp } from './entity-op'; +import { MergeStrategy } from './merge-strategy'; + +/** Action concerning an entity collection. */ +export interface EntityAction

extends Action { + readonly type: string; + readonly payload: EntityActionPayload

; +} + +/** Options of an EntityAction */ +export interface EntityActionOptions { + /** Correlate related EntityActions, particularly related saves. Must be serializable. */ + readonly correlationId?: any; + /** True if should perform action optimistically (before server responds) */ + readonly isOptimistic?: boolean; + readonly mergeStrategy?: MergeStrategy; + /** The tag to use in the action's type. The entityName if no tag specified. */ + readonly tag?: string; + + // Mutable actions are BAD. + // Unfortunately, these mutations are the only way to stop @ngrx/effects + // from processing these actions. + + /** + * The action was determined (usually by a reducer) to be in error. + * Downstream effects should not process but rather treat it as an error. + */ + error?: Error; + + /** + * Downstream effects should skip processing this action but should return + * an innocuous Observable of success. + */ + skip?: boolean; +} + +/** Payload of an EntityAction */ +export interface EntityActionPayload

extends EntityActionOptions { + readonly entityName: string; + readonly entityOp: EntityOp; + readonly data?: P; +} diff --git a/modules/data/src/actions/entity-cache-action.ts b/modules/data/src/actions/entity-cache-action.ts new file mode 100644 index 0000000000..16164a66e0 --- /dev/null +++ b/modules/data/src/actions/entity-cache-action.ts @@ -0,0 +1,214 @@ +/* + * Actions dedicated to the EntityCache as a whole + */ +import { Action } from '@ngrx/store'; + +import { ChangeSet, ChangeSetOperation } from './entity-cache-change-set'; +export { ChangeSet, ChangeSetOperation } from './entity-cache-change-set'; + +import { DataServiceError } from '../dataservices/data-service-error'; +import { EntityActionOptions } from '../actions/entity-action'; +import { EntityCache } from '../reducers/entity-cache'; +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', + + SAVE_ENTITIES = '@ngrx/data/entity-cache/save-entities', + SAVE_ENTITIES_CANCEL = '@ngrx/data/entity-cache/save-entities-cancel', + SAVE_ENTITIES_CANCELED = '@ngrx/data/entity-cache/save-entities-canceled', + SAVE_ENTITIES_ERROR = '@ngrx/data/entity-cache/save-entities-error', + SAVE_ENTITIES_SUCCESS = '@ngrx/data/entity-cache/save-entities-success', +} + +/** + * Hash of entities keyed by EntityCollection name, + * typically the result of a query that returned results from a multi-collection query + * that will be merged into an EntityCache via the `MergeQuerySet` action. + */ +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. + * Corresponding entity cache reducer should add and update all collections + * at the same time, before any selectors$ observables emit. + * @param querySet The result of the query in the form of a map of entity collections. + * 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, + tag?: string + ) { + this.payload = { + querySet, + mergeStrategy: + mergeStrategy === null ? MergeStrategy.PreserveChanges : mergeStrategy, + tag, + }; + } +} + +/** + * 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 cache: EntityCache, tag?: string) { + this.payload = { cache, tag }; + } +} + +// #region SaveEntities +export class SaveEntities implements Action { + readonly payload: { + readonly changeSet: ChangeSet; + readonly url: string; + readonly correlationId?: any; + readonly isOptimistic?: boolean; + readonly mergeStrategy?: MergeStrategy; + readonly tag?: string; + error?: Error; + skip?: boolean; // not used + }; + readonly type = EntityCacheAction.SAVE_ENTITIES; + + constructor( + changeSet: ChangeSet, + url: string, + options?: EntityActionOptions + ) { + options = options || {}; + if (changeSet) { + changeSet.tag = changeSet.tag || options.tag; + } + this.payload = { changeSet, url, ...options, tag: changeSet.tag }; + } +} + +export class SaveEntitiesCancel implements Action { + readonly payload: { + readonly correlationId: any; + readonly reason?: string; + readonly entityNames?: string[]; + readonly tag?: string; + }; + readonly type = EntityCacheAction.SAVE_ENTITIES_CANCEL; + + constructor( + correlationId: any, + reason?: string, + entityNames?: string[], + tag?: string + ) { + this.payload = { correlationId, reason, entityNames, tag }; + } +} + +export class SaveEntitiesCanceled implements Action { + readonly payload: { + readonly correlationId: any; + readonly reason?: string; + readonly tag?: string; + }; + readonly type = EntityCacheAction.SAVE_ENTITIES_CANCEL; + + constructor(correlationId: any, reason?: string, tag?: string) { + this.payload = { correlationId, reason, tag }; + } +} + +export class SaveEntitiesError { + readonly payload: { + readonly error: DataServiceError; + readonly originalAction: SaveEntities; + readonly correlationId: any; + }; + readonly type = EntityCacheAction.SAVE_ENTITIES_ERROR; + constructor(error: DataServiceError, originalAction: SaveEntities) { + const correlationId = originalAction.payload.correlationId; + this.payload = { error, originalAction, correlationId }; + } +} + +export class SaveEntitiesSuccess implements Action { + readonly payload: { + readonly changeSet: ChangeSet; + readonly url: string; + readonly correlationId?: any; + readonly isOptimistic?: boolean; + readonly mergeStrategy?: MergeStrategy; + readonly tag?: string; + error?: Error; + skip?: boolean; // not used + }; + readonly type = EntityCacheAction.SAVE_ENTITIES_SUCCESS; + + constructor( + changeSet: ChangeSet, + url: string, + options?: EntityActionOptions + ) { + options = options || {}; + if (changeSet) { + changeSet.tag = changeSet.tag || options.tag; + } + this.payload = { changeSet, url, ...options, tag: changeSet.tag }; + } +} +// #endregion SaveEntities diff --git a/modules/data/src/actions/entity-cache-change-set.ts b/modules/data/src/actions/entity-cache-change-set.ts new file mode 100644 index 0000000000..c9786a4951 --- /dev/null +++ b/modules/data/src/actions/entity-cache-change-set.ts @@ -0,0 +1,118 @@ +import { Action } from '@ngrx/store'; +import { Update } from '@ngrx/entity'; + +import { EntityActionOptions } from './entity-action'; +import { EntityCacheAction } from './entity-cache-action'; +import { DataServiceError } from '../dataservices/data-service-error'; + +export enum ChangeSetOperation { + Add = 'Add', + Delete = 'Delete', + Update = 'Update', + Upsert = 'Upsert', +} +export interface ChangeSetAdd { + op: ChangeSetOperation.Add; + entityName: string; + entities: T[]; +} + +export interface ChangeSetDelete { + op: ChangeSetOperation.Delete; + entityName: string; + entities: string[] | number[]; +} + +export interface ChangeSetUpdate { + op: ChangeSetOperation.Update; + entityName: string; + entities: Update[]; +} + +export interface ChangeSetUpsert { + op: ChangeSetOperation.Upsert; + entityName: string; + entities: T[]; +} + +/** + * A entities of a single entity type, which are changed in the same way by a ChangeSetOperation + */ +export type ChangeSetItem = + | ChangeSetAdd + | ChangeSetDelete + | ChangeSetUpdate + | ChangeSetUpsert; + +/* + * A set of entity Changes, typically to be saved. + */ +export interface ChangeSet { + /** An array of ChangeSetItems to be processed in the array order */ + changes: ChangeSetItem[]; + + /** + * An arbitrary, serializable object that should travel with the ChangeSet. + * Meaningful to the ChangeSet producer and consumer. Ignored by ngrx-data. + */ + extras?: T; + + /** An arbitrary string, identifying the ChangeSet and perhaps its purpose */ + tag?: string; +} + +/** + * Factory to create a ChangeSetItem for a ChangeSetOperation + */ +export class ChangeSetItemFactory { + /** Create the ChangeSetAdd for new entities of the given entity type */ + add(entityName: string, entities: T | T[]): ChangeSetAdd { + entities = Array.isArray(entities) ? entities : entities ? [entities] : []; + return { entityName, op: ChangeSetOperation.Add, entities }; + } + + /** Create the ChangeSetDelete for primary keys of the given entity type */ + delete( + entityName: string, + keys: number | number[] | string | string[] + ): ChangeSetDelete { + const ids = Array.isArray(keys) + ? keys + : keys + ? ([keys] as string[] | number[]) + : []; + return { entityName, op: ChangeSetOperation.Delete, entities: ids }; + } + + /** Create the ChangeSetUpdate for Updates of entities of the given entity type */ + update( + entityName: string, + updates: Update | Update[] + ): ChangeSetUpdate { + updates = Array.isArray(updates) ? updates : updates ? [updates] : []; + return { entityName, op: ChangeSetOperation.Update, entities: updates }; + } + + /** Create the ChangeSetUpsert for new or existing entities of the given entity type */ + upsert(entityName: string, entities: T | T[]): ChangeSetUpsert { + entities = Array.isArray(entities) ? entities : entities ? [entities] : []; + return { entityName, op: ChangeSetOperation.Upsert, entities }; + } +} + +/** + * Instance of a factory to create a ChangeSetItem for a ChangeSetOperation + */ +export const changeSetItemFactory = new ChangeSetItemFactory(); + +/** + * Return ChangeSet after filtering out null and empty ChangeSetItems. + * @param changeSet ChangeSet with changes to filter + */ +export function excludeEmptyChangeSetItems(changeSet: ChangeSet): ChangeSet { + changeSet = changeSet && changeSet.changes ? changeSet : { changes: [] }; + const changes = changeSet.changes.filter( + c => c != null && c.entities && c.entities.length > 0 + ); + return { ...changeSet, changes }; +} diff --git a/modules/data/src/actions/entity-op.ts b/modules/data/src/actions/entity-op.ts new file mode 100644 index 0000000000..80787ff148 --- /dev/null +++ b/modules/data/src/actions/entity-op.ts @@ -0,0 +1,100 @@ +// Ensure that these suffix values and the EntityOp suffixes match +// Cannot do that programmatically. + +/** General purpose entity action operations, good for any entity type */ +export enum EntityOp { + // Persistance operations + CANCEL_PERSIST = '@ngrx/data/cancel-persist', + CANCELED_PERSIST = '@ngrx/data/canceled-persist', + + QUERY_ALL = '@ngrx/data/query-all', + QUERY_ALL_SUCCESS = '@ngrx/data/query-all/success', + QUERY_ALL_ERROR = '@ngrx/data/query-all/error', + + QUERY_LOAD = '@ngrx/data/query-load', + QUERY_LOAD_SUCCESS = '@ngrx/data/query-load/success', + QUERY_LOAD_ERROR = '@ngrx/data/query-load/error', + + QUERY_MANY = '@ngrx/data/query-many', + QUERY_MANY_SUCCESS = '@ngrx/data/query-many/success', + QUERY_MANY_ERROR = '@ngrx/data/query-many/error', + + QUERY_BY_KEY = '@ngrx/data/query-by-key', + QUERY_BY_KEY_SUCCESS = '@ngrx/data/query-by-key/success', + QUERY_BY_KEY_ERROR = '@ngrx/data/query-by-key/error', + + SAVE_ADD_MANY = '@ngrx/data/save/add-many', + SAVE_ADD_MANY_ERROR = '@ngrx/data/save/add-many/error', + SAVE_ADD_MANY_SUCCESS = '@ngrx/data/save/add-many/success', + + SAVE_ADD_ONE = '@ngrx/data/save/add-one', + SAVE_ADD_ONE_ERROR = '@ngrx/data/save/add-one/error', + SAVE_ADD_ONE_SUCCESS = '@ngrx/data/save/add-one/success', + + SAVE_DELETE_MANY = '@ngrx/data/save/delete-many', + SAVE_DELETE_MANY_SUCCESS = '@ngrx/data/save/delete-many/success', + SAVE_DELETE_MANY_ERROR = '@ngrx/data/save/delete-many/error', + + SAVE_DELETE_ONE = '@ngrx/data/save/delete-one', + SAVE_DELETE_ONE_SUCCESS = '@ngrx/data/save/delete-one/success', + SAVE_DELETE_ONE_ERROR = '@ngrx/data/save/delete-one/error', + + SAVE_UPDATE_MANY = '@ngrx/data/save/update-many', + SAVE_UPDATE_MANY_SUCCESS = '@ngrx/data/save/update-many/success', + SAVE_UPDATE_MANY_ERROR = '@ngrx/data/save/update-many/error', + + SAVE_UPDATE_ONE = '@ngrx/data/save/update-one', + SAVE_UPDATE_ONE_SUCCESS = '@ngrx/data/save/update-one/success', + SAVE_UPDATE_ONE_ERROR = '@ngrx/data/save/update-one/error', + + // Use only if the server supports upsert; + SAVE_UPSERT_MANY = '@ngrx/data/save/upsert-many', + SAVE_UPSERT_MANY_SUCCESS = '@ngrx/data/save/upsert-many/success', + SAVE_UPSERT_MANY_ERROR = '@ngrx/data/save/upsert-many/error', + + // Use only if the server supports upsert; + SAVE_UPSERT_ONE = '@ngrx/data/save/upsert-one', + SAVE_UPSERT_ONE_SUCCESS = '@ngrx/data/save/upsert-one/success', + SAVE_UPSERT_ONE_ERROR = '@ngrx/data/save/upsert-one/error', + + // Cache operations + ADD_ALL = '@ngrx/data/add-all', + ADD_MANY = '@ngrx/data/add-many', + ADD_ONE = '@ngrx/data/add-one', + REMOVE_ALL = '@ngrx/data/remove-all', + REMOVE_MANY = '@ngrx/data/remove-many', + REMOVE_ONE = '@ngrx/data/remove-one', + UPDATE_MANY = '@ngrx/data/update-many', + UPDATE_ONE = '@ngrx/data/update-one', + UPSERT_MANY = '@ngrx/data/upsert-many', + UPSERT_ONE = '@ngrx/data/upsert-one', + + COMMIT_ALL = '@ngrx/data/commit-all', + COMMIT_MANY = '@ngrx/data/commit-many', + COMMIT_ONE = '@ngrx/data/commit-one', + UNDO_ALL = '@ngrx/data/undo-all', + UNDO_MANY = '@ngrx/data/undo-many', + UNDO_ONE = '@ngrx/data/undo-one', + + SET_CHANGE_STATE = '@ngrx/data/set-change-state', + SET_COLLECTION = '@ngrx/data/set-collection', + SET_FILTER = '@ngrx/data/set-filter', + SET_LOADED = '@ngrx/data/set-loaded', + SET_LOADING = '@ngrx/data/set-loading', +} + +/** "Success" suffix appended to EntityOps that are successful.*/ +export const OP_SUCCESS = '/success'; + +/** "Error" suffix appended to EntityOps that have failed.*/ +export const OP_ERROR = '/error'; + +/** Make the error EntityOp corresponding to the given EntityOp */ +export function makeErrorOp(op: EntityOp): EntityOp { + return (op + OP_ERROR); +} + +/** Make the success EntityOp corresponding to the given EntityOp */ +export function makeSuccessOp(op: EntityOp): EntityOp { + return (op + OP_SUCCESS); +} diff --git a/modules/data/src/actions/merge-strategy.ts b/modules/data/src/actions/merge-strategy.ts new file mode 100644 index 0000000000..ca1daed143 --- /dev/null +++ b/modules/data/src/actions/merge-strategy.ts @@ -0,0 +1,22 @@ +/** How to merge an entity, after query or save, when the corresponding entity in the collection has unsaved changes. */ +export enum MergeStrategy { + /** + * Update the collection entities and ignore all change tracking for this operation. + * ChangeState is untouched. + */ + IgnoreChanges, + /** + * Updates current values for unchanged entities. + * If entities are changed, preserves their current values and + * overwrites their originalValue with the merge entity. + * This is the query-success default. + */ + PreserveChanges, + /** + * Replace the current collection entities. + * Discards the ChangeState for the merged entities if set + * and their ChangeTypes becomes "unchanged". + * This is the save-success default. + */ + OverwriteChanges, +} diff --git a/modules/data/src/actions/update-response-data.ts b/modules/data/src/actions/update-response-data.ts new file mode 100644 index 0000000000..434f087b85 --- /dev/null +++ b/modules/data/src/actions/update-response-data.ts @@ -0,0 +1,20 @@ +/** + * Data returned in an EntityAction from the EntityEffects for SAVE_UPDATE_ONE_SUCCESS. + * Effectively extends Update with a 'changed' flag. + * The is true if the server sent back changes to the entity data after update. + * Such changes must be in the entity data in changes property. + * Default is false (server did not return entity data; assume it changed nothing). + * See EntityEffects. + */ +export interface UpdateResponseData { + /** Original key (id) of the entity */ + id: number | string; + /** Entity update data. Should include the key (original or changed) */ + changes: Partial; + /** + * Whether the server made additional changes after processing the update. + * Such additional changes should be in the 'changes' object. + * Default is false + */ + changed?: boolean; +} diff --git a/modules/data/src/dataservices/data-service-error.ts b/modules/data/src/dataservices/data-service-error.ts new file mode 100644 index 0000000000..e318bb9db8 --- /dev/null +++ b/modules/data/src/dataservices/data-service-error.ts @@ -0,0 +1,45 @@ +import { EntityAction } from '../actions/entity-action'; +import { RequestData } from './interfaces'; + +/** + * Error from a DataService + * The source error either comes from a failed HTTP response or was thrown within the service. + * @param error the HttpErrorResponse or the error thrown by the service + * @param requestData the HTTP request information such as the method and the url. + */ +// If extend from Error, `dse instanceof DataServiceError` returns false +// in some (all?) unit tests so don't bother trying. +export class DataServiceError { + message: string | null; + + constructor(public error: any, public requestData: RequestData | null) { + this.message = typeof error === 'string' ? error : extractMessage(error); + } +} + +// Many ways the error can be shaped. These are the ways we recognize. +function extractMessage(sourceError: any): string | null { + const { error, body, message } = sourceError; + let errMessage: string | null = null; + if (error) { + // prefer HttpErrorResponse.error to its message property + errMessage = typeof error === 'string' ? error : error.message; + } else if (message) { + errMessage = message; + } else if (body) { + // try the body if no error or message property + errMessage = typeof body === 'string' ? body : body.error; + } + + return typeof errMessage === 'string' + ? errMessage + : errMessage + ? JSON.stringify(errMessage) + : null; +} + +/** Payload for an EntityAction data service error such as QUERY_ALL_ERROR */ +export interface EntityActionDataServiceError { + error: DataServiceError; + originalAction: EntityAction; +} diff --git a/modules/data/src/dataservices/default-data-service-config.ts b/modules/data/src/dataservices/default-data-service-config.ts new file mode 100644 index 0000000000..0beff79a1c --- /dev/null +++ b/modules/data/src/dataservices/default-data-service-config.ts @@ -0,0 +1,23 @@ +import { HttpUrlGenerator, EntityHttpResourceUrls } from './http-url-generator'; + +/** + * Optional configuration settings for an entity collection data service + * such as the `DefaultDataService`. + */ +export abstract class DefaultDataServiceConfig { + /** root path of the web api (default: 'api') */ + root?: string; + /** + * Known entity HttpResourceUrls. + * HttpUrlGenerator will create these URLs for entity types not listed here. + */ + entityHttpResourceUrls?: EntityHttpResourceUrls; + /** Is a DELETE 404 really OK? (default: true) */ + delete404OK?: boolean; + /** Simulate GET latency in a demo (default: 0) */ + getDelay?: number; + /** Simulate save method (PUT/POST/DELETE) latency in a demo (default: 0) */ + saveDelay?: number; + /** request timeout in MS (default: 0)*/ + timeout?: number; // +} diff --git a/modules/data/src/dataservices/default-data.service.ts b/modules/data/src/dataservices/default-data.service.ts new file mode 100644 index 0000000000..02b5d5b835 --- /dev/null +++ b/modules/data/src/dataservices/default-data.service.ts @@ -0,0 +1,226 @@ +import { Injectable, Optional } from '@angular/core'; +import { + HttpClient, + HttpErrorResponse, + HttpParams, +} from '@angular/common/http'; + +import { Observable, of, throwError } from 'rxjs'; +import { catchError, delay, map, tap, timeout } from 'rxjs/operators'; + +import { Update } from '@ngrx/entity'; + +import { DataServiceError } from './data-service-error'; +import { DefaultDataServiceConfig } from './default-data-service-config'; +import { + EntityCollectionDataService, + HttpMethods, + QueryParams, + RequestData, +} from './interfaces'; +import { HttpUrlGenerator } from './http-url-generator'; + +/** + * A basic, generic entity data service + * suitable for persistence of most entities. + * Assumes a common REST-y web API + */ +export class DefaultDataService implements EntityCollectionDataService { + protected _name: string; + protected delete404OK: boolean; + protected entityName: string; + protected entityUrl: string; + protected entitiesUrl: string; + protected getDelay = 0; + protected saveDelay = 0; + protected timeout = 0; + + get name() { + return this._name; + } + + constructor( + entityName: string, + protected http: HttpClient, + protected httpUrlGenerator: HttpUrlGenerator, + config?: DefaultDataServiceConfig + ) { + this._name = `${entityName} DefaultDataService`; + this.entityName = entityName; + const { + root = 'api', + delete404OK = true, + getDelay = 0, + saveDelay = 0, + timeout: to = 0, + } = + config || {}; + this.delete404OK = delete404OK; + this.entityUrl = httpUrlGenerator.entityResource(entityName, root); + this.entitiesUrl = httpUrlGenerator.collectionResource(entityName, root); + this.getDelay = getDelay; + this.saveDelay = saveDelay; + this.timeout = to; + } + + add(entity: T): Observable { + const entityOrError = + entity || new Error(`No "${this.entityName}" entity to add`); + return this.execute('POST', this.entityUrl, entityOrError); + } + + delete(key: number | string): Observable { + let err: Error | undefined; + if (key == null) { + err = new Error(`No "${this.entityName}" key to delete`); + } + return this.execute('DELETE', this.entityUrl + key, err).pipe( + // forward the id of deleted entity as the result of the HTTP DELETE + map(result => key as number | string) + ); + } + + getAll(): Observable { + return this.execute('GET', this.entitiesUrl); + } + + getById(key: number | string): Observable { + let err: Error | undefined; + if (key == null) { + err = new Error(`No "${this.entityName}" key to get`); + } + return this.execute('GET', this.entityUrl + key, err); + } + + getWithQuery(queryParams: QueryParams | string): Observable { + const qParams = + typeof queryParams === 'string' + ? { fromString: queryParams } + : { fromObject: queryParams }; + const params = new HttpParams(qParams); + return this.execute('GET', this.entitiesUrl, undefined, { params }); + } + + update(update: Update): Observable { + const id = update && update.id; + const updateOrError = + id == null + ? new Error(`No "${this.entityName}" update data or id`) + : update.changes; + return this.execute('PUT', this.entityUrl + id, updateOrError); + } + + // Important! Only call if the backend service supports upserts as a POST to the target URL + upsert(entity: T): Observable { + const entityOrError = + entity || new Error(`No "${this.entityName}" entity to upsert`); + return this.execute('POST', this.entityUrl, entityOrError); + } + + protected execute( + method: HttpMethods, + url: string, + data?: any, // data, error, or undefined/null + options?: any + ): Observable { + const req: RequestData = { method, url, data, options }; + + if (data instanceof Error) { + return this.handleError(req)(data); + } + + let result$: Observable; + + switch (method) { + case 'DELETE': { + result$ = this.http.delete(url, options); + if (this.saveDelay) { + result$ = result$.pipe(delay(this.saveDelay)); + } + break; + } + case 'GET': { + result$ = this.http.get(url, options); + if (this.getDelay) { + result$ = result$.pipe(delay(this.getDelay)); + } + break; + } + case 'POST': { + result$ = this.http.post(url, data, options); + if (this.saveDelay) { + result$ = result$.pipe(delay(this.saveDelay)); + } + break; + } + // N.B.: It must return an Update + case 'PUT': { + result$ = this.http.put(url, data, options); + if (this.saveDelay) { + result$ = result$.pipe(delay(this.saveDelay)); + } + break; + } + default: { + const error = new Error('Unimplemented HTTP method, ' + method); + result$ = throwError(error); + } + } + if (this.timeout) { + result$ = result$.pipe(timeout(this.timeout + this.saveDelay)); + } + return result$.pipe(catchError(this.handleError(req))); + } + + private handleError(reqData: RequestData) { + return (err: any) => { + const ok = this.handleDelete404(err, reqData); + if (ok) { + return ok; + } + const error = new DataServiceError(err, reqData); + return throwError(error); + }; + } + + private handleDelete404(error: HttpErrorResponse, reqData: RequestData) { + if ( + error.status === 404 && + reqData.method === 'DELETE' && + this.delete404OK + ) { + return of({}); + } + return undefined; + } +} + +/** + * Create a basic, generic entity data service + * suitable for persistence of most entities. + * Assumes a common REST-y web API + */ +@Injectable() +export class DefaultDataServiceFactory { + constructor( + protected http: HttpClient, + protected httpUrlGenerator: HttpUrlGenerator, + @Optional() protected config?: DefaultDataServiceConfig + ) { + config = config || {}; + httpUrlGenerator.registerHttpResourceUrls(config.entityHttpResourceUrls); + } + + /** + * Create a default {EntityCollectionDataService} for the given entity type + * @param entityName {string} Name of the entity type for this data service + */ + create(entityName: string): EntityCollectionDataService { + return new DefaultDataService( + entityName, + this.http, + this.httpUrlGenerator, + this.config + ); + } +} diff --git a/modules/data/src/dataservices/entity-cache-data.service.ts b/modules/data/src/dataservices/entity-cache-data.service.ts new file mode 100644 index 0000000000..8efe1256b8 --- /dev/null +++ b/modules/data/src/dataservices/entity-cache-data.service.ts @@ -0,0 +1,170 @@ +import { Injectable, Optional } from '@angular/core'; +import { + HttpClient, + HttpErrorResponse, + HttpParams, +} from '@angular/common/http'; + +import { Observable, throwError } from 'rxjs'; +import { catchError, delay, map, timeout } from 'rxjs/operators'; + +import { IdSelector } from '@ngrx/entity'; + +import { + ChangeSetOperation, + ChangeSet, + ChangeSetItem, + ChangeSetUpdate, + excludeEmptyChangeSetItems, +} from '../actions/entity-cache-change-set'; +import { DataServiceError } from './data-service-error'; +import { DefaultDataServiceConfig } from './default-data-service-config'; +import { EntityDefinitionService } from '../entity-metadata/entity-definition.service'; +import { RequestData } from './interfaces'; + +const updateOp = ChangeSetOperation.Update; + +/** + * Default data service for making remote service calls targeting the entire EntityCache. + * See EntityDataService for services that target a single EntityCollection + */ +@Injectable() +export class EntityCacheDataService { + protected idSelectors: { [entityName: string]: IdSelector } = {}; + protected saveDelay = 0; + protected timeout = 0; + + constructor( + protected entityDefinitionService: EntityDefinitionService, + protected http: HttpClient, + @Optional() config?: DefaultDataServiceConfig + ) { + const { saveDelay = 0, timeout: to = 0 } = config || {}; + this.saveDelay = saveDelay; + this.timeout = to; + } + + /** + * Save changes to multiple entities across one or more entity collections. + * Server endpoint must understand the essential SaveEntities protocol, + * in particular the ChangeSet interface (except for Update). + * This implementation extracts the entity changes from a ChangeSet Update[] and sends those. + * It then reconstructs Update[] in the returned observable result. + * @param changeSet An array of SaveEntityItems. + * Each SaveEntityItem describe a change operation for one or more entities of a single collection, + * known by its 'entityName'. + * @param url The server endpoint that receives this request. + */ + saveEntities(changeSet: ChangeSet, url: string): Observable { + changeSet = this.filterChangeSet(changeSet); + // Assume server doesn't understand @ngrx/entity Update structure; + // Extract the entity changes from the Update[] and restore on the return from server + changeSet = this.flattenUpdates(changeSet); + + let result$: Observable = this.http + .post(url, changeSet) + .pipe( + map(result => this.restoreUpdates(result)), + catchError(this.handleError({ method: 'POST', url, data: changeSet })) + ); + + if (this.timeout) { + result$ = result$.pipe(timeout(this.timeout)); + } + + if (this.saveDelay) { + result$ = result$.pipe(delay(this.saveDelay)); + } + + return result$; + } + + // #region helpers + protected handleError(reqData: RequestData) { + return (err: any) => { + const error = new DataServiceError(err, reqData); + return throwError(error); + }; + } + + /** + * Filter changeSet to remove unwanted ChangeSetItems. + * This implementation excludes null and empty ChangeSetItems. + * @param changeSet ChangeSet with changes to filter + */ + protected filterChangeSet(changeSet: ChangeSet): ChangeSet { + return excludeEmptyChangeSetItems(changeSet); + } + + /** + * Convert the entities in update changes from @ngrx Update structure to just T. + * Reverse of restoreUpdates(). + */ + protected flattenUpdates(changeSet: ChangeSet): ChangeSet { + let changes = changeSet.changes; + if (changes.length === 0) { + return changeSet; + } + let hasMutated = false; + changes = changes.map(item => { + if (item.op === updateOp && item.entities.length > 0) { + hasMutated = true; + return { + ...item, + entities: (item as ChangeSetUpdate).entities.map(u => u.changes), + }; + } else { + return item; + } + }) as ChangeSetItem[]; + return hasMutated ? { ...changeSet, changes } : changeSet; + } + + /** + * Convert the flattened T entities in update changes back to @ngrx Update structures. + * Reverse of flattenUpdates(). + */ + protected restoreUpdates(changeSet: ChangeSet): ChangeSet { + if (changeSet == null) { + // Nothing? Server probably responded with 204 - No Content because it made no changes to the inserted or updated entities + return changeSet; + } + let changes = changeSet.changes; + if (changes.length === 0) { + return changeSet; + } + let hasMutated = false; + changes = changes.map(item => { + if (item.op === updateOp) { + // These are entities, not Updates; convert back to Updates + hasMutated = true; + const selectId = this.getIdSelector(item.entityName); + return { + ...item, + entities: item.entities.map((u: any) => ({ + id: selectId(u), + changes: u, + })), + } as ChangeSetUpdate; + } else { + return item; + } + }) as ChangeSetItem[]; + return hasMutated ? { ...changeSet, changes } : changeSet; + } + + /** + * Get the id (primary key) selector function for an entity type + * @param entityName name of the entity type + */ + protected getIdSelector(entityName: string) { + let idSelector = this.idSelectors[entityName]; + if (!idSelector) { + idSelector = this.entityDefinitionService.getDefinition(entityName) + .selectId; + this.idSelectors[entityName] = idSelector; + } + return idSelector; + } + // #endregion helpers +} diff --git a/modules/data/src/dataservices/entity-data.service.ts b/modules/data/src/dataservices/entity-data.service.ts new file mode 100644 index 0000000000..e2f006e3bb --- /dev/null +++ b/modules/data/src/dataservices/entity-data.service.ts @@ -0,0 +1,67 @@ +import { Injectable } from '@angular/core'; + +import { EntityCollectionDataService } from './interfaces'; +import { DefaultDataServiceFactory } from './default-data.service'; + +/** + * Registry of EntityCollection data services that make REST-like CRUD calls + * to entity collection endpoints. + */ +@Injectable() +export class EntityDataService { + protected services: { [name: string]: EntityCollectionDataService } = {}; + + // TODO: Optionally inject specialized entity data services + // for those that aren't derived from BaseDataService. + constructor(protected defaultDataServiceFactory: DefaultDataServiceFactory) {} + + /** + * Get (or create) a data service for entity type + * @param entityName - the name of the type + * + * Examples: + * getService('Hero'); // data service for Heroes, untyped + * getService('Hero'); // data service for Heroes, typed as Hero + */ + getService(entityName: string): EntityCollectionDataService { + entityName = entityName.trim(); + let service = this.services[entityName]; + if (!service) { + service = this.defaultDataServiceFactory.create(entityName); + this.services[entityName] = service; + } + return service; + } + + /** + * Register an EntityCollectionDataService for an entity type + * @param entityName - the name of the entity type + * @param service - data service for that entity type + * + * Examples: + * registerService('Hero', myHeroDataService); + * registerService('Villain', myVillainDataService); + */ + registerService( + entityName: string, + service: EntityCollectionDataService + ) { + this.services[entityName.trim()] = service; + } + + /** + * Register a batch of data services. + * @param services - data services to merge into existing services + * + * Examples: + * registerServices({ + * Hero: myHeroDataService, + * Villain: myVillainDataService + * }); + */ + registerServices(services: { + [name: string]: EntityCollectionDataService; + }) { + this.services = { ...this.services, ...services }; + } +} diff --git a/modules/data/src/dataservices/http-url-generator.ts b/modules/data/src/dataservices/http-url-generator.ts new file mode 100644 index 0000000000..24418f4d60 --- /dev/null +++ b/modules/data/src/dataservices/http-url-generator.ts @@ -0,0 +1,135 @@ +import { Inject, Injectable, InjectionToken, Optional } from '@angular/core'; +import { Pluralizer } from '../utils/interfaces'; + +/** + * Known resource URLS for specific entity types. + * Each entity's resource URLS are endpoints that + * target single entity and multi-entity HTTP operations. + * Used by the `DefaultHttpUrlGenerator`. + */ +export abstract class EntityHttpResourceUrls { + [entityName: string]: HttpResourceUrls; +} + +/** + * Resource URLS for HTTP operations that target single entity + * and multi-entity endpoints. + */ +export interface HttpResourceUrls { + /** + * The URL path for a single entity endpoint, e.g, `some-api-root/hero/` + * such as you'd use to add a hero. + * Example: `httpClient.post('some-api-root/hero/', addedHero)`. + * Note trailing slash (/). + */ + entityResourceUrl: string; + /** + * The URL path for a multiple-entity endpoint, e.g, `some-api-root/heroes/` + * such as you'd use when getting all heroes. + * Example: `httpClient.get('some-api-root/heroes/')` + * Note trailing slash (/). + */ + collectionResourceUrl: string; +} + +/** + * Generate the base part of an HTTP URL for + * single entity or entity collection resource + */ +export abstract class HttpUrlGenerator { + /** + * Return the base URL for a single entity resource, + * e.g., the base URL to get a single hero by its id + */ + abstract entityResource(entityName: string, root: string): string; + + /** + * Return the base URL for a collection resource, + * e.g., the base URL to get all heroes + */ + abstract collectionResource(entityName: string, root: string): string; + + /** + * Register known single-entity and collection resource URLs for HTTP calls + * @param entityHttpResourceUrls {EntityHttpResourceUrls} resource urls for specific entity type names + */ + abstract registerHttpResourceUrls( + entityHttpResourceUrls?: EntityHttpResourceUrls + ): void; +} + +@Injectable() +export class DefaultHttpUrlGenerator implements HttpUrlGenerator { + /** + * Known single-entity and collection resource URLs for HTTP calls. + * Generator methods returns these resource URLs for a given entity type name. + * If the resources for an entity type name are not know, it generates + * and caches a resource name for future use + */ + protected knownHttpResourceUrls: EntityHttpResourceUrls = {}; + + constructor(private pluralizer: Pluralizer) {} + + /** + * Get or generate the entity and collection resource URLs for the given entity type name + * @param entityName {string} Name of the entity type, e.g, 'Hero' + * @param root {string} Root path to the resource, e.g., 'some-api` + */ + protected getResourceUrls( + entityName: string, + root: string + ): HttpResourceUrls { + let resourceUrls = this.knownHttpResourceUrls[entityName]; + if (!resourceUrls) { + const nRoot = normalizeRoot(root); + resourceUrls = { + entityResourceUrl: `${nRoot}/${entityName}/`.toLowerCase(), + collectionResourceUrl: `${nRoot}/${this.pluralizer.pluralize( + entityName + )}/`.toLowerCase(), + }; + this.registerHttpResourceUrls({ [entityName]: resourceUrls }); + } + return resourceUrls; + } + + /** + * Create the path to a single entity resource + * @param entityName {string} Name of the entity type, e.g, 'Hero' + * @param root {string} Root path to the resource, e.g., 'some-api` + * @returns complete path to resource, e.g, 'some-api/hero' + */ + entityResource(entityName: string, root: string): string { + return this.getResourceUrls(entityName, root).entityResourceUrl; + } + + /** + * Create the path to a multiple entity (collection) resource + * @param entityName {string} Name of the entity type, e.g, 'Hero' + * @param root {string} Root path to the resource, e.g., 'some-api` + * @returns complete path to resource, e.g, 'some-api/heroes' + */ + collectionResource(entityName: string, root: string): string { + return this.getResourceUrls(entityName, root).collectionResourceUrl; + } + + /** + * Register known single-entity and collection resource URLs for HTTP calls + * @param entityHttpResourceUrls {EntityHttpResourceUrls} resource urls for specific entity type names + * Well-formed resource urls end in a '/'; + * Note: this method does not ensure that resource urls are well-formed. + */ + registerHttpResourceUrls( + entityHttpResourceUrls: EntityHttpResourceUrls + ): void { + this.knownHttpResourceUrls = { + ...this.knownHttpResourceUrls, + ...(entityHttpResourceUrls || {}), + }; + } +} + +/** Remove leading & trailing spaces or slashes */ +export function normalizeRoot(root: string) { + return root.replace(/^[\/\s]+|[\/\s]+$/g, ''); +} diff --git a/modules/data/src/dataservices/interfaces.ts b/modules/data/src/dataservices/interfaces.ts new file mode 100644 index 0000000000..657a88da21 --- /dev/null +++ b/modules/data/src/dataservices/interfaces.ts @@ -0,0 +1,32 @@ +import { Observable } from 'rxjs'; +import { Update } from '@ngrx/entity'; + +/** A service that performs REST-like HTTP data operations for an entity collection */ +export interface EntityCollectionDataService { + readonly name: string; + add(entity: T): Observable; + delete(id: number | string): Observable; + getAll(): Observable; + getById(id: any): Observable; + getWithQuery(params: QueryParams | string): Observable; + update(update: Update): Observable; + upsert(entity: T): Observable; +} + +export type HttpMethods = 'DELETE' | 'GET' | 'POST' | 'PUT'; + +export interface RequestData { + method: HttpMethods; + url: string; + data?: any; + options?: any; +} + +/** + * A key/value map of parameters to be turned into an HTTP query string + * Same as HttpClient's HttpParamsOptions which is NOT exported at package level + * https://github.com/angular/angular/issues/22013 + */ +export interface QueryParams { + [name: string]: string | string[]; +} diff --git a/modules/data/src/dataservices/persistence-result-handler.service.ts b/modules/data/src/dataservices/persistence-result-handler.service.ts new file mode 100644 index 0000000000..accb398b50 --- /dev/null +++ b/modules/data/src/dataservices/persistence-result-handler.service.ts @@ -0,0 +1,74 @@ +import { Injectable } from '@angular/core'; +import { Action } from '@ngrx/store'; + +import { Observable, of } from 'rxjs'; + +import { + DataServiceError, + EntityActionDataServiceError, +} from './data-service-error'; +import { EntityAction } from '../actions/entity-action'; +import { EntityActionFactory } from '../actions/entity-action-factory'; +import { EntityOp, makeErrorOp, makeSuccessOp } from '../actions/entity-op'; +import { Logger } from '../utils/interfaces'; + +/** + * Handling of responses from persistence operation + */ +export abstract class PersistenceResultHandler { + /** Handle successful result of persistence operation for an action */ + abstract handleSuccess(originalAction: EntityAction): (data: any) => Action; + + /** Handle error result of persistence operation for an action */ + abstract handleError( + originalAction: EntityAction + ): ( + error: DataServiceError | Error + ) => EntityAction; +} + +/** + * Default handling of responses from persistence operation, + * specifically an EntityDataService + */ +@Injectable() +export class DefaultPersistenceResultHandler + implements PersistenceResultHandler { + constructor( + private logger: Logger, + private entityActionFactory: EntityActionFactory + ) {} + + /** Handle successful result of persistence operation on an EntityAction */ + handleSuccess(originalAction: EntityAction): (data: any) => Action { + const successOp = makeSuccessOp(originalAction.payload.entityOp); + return (data: any) => + this.entityActionFactory.createFromAction(originalAction, { + entityOp: successOp, + data, + }); + } + + /** Handle error result of persistence operation on an EntityAction */ + handleError( + originalAction: EntityAction + ): ( + error: DataServiceError | Error + ) => EntityAction { + const errorOp = makeErrorOp(originalAction.payload.entityOp); + + return (err: DataServiceError | Error) => { + const error = + err instanceof DataServiceError ? err : new DataServiceError(err, null); + const errorData: EntityActionDataServiceError = { error, originalAction }; + this.logger.error(errorData); + const action = this.entityActionFactory.createFromAction< + EntityActionDataServiceError + >(originalAction, { + entityOp: errorOp, + data: errorData, + }); + return action; + }; + } +} diff --git a/modules/data/src/dispatchers/entity-cache-dispatcher.ts b/modules/data/src/dispatchers/entity-cache-dispatcher.ts new file mode 100644 index 0000000000..d2f255246c --- /dev/null +++ b/modules/data/src/dispatchers/entity-cache-dispatcher.ts @@ -0,0 +1,227 @@ +import { Injectable, Inject } from '@angular/core'; +import { + Action, + createSelector, + ScannedActionsSubject, + select, + Store, +} from '@ngrx/store'; + +import { Observable, of, Subscription, throwError } from 'rxjs'; +import { filter, map, mergeMap, shareReplay, take } from 'rxjs/operators'; + +import { CorrelationIdGenerator } from '../utils/correlation-id-generator'; +import { EntityAction, EntityActionOptions } from '../actions/entity-action'; +import { EntityActionFactory } from '../actions/entity-action-factory'; +import { EntityCache } from '../reducers/entity-cache'; +import { EntityDispatcherDefaultOptions } from './entity-dispatcher-default-options'; + +import { MergeStrategy } from '../actions/merge-strategy'; +import { PersistanceCanceled } from './entity-dispatcher'; +import { UpdateResponseData } from '../actions/update-response-data'; + +import { ChangeSet, ChangeSetItem } from '../actions/entity-cache-change-set'; +import { + ClearCollections, + EntityCacheAction, + EntityCacheQuerySet, + LoadCollections, + MergeQuerySet, + SetEntityCache, + SaveEntities, + SaveEntitiesCancel, + SaveEntitiesError, + SaveEntitiesSuccess, +} from '../actions/entity-cache-action'; + +/** + * Dispatches Entity Cache actions to the EntityCache reducer + */ +@Injectable() +export class EntityCacheDispatcher { + /** + * Actions scanned by the store after it processed them with reducers. + * A replay observable of the most recent action reduced by the store. + */ + reducedActions$: Observable; + private raSubscription: Subscription; + + constructor( + /** Generates correlation ids for query and save methods */ + private correlationIdGenerator: CorrelationIdGenerator, + /** + * Dispatcher options configure dispatcher behavior such as + * whether add is optimistic or pessimistic by default. + */ + private defaultDispatcherOptions: EntityDispatcherDefaultOptions, + /** Actions scanned by the store after it processed them with reducers. */ + @Inject(ScannedActionsSubject) scannedActions$: Observable, + /** The store, scoped to the EntityCache */ + private store: Store + ) { + // Replay because sometimes in tests will fake data service with synchronous observable + // which makes subscriber miss the dispatched actions. + // Of course that's a testing mistake. But easy to forget, leading to painful debugging. + this.reducedActions$ = scannedActions$.pipe(shareReplay(1)); + // Start listening so late subscriber won't miss the most recent action. + this.raSubscription = this.reducedActions$.subscribe(); + } + + /** + * Dispatch an Action to the store. + * @param action the Action + * @returns the dispatched Action + */ + dispatch(action: Action): Action { + this.store.dispatch(action); + return action; + } + + /** + * Dispatch action to cancel the saveEntities request with matching correlation id. + * @param correlationId The correlation id for the corresponding action + * @param [reason] explains why canceled and by whom. + * @param [entityNames] array of entity names so can turn off loading flag for their collections. + * @param [tag] tag to identify the operation from the app perspective. + */ + cancelSaveEntities( + correlationId: any, + reason?: string, + entityNames?: string[], + tag?: string + ): void { + if (!correlationId) { + throw new Error('Missing correlationId'); + } + const action = new SaveEntitiesCancel( + correlationId, + reason, + entityNames, + tag + ); + this.dispatch(action); + } + + /** Clear the named entity collections in cache + * @param [collections] Array of names of the collections to clear. + * If empty array, does nothing. If null/undefined/no array, clear all collections. + * @param [tag] tag to identify the operation from the app perspective. + */ + clearCollections(collections?: string[], tag?: string) { + this.dispatch(new ClearCollections(collections, tag)); + } + + /** + * Load multiple entity collections at the same time. + * before any selectors$ observables emit. + * @param collections The collections to load, typically the result of a query. + * @param [tag] tag to identify the operation from the app perspective. + * in the form of a map of entity collections. + */ + loadCollections(collections: EntityCacheQuerySet, tag?: string) { + this.dispatch(new LoadCollections(collections, tag)); + } + + /** + * Merges entities from a query result + * that returned entities from multiple collections. + * Corresponding entity cache reducer should add and update all collections + * at the same time, before any selectors$ observables emit. + * @param querySet The result of the query in the form of a map of entity collections. + * 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] tag to identify the operation from the app perspective. + */ + mergeQuerySet( + querySet: EntityCacheQuerySet, + mergeStrategy?: MergeStrategy, + tag?: string + ) { + this.dispatch(new MergeQuerySet(querySet, mergeStrategy, tag)); + } + + /** + * 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] tag to identify the operation from the app perspective. + */ + setEntityCache(cache: EntityCache, tag?: string) { + this.dispatch(new SetEntityCache(cache, tag)); + } + + /** + * Dispatch action to save multiple entity changes to remote storage. + * Relies on an Ngrx Effect such as EntityEffects.saveEntities$. + * Important: only call if your server supports the SaveEntities protocol + * through your EntityDataService.saveEntities method. + * @param changes Either the entities to save, as an array of {ChangeSetItem}, or + * a ChangeSet that holds such changes. + * @param url The server url which receives the save request + * @param [options] options such as tag, correlationId, isOptimistic, and mergeStrategy. + * These values are defaulted if not supplied. + * @returns A terminating Observable with data returned from the server + * after server reports successful save OR the save error. + * TODO: should return the matching entities from cache rather than the raw server data. + */ + saveEntities( + changes: ChangeSetItem[] | ChangeSet, + url: string, + options?: EntityActionOptions + ): Observable { + const changeSet = Array.isArray(changes) ? { changes } : changes; + options = options || {}; + const correlationId = + options.correlationId == null + ? this.correlationIdGenerator.next() + : options.correlationId; + const isOptimistic = + options.isOptimistic == null + ? this.defaultDispatcherOptions.optimisticSaveEntities || false + : options.isOptimistic === true; + const tag = options.tag || 'Save Entities'; + options = { ...options, correlationId, isOptimistic, tag }; + const action = new SaveEntities(changeSet, url, options); + this.dispatch(action); + return this.getSaveEntitiesResponseData$(options.correlationId).pipe( + shareReplay(1) + ); + } + + /** + * Return Observable of data from the server-success SaveEntities action with + * the given Correlation Id, after that action was processed by the ngrx store. + * or else put the server error on the Observable error channel. + * @param crid The correlationId for both the save and response actions. + */ + private getSaveEntitiesResponseData$(crid: any): Observable { + /** + * reducedActions$ must be replay observable of the most recent action reduced by the store. + * because the response action might have been dispatched to the store + * before caller had a chance to subscribe. + */ + return this.reducedActions$.pipe( + filter( + (act: Action) => + act.type === EntityCacheAction.SAVE_ENTITIES_SUCCESS || + act.type === EntityCacheAction.SAVE_ENTITIES_ERROR || + act.type === EntityCacheAction.SAVE_ENTITIES_CANCEL + ), + filter((act: Action) => crid === (act as any).payload.correlationId), + take(1), + mergeMap(act => { + return act.type === EntityCacheAction.SAVE_ENTITIES_CANCEL + ? throwError( + new PersistanceCanceled( + (act as SaveEntitiesCancel).payload.reason + ) + ) + : act.type === EntityCacheAction.SAVE_ENTITIES_SUCCESS + ? of((act as SaveEntitiesSuccess).payload.changeSet) + : throwError((act as SaveEntitiesError).payload); + }) + ); + } +} diff --git a/modules/data/src/dispatchers/entity-commands.ts b/modules/data/src/dispatchers/entity-commands.ts new file mode 100644 index 0000000000..0b954d86a0 --- /dev/null +++ b/modules/data/src/dispatchers/entity-commands.ts @@ -0,0 +1,230 @@ +import { Observable } from 'rxjs'; +import { EntityActionOptions } from '../actions/entity-action'; +import { MergeStrategy } from '../actions/merge-strategy'; +import { QueryParams } from '../dataservices/interfaces'; + +/** Commands that update the remote server. */ +export interface EntityServerCommands { + /** + * Dispatch action to save a new entity to remote storage. + * @param entity entity to add, which may omit its key if pessimistic and the server creates the key; + * must have a key if optimistic save. + * @returns A terminating Observable of the entity + * after server reports successful save or the save error. + */ + add(entity: T, options?: EntityActionOptions): Observable; + + /** + * Dispatch action to cancel the persistence operation (query or save) with the given correlationId. + * @param correlationId The correlation id for the corresponding EntityAction + * @param [reason] explains why canceled and by whom. + * @param [options] options such as the tag + */ + cancel( + correlationId: any, + reason?: string, + options?: EntityActionOptions + ): void; + + /** + * Dispatch action to delete entity from remote storage by key. + * @param key The entity to delete + * @param [options] options that influence save and merge behavior + * @returns A terminating Observable of the deleted key + * after server reports successful save or the save error. + */ + delete(entity: T, options?: EntityActionOptions): Observable; + + /** + * Dispatch action to delete entity from remote storage by key. + * @param key The primary key of the entity to remove + * @param [options] options that influence save and merge behavior + * @returns Observable of the deleted key + * after server reports successful save or the save error. + */ + delete( + key: number | string, + options?: EntityActionOptions + ): Observable; + + /** + * Dispatch action to query remote storage for all entities and + * merge the queried entities into the cached collection. + * @param [options] options that influence merge behavior + * @returns A terminating Observable of the collection + * after server reports successful query or the query error. + * @see load() + */ + getAll(options?: EntityActionOptions): Observable; + + /** + * Dispatch action to query remote storage for the entity with this primary key. + * If the server returns an entity, + * merge it into the cached collection. + * @param key The primary key of the entity to get. + * @param [options] options that influence merge behavior + * @returns A terminating Observable of the queried entities that are in the collection + * after server reports success or the query error. + */ + getByKey(key: any, options?: EntityActionOptions): Observable; + + /** + * Dispatch action to query remote storage for the entities that satisfy a query expressed + * with either a query parameter map or an HTTP URL query string, + * and merge the results into the cached collection. + * @params queryParams the query in a form understood by the server + * @param [options] options that influence merge behavior + * @returns A terminating Observable of the queried entities + * after server reports successful query or the query error. + */ + getWithQuery( + queryParams: QueryParams | string, + options?: EntityActionOptions + ): Observable; + + /** + * Dispatch action to query remote storage for all entities and + * completely replace the cached collection with the queried entities. + * @param [options] options that influence load behavior + * @returns A terminating Observable of the entities in the collection + * after server reports successful query or the query error. + * @see getAll + */ + load(options?: EntityActionOptions): Observable; + + /** + * Dispatch action to save the updated entity (or partial entity) in remote storage. + * The update entity may be partial (but must have its key) + * in which case it patches the existing entity. + * @param entity update entity, which might be a partial of T but must at least have its key. + * @param [options] options that influence save and merge behavior + * @returns A terminating Observable of the updated entity + * after server reports successful save or the save error. + */ + update(entity: Partial, options?: EntityActionOptions): Observable; + + /** + * Dispatch action to save a new or update an existing entity to remote storage. + * Only dispatch this action if your server supports upsert. + * @param entity entity to upsert, which may omit its key if pessimistic and the server creates the key; + * must have a key if optimistic save. + * @returns A terminating Observable of the entity + * after server reports successful save or the save error. + */ + upsert(entity: T, options?: EntityActionOptions): Observable; +} + +/*** A collection's cache-only commands, which do not update remote storage ***/ + +export interface EntityCacheCommands { + /** + * Replace all entities in the cached collection. + * Does not save to remote storage. + */ + addAllToCache(entities: T[], options?: EntityActionOptions): void; + + /** + * Add a new entity directly to the cache. + * Does not save to remote storage. + * Ignored if an entity with the same primary key is already in cache. + */ + addOneToCache(entity: T, options?: EntityActionOptions): void; + + /** + * Add multiple new entities directly to the cache. + * Does not save to remote storage. + * Entities with primary keys already in cache are ignored. + */ + addManyToCache(entities: T[], options?: EntityActionOptions): void; + + /** Clear the cached entity collection */ + clearCache(options?: EntityActionOptions): void; + + /** + * Remove an entity directly from the cache. + * Does not delete that entity from remote storage. + * @param entity The entity to remove + */ + removeOneFromCache(entity: T, options?: EntityActionOptions): void; + + /** + * Remove an entity directly from the cache. + * Does not delete that entity from remote storage. + * @param key The primary key of the entity to remove + */ + removeOneFromCache(key: number | string, options?: EntityActionOptions): void; + + /** + * Remove multiple entities directly from the cache. + * Does not delete these entities from remote storage. + * @param entity The entities to remove + */ + removeManyFromCache(entities: T[], options?: EntityActionOptions): void; + + /** + * Remove multiple entities directly from the cache. + * Does not delete these entities from remote storage. + * @param keys The primary keys of the entities to remove + */ + removeManyFromCache( + keys: (number | string)[], + options?: EntityActionOptions + ): void; + + /** + * Update a cached entity directly. + * Does not update that entity in remote storage. + * Ignored if an entity with matching primary key is not in cache. + * The update entity may be partial (but must have its key) + * in which case it patches the existing entity. + */ + updateOneInCache(entity: Partial, options?: EntityActionOptions): void; + + /** + * Update multiple cached entities directly. + * Does not update these entities in remote storage. + * Entities whose primary keys are not in cache are ignored. + * Update entities may be partial but must at least have their keys. + * such partial entities patch their cached counterparts. + */ + updateManyInCache( + entities: Partial[], + options?: EntityActionOptions + ): void; + + /** + * Insert or update a cached entity directly. + * Does not save to remote storage. + * Upsert entity might be a partial of T but must at least have its key. + * Pass the Update structure as the payload + */ + upsertOneInCache(entity: Partial, options?: EntityActionOptions): void; + + /** + * Insert or update multiple cached entities directly. + * Does not save to remote storage. + * Upsert entities might be partial but must at least have their keys. + * Pass an array of the Update structure as the payload + */ + upsertManyInCache( + entities: Partial[], + options?: EntityActionOptions + ): void; + + /** + * Set the pattern that the collection's filter applies + * when using the `filteredEntities` selector. + */ + setFilter(pattern: any, options?: EntityActionOptions): void; + + /** Set the loaded flag */ + setLoaded(isLoaded: boolean, options?: EntityActionOptions): void; + + /** Set the loading flag */ + setLoading(isLoading: boolean, options?: EntityActionOptions): void; +} + +/** Commands that dispatch entity actions for a collection */ +export interface EntityCommands + extends EntityServerCommands, + EntityCacheCommands {} diff --git a/modules/data/src/dispatchers/entity-dispatcher-base.ts b/modules/data/src/dispatchers/entity-dispatcher-base.ts new file mode 100644 index 0000000000..2bc936023c --- /dev/null +++ b/modules/data/src/dispatchers/entity-dispatcher-base.ts @@ -0,0 +1,630 @@ +import { Action, createSelector, select, Store } from '@ngrx/store'; +import { IdSelector, Update } from '@ngrx/entity'; + +import { Observable, of, throwError } from 'rxjs'; +import { + filter, + map, + mergeMap, + shareReplay, + withLatestFrom, + take, +} from 'rxjs/operators'; + +import { CorrelationIdGenerator } from '../utils/correlation-id-generator'; +import { defaultSelectId, toUpdateFactory } from '../utils/utilities'; +import { EntityAction, EntityActionOptions } from '../actions/entity-action'; +import { EntityActionFactory } from '../actions/entity-action-factory'; +import { EntityActionGuard } from '../actions/entity-action-guard'; +import { EntityCache } from '../reducers/entity-cache'; +import { EntityCacheSelector } from '../selectors/entity-cache-selector'; +import { EntityCollection } from '../reducers/entity-collection'; +import { EntityCommands } from './entity-commands'; +import { EntityDispatcher, PersistanceCanceled } from './entity-dispatcher'; +import { EntityDispatcherDefaultOptions } from './entity-dispatcher-default-options'; +import { EntityOp, OP_ERROR, OP_SUCCESS } from '../actions/entity-op'; +import { MergeStrategy } from '../actions/merge-strategy'; +import { QueryParams } from '../dataservices/interfaces'; +import { UpdateResponseData } from '../actions/update-response-data'; + +/** + * Dispatches EntityCollection actions to their reducers and effects (default implementation). + * All save commands rely on an Ngrx Effect such as `EntityEffects.persist$`. + */ +export class EntityDispatcherBase implements EntityDispatcher { + /** Utility class with methods to validate EntityAction payloads.*/ + guard: EntityActionGuard; + + private entityCollection$: Observable>; + + /** + * Convert an entity (or partial entity) into the `Update` object + * `update...` and `upsert...` methods take `Update` args + */ + toUpdate: (entity: Partial) => Update; + + constructor( + /** Name of the entity type for which entities are dispatched */ + public entityName: string, + /** Creates an {EntityAction} */ + public entityActionFactory: EntityActionFactory, + /** The store, scoped to the EntityCache */ + public store: Store, + /** Returns the primary key (id) of this entity */ + public selectId: IdSelector = defaultSelectId, + /** + * Dispatcher options configure dispatcher behavior such as + * whether add is optimistic or pessimistic by default. + */ + private defaultDispatcherOptions: EntityDispatcherDefaultOptions, + /** Actions scanned by the store after it processed them with reducers. */ + private reducedActions$: Observable, + /** Store selector for the EntityCache */ + entityCacheSelector: EntityCacheSelector, + /** Generates correlation ids for query and save methods */ + private correlationIdGenerator: CorrelationIdGenerator + ) { + this.guard = new EntityActionGuard(entityName, selectId); + this.toUpdate = toUpdateFactory(selectId); + + const collectionSelector = createSelector( + entityCacheSelector, + cache => cache[entityName] as EntityCollection + ); + this.entityCollection$ = store.select(collectionSelector); + } + + /** + * Create an {EntityAction} for this entity type. + * @param entityOp {EntityOp} the entity operation + * @param [data] the action data + * @param [options] additional options + * @returns the EntityAction + */ + createEntityAction

( + entityOp: EntityOp, + data?: P, + options?: EntityActionOptions + ): EntityAction

{ + return this.entityActionFactory.create({ + entityName: this.entityName, + entityOp, + data, + ...options, + }); + } + + /** + * Create an {EntityAction} for this entity type and + * dispatch it immediately to the store. + * @param op {EntityOp} the entity operation + * @param [data] the action data + * @param [options] additional options + * @returns the dispatched EntityAction + */ + createAndDispatch

( + op: EntityOp, + data?: P, + options?: EntityActionOptions + ): EntityAction

{ + const action = this.createEntityAction(op, data, options); + this.dispatch(action); + return action; + } + + /** + * Dispatch an Action to the store. + * @param action the Action + * @returns the dispatched Action + */ + dispatch(action: Action): Action { + this.store.dispatch(action); + return action; + } + + // #region Query and save operations + + /** + * Dispatch action to save a new entity to remote storage. + * @param entity entity to add, which may omit its key if pessimistic and the server creates the key; + * must have a key if optimistic save. + * @returns A terminating Observable of the entity + * after server reports successful save or the save error. + */ + add(entity: T, options?: EntityActionOptions): Observable { + options = this.setSaveEntityActionOptions( + options, + this.defaultDispatcherOptions.optimisticAdd + ); + const action = this.createEntityAction( + EntityOp.SAVE_ADD_ONE, + entity, + options + ); + if (options.isOptimistic) { + this.guard.mustBeEntity(action); + } + this.dispatch(action); + return this.getResponseData$(options.correlationId).pipe( + // Use the returned entity data's id to get the entity from the collection + // as it might be different from the entity returned from the server. + withLatestFrom(this.entityCollection$), + map(([e, collection]) => collection.entities[this.selectId(e)]), + shareReplay(1) + ); + } + + /** + * Dispatch action to cancel the persistence operation (query or save). + * Will cause save observable to error with a PersistenceCancel error. + * Caller is responsible for undoing changes in cache from pending optimistic save + * @param correlationId The correlation id for the corresponding EntityAction + * @param [reason] explains why canceled and by whom. + */ + cancel( + correlationId: any, + reason?: string, + options?: EntityActionOptions + ): void { + if (!correlationId) { + throw new Error('Missing correlationId'); + } + this.createAndDispatch(EntityOp.CANCEL_PERSIST, reason, { correlationId }); + } + + /** + * Dispatch action to delete entity from remote storage by key. + * @param key The primary key of the entity to remove + * @returns A terminating Observable of the deleted key + * after server reports successful save or the save error. + */ + delete(entity: T, options?: EntityActionOptions): Observable; + + /** + * Dispatch action to delete entity from remote storage by key. + * @param key The entity to delete + * @returns A terminating Observable of the deleted key + * after server reports successful save or the save error. + */ + delete( + key: number | string, + options?: EntityActionOptions + ): Observable; + delete( + arg: number | string | T, + options?: EntityActionOptions + ): Observable { + options = this.setSaveEntityActionOptions( + options, + this.defaultDispatcherOptions.optimisticDelete + ); + const key = this.getKey(arg); + const action = this.createEntityAction( + EntityOp.SAVE_DELETE_ONE, + key, + options + ); + this.guard.mustBeKey(action); + this.dispatch(action); + return this.getResponseData$(options.correlationId).pipe( + map(() => key), + shareReplay(1) + ); + } + + /** + * Dispatch action to query remote storage for all entities and + * merge the queried entities into the cached collection. + * @returns A terminating Observable of the queried entities that are in the collection + * after server reports success query or the query error. + * @see load() + */ + getAll(options?: EntityActionOptions): Observable { + options = this.setQueryEntityActionOptions(options); + const action = this.createEntityAction(EntityOp.QUERY_ALL, null, options); + this.dispatch(action); + return this.getResponseData$(options.correlationId).pipe( + // Use the returned entity ids to get the entities from the collection + // as they might be different from the entities returned from the server + // because of unsaved changes (deletes or updates). + withLatestFrom(this.entityCollection$), + map(([entities, collection]) => + entities.reduce( + (acc, e) => { + const entity = collection.entities[this.selectId(e)]; + if (entity) { + acc.push(entity); // only return an entity found in the collection + } + return acc; + }, + [] as T[] + ) + ), + shareReplay(1) + ); + } + + /** + * Dispatch action to query remote storage for the entity with this primary key. + * If the server returns an entity, + * merge it into the cached collection. + * @returns A terminating Observable of the collection + * after server reports successful query or the query error. + */ + getByKey(key: any, options?: EntityActionOptions): Observable { + options = this.setQueryEntityActionOptions(options); + const action = this.createEntityAction(EntityOp.QUERY_BY_KEY, key, options); + this.dispatch(action); + return this.getResponseData$(options.correlationId).pipe( + // Use the returned entity data's id to get the entity from the collection + // as it might be different from the entity returned from the server. + withLatestFrom(this.entityCollection$), + map(([entity, collection]) => collection.entities[this.selectId(entity)]), + shareReplay(1) + ); + } + + /** + * Dispatch action to query remote storage for the entities that satisfy a query expressed + * with either a query parameter map or an HTTP URL query string, + * and merge the results into the cached collection. + * @params queryParams the query in a form understood by the server + * @returns A terminating Observable of the queried entities + * after server reports successful query or the query error. + */ + getWithQuery( + queryParams: QueryParams | string, + options?: EntityActionOptions + ): Observable { + options = this.setQueryEntityActionOptions(options); + const action = this.createEntityAction( + EntityOp.QUERY_MANY, + queryParams, + options + ); + this.dispatch(action); + return this.getResponseData$(options.correlationId).pipe( + // Use the returned entity ids to get the entities from the collection + // as they might be different from the entities returned from the server + // because of unsaved changes (deletes or updates). + withLatestFrom(this.entityCollection$), + map(([entities, collection]) => + entities.reduce( + (acc, e) => { + const entity = collection.entities[this.selectId(e)]; + if (entity) { + acc.push(entity); // only return an entity found in the collection + } + return acc; + }, + [] as T[] + ) + ), + shareReplay(1) + ); + } + + /** + * Dispatch action to query remote storage for all entities and + * completely replace the cached collection with the queried entities. + * @returns A terminating Observable of the entities in the collection + * after server reports successful query or the query error. + * @see getAll + */ + load(options?: EntityActionOptions): Observable { + options = this.setQueryEntityActionOptions(options); + const action = this.createEntityAction(EntityOp.QUERY_LOAD, null, options); + this.dispatch(action); + return this.getResponseData$(options.correlationId).pipe( + shareReplay(1) + ); + } + + /** + * Dispatch action to save the updated entity (or partial entity) in remote storage. + * The update entity may be partial (but must have its key) + * in which case it patches the existing entity. + * @param entity update entity, which might be a partial of T but must at least have its key. + * @returns A terminating Observable of the updated entity + * after server reports successful save or the save error. + */ + update(entity: Partial, options?: EntityActionOptions): Observable { + // update entity might be a partial of T but must at least have its key. + // pass the Update structure as the payload + const update = this.toUpdate(entity); + options = this.setSaveEntityActionOptions( + options, + this.defaultDispatcherOptions.optimisticUpdate + ); + const action = this.createEntityAction( + EntityOp.SAVE_UPDATE_ONE, + update, + options + ); + if (options.isOptimistic) { + this.guard.mustBeEntity(action as EntityAction); + } + this.dispatch(action); + return this.getResponseData$>( + options.correlationId + ).pipe( + // Use the update entity data id to get the entity from the collection + // as might be different from the entity returned from the server + // because the id changed or there are unsaved changes. + map(updateData => updateData.changes), + withLatestFrom(this.entityCollection$), + map(([e, collection]) => collection.entities[this.selectId(e as T)]), + shareReplay(1) + ); + } + + /** + * Dispatch action to save a new or existing entity to remote storage. + * Only dispatch this action if your server supports upsert. + * @param entity entity to add, which may omit its key if pessimistic and the server creates the key; + * must have a key if optimistic save. + * @returns A terminating Observable of the entity + * after server reports successful save or the save error. + */ + upsert(entity: T, options?: EntityActionOptions): Observable { + options = this.setSaveEntityActionOptions( + options, + this.defaultDispatcherOptions.optimisticUpsert + ); + const action = this.createEntityAction( + EntityOp.SAVE_UPSERT_ONE, + entity, + options + ); + if (options.isOptimistic) { + this.guard.mustBeEntity(action); + } + this.dispatch(action); + return this.getResponseData$(options.correlationId).pipe( + // Use the returned entity data's id to get the entity from the collection + // as it might be different from the entity returned from the server. + withLatestFrom(this.entityCollection$), + map(([e, collection]) => collection.entities[this.selectId(e)]), + shareReplay(1) + ); + } + // #endregion Query and save operations + + // #region Cache-only operations that do not update remote storage + + // Unguarded for performance. + // EntityCollectionReducer runs a guard (which throws) + // Developer should understand cache-only methods well enough + // to call them with the proper entities. + // May reconsider and add guards in future. + + /** + * Replace all entities in the cached collection. + * Does not save to remote storage. + */ + addAllToCache(entities: T[], options?: EntityActionOptions): void { + this.createAndDispatch(EntityOp.ADD_ALL, entities, options); + } + + /** + * Add a new entity directly to the cache. + * Does not save to remote storage. + * Ignored if an entity with the same primary key is already in cache. + */ + addOneToCache(entity: T, options?: EntityActionOptions): void { + this.createAndDispatch(EntityOp.ADD_ONE, entity, options); + } + + /** + * Add multiple new entities directly to the cache. + * Does not save to remote storage. + * Entities with primary keys already in cache are ignored. + */ + addManyToCache(entities: T[], options?: EntityActionOptions): void { + this.createAndDispatch(EntityOp.ADD_MANY, entities, options); + } + + /** Clear the cached entity collection */ + clearCache(options?: EntityActionOptions): void { + this.createAndDispatch(EntityOp.REMOVE_ALL, undefined, options); + } + + /** + * Remove an entity directly from the cache. + * Does not delete that entity from remote storage. + * @param entity The entity to remove + */ + removeOneFromCache(entity: T, options?: EntityActionOptions): void; + + /** + * Remove an entity directly from the cache. + * Does not delete that entity from remote storage. + * @param key The primary key of the entity to remove + */ + removeOneFromCache(key: number | string, options?: EntityActionOptions): void; + removeOneFromCache( + arg: (number | string) | T, + options?: EntityActionOptions + ): void { + this.createAndDispatch(EntityOp.REMOVE_ONE, this.getKey(arg), options); + } + + /** + * Remove multiple entities directly from the cache. + * Does not delete these entities from remote storage. + * @param entity The entities to remove + */ + removeManyFromCache(entities: T[], options?: EntityActionOptions): void; + + /** + * Remove multiple entities directly from the cache. + * Does not delete these entities from remote storage. + * @param keys The primary keys of the entities to remove + */ + removeManyFromCache( + keys: (number | string)[], + options?: EntityActionOptions + ): void; + removeManyFromCache( + args: (number | string)[] | T[], + options?: EntityActionOptions + ): void { + if (!args || args.length === 0) { + return; + } + const keys = + typeof args[0] === 'object' + ? // if array[0] is a key, assume they're all keys + (args).map(arg => this.getKey(arg)) + : args; + this.createAndDispatch(EntityOp.REMOVE_MANY, keys, options); + } + + /** + * Update a cached entity directly. + * Does not update that entity in remote storage. + * Ignored if an entity with matching primary key is not in cache. + * The update entity may be partial (but must have its key) + * in which case it patches the existing entity. + */ + updateOneInCache(entity: Partial, options?: EntityActionOptions): void { + // update entity might be a partial of T but must at least have its key. + // pass the Update structure as the payload + const update: Update = this.toUpdate(entity); + this.createAndDispatch(EntityOp.UPDATE_ONE, update, options); + } + + /** + * Update multiple cached entities directly. + * Does not update these entities in remote storage. + * Entities whose primary keys are not in cache are ignored. + * Update entities may be partial but must at least have their keys. + * such partial entities patch their cached counterparts. + */ + updateManyInCache( + entities: Partial[], + options?: EntityActionOptions + ): void { + if (!entities || entities.length === 0) { + return; + } + const updates: Update[] = entities.map(entity => this.toUpdate(entity)); + this.createAndDispatch(EntityOp.UPDATE_MANY, updates, options); + } + + /** + * Add or update a new entity directly to the cache. + * Does not save to remote storage. + * Upsert entity might be a partial of T but must at least have its key. + * Pass the Update structure as the payload + */ + upsertOneInCache(entity: Partial, options?: EntityActionOptions): void { + this.createAndDispatch(EntityOp.UPSERT_ONE, entity, options); + } + + /** + * Add or update multiple cached entities directly. + * Does not save to remote storage. + */ + upsertManyInCache( + entities: Partial[], + options?: EntityActionOptions + ): void { + if (!entities || entities.length === 0) { + return; + } + this.createAndDispatch(EntityOp.UPSERT_MANY, entities, options); + } + + /** + * Set the pattern that the collection's filter applies + * when using the `filteredEntities` selector. + */ + setFilter(pattern: any): void { + this.createAndDispatch(EntityOp.SET_FILTER, pattern); + } + + /** Set the loaded flag */ + setLoaded(isLoaded: boolean): void { + this.createAndDispatch(EntityOp.SET_LOADED, !!isLoaded); + } + + /** Set the loading flag */ + setLoading(isLoading: boolean): void { + this.createAndDispatch(EntityOp.SET_LOADING, !!isLoading); + } + // #endregion Cache-only operations that do not update remote storage + + // #region private helpers + + /** Get key from entity (unless arg is already a key) */ + private getKey(arg: number | string | T) { + return typeof arg === 'object' + ? this.selectId(arg) + : (arg as number | string); + } + + /** + * Return Observable of data from the server-success EntityAction with + * the given Correlation Id, after that action was processed by the ngrx store. + * or else put the server error on the Observable error channel. + * @param crid The correlationId for both the save and response actions. + */ + private getResponseData$(crid: any): Observable { + /** + * reducedActions$ must be replay observable of the most recent action reduced by the store. + * because the response action might have been dispatched to the store + * before caller had a chance to subscribe. + */ + return this.reducedActions$.pipe( + filter((act: any) => !!act.payload), + filter((act: EntityAction) => { + const { correlationId, entityName, entityOp } = act.payload; + return ( + entityName === this.entityName && + correlationId === crid && + (entityOp.endsWith(OP_SUCCESS) || + entityOp.endsWith(OP_ERROR) || + entityOp === EntityOp.CANCEL_PERSIST) + ); + }), + take(1), + mergeMap(act => { + const { entityOp } = act.payload; + return entityOp === EntityOp.CANCEL_PERSIST + ? throwError(new PersistanceCanceled(act.payload.data)) + : entityOp.endsWith(OP_SUCCESS) + ? of(act.payload.data as D) + : throwError(act.payload.data.error); + }) + ); + } + + private setQueryEntityActionOptions( + options?: EntityActionOptions + ): EntityActionOptions { + options = options || {}; + const correlationId = + options.correlationId == null + ? this.correlationIdGenerator.next() + : options.correlationId; + return { ...options, correlationId }; + } + + private setSaveEntityActionOptions( + options?: EntityActionOptions, + defaultOptimism?: boolean + ): EntityActionOptions { + options = options || {}; + const correlationId = + options.correlationId == null + ? this.correlationIdGenerator.next() + : options.correlationId; + const isOptimistic = + options.isOptimistic == null + ? defaultOptimism || false + : options.isOptimistic === true; + return { ...options, correlationId, isOptimistic }; + } + // #endregion private helpers +} diff --git a/modules/data/src/dispatchers/entity-dispatcher-default-options.ts b/modules/data/src/dispatchers/entity-dispatcher-default-options.ts new file mode 100644 index 0000000000..7c8a437c43 --- /dev/null +++ b/modules/data/src/dispatchers/entity-dispatcher-default-options.ts @@ -0,0 +1,22 @@ +import { Injectable } from '@angular/core'; +/** + * Default options for EntityDispatcher behavior + * such as whether `add()` is optimistic or pessimistic by default. + * An optimistic save modifies the collection immediately and before saving to the server. + * A pessimistic save modifies the collection after the server confirms the save was successful. + * This class initializes the defaults to the safest values. + * Provide an alternative to change the defaults for all entity collections. + */ +@Injectable() +export class EntityDispatcherDefaultOptions { + /** True if added entities are saved optimistically; false if saved pessimistically. */ + optimisticAdd = false; + /** True if deleted entities are saved optimistically; false if saved pessimistically. */ + optimisticDelete = true; + /** True if updated entities are saved optimistically; false if saved pessimistically. */ + optimisticUpdate = false; + /** True if upsert entities are saved optimistically; false if saved pessimistically. */ + optimisticUpsert = false; + /** True if entities in a cache saveEntities request are saved optimistically; false if saved pessimistically. */ + optimisticSaveEntities = false; +} diff --git a/modules/data/src/dispatchers/entity-dispatcher-factory.ts b/modules/data/src/dispatchers/entity-dispatcher-factory.ts new file mode 100644 index 0000000000..5f21792643 --- /dev/null +++ b/modules/data/src/dispatchers/entity-dispatcher-factory.ts @@ -0,0 +1,86 @@ +import { Inject, Injectable, OnDestroy } from '@angular/core'; +import { Action, Store, ScannedActionsSubject } from '@ngrx/store'; +import { IdSelector, Update } from '@ngrx/entity'; +import { Observable, Subscription } from 'rxjs'; +import { shareReplay } from 'rxjs/operators'; + +import { CorrelationIdGenerator } from '../utils/correlation-id-generator'; +import { EntityDispatcherDefaultOptions } from './entity-dispatcher-default-options'; +import { defaultSelectId, toUpdateFactory } from '../utils/utilities'; +import { EntityAction } from '../actions/entity-action'; +import { EntityActionFactory } from '../actions/entity-action-factory'; +import { EntityCache } from '../reducers/entity-cache'; +import { + EntityCacheSelector, + ENTITY_CACHE_SELECTOR_TOKEN, + createEntityCacheSelector, +} from '../selectors/entity-cache-selector'; +import { EntityDispatcher } from './entity-dispatcher'; +import { EntityDispatcherBase } from './entity-dispatcher-base'; +import { EntityOp } from '../actions/entity-op'; +import { QueryParams } from '../dataservices/interfaces'; + +/** Creates EntityDispatchers for entity collections */ +@Injectable() +export class EntityDispatcherFactory implements OnDestroy { + /** + * Actions scanned by the store after it processed them with reducers. + * A replay observable of the most recent action reduced by the store. + */ + reducedActions$: Observable; + private raSubscription: Subscription; + + constructor( + private entityActionFactory: EntityActionFactory, + private store: Store, + private entityDispatcherDefaultOptions: EntityDispatcherDefaultOptions, + @Inject(ScannedActionsSubject) scannedActions$: Observable, + @Inject(ENTITY_CACHE_SELECTOR_TOKEN) + private entityCacheSelector: EntityCacheSelector, + private correlationIdGenerator: CorrelationIdGenerator + ) { + // Replay because sometimes in tests will fake data service with synchronous observable + // which makes subscriber miss the dispatched actions. + // Of course that's a testing mistake. But easy to forget, leading to painful debugging. + this.reducedActions$ = scannedActions$.pipe(shareReplay(1)); + // Start listening so late subscriber won't miss the most recent action. + this.raSubscription = this.reducedActions$.subscribe(); + } + + /** + * Create an `EntityDispatcher` for an entity type `T` and store. + */ + create( + /** Name of the entity type */ + entityName: string, + /** + * Function that returns the primary key for an entity `T`. + * Usually acquired from `EntityDefinition` metadata. + */ + selectId: IdSelector = defaultSelectId, + /** Defaults for options that influence dispatcher behavior such as whether + * `add()` is optimistic or pessimistic; + */ + defaultOptions: Partial = {} + ): EntityDispatcher { + // merge w/ defaultOptions with injected defaults + const options: EntityDispatcherDefaultOptions = { + ...this.entityDispatcherDefaultOptions, + ...defaultOptions, + }; + return new EntityDispatcherBase( + entityName, + this.entityActionFactory, + this.store, + selectId, + options, + this.reducedActions$, + this.entityCacheSelector, + this.correlationIdGenerator + ); + } + + ngOnDestroy() { + this.raSubscription.unsubscribe(); + } +} diff --git a/modules/data/src/dispatchers/entity-dispatcher.ts b/modules/data/src/dispatchers/entity-dispatcher.ts new file mode 100644 index 0000000000..af9d9f9590 --- /dev/null +++ b/modules/data/src/dispatchers/entity-dispatcher.ts @@ -0,0 +1,77 @@ +import { Action, Store } from '@ngrx/store'; +import { IdSelector, Update } from '@ngrx/entity'; + +import { EntityAction, EntityActionOptions } from '../actions/entity-action'; +import { EntityActionGuard } from '../actions/entity-action-guard'; +import { EntityCommands } from './entity-commands'; +import { EntityCache } from '../reducers/entity-cache'; +import { EntityOp } from '../actions/entity-op'; + +/** + * Dispatches EntityCollection actions to their reducers and effects. + * The substance of the interface is in EntityCommands. + */ +export interface EntityDispatcher extends EntityCommands { + /** Name of the entity type */ + readonly entityName: string; + + /** + * Utility class with methods to validate EntityAction payloads. + */ + readonly guard: EntityActionGuard; + + /** Returns the primary key (id) of this entity */ + readonly selectId: IdSelector; + + /** Returns the store, scoped to the EntityCache */ + readonly store: Store; + + /** + * Create an {EntityAction} for this entity type. + * @param op {EntityOp} the entity operation + * @param [data] the action data + * @param [options] additional options + * @returns the EntityAction + */ + createEntityAction

( + op: EntityOp, + data?: P, + options?: EntityActionOptions + ): EntityAction

; + + /** + * Create an {EntityAction} for this entity type and + * dispatch it immediately to the store. + * @param op {EntityOp} the entity operation + * @param [data] the action data + * @param [options] additional options + * @returns the dispatched EntityAction + */ + createAndDispatch

( + op: EntityOp, + data?: P, + options?: EntityActionOptions + ): EntityAction

; + + /** + * Dispatch an Action to the store. + * @param action the Action + * @returns the dispatched Action + */ + dispatch(action: Action): Action; + + /** + * Convert an entity (or partial entity) into the `Update` object + * `update...` and `upsert...` methods take `Update` args + */ + toUpdate(entity: Partial): Update; +} + +/** + * Persistence operation canceled + */ +export class PersistanceCanceled { + constructor(public readonly message?: string) { + this.message = message || 'Canceled by user'; + } +} diff --git a/modules/data/src/effects/entity-cache-effects.ts b/modules/data/src/effects/entity-cache-effects.ts new file mode 100644 index 0000000000..39b468b67c --- /dev/null +++ b/modules/data/src/effects/entity-cache-effects.ts @@ -0,0 +1,188 @@ +import { Inject, Injectable, Optional } from '@angular/core'; +import { Action } from '@ngrx/store'; +import { Actions, Effect, ofType } from '@ngrx/effects'; + +import { + asyncScheduler, + Observable, + of, + merge, + race, + SchedulerLike, +} from 'rxjs'; +import { + concatMap, + catchError, + delay, + filter, + map, + mergeMap, +} from 'rxjs/operators'; + +import { DataServiceError } from '../dataservices/data-service-error'; +import { + ChangeSet, + excludeEmptyChangeSetItems, +} from '../actions/entity-cache-change-set'; +import { EntityActionFactory } from '../actions/entity-action-factory'; +import { EntityOp } from '../actions/entity-op'; + +import { + EntityCacheAction, + SaveEntities, + SaveEntitiesCancel, + SaveEntitiesCanceled, + SaveEntitiesError, + SaveEntitiesSuccess, +} from '../actions/entity-cache-action'; +import { EntityCacheDataService } from '../dataservices/entity-cache-data.service'; +import { ENTITY_EFFECTS_SCHEDULER } from './entity-effects-scheduler'; +import { Logger } from '../utils/interfaces'; +import { PersistenceResultHandler } from '../dataservices/persistence-result-handler.service'; + +@Injectable() +export class EntityCacheEffects { + // See https://github.com/ReactiveX/rxjs/blob/master/doc/marble-testing.md + /** Delay for error and skip observables. Must be multiple of 10 for marble testing. */ + private responseDelay = 10; + + constructor( + private actions: Actions, + private dataService: EntityCacheDataService, + private entityActionFactory: EntityActionFactory, + private logger: Logger, + /** + * Injecting an optional Scheduler that will be undefined + * in normal application usage, but its injected here so that you can mock out + * during testing using the RxJS TestScheduler for simulating passages of time. + */ + @Optional() + @Inject(ENTITY_EFFECTS_SCHEDULER) + private scheduler: SchedulerLike + ) {} + + /** + * Observable of SAVE_ENTITIES_CANCEL actions with non-null correlation ids + */ + @Effect({ dispatch: false }) + saveEntitiesCancel$: Observable = this.actions.pipe( + ofType(EntityCacheAction.SAVE_ENTITIES_CANCEL), + filter((a: SaveEntitiesCancel) => a.payload.correlationId != null) + ); + + @Effect() + // Concurrent persistence requests considered unsafe. + // `mergeMap` allows for concurrent requests which may return in any order + saveEntities$: Observable = this.actions.pipe( + ofType(EntityCacheAction.SAVE_ENTITIES), + mergeMap((action: SaveEntities) => this.saveEntities(action)) + ); + + /** + * Perform the requested SaveEntities actions and return a scalar Observable + * that the effect should dispatch to the store after the server responds. + * @param action The SaveEntities action + */ + saveEntities(action: SaveEntities): Observable { + const error = action.payload.error; + if (error) { + return this.handleSaveEntitiesError$(action)(error); + } + try { + const changeSet = excludeEmptyChangeSetItems(action.payload.changeSet); + const { correlationId, mergeStrategy, tag, url } = action.payload; + const options = { correlationId, mergeStrategy, tag }; + + if (changeSet.changes.length === 0) { + // nothing to save + return of(new SaveEntitiesSuccess(changeSet, url, options)); + } + + // Cancellation: returns Observable for a saveEntities action + // whose correlationId matches the cancellation correlationId + const c = this.saveEntitiesCancel$.pipe( + filter(a => correlationId === a.payload.correlationId), + map( + a => + new SaveEntitiesCanceled( + correlationId, + a.payload.reason, + a.payload.tag + ) + ) + ); + + // Data: SaveEntities result as a SaveEntitiesSuccess action + const d = this.dataService.saveEntities(changeSet, url).pipe( + concatMap(result => + this.handleSaveEntitiesSuccess$(action, this.entityActionFactory)( + result + ) + ), + catchError(this.handleSaveEntitiesError$(action)) + ); + + // Emit which ever gets there first; the other observable is terminated. + return race(c, d); + } catch (err) { + return this.handleSaveEntitiesError$(action)(err); + } + } + + /** return handler of error result of saveEntities, returning a scalar observable of error action */ + private handleSaveEntitiesError$( + action: SaveEntities + ): (err: DataServiceError | Error) => Observable { + // Although error may return immediately, + // ensure observable takes some time, + // as app likely assumes asynchronous response. + return (err: DataServiceError | Error) => { + const error = + err instanceof DataServiceError ? err : new DataServiceError(err, null); + return of(new SaveEntitiesError(error, action)).pipe( + delay(this.responseDelay, this.scheduler || asyncScheduler) + ); + }; + } + + /** return handler of the ChangeSet result of successful saveEntities() */ + private handleSaveEntitiesSuccess$( + action: SaveEntities, + entityActionFactory: EntityActionFactory + ): (changeSet: ChangeSet) => Observable { + const { url, correlationId, mergeStrategy, tag } = action.payload; + const options = { correlationId, mergeStrategy, tag }; + + return changeSet => { + // DataService returned a ChangeSet with possible updates to the saved entities + if (changeSet) { + return of(new SaveEntitiesSuccess(changeSet, url, options)); + } + + // No ChangeSet = Server probably responded '204 - No Content' because + // it made no changes to the inserted/updated entities. + // Respond with success action best on the ChangeSet in the request. + changeSet = action.payload.changeSet; + + // If pessimistic save, return success action with the original ChangeSet + if (!action.payload.isOptimistic) { + return of(new SaveEntitiesSuccess(changeSet, url, options)); + } + + // If optimistic save, avoid cache grinding by just turning off the loading flags + // for all collections in the original ChangeSet + const entityNames = changeSet.changes.reduce( + (acc, item) => + acc.indexOf(item.entityName) === -1 + ? acc.concat(item.entityName) + : acc, + [] as string[] + ); + return merge( + entityNames.map(name => + entityActionFactory.create(name, EntityOp.SET_LOADING, false) + ) + ); + }; + } +} diff --git a/modules/data/src/effects/entity-effects-scheduler.ts b/modules/data/src/effects/entity-effects-scheduler.ts new file mode 100644 index 0000000000..5e028c3b6f --- /dev/null +++ b/modules/data/src/effects/entity-effects-scheduler.ts @@ -0,0 +1,8 @@ +import { Inject, Injectable, InjectionToken, Optional } from '@angular/core'; +import { SchedulerLike } from 'rxjs'; + +// See https://github.com/ReactiveX/rxjs/blob/master/doc/marble-testing.md +/** Token to inject a special RxJS Scheduler during marble tests. */ +export const ENTITY_EFFECTS_SCHEDULER = new InjectionToken( + 'EntityEffects Scheduler' +); diff --git a/modules/data/src/effects/entity-effects.ts b/modules/data/src/effects/entity-effects.ts new file mode 100644 index 0000000000..363e9f5e8b --- /dev/null +++ b/modules/data/src/effects/entity-effects.ts @@ -0,0 +1,202 @@ +import { Inject, Injectable, Optional } from '@angular/core'; +import { Action } from '@ngrx/store'; +import { Actions, Effect } from '@ngrx/effects'; +import { Update } from '@ngrx/entity'; + +import { asyncScheduler, Observable, of, race, SchedulerLike } from 'rxjs'; +import { + concatMap, + catchError, + delay, + filter, + map, + mergeMap, +} from 'rxjs/operators'; + +import { EntityAction } from '../actions/entity-action'; +import { EntityActionFactory } from '../actions/entity-action-factory'; +import { ENTITY_EFFECTS_SCHEDULER } from './entity-effects-scheduler'; +import { EntityOp, makeSuccessOp } from '../actions/entity-op'; +import { ofEntityOp } from '../actions/entity-action-operators'; +import { UpdateResponseData } from '../actions/update-response-data'; + +import { EntityDataService } from '../dataservices/entity-data.service'; +import { PersistenceResultHandler } from '../dataservices/persistence-result-handler.service'; + +export const persistOps: EntityOp[] = [ + EntityOp.QUERY_ALL, + EntityOp.QUERY_LOAD, + EntityOp.QUERY_BY_KEY, + EntityOp.QUERY_MANY, + EntityOp.SAVE_ADD_ONE, + EntityOp.SAVE_DELETE_ONE, + EntityOp.SAVE_UPDATE_ONE, + EntityOp.SAVE_UPSERT_ONE, +]; + +@Injectable() +export class EntityEffects { + // See https://github.com/ReactiveX/rxjs/blob/master/doc/marble-testing.md + /** Delay for error and skip observables. Must be multiple of 10 for marble testing. */ + private responseDelay = 10; + + /** + * Observable of non-null cancellation correlation ids from CANCEL_PERSIST actions + */ + @Effect({ dispatch: false }) + cancel$: Observable = this.actions.pipe( + ofEntityOp(EntityOp.CANCEL_PERSIST), + map((action: EntityAction) => action.payload.correlationId), + filter(id => id != null) + ); + + @Effect() + // `mergeMap` allows for concurrent requests which may return in any order + persist$: Observable = this.actions.pipe( + ofEntityOp(persistOps), + mergeMap(action => this.persist(action)) + ); + + constructor( + private actions: Actions, + private dataService: EntityDataService, + private entityActionFactory: EntityActionFactory, + private resultHandler: PersistenceResultHandler, + /** + * Injecting an optional Scheduler that will be undefined + * in normal application usage, but its injected here so that you can mock out + * during testing using the RxJS TestScheduler for simulating passages of time. + */ + @Optional() + @Inject(ENTITY_EFFECTS_SCHEDULER) + private scheduler: SchedulerLike + ) {} + + /** + * Perform the requested persistence operation and return a scalar Observable + * that the effect should dispatch to the store after the server responds. + * @param action A persistence operation EntityAction + */ + persist(action: EntityAction): Observable { + if (action.payload.skip) { + // Should not persist. Pretend it succeeded. + return this.handleSkipSuccess$(action); + } + if (action.payload.error) { + return this.handleError$(action)(action.payload.error); + } + try { + // Cancellation: returns Observable of CANCELED_PERSIST for a persistence EntityAction + // whose correlationId matches cancellation correlationId + const c = this.cancel$.pipe( + filter(id => action.payload.correlationId === id), + map(id => + this.entityActionFactory.createFromAction(action, { + entityOp: EntityOp.CANCELED_PERSIST, + }) + ) + ); + + // Data: entity collection DataService result as a successful persistence EntityAction + const d = this.callDataService(action).pipe( + map(this.resultHandler.handleSuccess(action)), + catchError(this.handleError$(action)) + ); + + // Emit which ever gets there first; the other observable is terminated. + return race(c, d); + } catch (err) { + return this.handleError$(action)(err); + } + } + + private callDataService(action: EntityAction) { + const { entityName, entityOp, data } = action.payload; + const service = this.dataService.getService(entityName); + switch (entityOp) { + case EntityOp.QUERY_ALL: + case EntityOp.QUERY_LOAD: + return service.getAll(); + + case EntityOp.QUERY_BY_KEY: + return service.getById(data); + + case EntityOp.QUERY_MANY: + return service.getWithQuery(data); + + case EntityOp.SAVE_ADD_ONE: + return service.add(data); + + case EntityOp.SAVE_DELETE_ONE: + return service.delete(data); + + case EntityOp.SAVE_UPDATE_ONE: + const { id, changes } = data as Update; // data must be Update + return service.update(data).pipe( + map(updatedEntity => { + // Return an Update with updated entity data. + // If server returned entity data, merge with the changes that were sent + // and set the 'changed' flag to true. + // If server did not return entity data, + // assume it made no additional changes of its own, return the original changes, + // and set the `changed` flag to `false`. + const hasData = + updatedEntity && Object.keys(updatedEntity).length > 0; + const responseData: UpdateResponseData = hasData + ? { id, changes: { ...changes, ...updatedEntity }, changed: true } + : { id, changes, changed: false }; + return responseData; + }) + ); + + case EntityOp.SAVE_UPSERT_ONE: + return service.upsert(data).pipe( + map(upsertedEntity => { + const hasData = + upsertedEntity && Object.keys(upsertedEntity).length > 0; + return hasData ? upsertedEntity : data; // ensure a returned entity value. + }) + ); + default: + throw new Error(`Persistence action "${entityOp}" is not implemented.`); + } + } + + /** + * Handle error result of persistence operation on an EntityAction, + * returning a scalar observable of error action + */ + private handleError$( + action: EntityAction + ): (error: Error) => Observable { + // Although error may return immediately, + // ensure observable takes some time, + // as app likely assumes asynchronous response. + return (error: Error) => + of(this.resultHandler.handleError(action)(error)).pipe( + delay(this.responseDelay, this.scheduler || asyncScheduler) + ); + } + + /** + * Because EntityAction.payload.skip is true, skip the persistence step and + * return a scalar success action that looks like the operation succeeded. + */ + private handleSkipSuccess$( + originalAction: EntityAction + ): Observable { + const successOp = makeSuccessOp(originalAction.payload.entityOp); + const successAction = this.entityActionFactory.createFromAction( + originalAction, + { + entityOp: successOp, + } + ); + // Although returns immediately, + // ensure observable takes one tick (by using a promise), + // as app likely assumes asynchronous response. + return of(successAction).pipe( + delay(this.responseDelay, this.scheduler || asyncScheduler) + ); + } +} diff --git a/modules/data/src/entity-metadata/entity-definition.service.ts b/modules/data/src/entity-metadata/entity-definition.service.ts new file mode 100644 index 0000000000..29631f2816 --- /dev/null +++ b/modules/data/src/entity-metadata/entity-definition.service.ts @@ -0,0 +1,109 @@ +import { Inject, Injectable, InjectionToken, Optional } from '@angular/core'; + +import { createEntityDefinition, EntityDefinition } from './entity-definition'; +import { + EntityMetadata, + EntityMetadataMap, + ENTITY_METADATA_TOKEN, +} from './entity-metadata'; +import { ENTITY_CACHE_NAME } from '../reducers/constants'; + +export interface EntityDefinitions { + [entityName: string]: EntityDefinition; +} + +/** Registry of EntityDefinitions for all cached entity types */ +@Injectable() +export class EntityDefinitionService { + /** {EntityDefinition} for all cached entity types */ + private readonly definitions: EntityDefinitions = {}; + + constructor( + @Optional() + @Inject(ENTITY_METADATA_TOKEN) + entityMetadataMaps: EntityMetadataMap[] + ) { + if (entityMetadataMaps) { + entityMetadataMaps.forEach(map => this.registerMetadataMap(map)); + } + } + + /** + * Get (or create) a data service for entity type + * @param entityName - the name of the type + * + * Examples: + * getDefinition('Hero'); // definition for Heroes, untyped + * getDefinition(`Hero`); // definition for Heroes, typed with Hero interface + */ + getDefinition( + entityName: string, + shouldThrow = true + ): EntityDefinition { + entityName = entityName.trim(); + const definition = this.definitions[entityName]; + if (!definition && shouldThrow) { + throw new Error(`No EntityDefinition for entity type "${entityName}".`); + } + return definition; + } + + //////// Registration methods ////////// + + /** + * Create and register the {EntityDefinition} for the {EntityMetadata} of an entity type + * @param name - the name of the entity type + * @param definition - {EntityMetadata} for a collection for that entity type + * + * Examples: + * registerMetadata(myHeroEntityDefinition); + */ + registerMetadata(metadata: EntityMetadata) { + if (metadata) { + const definition = createEntityDefinition(metadata); + this.registerDefinition(definition); + } + } + + /** + * Register an EntityMetadataMap. + * @param metadataMap - a map of entityType names to entity metadata + * + * Examples: + * registerMetadataMap({ + * 'Hero': myHeroMetadata, + * Villain: myVillainMetadata + * }); + */ + registerMetadataMap(metadataMap: EntityMetadataMap = {}) { + // The entity type name should be the same as the map key + Object.keys(metadataMap || {}).forEach(entityName => + this.registerMetadata({ entityName, ...metadataMap[entityName] }) + ); + } + + /** + * Register an {EntityDefinition} for an entity type + * @param definition - EntityDefinition of a collection for that entity type + * + * Examples: + * registerDefinition('Hero', myHeroEntityDefinition); + */ + registerDefinition(definition: EntityDefinition) { + this.definitions[definition.entityName] = definition; + } + + /** + * Register a batch of EntityDefinitions. + * @param definitions - map of entityType name and associated EntityDefinitions to merge. + * + * Examples: + * registerDefinitions({ + * 'Hero': myHeroEntityDefinition, + * Villain: myVillainEntityDefinition + * }); + */ + registerDefinitions(definitions: EntityDefinitions) { + Object.assign(this.definitions, definitions); + } +} diff --git a/modules/data/src/entity-metadata/entity-definition.ts b/modules/data/src/entity-metadata/entity-definition.ts new file mode 100644 index 0000000000..ad15199616 --- /dev/null +++ b/modules/data/src/entity-metadata/entity-definition.ts @@ -0,0 +1,62 @@ +import { EntityState, EntityAdapter, createEntityAdapter } from '@ngrx/entity'; +import { Comparer, Dictionary, IdSelector, Update } from '@ngrx/entity'; + +import { + EntitySelectors, + EntitySelectorsFactory, +} from '../selectors/entity-selectors'; +import { EntityDispatcherDefaultOptions } from '../dispatchers/entity-dispatcher-default-options'; +import { defaultSelectId } from '../utils/utilities'; +import { EntityCollection } from '../reducers/entity-collection'; +import { EntityFilterFn } from './entity-filters'; +import { EntityMetadata } from './entity-metadata'; + +export interface EntityDefinition { + entityName: string; + entityAdapter: EntityAdapter; + entityDispatcherOptions?: Partial; + initialState: EntityCollection; + metadata: EntityMetadata; + noChangeTracking: boolean; + selectId: IdSelector; + sortComparer: false | Comparer; +} + +export function createEntityDefinition( + metadata: EntityMetadata +): EntityDefinition { + let entityName = metadata.entityName; + if (!entityName) { + throw new Error('Missing required entityName'); + } + metadata.entityName = entityName = entityName.trim(); + const selectId = metadata.selectId || defaultSelectId; + const sortComparer = (metadata.sortComparer = metadata.sortComparer || false); + + const entityAdapter = createEntityAdapter({ selectId, sortComparer }); + + const entityDispatcherOptions: Partial = + metadata.entityDispatcherOptions || {}; + + const initialState: EntityCollection = entityAdapter.getInitialState({ + entityName, + filter: '', + loaded: false, + loading: false, + changeState: {}, + ...(metadata.additionalCollectionState || {}), + }); + + const noChangeTracking = metadata.noChangeTracking === true; // false by default + + return { + entityName, + entityAdapter, + entityDispatcherOptions, + initialState, + metadata, + noChangeTracking, + selectId, + sortComparer, + }; +} diff --git a/modules/data/src/entity-metadata/entity-filters.ts b/modules/data/src/entity-metadata/entity-filters.ts new file mode 100644 index 0000000000..10f8f8bf63 --- /dev/null +++ b/modules/data/src/entity-metadata/entity-filters.ts @@ -0,0 +1,34 @@ +/** + * Filters the `entities` array argument and returns the original `entities`, + * or a new filtered array of entities. + * NEVER mutate the original `entities` array itself. + **/ +export type EntityFilterFn = (entities: T[], pattern?: any) => T[]; + +/** + * Creates an {EntityFilterFn} that matches RegExp or RegExp string pattern + * anywhere in any of the given props of an entity. + * If pattern is a string, spaces are significant and ignores case. + */ +export function PropsFilterFnFactory( + props: (keyof T)[] = [] +): EntityFilterFn { + if (props.length === 0) { + // No properties -> nothing could match -> return unfiltered + return (entities: T[], pattern: string) => entities; + } + + return (entities: T[], pattern: string | RegExp) => { + if (!entities) { + return []; + } + + const regExp = + typeof pattern === 'string' ? new RegExp(pattern, 'i') : pattern; + if (regExp) { + const predicate = (e: any) => props.some(prop => regExp.test(e[prop])); + return entities.filter(predicate); + } + return entities; + }; +} diff --git a/modules/data/src/entity-metadata/entity-metadata.ts b/modules/data/src/entity-metadata/entity-metadata.ts new file mode 100644 index 0000000000..5906e1dd1a --- /dev/null +++ b/modules/data/src/entity-metadata/entity-metadata.ts @@ -0,0 +1,26 @@ +import { InjectionToken } from '@angular/core'; + +import { IdSelector, Comparer } from '@ngrx/entity'; + +import { EntityDispatcherDefaultOptions } from '../dispatchers/entity-dispatcher-default-options'; +import { EntityFilterFn } from './entity-filters'; + +export const ENTITY_METADATA_TOKEN = new InjectionToken( + '@ngrx/data/entity-metadata' +); + +/** Metadata that describe an entity type and its collection to ngrx-data */ +export interface EntityMetadata { + entityName: string; + entityDispatcherOptions?: Partial; + filterFn?: EntityFilterFn; + noChangeTracking?: boolean; + selectId?: IdSelector; + sortComparer?: false | Comparer; + additionalCollectionState?: S; +} + +/** Map entity-type name to its EntityMetadata */ +export interface EntityMetadataMap { + [entityName: string]: Partial>; +} diff --git a/modules/data/src/entity-services/entity-collection-service-base.ts b/modules/data/src/entity-services/entity-collection-service-base.ts new file mode 100644 index 0000000000..c13ce18490 --- /dev/null +++ b/modules/data/src/entity-services/entity-collection-service-base.ts @@ -0,0 +1,439 @@ +import { Injectable } from '@angular/core'; +import { Action, Store } from '@ngrx/store'; +import { Actions } from '@ngrx/effects'; +import { Dictionary, IdSelector, Update } from '@ngrx/entity'; + +import { Observable } from 'rxjs'; + +import { EntityAction, EntityActionOptions } from '../actions/entity-action'; +import { EntityActionGuard } from '../actions/entity-action-guard'; +import { EntityCache } from '../reducers/entity-cache'; +import { + EntityCollection, + ChangeStateMap, +} from '../reducers/entity-collection'; +import { EntityDispatcher } from '../dispatchers/entity-dispatcher'; +import { EntityCollectionService } from './entity-collection-service'; +import { EntityCollectionServiceElementsFactory } from './entity-collection-service-elements-factory'; +import { EntityOp } from '../actions/entity-op'; +import { EntitySelectors } from '../selectors/entity-selectors'; +import { EntitySelectors$ } from '../selectors/entity-selectors$'; +import { QueryParams } from '../dataservices/interfaces'; + +// tslint:disable:member-ordering + +/** + * Base class for a concrete EntityCollectionService. + * Can be instantiated. Cannot be injected. Use EntityCollectionServiceFactory to create. + * @param EntityCollectionServiceElements The ingredients for this service + * as a source of supporting services for creating an EntityCollectionService instance. + */ +export class EntityCollectionServiceBase< + T, + S$ extends EntitySelectors$ = EntitySelectors$ +> implements EntityCollectionService { + /** Dispatcher of EntityCommands (EntityActions) */ + readonly dispatcher: EntityDispatcher; + + /** All selectors of entity collection properties */ + readonly selectors: EntitySelectors; + + /** All selectors$ (observables of entity collection properties) */ + readonly selectors$: S$; + + constructor( + /** Name of the entity type of this collection service */ + public readonly entityName: string, + /** Creates the core elements of the EntityCollectionService for this entity type */ + serviceElementsFactory: EntityCollectionServiceElementsFactory + ) { + entityName = entityName.trim(); + const { dispatcher, selectors, selectors$ } = serviceElementsFactory.create< + T, + S$ + >(entityName); + + this.entityName = entityName; + this.dispatcher = dispatcher; + this.guard = dispatcher.guard; + this.selectId = dispatcher.selectId; + this.toUpdate = dispatcher.toUpdate; + + this.selectors = selectors; + this.selectors$ = selectors$; + this.collection$ = selectors$.collection$; + this.count$ = selectors$.count$; + this.entities$ = selectors$.entities$; + this.entityActions$ = selectors$.entityActions$; + this.entityMap$ = selectors$.entityMap$; + this.errors$ = selectors$.errors$; + this.filter$ = selectors$.filter$; + this.filteredEntities$ = selectors$.filteredEntities$; + this.keys$ = selectors$.keys$; + this.loaded$ = selectors$.loaded$; + this.loading$ = selectors$.loading$; + this.changeState$ = selectors$.changeState$; + } + + /** + * Create an {EntityAction} for this entity type. + * @param op {EntityOp} the entity operation + * @param [data] the action data + * @param [options] additional options + * @returns the EntityAction + */ + createEntityAction

( + op: EntityOp, + data?: P, + options?: EntityActionOptions + ): EntityAction

{ + return this.dispatcher.createEntityAction(op, data, options); + } + + /** + * Create an {EntityAction} for this entity type and + * dispatch it immediately to the store. + * @param op {EntityOp} the entity operation + * @param [data] the action data + * @param [options] additional options + * @returns the dispatched EntityAction + */ + createAndDispatch

( + op: EntityOp, + data?: P, + options?: EntityActionOptions + ): EntityAction

{ + return this.dispatcher.createAndDispatch(op, data, options); + } + + /** + * Dispatch an action of any type to the ngrx store. + * @param action the Action + * @returns the dispatched Action + */ + dispatch(action: Action): Action { + return this.dispatcher.dispatch(action); + } + + /** The NgRx Store for the {EntityCache} */ + get store() { + return this.dispatcher.store; + } + + /** + * Utility class with methods to validate EntityAction payloads. + */ + guard: EntityActionGuard; + + /** Returns the primary key (id) of this entity */ + selectId: IdSelector; + + /** + * Convert an entity (or partial entity) into the `Update` object + * `update...` and `upsert...` methods take `Update` args + */ + toUpdate: (entity: Partial) => Update; + + // region Dispatch commands + + /** + * Dispatch action to save a new entity to remote storage. + * @param entity entity to add, which may omit its key if pessimistic and the server creates the key; + * must have a key if optimistic save. + * @param [options] options that influence save and merge behavior + * @returns Observable of the entity + * after server reports successful save or the save error. + */ + add(entity: T, options?: EntityActionOptions): Observable { + return this.dispatcher.add(entity, options); + } + + /** + * Dispatch action to cancel the persistence operation (query or save) with the given correlationId. + * @param correlationId The correlation id for the corresponding EntityAction + * @param [reason] explains why canceled and by whom. + * @param [options] options such as the tag + */ + cancel( + correlationId: any, + reason?: string, + options?: EntityActionOptions + ): void { + this.dispatcher.cancel(correlationId, reason, options); + } + + /** + * Dispatch action to delete entity from remote storage by key. + * @param key The entity to delete + * @param [options] options that influence save and merge behavior + * @returns Observable of the deleted key + * after server reports successful save or the save error. + */ + delete(entity: T, options?: EntityActionOptions): Observable; + + /** + * Dispatch action to delete entity from remote storage by key. + * @param key The primary key of the entity to remove + * @param [options] options that influence save and merge behavior + * @returns Observable of the deleted key + * after server reports successful save or the save error. + */ + delete( + key: number | string, + options?: EntityActionOptions + ): Observable; + delete( + arg: number | string | T, + options?: EntityActionOptions + ): Observable { + return this.dispatcher.delete(arg as any, options); + } + + /** + * Dispatch action to query remote storage for all entities and + * merge the queried entities into the cached collection. + * @param [options] options that influence merge behavior + * @returns Observable of the collection + * after server reports successful query or the query error. + * @see load() + */ + getAll(options?: EntityActionOptions): Observable { + return this.dispatcher.getAll(options); + } + + /** + * Dispatch action to query remote storage for the entity with this primary key. + * If the server returns an entity, + * merge it into the cached collection. + * @param key The primary key of the entity to get. + * @param [options] options that influence merge behavior + * @returns Observable of the queried entity that is in the collection + * after server reports success or the query error. + */ + getByKey(key: any, options?: EntityActionOptions): Observable { + return this.dispatcher.getByKey(key, options); + } + + /** + * Dispatch action to query remote storage for the entities that satisfy a query expressed + * with either a query parameter map or an HTTP URL query string, + * and merge the results into the cached collection. + * @params queryParams the query in a form understood by the server + * @param [options] options that influence merge behavior + * @returns Observable of the queried entities + * after server reports successful query or the query error. + */ + getWithQuery( + queryParams: QueryParams | string, + options?: EntityActionOptions + ): Observable { + return this.dispatcher.getWithQuery(queryParams, options); + } + + /** + * Dispatch action to query remote storage for all entities and + * completely replace the cached collection with the queried entities. + * @param [options] options that influence load behavior + * @returns Observable of the collection + * after server reports successful query or the query error. + * @see getAll + */ + load(options?: EntityActionOptions): Observable { + return this.dispatcher.load(options); + } + + /** + * Dispatch action to save the updated entity (or partial entity) in remote storage. + * The update entity may be partial (but must have its key) + * in which case it patches the existing entity. + * @param entity update entity, which might be a partial of T but must at least have its key. + * @param [options] options that influence save and merge behavior + * @returns Observable of the updated entity + * after server reports successful save or the save error. + */ + update(entity: Partial, options?: EntityActionOptions): Observable { + return this.dispatcher.update(entity, options); + } + + /** + * Dispatch action to save a new or existing entity to remote storage. + * Call only if the server supports upsert. + * @param entity entity to add or upsert. + * It may omit its key if an add, and is pessimistic, and the server creates the key; + * must have a key if optimistic save. + * @param [options] options that influence save and merge behavior + * @returns Observable of the entity + * after server reports successful save or the save error. + */ + upsert(entity: T, options?: EntityActionOptions): Observable { + return this.dispatcher.upsert(entity, options); + } + + /*** Cache-only operations that do not update remote storage ***/ + + /** + * Replace all entities in the cached collection. + * Does not save to remote storage. + */ + addAllToCache(entities: T[]): void { + this.dispatcher.addAllToCache(entities); + } + + /** + * Add a new entity directly to the cache. + * Does not save to remote storage. + * Ignored if an entity with the same primary key is already in cache. + */ + addOneToCache(entity: T): void { + this.dispatcher.addOneToCache(entity); + } + + /** + * Add multiple new entities directly to the cache. + * Does not save to remote storage. + * Entities with primary keys already in cache are ignored. + */ + addManyToCache(entities: T[]): void { + this.dispatcher.addManyToCache(entities); + } + + /** Clear the cached entity collection */ + clearCache(): void { + this.dispatcher.clearCache(); + } + + /** + * Remove an entity directly from the cache. + * Does not delete that entity from remote storage. + * @param entity The entity to remove + */ + removeOneFromCache(entity: T): void; + + /** + * Remove an entity directly from the cache. + * Does not delete that entity from remote storage. + * @param key The primary key of the entity to remove + */ + removeOneFromCache(key: number | string): void; + removeOneFromCache(arg: (number | string) | T): void { + this.dispatcher.removeOneFromCache(arg as any); + } + + /** + * Remove multiple entities directly from the cache. + * Does not delete these entities from remote storage. + * @param entity The entities to remove + */ + removeManyFromCache(entities: T[]): void; + + /** + * Remove multiple entities directly from the cache. + * Does not delete these entities from remote storage. + * @param keys The primary keys of the entities to remove + */ + removeManyFromCache(keys: (number | string)[]): void; + removeManyFromCache(args: (number | string)[] | T[]): void { + this.dispatcher.removeManyFromCache(args as any[]); + } + + /** + * Update a cached entity directly. + * Does not update that entity in remote storage. + * Ignored if an entity with matching primary key is not in cache. + * The update entity may be partial (but must have its key) + * in which case it patches the existing entity. + */ + updateOneInCache(entity: Partial): void { + // update entity might be a partial of T but must at least have its key. + // pass the Update structure as the payload + this.dispatcher.updateOneInCache(entity); + } + + /** + * Update multiple cached entities directly. + * Does not update these entities in remote storage. + * Entities whose primary keys are not in cache are ignored. + * Update entities may be partial but must at least have their keys. + * such partial entities patch their cached counterparts. + */ + updateManyInCache(entities: Partial[]): void { + this.dispatcher.updateManyInCache(entities); + } + + /** + * Add or update a new entity directly to the cache. + * Does not save to remote storage. + * Upsert entity might be a partial of T but must at least have its key. + * Pass the Update structure as the payload + */ + upsertOneInCache(entity: Partial): void { + this.dispatcher.upsertOneInCache(entity); + } + + /** + * Add or update multiple cached entities directly. + * Does not save to remote storage. + */ + upsertManyInCache(entities: Partial[]): void { + this.dispatcher.upsertManyInCache(entities); + } + + /** + * Set the pattern that the collection's filter applies + * when using the `filteredEntities` selector. + */ + setFilter(pattern: any): void { + this.dispatcher.setFilter(pattern); + } + + /** Set the loaded flag */ + setLoaded(isLoaded: boolean): void { + this.dispatcher.setLoaded(!!isLoaded); + } + + /** Set the loading flag */ + setLoading(isLoading: boolean): void { + this.dispatcher.setLoading(!!isLoading); + } + + // endregion Dispatch commands + + // region Selectors$ + /** Observable of the collection as a whole */ + collection$: Observable> | Store>; + + /** Observable of count of entities in the cached collection. */ + count$: Observable | Store; + + /** Observable of all entities in the cached collection. */ + entities$: Observable | Store; + + /** Observable of actions related to this entity type. */ + entityActions$: Observable; + + /** Observable of the map of entity keys to entities */ + entityMap$: Observable> | Store>; + + /** Observable of error actions related to this entity type. */ + errors$: Observable; + + /** Observable of the filter pattern applied by the entity collection's filter function */ + filter$: Observable | Store; + + /** Observable of entities in the cached collection that pass the filter function */ + filteredEntities$: Observable | Store; + + /** Observable of the keys of the cached collection, in the collection's native sort order */ + keys$: Observable | Store; + + /** Observable true when the collection has been loaded */ + loaded$: Observable | Store; + + /** Observable true when a multi-entity query command is in progress. */ + loading$: Observable | Store; + + /** Original entity values for entities with unsaved changes */ + changeState$: Observable> | Store>; + + // endregion Selectors$ +} diff --git a/modules/data/src/entity-services/entity-collection-service-elements-factory.ts b/modules/data/src/entity-services/entity-collection-service-elements-factory.ts new file mode 100644 index 0000000000..6ea26ec862 --- /dev/null +++ b/modules/data/src/entity-services/entity-collection-service-elements-factory.ts @@ -0,0 +1,67 @@ +import { Injectable } from '@angular/core'; +import { EntityCollectionService } from './entity-collection-service'; +import { EntityCollectionServiceBase } from './entity-collection-service-base'; +import { EntityDispatcher } from '../dispatchers/entity-dispatcher'; +import { EntityDispatcherFactory } from '../dispatchers/entity-dispatcher-factory'; +import { EntityDefinitionService } from '../entity-metadata/entity-definition.service'; +import { + EntitySelectors, + EntitySelectorsFactory, +} from '../selectors/entity-selectors'; +import { + EntitySelectors$, + EntitySelectors$Factory, +} from '../selectors/entity-selectors$'; + +/** Core ingredients of an EntityCollectionService */ +export interface EntityCollectionServiceElements< + T, + S$ extends EntitySelectors$ = EntitySelectors$ +> { + readonly dispatcher: EntityDispatcher; + readonly entityName: string; + readonly selectors: EntitySelectors; + readonly selectors$: S$; +} + +/** Creates the core elements of the EntityCollectionService for an entity type. */ +@Injectable() +export class EntityCollectionServiceElementsFactory { + constructor( + private entityDispatcherFactory: EntityDispatcherFactory, + private entityDefinitionService: EntityDefinitionService, + private entitySelectorsFactory: EntitySelectorsFactory, + private entitySelectors$Factory: EntitySelectors$Factory + ) {} + + /** + * Get the ingredients for making an EntityCollectionService for this entity type + * @param entityName - name of the entity type + */ + create = EntitySelectors$>( + entityName: string + ): EntityCollectionServiceElements { + entityName = entityName.trim(); + const definition = this.entityDefinitionService.getDefinition( + entityName + ); + const dispatcher = this.entityDispatcherFactory.create( + entityName, + definition.selectId, + definition.entityDispatcherOptions + ); + const selectors = this.entitySelectorsFactory.create( + definition.metadata + ); + const selectors$ = this.entitySelectors$Factory.create( + entityName, + selectors + ); + return { + dispatcher, + entityName, + selectors, + selectors$, + }; + } +} diff --git a/modules/data/src/entity-services/entity-collection-service-factory.ts b/modules/data/src/entity-services/entity-collection-service-factory.ts new file mode 100644 index 0000000000..099e2a15d9 --- /dev/null +++ b/modules/data/src/entity-services/entity-collection-service-factory.ts @@ -0,0 +1,30 @@ +import { Injectable } from '@angular/core'; +import { EntityCollectionService } from './entity-collection-service'; +import { EntityCollectionServiceBase } from './entity-collection-service-base'; +import { EntityCollectionServiceElementsFactory } from './entity-collection-service-elements-factory'; +import { EntitySelectors$ } from '../selectors/entity-selectors$'; + +/** + * Creates EntityCollectionService instances for + * a cached collection of T entities in the ngrx store. + */ +@Injectable() +export class EntityCollectionServiceFactory { + constructor( + /** Creates the core elements of the EntityCollectionService for an entity type. */ + public entityCollectionServiceElementsFactory: EntityCollectionServiceElementsFactory + ) {} + + /** + * Create an EntityCollectionService for an entity type + * @param entityName - name of the entity type + */ + create = EntitySelectors$>( + entityName: string + ): EntityCollectionService { + return new EntityCollectionServiceBase( + entityName, + this.entityCollectionServiceElementsFactory + ); + } +} diff --git a/modules/data/src/entity-services/entity-collection-service.ts b/modules/data/src/entity-services/entity-collection-service.ts new file mode 100644 index 0000000000..71c36baf25 --- /dev/null +++ b/modules/data/src/entity-services/entity-collection-service.ts @@ -0,0 +1,55 @@ +import { EntityAction, EntityActionOptions } from '../actions/entity-action'; +import { EntityCommands } from '../dispatchers/entity-commands'; +import { EntityDispatcher } from '../dispatchers/entity-dispatcher'; +import { EntityOp } from '../actions/entity-op'; +import { EntitySelectors$ } from '../selectors/entity-selectors$'; +import { EntitySelectors } from '../selectors/entity-selectors'; + +// tslint:disable:member-ordering + +/** + * A facade for managing + * a cached collection of T entities in the ngrx store. + */ +export interface EntityCollectionService + extends EntityCommands, + EntitySelectors$ { + /** + * Create an {EntityAction} for this entity type. + * @param op {EntityOp} the entity operation + * @param [data] the action data + * @param [options] additional options + * @returns the EntityAction + */ + createEntityAction( + op: EntityOp, + payload?: any, + options?: EntityActionOptions + ): EntityAction; + + /** + * Create an {EntityAction} for this entity type and + * dispatch it immediately to the store. + * @param op {EntityOp} the entity operation + * @param [data] the action data + * @param [options] additional options + * @returns the dispatched EntityAction + */ + createAndDispatch

( + op: EntityOp, + data?: P, + options?: EntityActionOptions + ): EntityAction

; + + /** Dispatcher of EntityCommands (EntityActions) */ + readonly dispatcher: EntityDispatcher; + + /** Name of the entity type for this collection service */ + readonly entityName: string; + + /** All selector functions of the entity collection */ + readonly selectors: EntitySelectors; + + /** All selectors$ (observables of the selectors of entity collection properties) */ + readonly selectors$: EntitySelectors$; +} diff --git a/modules/data/src/entity-services/entity-services-base.ts b/modules/data/src/entity-services/entity-services-base.ts new file mode 100644 index 0000000000..cfcf41554f --- /dev/null +++ b/modules/data/src/entity-services/entity-services-base.ts @@ -0,0 +1,156 @@ +import { Inject, Injectable } from '@angular/core'; +import { Action, Store } from '@ngrx/store'; + +import { Observable } from 'rxjs'; + +import { EntityAction } from '../actions/entity-action'; +import { EntityCache } from '../reducers/entity-cache'; +import { EntityCollectionService } from './entity-collection-service'; +import { EntityCollectionServiceBase } from './entity-collection-service-base'; +import { EntityCollectionServiceFactory } from './entity-collection-service-factory'; +import { EntityCollectionServiceMap, EntityServices } from './entity-services'; +import { EntitySelectorsFactory } from '../selectors/entity-selectors'; +import { + EntitySelectors$, + EntitySelectors$Factory, +} from '../selectors/entity-selectors$'; +import { EntityServicesElements } from './entity-services-elements'; + +// tslint:disable:member-ordering + +/** + * Base/default class of a central registry of EntityCollectionServices for all entity types. + * Create your own subclass to add app-specific members for an improved developer experience. + * + * @example + * export class EntityServices extends EntityServicesBase { + * constructor(entityServicesElements: EntityServicesElements) { + * super(entityServicesElements); + * } + * // Extend with well-known, app entity collection services + * // Convenience property to return a typed custom entity collection service + * get companyService() { + * return this.getEntityCollectionService('Company') as CompanyService; + * } + * // Convenience dispatch methods + * clearCompany(companyId: string) { + * this.dispatch(new ClearCompanyAction(companyId)); + * } + * } + */ +@Injectable() +export class EntityServicesBase implements EntityServices { + // 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. + // + // 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) {} + + // #region EntityServicesElement-based properties + + /** Observable of error EntityActions (e.g. QUERY_ALL_ERROR) for all entity types */ + get entityActionErrors$(): Observable { + return this.entityServicesElements.entityActionErrors$; + } + + /** Observable of the entire entity cache */ + get entityCache$(): Observable | Store { + return this.entityServicesElements.entityCache$; + } + + /** Factory to create a default instance of an EntityCollectionService */ + 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. + */ + get reducedActions$(): Observable { + return this.entityServicesElements.reducedActions$; + } + + /** The ngrx store, scoped to the EntityCache */ + protected get store(): Store { + 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. + * Prefer getEntityCollectionService() unless you really want a new default instance. + * This one will NOT be registered with EntityServices! + * @param entityName {string} Name of the entity type of the service + */ + protected createEntityCollectionService< + T, + S$ extends EntitySelectors$ = EntitySelectors$ + >(entityName: string): EntityCollectionService { + return this.entityCollectionServiceFactory.create(entityName); + } + + /** Get (or create) the singleton instance of an EntityCollectionService + * @param entityName {string} Name of the entity type of the service + */ + getEntityCollectionService< + T, + S$ extends EntitySelectors$ = EntitySelectors$ + >(entityName: string): EntityCollectionService { + let service = this.EntityCollectionServices[entityName]; + if (!service) { + service = this.createEntityCollectionService(entityName); + this.EntityCollectionServices[entityName] = service; + } + return service; + } + + /** Register an EntityCollectionService under its entity type name. + * Will replace a pre-existing service for that type. + * @param service {EntityCollectionService} The entity service + * @param serviceName {string} optional service name to use instead of the service's entityName + */ + registerEntityCollectionService( + service: EntityCollectionService, + serviceName?: string + ) { + this.EntityCollectionServices[serviceName || service.entityName] = service; + } + + /** + * Register entity services for several entity types at once. + * Will replace a pre-existing service for that type. + * @param entityCollectionServices {EntityCollectionServiceMap | EntityCollectionService[]} + * EntityCollectionServices to register, either as a map or an array + */ + registerEntityCollectionServices( + entityCollectionServices: + | EntityCollectionServiceMap + | EntityCollectionService[] + ): void { + if (Array.isArray(entityCollectionServices)) { + entityCollectionServices.forEach(service => + this.registerEntityCollectionService(service) + ); + } else { + Object.keys(entityCollectionServices || {}).forEach(serviceName => { + this.registerEntityCollectionService( + entityCollectionServices[serviceName], + serviceName + ); + }); + } + } +} diff --git a/modules/data/src/entity-services/entity-services-elements.ts b/modules/data/src/entity-services/entity-services-elements.ts new file mode 100644 index 0000000000..de26b806f4 --- /dev/null +++ b/modules/data/src/entity-services/entity-services-elements.ts @@ -0,0 +1,43 @@ +import { Injectable } from '@angular/core'; +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$'; +import { EntityCollectionServiceFactory } from './entity-collection-service-factory'; + +/** Core ingredients of an EntityServices class */ +@Injectable() +export class EntityServicesElements { + constructor( + /** + * Creates EntityCollectionService instances for + * a cached collection of T entities in the ngrx store. + */ + public readonly entityCollectionServiceFactory: EntityCollectionServiceFactory, + /** Creates EntityDispatchers for entity collections */ + entityDispatcherFactory: EntityDispatcherFactory, + /** Creates observable EntitySelectors$ for entity collections. */ + entitySelectors$Factory: EntitySelectors$Factory, + /** The ngrx store, scoped to the EntityCache */ + public readonly store: Store + ) { + 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; + + /** Observable of the entire entity cache */ + readonly entityCache$: Observable | Store; + + /** + * 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; +} diff --git a/modules/data/src/entity-services/entity-services.ts b/modules/data/src/entity-services/entity-services.ts new file mode 100644 index 0000000000..af4ae4c0e5 --- /dev/null +++ b/modules/data/src/entity-services/entity-services.ts @@ -0,0 +1,75 @@ +import { Action, Store } from '@ngrx/store'; +import { Observable } from 'rxjs'; + +import { EntityAction } from '../actions/entity-action'; +import { EntityCache } from '../reducers/entity-cache'; +import { EntityCollectionService } from './entity-collection-service'; +import { EntityCollectionServiceFactory } from './entity-collection-service-factory'; + +// tslint:disable:member-ordering + +/** + * Class-Interface for EntityCache and EntityCollection services. + * Serves as an Angular provider token for this service class. + * Includes a registry of EntityCollectionServices for all entity types. + * Creates a new default EntityCollectionService for any entity type not in the registry. + * Optionally register specialized EntityCollectionServices for individual types + */ +export abstract class EntityServices { + /** Dispatch any action to the store */ + abstract dispatch(action: Action): void; + + /** Observable of error EntityActions (e.g. QUERY_ALL_ERROR) for all entity types */ + abstract readonly entityActionErrors$: Observable; + + /** Observable of the entire entity cache */ + abstract readonly entityCache$: Observable | Store; + + /** Get (or create) the singleton instance of an EntityCollectionService + * @param entityName {string} Name of the entity type of the service + */ + abstract getEntityCollectionService( + entityName: string + ): EntityCollectionService; + + /** + * Actions scanned by the store after it processed them with reducers. + * A replay observable of the most recent Action (not just EntityAction) reduced by the store. + */ + abstract readonly reducedActions$: Observable; + + // #region EntityCollectionService creation and registration API + + /** Register an EntityCollectionService under its entity type name. + * Will replace a pre-existing service for that type. + * @param service {EntityCollectionService} The entity service + */ + abstract registerEntityCollectionService( + service: EntityCollectionService + ): void; + + /** Register entity services for several entity types at once. + * Will replace a pre-existing service for that type. + * @param entityCollectionServices Array of EntityCollectionServices to register + */ + abstract registerEntityCollectionServices( + entityCollectionServices: EntityCollectionService[] + ): void; + + /** Register entity services for several entity types at once. + * Will replace a pre-existing service for that type. + * @param entityCollectionServiceMap Map of service-name to entity-collection-service + */ + abstract registerEntityCollectionServices( + // tslint:disable-next-line:unified-signatures + entityCollectionServiceMap: EntityCollectionServiceMap + ): void; + // #endregion EntityCollectionService creation and registration API +} + +/** + * A map of service or entity names to their corresponding EntityCollectionServices. + */ +export interface EntityCollectionServiceMap { + [entityName: string]: EntityCollectionService; +} diff --git a/modules/data/src/index.ts b/modules/data/src/index.ts new file mode 100644 index 0000000000..dac87e8e00 --- /dev/null +++ b/modules/data/src/index.ts @@ -0,0 +1,81 @@ +// AOT v5 bug: +// NO BARRELS or else `ng build --aot` of any app using ngrx-data produces strange errors +// actions +export * from './actions/entity-action-factory'; +export * from './actions/entity-action-guard'; +export * from './actions/entity-action-operators'; +export * from './actions/entity-action'; +export * from './actions/entity-cache-action'; +export * from './actions/entity-cache-change-set'; +export * from './actions/entity-op'; +export * from './actions/merge-strategy'; +export * from './actions/update-response-data'; + +// dataservices +export * from './dataservices/data-service-error'; +export * from './dataservices/default-data-service-config'; +export * from './dataservices/default-data.service'; +export * from './dataservices/entity-cache-data.service'; +export * from './dataservices/entity-data.service'; +export * from './dataservices/http-url-generator'; +export * from './dataservices/interfaces'; +export * from './dataservices/persistence-result-handler.service'; + +// dispatchers +export * from './dispatchers/entity-cache-dispatcher'; +export * from './dispatchers/entity-commands'; +export * from './dispatchers/entity-dispatcher-base'; +export * from './dispatchers/entity-dispatcher-default-options'; +export * from './dispatchers/entity-dispatcher-factory'; +export * from './dispatchers/entity-dispatcher'; + +// effects +export * from './effects/entity-cache-effects'; +export * from './effects/entity-effects'; + +// entity-metadata +export * from './entity-metadata/entity-definition.service'; +export * from './entity-metadata/entity-definition'; +export * from './entity-metadata/entity-filters'; +export * from './entity-metadata/entity-metadata'; + +// entity-services +export * from './entity-services/entity-collection-service-base'; +export * from './entity-services/entity-collection-service-elements-factory'; +export * from './entity-services/entity-collection-service-factory'; +export * from './entity-services/entity-collection-service'; +export * from './entity-services/entity-services-base'; +export * from './entity-services/entity-services-elements'; +export * from './entity-services/entity-services'; + +// reducers +export * from './reducers/constants'; +export * from './reducers/entity-cache-reducer'; +export * from './reducers/entity-cache'; +export * from './reducers/entity-change-tracker-base'; +export * from './reducers/entity-change-tracker'; +export * from './reducers/entity-collection-creator'; +export * from './reducers/entity-collection-reducer-methods'; +export * from './reducers/entity-collection-reducer-registry'; +export * from './reducers/entity-collection-reducer'; +export * from './reducers/entity-collection'; + +// selectors +export * from './selectors/entity-cache-selector'; +export * from './selectors/entity-selectors'; +export * from './selectors/entity-selectors$'; + +// Utils +export * from './utils/correlation-id-generator'; +export * from './utils/default-logger'; +export * from './utils/default-pluralizer'; +export * from './utils/guid-fns'; +export * from './utils/interfaces'; +export * from './utils/utilities'; + +// NgrxDataModule +export { NgrxDataModule } from './ngrx-data.module'; +export { + NgrxDataModuleWithoutEffects, + NgrxDataModuleConfig, +} from './ngrx-data-without-effects.module'; diff --git a/modules/data/src/ngrx-data-without-effects.module.ts b/modules/data/src/ngrx-data-without-effects.module.ts new file mode 100644 index 0000000000..871d9ffdbe --- /dev/null +++ b/modules/data/src/ngrx-data-without-effects.module.ts @@ -0,0 +1,180 @@ +import { + ModuleWithProviders, + NgModule, + Inject, + Injector, + InjectionToken, + Optional, + OnDestroy, +} from '@angular/core'; + +import { + Action, + ActionReducer, + combineReducers, + MetaReducer, + ReducerManager, + StoreModule, +} from '@ngrx/store'; + +import { CorrelationIdGenerator } from './utils/correlation-id-generator'; +import { EntityDispatcherDefaultOptions } from './dispatchers/entity-dispatcher-default-options'; +import { EntityAction } from './actions/entity-action'; +import { EntityActionFactory } from './actions/entity-action-factory'; +import { EntityCache } from './reducers/entity-cache'; +import { EntityCacheDispatcher } from './dispatchers/entity-cache-dispatcher'; +import { entityCacheSelectorProvider } from './selectors/entity-cache-selector'; +import { EntityCollectionService } from './entity-services/entity-collection-service'; +import { EntityCollectionServiceElementsFactory } from './entity-services/entity-collection-service-elements-factory'; +import { EntityCollectionServiceFactory } from './entity-services/entity-collection-service-factory'; +import { + EntityCollectionServiceMap, + EntityServices, +} from './entity-services/entity-services'; +import { EntityCollection } from './reducers/entity-collection'; +import { EntityCollectionCreator } from './reducers/entity-collection-creator'; +import { EntityCollectionReducerFactory } from './reducers/entity-collection-reducer'; +import { EntityCollectionReducerMethodsFactory } from './reducers/entity-collection-reducer-methods'; +import { EntityCollectionReducerRegistry } from './reducers/entity-collection-reducer-registry'; +import { EntityDispatcherFactory } from './dispatchers/entity-dispatcher-factory'; +import { EntityDefinitionService } from './entity-metadata/entity-definition.service'; +import { EntityEffects } from './effects/entity-effects'; +import { + EntityMetadataMap, + ENTITY_METADATA_TOKEN, +} from './entity-metadata/entity-metadata'; + +import { EntityCacheReducerFactory } from './reducers/entity-cache-reducer'; +import { + ENTITY_CACHE_NAME, + ENTITY_CACHE_NAME_TOKEN, + ENTITY_CACHE_META_REDUCERS, + ENTITY_COLLECTION_META_REDUCERS, + INITIAL_ENTITY_CACHE_STATE, +} from './reducers/constants'; + +import { DefaultLogger } from './utils/default-logger'; +import { DefaultPluralizer } from './utils/default-pluralizer'; +import { EntitySelectors } from './selectors/entity-selectors'; +import { EntitySelectorsFactory } from './selectors/entity-selectors'; +import { EntitySelectors$Factory } from './selectors/entity-selectors$'; +import { EntityServicesBase } from './entity-services/entity-services-base'; +import { EntityServicesElements } from './entity-services/entity-services-elements'; +import { Logger, Pluralizer, PLURAL_NAMES_TOKEN } from './utils/interfaces'; + +export interface NgrxDataModuleConfig { + entityMetadata?: EntityMetadataMap; + entityCacheMetaReducers?: ( + | MetaReducer + | InjectionToken>)[]; + entityCollectionMetaReducers?: MetaReducer[]; + // Initial EntityCache state or a function that returns that state + initialEntityCacheState?: EntityCache | (() => EntityCache); + pluralNames?: { [name: string]: string }; +} + +/** + * Module without effects or dataservices which means no HTTP calls + * This module helpful for internal testing. + * Also helpful for apps that handle server access on their own and + * therefore opt-out of @ngrx/effects for entities + */ +@NgModule({ + imports: [ + StoreModule, // rely on Store feature providers rather than Store.forFeature() + ], + providers: [ + CorrelationIdGenerator, + EntityDispatcherDefaultOptions, + EntityActionFactory, + EntityCacheDispatcher, + EntityCacheReducerFactory, + entityCacheSelectorProvider, + EntityCollectionCreator, + EntityCollectionReducerFactory, + EntityCollectionReducerMethodsFactory, + EntityCollectionReducerRegistry, + EntityCollectionServiceElementsFactory, + EntityCollectionServiceFactory, + EntityDefinitionService, + EntityDispatcherFactory, + EntitySelectorsFactory, + EntitySelectors$Factory, + EntityServicesElements, + { provide: ENTITY_CACHE_NAME_TOKEN, useValue: ENTITY_CACHE_NAME }, + { provide: EntityServices, useClass: EntityServicesBase }, + { provide: Logger, useClass: DefaultLogger }, + ], +}) +export class NgrxDataModuleWithoutEffects implements OnDestroy { + private entityCacheFeature: any; + + static forRoot(config: NgrxDataModuleConfig): ModuleWithProviders { + return { + ngModule: NgrxDataModuleWithoutEffects, + providers: [ + { + provide: ENTITY_CACHE_META_REDUCERS, + useValue: config.entityCacheMetaReducers + ? config.entityCacheMetaReducers + : [], + }, + { + provide: ENTITY_COLLECTION_META_REDUCERS, + useValue: config.entityCollectionMetaReducers + ? config.entityCollectionMetaReducers + : [], + }, + { + provide: PLURAL_NAMES_TOKEN, + multi: true, + useValue: config.pluralNames ? config.pluralNames : {}, + }, + ], + }; + } + + constructor( + private reducerManager: ReducerManager, + entityCacheReducerFactory: EntityCacheReducerFactory, + private injector: Injector, + // optional params + @Optional() + @Inject(ENTITY_CACHE_NAME_TOKEN) + private entityCacheName: string, + @Optional() + @Inject(INITIAL_ENTITY_CACHE_STATE) + private initialState: any, + @Optional() + @Inject(ENTITY_CACHE_META_REDUCERS) + private metaReducers: ( + | MetaReducer + | InjectionToken>)[] + ) { + // Add the ngrx-data feature to the Store's features + // as Store.forFeature does for StoreFeatureModule + const key = entityCacheName || ENTITY_CACHE_NAME; + + initialState = + typeof initialState === 'function' ? initialState() : initialState; + + const reducers: MetaReducer[] = ( + metaReducers || [] + ).map(mr => { + return mr instanceof InjectionToken ? injector.get(mr) : mr; + }); + + this.entityCacheFeature = { + key, + reducers: entityCacheReducerFactory.create(), + reducerFactory: combineReducers, + initialState: initialState || {}, + metaReducers: reducers, + }; + reducerManager.addFeature(this.entityCacheFeature); + } + + ngOnDestroy() { + this.reducerManager.removeFeature(this.entityCacheFeature); + } +} diff --git a/modules/data/src/ngrx-data.module.ts b/modules/data/src/ngrx-data.module.ts new file mode 100644 index 0000000000..5444ff6ac2 --- /dev/null +++ b/modules/data/src/ngrx-data.module.ts @@ -0,0 +1,120 @@ +import { ModuleWithProviders, NgModule } from '@angular/core'; + +import { EffectsModule, EffectSources } from '@ngrx/effects'; + +import { DefaultDataServiceFactory } from './dataservices/default-data.service'; + +import { + DefaultPersistenceResultHandler, + PersistenceResultHandler, +} from './dataservices/persistence-result-handler.service'; + +import { + DefaultHttpUrlGenerator, + HttpUrlGenerator, +} from './dataservices/http-url-generator'; + +import { EntityCacheDataService } from './dataservices/entity-cache-data.service'; +import { EntityCacheEffects } from './effects/entity-cache-effects'; +import { EntityDataService } from './dataservices/entity-data.service'; +import { EntityEffects } from './effects/entity-effects'; + +import { ENTITY_METADATA_TOKEN } from './entity-metadata/entity-metadata'; + +import { + ENTITY_CACHE_META_REDUCERS, + ENTITY_COLLECTION_META_REDUCERS, +} from './reducers/constants'; +import { Pluralizer, PLURAL_NAMES_TOKEN } from './utils/interfaces'; +import { DefaultPluralizer } from './utils/default-pluralizer'; + +import { + NgrxDataModuleConfig, + NgrxDataModuleWithoutEffects, +} from './ngrx-data-without-effects.module'; + +/** + * Ngrx-data main module includes effects and HTTP data services + * Configure with `forRoot`. + * No `forFeature` yet. + */ +@NgModule({ + imports: [ + NgrxDataModuleWithoutEffects, + EffectsModule, // do not supply effects because can't replace later + ], + providers: [ + DefaultDataServiceFactory, + EntityCacheDataService, + EntityDataService, + EntityCacheEffects, + EntityEffects, + { provide: HttpUrlGenerator, useClass: DefaultHttpUrlGenerator }, + { + provide: PersistenceResultHandler, + useClass: DefaultPersistenceResultHandler, + }, + { provide: Pluralizer, useClass: DefaultPluralizer }, + ], +}) +export class NgrxDataModule { + static forRoot(config: NgrxDataModuleConfig): ModuleWithProviders { + return { + ngModule: NgrxDataModule, + providers: [ + // TODO: Moved these effects classes up to NgrxDataModule itself + // Remove this comment if that was a mistake. + // EntityCacheEffects, + // EntityEffects, + { + provide: ENTITY_METADATA_TOKEN, + multi: true, + useValue: config.entityMetadata ? config.entityMetadata : [], + }, + { + provide: ENTITY_CACHE_META_REDUCERS, + useValue: config.entityCacheMetaReducers + ? config.entityCacheMetaReducers + : [], + }, + { + provide: ENTITY_COLLECTION_META_REDUCERS, + useValue: config.entityCollectionMetaReducers + ? config.entityCollectionMetaReducers + : [], + }, + { + provide: PLURAL_NAMES_TOKEN, + multi: true, + useValue: config.pluralNames ? config.pluralNames : {}, + }, + ], + }; + } + + constructor( + private effectSources: EffectSources, + entityCacheEffects: EntityCacheEffects, + entityEffects: EntityEffects + ) { + // We can't use `forFeature()` because, if we did, the developer could not + // replace the ngrx-data `EntityEffects` with a custom alternative. + // Replacing that class is an extensibility point we need. + // + // The FEATURE_EFFECTS token is not exposed, so can't use that technique. + // Warning: this alternative approach relies on an undocumented API + // to add effect directly rather than through `forFeature()`. + // The danger is that EffectsModule.forFeature evolves and we no longer perform a crucial step. + this.addEffects(entityCacheEffects); + this.addEffects(entityEffects); + } + + /** + * Add another class instance that contains @Effect methods. + * @param effectSourceInstance a class instance that implements effects. + * Warning: undocumented @ngrx/effects API + */ + addEffects(effectSourceInstance: any) { + this.effectSources.addEffects(effectSourceInstance); + } +} diff --git a/modules/data/src/reducers/constants.ts b/modules/data/src/reducers/constants.ts new file mode 100644 index 0000000000..152cb215f5 --- /dev/null +++ b/modules/data/src/reducers/constants.ts @@ -0,0 +1,19 @@ +import { InjectionToken } from '@angular/core'; +import { Action, ActionReducer, MetaReducer } from '@ngrx/store'; +import { EntityCache } from './entity-cache'; + +export const ENTITY_CACHE_NAME = 'entityCache'; +export const ENTITY_CACHE_NAME_TOKEN = new InjectionToken( + '@ngrx/data/entity-cache-name' +); + +export const ENTITY_CACHE_META_REDUCERS = new InjectionToken< + MetaReducer[] +>('@ngrx/data/entity-cache-meta-reducers'); +export const ENTITY_COLLECTION_META_REDUCERS = new InjectionToken< + MetaReducer[] +>('@ngrx/data/entity-collection-meta-reducers'); + +export const INITIAL_ENTITY_CACHE_STATE = new InjectionToken< + EntityCache | (() => EntityCache) +>('@ngrx/data/initial-entity-cache-state'); diff --git a/modules/data/src/reducers/entity-cache-reducer.ts b/modules/data/src/reducers/entity-cache-reducer.ts new file mode 100644 index 0000000000..f26e0d4ea8 --- /dev/null +++ b/modules/data/src/reducers/entity-cache-reducer.ts @@ -0,0 +1,381 @@ +import { Injectable } from '@angular/core'; +import { Action, ActionReducer } from '@ngrx/store'; + +import { EntityAction } from '../actions/entity-action'; +import { EntityActionDataServiceError } from '../dataservices/data-service-error'; +import { EntityCache } from './entity-cache'; + +import { + EntityCacheAction, + ClearCollections, + LoadCollections, + MergeQuerySet, + SaveEntities, + SaveEntitiesCancel, + SaveEntitiesError, + SaveEntitiesSuccess, +} from '../actions/entity-cache-action'; + +import { + ChangeSetOperation, + ChangeSetItem, +} from '../actions/entity-cache-change-set'; + +import { EntityCollection } from './entity-collection'; +import { EntityCollectionCreator } from './entity-collection-creator'; +import { EntityCollectionReducerRegistry } from './entity-collection-reducer-registry'; +import { EntityOp } from '../actions/entity-op'; +import { Logger } from '../utils/interfaces'; +import { MergeStrategy } from '../actions/merge-strategy'; + +/** + * Creates the EntityCacheReducer via its create() method + */ +@Injectable() +export class EntityCacheReducerFactory { + constructor( + private entityCollectionCreator: EntityCollectionCreator, + private entityCollectionReducerRegistry: EntityCollectionReducerRegistry, + private logger: Logger + ) {} + + /** + * Create the ngrx-data entity cache reducer which either responds to entity cache level actions + * or (more commonly) delegates to an EntityCollectionReducer based on the action.payload.entityName. + */ + create(): ActionReducer { + // This technique ensures a named function appears in the debugger + return entityCacheReducer.bind(this); + + function entityCacheReducer( + this: EntityCacheReducerFactory, + entityCache: EntityCache = {}, + action: { type: string; payload?: any } + ): EntityCache { + // EntityCache actions + switch (action.type) { + case EntityCacheAction.CLEAR_COLLECTIONS: { + return this.clearCollectionsReducer( + entityCache, + action as ClearCollections + ); + } + + case EntityCacheAction.LOAD_COLLECTIONS: { + return this.loadCollectionsReducer( + entityCache, + action as LoadCollections + ); + } + + case EntityCacheAction.MERGE_QUERY_SET: { + return this.mergeQuerySetReducer( + entityCache, + action as MergeQuerySet + ); + } + + case EntityCacheAction.SAVE_ENTITIES: { + return this.saveEntitiesReducer(entityCache, action as SaveEntities); + } + + case EntityCacheAction.SAVE_ENTITIES_CANCEL: { + return this.saveEntitiesCancelReducer( + entityCache, + action as SaveEntitiesCancel + ); + } + + case EntityCacheAction.SAVE_ENTITIES_ERROR: { + return this.saveEntitiesErrorReducer( + entityCache, + action as SaveEntitiesError + ); + } + + case EntityCacheAction.SAVE_ENTITIES_SUCCESS: { + return this.saveEntitiesSuccessReducer( + entityCache, + action as SaveEntitiesSuccess + ); + } + + case EntityCacheAction.SET_ENTITY_CACHE: { + // Completely replace the EntityCache. Be careful! + return action.payload.cache; + } + } + + // Apply entity collection reducer if this is a valid EntityAction for a collection + const payload = action.payload; + if (payload && payload.entityName && payload.entityOp && !payload.error) { + return this.applyCollectionReducer(entityCache, action as EntityAction); + } + + // Not a valid EntityAction + return entityCache; + } + } + + /** + * 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, tag } = action.payload; + mergeStrategy = + mergeStrategy === null ? MergeStrategy.PreserveChanges : mergeStrategy; + const entityOp = EntityOp.UPSERT_MANY; + + const entityNames = Object.keys(querySet); + entityCache = entityNames.reduce((newCache, entityName) => { + const payload = { + entityName, + entityOp, + data: querySet[entityName], + mergeStrategy, + }; + const act: EntityAction = { + type: `[${entityName}] ${action.type}`, + payload, + }; + newCache = this.applyCollectionReducer(newCache, act); + return newCache; + }, entityCache); + return entityCache; + } + + // #region saveEntities reducers + protected saveEntitiesReducer( + entityCache: EntityCache, + action: SaveEntities + ) { + const { + changeSet, + correlationId, + isOptimistic, + mergeStrategy, + tag, + } = action.payload; + + try { + changeSet.changes.forEach(item => { + const entityName = item.entityName; + const payload = { + entityName, + entityOp: getEntityOp(item), + data: item.entities, + correlationId, + isOptimistic, + mergeStrategy, + tag, + }; + + const act: EntityAction = { + type: `[${entityName}] ${action.type}`, + payload, + }; + entityCache = this.applyCollectionReducer(entityCache, act); + if (act.payload.error) { + throw act.payload.error; + } + }); + } catch (error) { + action.payload.error = error; + } + + return entityCache; + function getEntityOp(item: ChangeSetItem) { + switch (item.op) { + case ChangeSetOperation.Add: + return EntityOp.SAVE_ADD_MANY; + case ChangeSetOperation.Delete: + return EntityOp.SAVE_DELETE_MANY; + case ChangeSetOperation.Update: + return EntityOp.SAVE_UPDATE_MANY; + case ChangeSetOperation.Upsert: + return EntityOp.SAVE_UPSERT_MANY; + } + } + } + + protected saveEntitiesCancelReducer( + entityCache: EntityCache, + action: SaveEntitiesCancel + ) { + // This implementation can only clear the loading flag for the collections involved + // If the save was optimistic, you'll have to compensate to fix the cache as you think necessary + return this.clearLoadingFlags( + entityCache, + action.payload.entityNames || [] + ); + } + + protected saveEntitiesErrorReducer( + entityCache: EntityCache, + action: SaveEntitiesError + ) { + const originalAction = action.payload.originalAction; + const originalChangeSet = originalAction.payload.changeSet; + + // This implementation can only clear the loading flag for the collections involved + // If the save was optimistic, you'll have to compensate to fix the cache as you think necessary + const entityNames = originalChangeSet.changes.map(item => item.entityName); + return this.clearLoadingFlags(entityCache, entityNames); + } + + protected saveEntitiesSuccessReducer( + entityCache: EntityCache, + action: SaveEntitiesSuccess + ) { + const { + changeSet, + correlationId, + isOptimistic, + mergeStrategy, + tag, + } = action.payload; + + changeSet.changes.forEach(item => { + const entityName = item.entityName; + const payload = { + entityName, + entityOp: getEntityOp(item), + data: item.entities, + correlationId, + isOptimistic, + mergeStrategy, + tag, + }; + + const act: EntityAction = { + type: `[${entityName}] ${action.type}`, + payload, + }; + entityCache = this.applyCollectionReducer(entityCache, act); + }); + + return entityCache; + function getEntityOp(item: ChangeSetItem) { + switch (item.op) { + case ChangeSetOperation.Add: + return EntityOp.SAVE_ADD_MANY_SUCCESS; + case ChangeSetOperation.Delete: + return EntityOp.SAVE_DELETE_MANY_SUCCESS; + case ChangeSetOperation.Update: + return EntityOp.SAVE_UPDATE_MANY_SUCCESS; + case ChangeSetOperation.Upsert: + return EntityOp.SAVE_UPSERT_MANY_SUCCESS; + } + } + } + // #endregion saveEntities reducers + + // #region helpers + /** Apply reducer for the action's EntityCollection (if the action targets a collection) */ + private applyCollectionReducer( + cache: EntityCache = {}, + action: EntityAction + ) { + const entityName = action.payload.entityName; + const collection = cache[entityName]; + const reducer = this.entityCollectionReducerRegistry.getOrCreateReducer( + entityName + ); + + let newCollection: EntityCollection; + try { + newCollection = collection + ? reducer(collection, action) + : reducer(this.entityCollectionCreator.create(entityName), action); + } catch (error) { + this.logger.error(error); + action.payload.error = error; + } + + return action.payload.error || collection === newCollection! + ? cache + : { ...cache, [entityName]: newCollection! }; + } + + /** Ensure loading is false for every collection in entityNames */ + private clearLoadingFlags(entityCache: EntityCache, entityNames: string[]) { + let isMutated = false; + entityNames.forEach(entityName => { + const collection = entityCache[entityName]; + if (collection.loading) { + if (!isMutated) { + entityCache = { ...entityCache }; + isMutated = true; + } + entityCache[entityName] = { ...collection, loading: false }; + } + }); + return entityCache; + } + // #endregion helpers +} diff --git a/modules/data/src/reducers/entity-cache.ts b/modules/data/src/reducers/entity-cache.ts new file mode 100644 index 0000000000..15e74e1feb --- /dev/null +++ b/modules/data/src/reducers/entity-cache.ts @@ -0,0 +1,6 @@ +import { EntityCollection } from './entity-collection'; + +export interface EntityCache { + // Must be `any` since we don't know what type of collections we will have + [name: string]: EntityCollection; +} diff --git a/modules/data/src/reducers/entity-change-tracker-base.ts b/modules/data/src/reducers/entity-change-tracker-base.ts new file mode 100644 index 0000000000..3f10746a2b --- /dev/null +++ b/modules/data/src/reducers/entity-change-tracker-base.ts @@ -0,0 +1,748 @@ +import { + EntityAdapter, + EntityState, + Dictionary, + IdSelector, + Update, +} from '@ngrx/entity'; + +import { + ChangeState, + ChangeStateMap, + ChangeType, + EntityCollection, +} from './entity-collection'; +import { defaultSelectId } from '../utils/utilities'; +import { EntityAction, EntityActionOptions } from '../actions/entity-action'; +import { EntityChangeTracker } from './entity-change-tracker'; +import { MergeStrategy } from '../actions/merge-strategy'; +import { UpdateResponseData } from '../actions/update-response-data'; + +/** + * The default implementation of EntityChangeTracker with + * methods for tracking, committing, and reverting/undoing unsaved entity changes. + * Used by EntityCollectionReducerMethods which should call tracker methods BEFORE modifying the collection. + * See EntityChangeTracker docs. + */ +export class EntityChangeTrackerBase implements EntityChangeTracker { + constructor( + private adapter: EntityAdapter, + private selectId: IdSelector + ) { + /** Extract the primary key (id); default to `id` */ + this.selectId = selectId || defaultSelectId; + } + + // #region commit methods + /** + * Commit all changes as when the collection has been completely reloaded from the server. + * Harmless when there are no entity changes to commit. + * @param collection The entity collection + */ + commitAll(collection: EntityCollection): EntityCollection { + return Object.keys(collection.changeState).length === 0 + ? collection + : { ...collection, changeState: {} }; + } + + /** + * Commit changes for the given entities as when they have been refreshed from the server. + * Harmless when there are no entity changes to commit. + * @param entityOrIdList The entities to clear tracking or their ids. + * @param collection The entity collection + */ + commitMany( + entityOrIdList: (number | string | T)[], + collection: EntityCollection + ): EntityCollection { + if (entityOrIdList == null || entityOrIdList.length === 0) { + return collection; // nothing to commit + } + let didMutate = false; + const changeState = entityOrIdList.reduce((chgState, entityOrId) => { + const id = + typeof entityOrId === 'object' + ? this.selectId(entityOrId) + : (entityOrId as string | number); + if (chgState[id]) { + if (!didMutate) { + chgState = { ...chgState }; + didMutate = true; + } + delete chgState[id]; + } + return chgState; + }, collection.changeState); + + return didMutate ? { ...collection, changeState } : collection; + } + + /** + * Commit changes for the given entity as when it have been refreshed from the server. + * Harmless when no entity changes to commit. + * @param entityOrId The entity to clear tracking or its id. + * @param collection The entity collection + */ + commitOne( + entityOrId: number | string | T, + collection: EntityCollection + ): EntityCollection { + return entityOrId == null + ? collection + : this.commitMany([entityOrId], collection); + } + + // #endregion commit methods + + // #region merge query + /** + * Merge query results into the collection, adjusting the ChangeState per the mergeStrategy. + * @param entities Entities returned from querying the server. + * @param collection The entity collection + * @param [mergeStrategy] How to merge a queried entity when the corresponding entity in the collection has an unsaved change. + * Defaults to MergeStrategy.PreserveChanges. + * @returns The merged EntityCollection. + */ + mergeQueryResults( + entities: T[], + collection: EntityCollection, + mergeStrategy?: MergeStrategy + ): EntityCollection { + return this.mergeServerUpserts( + entities, + collection, + MergeStrategy.PreserveChanges, + mergeStrategy + ); + } + // #endregion merge query results + + // #region merge save results + /** + * Merge result of saving new entities into the collection, adjusting the ChangeState per the mergeStrategy. + * The default is MergeStrategy.OverwriteChanges. + * @param entities Entities returned from saving new entities to the server. + * @param collection The entity collection + * @param [mergeStrategy] How to merge a saved entity when the corresponding entity in the collection has an unsaved change. + * Defaults to MergeStrategy.OverwriteChanges. + * @returns The merged EntityCollection. + */ + mergeSaveAdds( + entities: T[], + collection: EntityCollection, + mergeStrategy?: MergeStrategy + ): EntityCollection { + return this.mergeServerUpserts( + entities, + collection, + MergeStrategy.OverwriteChanges, + mergeStrategy + ); + } + + /** + * Merge successful result of deleting entities on the server that have the given primary keys + * Clears the entity changeState for those keys unless the MergeStrategy is ignoreChanges. + * @param entities keys primary keys of the entities to remove/delete. + * @param collection The entity collection + * @param [mergeStrategy] How to adjust change tracking when the corresponding entity in the collection has an unsaved change. + * Defaults to MergeStrategy.OverwriteChanges. + * @returns The merged EntityCollection. + */ + mergeSaveDeletes( + keys: (number | string)[], + collection: EntityCollection, + mergeStrategy?: MergeStrategy + ): EntityCollection { + mergeStrategy = + mergeStrategy == null ? MergeStrategy.OverwriteChanges : mergeStrategy; + // same logic for all non-ignore merge strategies: always clear (commit) the changes + const deleteIds = keys as string[]; // make TypeScript happy + collection = + mergeStrategy === MergeStrategy.IgnoreChanges + ? collection + : this.commitMany(deleteIds, collection); + return this.adapter.removeMany(deleteIds, collection); + } + + /** + * Merge result of saving updated entities into the collection, adjusting the ChangeState per the mergeStrategy. + * The default is MergeStrategy.OverwriteChanges. + * @param updateResponseData Entity response data returned from saving updated entities to the server. + * @param collection The entity collection + * @param [mergeStrategy] How to merge a saved entity when the corresponding entity in the collection has an unsaved change. + * Defaults to MergeStrategy.OverwriteChanges. + * @param [skipUnchanged] True means skip update if server didn't change it. False by default. + * If the update was optimistic and the server didn't make more changes of its own + * then the updates are already in the collection and shouldn't make them again. + * @returns The merged EntityCollection. + */ + mergeSaveUpdates( + updateResponseData: UpdateResponseData[], + collection: EntityCollection, + mergeStrategy?: MergeStrategy, + skipUnchanged = false + ): EntityCollection { + if (updateResponseData == null || updateResponseData.length === 0) { + return collection; // nothing to merge. + } + + let didMutate = false; + let changeState = collection.changeState; + mergeStrategy = + mergeStrategy == null ? MergeStrategy.OverwriteChanges : mergeStrategy; + let updates: Update[]; + + switch (mergeStrategy) { + case MergeStrategy.IgnoreChanges: + updates = filterChanged(updateResponseData); + return this.adapter.updateMany(updates, collection); + + case MergeStrategy.OverwriteChanges: + changeState = updateResponseData.reduce((chgState, update) => { + const oldId = update.id; + const change = chgState[oldId]; + if (change) { + if (!didMutate) { + chgState = { ...chgState }; + didMutate = true; + } + delete chgState[oldId]; + } + return chgState; + }, collection.changeState); + + collection = didMutate ? { ...collection, changeState } : collection; + + updates = filterChanged(updateResponseData); + return this.adapter.updateMany(updates, collection); + + case MergeStrategy.PreserveChanges: { + const updateableEntities = [] as UpdateResponseData[]; + changeState = updateResponseData.reduce((chgState, update) => { + const oldId = update.id; + const change = chgState[oldId]; + if (change) { + // Tracking a change so update original value but not the current value + if (!didMutate) { + chgState = { ...chgState }; + didMutate = true; + } + const newId = this.selectId(update.changes as T); + const oldChangeState = chgState[oldId]; + // If the server changed the id, register the new "originalValue" under the new id + // and remove the change tracked under the old id. + if (newId !== oldId) { + delete chgState[oldId]; + } + const newOrigValue = { + ...(oldChangeState.originalValue as any), + ...(update.changes as any), + }; + chgState[newId] = { + ...oldChangeState, + originalValue: newOrigValue, + }; + } else { + updateableEntities.push(update); + } + return chgState; + }, collection.changeState); + collection = didMutate ? { ...collection, changeState } : collection; + + updates = filterChanged(updateableEntities); + return this.adapter.updateMany(updates, collection); + } + } + + /** + * Conditionally keep only those updates that have additional server changes. + * (e.g., for optimistic saves because they updates are already in the current collection) + * Strip off the `changed` property. + * @responseData Entity response data from server. + * May be an UpdateResponseData, a subclass of Update with a 'changed' flag. + * @returns Update (without the changed flag) + */ + function filterChanged(responseData: UpdateResponseData[]): Update[] { + if (skipUnchanged === true) { + // keep only those updates that the server changed (knowable if is UpdateResponseData) + responseData = responseData.filter(r => r.changed === true); + } + // Strip unchanged property from responseData, leaving just the pure Update + // TODO: Remove? probably not necessary as the Update isn't stored and adapter will ignore `changed`. + return responseData.map(r => ({ id: r.id as any, changes: r.changes })); + } + } + + /** + * Merge result of saving upserted entities into the collection, adjusting the ChangeState per the mergeStrategy. + * The default is MergeStrategy.OverwriteChanges. + * @param entities Entities returned from saving upserts to the server. + * @param collection The entity collection + * @param [mergeStrategy] How to merge a saved entity when the corresponding entity in the collection has an unsaved change. + * Defaults to MergeStrategy.OverwriteChanges. + * @returns The merged EntityCollection. + */ + mergeSaveUpserts( + entities: T[], + collection: EntityCollection, + mergeStrategy?: MergeStrategy + ): EntityCollection { + return this.mergeServerUpserts( + entities, + collection, + MergeStrategy.OverwriteChanges, + mergeStrategy + ); + } + // #endregion merge save results + + // #region query & save helpers + /** + * + * @param entities Entities to merge + * @param collection Collection into which entities are merged + * @param defaultMergeStrategy How to merge when action's MergeStrategy is unspecified + * @param [mergeStrategy] The action's MergeStrategy + */ + private mergeServerUpserts( + entities: T[], + collection: EntityCollection, + defaultMergeStrategy: MergeStrategy, + mergeStrategy?: MergeStrategy + ): EntityCollection { + if (entities == null || entities.length === 0) { + return collection; // nothing to merge. + } + + let didMutate = false; + let changeState = collection.changeState; + mergeStrategy = + mergeStrategy == null ? defaultMergeStrategy : mergeStrategy; + + switch (mergeStrategy) { + case MergeStrategy.IgnoreChanges: + return this.adapter.upsertMany(entities, collection); + + case MergeStrategy.OverwriteChanges: + collection = this.adapter.upsertMany(entities, collection); + + changeState = entities.reduce((chgState, entity) => { + const id = this.selectId(entity); + const change = chgState[id]; + if (change) { + if (!didMutate) { + chgState = { ...chgState }; + didMutate = true; + } + delete chgState[id]; + } + return chgState; + }, collection.changeState); + + return didMutate ? { ...collection, changeState } : collection; + + case MergeStrategy.PreserveChanges: { + const upsertEntities = [] as T[]; + changeState = entities.reduce((chgState, entity) => { + const id = this.selectId(entity); + const change = chgState[id]; + if (change) { + if (!didMutate) { + chgState = { ...chgState }; + didMutate = true; + } + chgState[id].originalValue = entity; + } else { + upsertEntities.push(entity); + } + return chgState; + }, collection.changeState); + + collection = this.adapter.upsertMany(upsertEntities, collection); + return didMutate ? { ...collection, changeState } : collection; + } + } + } + // #endregion query & save helpers + + // #region track methods + /** + * Track multiple entities before adding them to the collection. + * Does NOT add to the collection (the reducer's job). + * @param entities The entities to add. They must all have their ids. + * @param collection The entity collection + * @param [mergeStrategy] Track by default. Don't track if is MergeStrategy.IgnoreChanges. + */ + trackAddMany( + entities: T[], + collection: EntityCollection, + mergeStrategy?: MergeStrategy + ): EntityCollection { + if ( + mergeStrategy === MergeStrategy.IgnoreChanges || + entities == null || + entities.length === 0 + ) { + return collection; // nothing to track + } + let didMutate = false; + const changeState = entities.reduce((chgState, entity) => { + const id = this.selectId(entity); + if (id == null || id === '') { + throw new Error( + `${collection.entityName} entity add requires a key to be tracked` + ); + } + const trackedChange = chgState[id]; + + if (!trackedChange) { + if (!didMutate) { + didMutate = true; + chgState = { ...chgState }; + } + chgState[id] = { changeType: ChangeType.Added }; + } + return chgState; + }, collection.changeState); + return didMutate ? { ...collection, changeState } : collection; + } + + /** + * Track an entity before adding it to the collection. + * Does NOT add to the collection (the reducer's job). + * @param entity The entity to add. It must have an id. + * @param collection The entity collection + * @param [mergeStrategy] Track by default. Don't track if is MergeStrategy.IgnoreChanges. + * If not specified, implementation supplies a default strategy. + */ + trackAddOne( + entity: T, + collection: EntityCollection, + mergeStrategy?: MergeStrategy + ): EntityCollection { + return entity == null + ? collection + : this.trackAddMany([entity], collection, mergeStrategy); + } + + /** + * Track multiple entities before removing them with the intention of deleting them on the server. + * Does NOT remove from the collection (the reducer's job). + * @param keys The primary keys of the entities to delete. + * @param collection The entity collection + * @param [mergeStrategy] Track by default. Don't track if is MergeStrategy.IgnoreChanges. + */ + trackDeleteMany( + keys: (number | string)[], + collection: EntityCollection, + mergeStrategy?: MergeStrategy + ): EntityCollection { + if ( + mergeStrategy === MergeStrategy.IgnoreChanges || + keys == null || + keys.length === 0 + ) { + return collection; // nothing to track + } + let didMutate = false; + const entityMap = collection.entities; + const changeState = keys.reduce((chgState, id) => { + const originalValue = entityMap[id]; + if (originalValue) { + const trackedChange = chgState[id]; + if (trackedChange) { + if (trackedChange.changeType === ChangeType.Added) { + // Special case: stop tracking an added entity that you delete + // The caller must also detect this, remove it immediately from the collection + // and skip attempt to delete on the server. + cloneChgStateOnce(); + delete chgState[id]; + } else if (trackedChange.changeType === ChangeType.Updated) { + // Special case: switch change type from Updated to Deleted. + cloneChgStateOnce(); + chgState[id].changeType = ChangeType.Deleted; + } + } else { + // Start tracking this entity + cloneChgStateOnce(); + chgState[id] = { changeType: ChangeType.Deleted, originalValue }; + } + } + return chgState; + + function cloneChgStateOnce() { + if (!didMutate) { + didMutate = true; + chgState = { ...chgState }; + } + } + }, collection.changeState); + + return didMutate ? { ...collection, changeState } : collection; + } + + /** + * Track an entity before it is removed with the intention of deleting it on the server. + * Does NOT remove from the collection (the reducer's job). + * @param key The primary key of the entity to delete. + * @param collection The entity collection + * @param [mergeStrategy] Track by default. Don't track if is MergeStrategy.IgnoreChanges. + */ + trackDeleteOne( + key: number | string, + collection: EntityCollection, + mergeStrategy?: MergeStrategy + ): EntityCollection { + return key == null + ? collection + : this.trackDeleteMany([key], collection, mergeStrategy); + } + + /** + * Track multiple entities before updating them in the collection. + * Does NOT update the collection (the reducer's job). + * @param updates The entities to update. + * @param collection The entity collection + * @param [mergeStrategy] Track by default. Don't track if is MergeStrategy.IgnoreChanges. + */ + trackUpdateMany( + updates: Update[], + collection: EntityCollection, + mergeStrategy?: MergeStrategy + ): EntityCollection { + if ( + mergeStrategy === MergeStrategy.IgnoreChanges || + updates == null || + updates.length === 0 + ) { + return collection; // nothing to track + } + let didMutate = false; + const entityMap = collection.entities; + const changeState = updates.reduce((chgState, update) => { + const { id, changes: entity } = update; + if (id == null || id === '') { + throw new Error( + `${collection.entityName} entity update requires a key to be tracked` + ); + } + const originalValue = entityMap[id]; + // Only track if it is in the collection. Silently ignore if it is not. + // @ngrx/entity adapter would also silently ignore. + // Todo: should missing update entity really be reported as an error? + if (originalValue) { + const trackedChange = chgState[id]; + if (!trackedChange) { + if (!didMutate) { + didMutate = true; + chgState = { ...chgState }; + } + chgState[id] = { changeType: ChangeType.Updated, originalValue }; + } + } + return chgState; + }, collection.changeState); + return didMutate ? { ...collection, changeState } : collection; + } + + /** + * Track an entity before updating it in the collection. + * Does NOT update the collection (the reducer's job). + * @param update The entity to update. + * @param collection The entity collection + * @param [mergeStrategy] Track by default. Don't track if is MergeStrategy.IgnoreChanges. + */ + trackUpdateOne( + update: Update, + collection: EntityCollection, + mergeStrategy?: MergeStrategy + ): EntityCollection { + return update == null + ? collection + : this.trackUpdateMany([update], collection, mergeStrategy); + } + + /** + * Track multiple entities before upserting (adding and updating) them to the collection. + * Does NOT update the collection (the reducer's job). + * @param entities The entities to add or update. They must be complete entities with ids. + * @param collection The entity collection + * @param [mergeStrategy] Track by default. Don't track if is MergeStrategy.IgnoreChanges. + */ + trackUpsertMany( + entities: T[], + collection: EntityCollection, + mergeStrategy?: MergeStrategy + ): EntityCollection { + if ( + mergeStrategy === MergeStrategy.IgnoreChanges || + entities == null || + entities.length === 0 + ) { + return collection; // nothing to track + } + let didMutate = false; + const entityMap = collection.entities; + const changeState = entities.reduce((chgState, entity) => { + const id = this.selectId(entity); + if (id == null || id === '') { + throw new Error( + `${collection.entityName} entity upsert requires a key to be tracked` + ); + } + const trackedChange = chgState[id]; + + if (!trackedChange) { + if (!didMutate) { + didMutate = true; + chgState = { ...chgState }; + } + + const originalValue = entityMap[id]; + chgState[id] = + originalValue == null + ? { changeType: ChangeType.Added } + : { changeType: ChangeType.Updated, originalValue }; + } + return chgState; + }, collection.changeState); + return didMutate ? { ...collection, changeState } : collection; + } + + /** + * Track an entity before upsert (adding and updating) it to the collection. + * Does NOT update the collection (the reducer's job). + * @param entities The entity to add or update. It must be a complete entity with its id. + * @param collection The entity collection + * @param [mergeStrategy] Track by default. Don't track if is MergeStrategy.IgnoreChanges. + */ + trackUpsertOne( + entity: T, + collection: EntityCollection, + mergeStrategy?: MergeStrategy + ): EntityCollection { + return entity == null + ? collection + : this.trackUpsertMany([entity], collection, mergeStrategy); + } + // #endregion track methods + + // #region undo methods + /** + * Revert the unsaved changes for all collection. + * Harmless when there are no entity changes to undo. + * @param collection The entity collection + */ + undoAll(collection: EntityCollection): EntityCollection { + const ids = Object.keys(collection.changeState); + + const { remove, upsert } = ids.reduce( + (acc, id) => { + const changeState = acc.chgState[id]; + switch (changeState.changeType) { + case ChangeType.Added: + acc.remove.push(id); + break; + case ChangeType.Deleted: + const removed = changeState.originalValue; + if (removed) { + acc.upsert.push(removed); + } + break; + case ChangeType.Updated: + acc.upsert.push(changeState.originalValue!); + break; + } + return acc; + }, + // entitiesToUndo + { + remove: [] as (number | string)[], + upsert: [] as T[], + chgState: collection.changeState, + } + ); + + collection = this.adapter.removeMany(remove as string[], collection); + collection = this.adapter.upsertMany(upsert, collection); + + return { ...collection, changeState: {} }; + } + + /** + * Revert the unsaved changes for the given entities. + * Harmless when there are no entity changes to undo. + * @param entityOrIdList The entities to revert or their ids. + * @param collection The entity collection + */ + undoMany( + entityOrIdList: (number | string | T)[], + collection: EntityCollection + ): EntityCollection { + if (entityOrIdList == null || entityOrIdList.length === 0) { + return collection; // nothing to undo + } + let didMutate = false; + + const { changeState, remove, upsert } = entityOrIdList.reduce( + (acc, entityOrId) => { + let chgState = acc.changeState; + const id = + typeof entityOrId === 'object' + ? this.selectId(entityOrId) + : (entityOrId as string | number); + if (chgState[id]) { + if (!didMutate) { + chgState = { ...chgState }; + didMutate = true; + } + const change = chgState[id]; + delete chgState[id]; // clear tracking of this entity + + switch (change.changeType) { + case ChangeType.Added: + acc.remove.push(id); + break; + case ChangeType.Deleted: + const removed = change.originalValue; + if (removed) { + acc.upsert.push(removed); + } + break; + case ChangeType.Updated: + acc.upsert.push(change.originalValue!); + break; + } + } + return acc; + }, + // entitiesToUndo + { + remove: [] as (number | string)[], + upsert: [] as T[], + changeState: collection.changeState, + } + ); + + collection = this.adapter.removeMany(remove as string[], collection); + collection = this.adapter.upsertMany(upsert, collection); + return didMutate ? collection : { ...collection, changeState }; + } + + /** + * Revert the unsaved changes for the given entity. + * Harmless when there are no entity changes to undo. + * @param entityOrId The entity to revert or its id. + * @param collection The entity collection + */ + undoOne( + entityOrId: number | string | T, + collection: EntityCollection + ): EntityCollection { + return entityOrId == null + ? collection + : this.undoMany([entityOrId], collection); + } + // #endregion undo methods +} diff --git a/modules/data/src/reducers/entity-change-tracker.ts b/modules/data/src/reducers/entity-change-tracker.ts new file mode 100644 index 0000000000..b494d0cd0a --- /dev/null +++ b/modules/data/src/reducers/entity-change-tracker.ts @@ -0,0 +1,267 @@ +import { Update } from '@ngrx/entity'; +import { + ChangeState, + ChangeStateMap, + ChangeType, + EntityCollection, +} from './entity-collection'; +import { MergeStrategy } from '../actions/merge-strategy'; +import { UpdateResponseData } from '../actions/update-response-data'; + +/** + * Methods for tracking, committing, and reverting/undoing unsaved entity changes. + * Used by EntityCollectionReducerMethods which should call tracker methods BEFORE modifying the collection. + * See EntityChangeTracker docs. + */ +export interface EntityChangeTracker { + // #region commit + /** + * Commit all changes as when the collection has been completely reloaded from the server. + * Harmless when there are no entity changes to commit. + * @param collection The entity collection + */ + commitAll(collection: EntityCollection): EntityCollection; + + /** + * Commit changes for the given entities as when they have been refreshed from the server. + * Harmless when there are no entity changes to commit. + * @param entityOrIdList The entities to clear tracking or their ids. + * @param collection The entity collection + */ + commitMany( + entityOrIdList: (number | string | T)[], + collection: EntityCollection + ): EntityCollection; + + /** + * Commit changes for the given entity as when it have been refreshed from the server. + * Harmless when no entity changes to commit. + * @param entityOrId The entity to clear tracking or its id. + * @param collection The entity collection + */ + commitOne( + entityOrId: number | string | T, + collection: EntityCollection + ): EntityCollection; + // #endregion commit + + // #region mergeQuery + /** + * Merge query results into the collection, adjusting the ChangeState per the mergeStrategy. + * @param entities Entities returned from querying the server. + * @param collection The entity collection + * @param [mergeStrategy] How to merge a queried entity when the corresponding entity in the collection has an unsaved change. + * If not specified, implementation supplies a default strategy. + * @returns The merged EntityCollection. + */ + mergeQueryResults( + entities: T[], + collection: EntityCollection, + mergeStrategy?: MergeStrategy + ): EntityCollection; + // #endregion mergeQuery + + // #region mergeSave + /** + * Merge result of saving new entities into the collection, adjusting the ChangeState per the mergeStrategy. + * The default is MergeStrategy.OverwriteChanges. + * @param entities Entities returned from saving new entities to the server. + * @param collection The entity collection + * @param [mergeStrategy] How to merge a saved entity when the corresponding entity in the collection has an unsaved change. + * If not specified, implementation supplies a default strategy. + * @returns The merged EntityCollection. + */ + mergeSaveAdds( + entities: T[], + collection: EntityCollection, + mergeStrategy?: MergeStrategy + ): EntityCollection; + /** + * Merge successful result of deleting entities on the server that have the given primary keys + * Clears the entity changeState for those keys unless the MergeStrategy is ignoreChanges. + * @param entities keys primary keys of the entities to remove/delete. + * @param collection The entity collection + * @param [mergeStrategy] How to adjust change tracking when the corresponding entity in the collection has an unsaved change. + * If not specified, implementation supplies a default strategy. + * @returns The merged EntityCollection. + */ + mergeSaveDeletes( + keys: (number | string)[], + collection: EntityCollection, + mergeStrategy?: MergeStrategy + ): EntityCollection; + + /** + * Merge result of saving upserted entities into the collection, adjusting the ChangeState per the mergeStrategy. + * The default is MergeStrategy.OverwriteChanges. + * @param entities Entities returned from saving upsert entities to the server. + * @param collection The entity collection + * @param [mergeStrategy] How to merge a saved entity when the corresponding entity in the collection has an unsaved change. + * If not specified, implementation supplies a default strategy. + * @returns The merged EntityCollection. + */ + mergeSaveUpserts( + entities: T[], + collection: EntityCollection, + mergeStrategy?: MergeStrategy + ): EntityCollection; + + /** + * Merge result of saving updated entities into the collection, adjusting the ChangeState per the mergeStrategy. + * The default is MergeStrategy.OverwriteChanges. + * @param updates Entity response data returned from saving updated entities to the server. + * @param [mergeStrategy] How to merge a saved entity when the corresponding entity in the collection has an unsaved change. + * If not specified, implementation supplies a default strategy. + * @param [skipUnchanged] True means skip update if server didn't change it. False by default. + * If the update was optimistic and the server didn't make more changes of its own + * then the updates are already in the collection and shouldn't make them again. + * @param collection The entity collection + * @returns The merged EntityCollection. + */ + mergeSaveUpdates( + updates: UpdateResponseData[], + collection: EntityCollection, + mergeStrategy?: MergeStrategy, + skipUnchanged?: boolean + ): EntityCollection; + // #endregion mergeSave + + // #region track + /** + * Track multiple entities before adding them to the collection. + * Does NOT add to the collection (the reducer's job). + * @param entities The entities to add. They must all have their ids. + * @param collection The entity collection + * @param [mergeStrategy] Track by default. Don't track if is MergeStrategy.IgnoreChanges. + * If not specified, implementation supplies a default strategy. + */ + trackAddMany( + entities: T[], + collection: EntityCollection, + mergeStrategy?: MergeStrategy + ): EntityCollection; + + /** + * Track an entity before adding it to the collection. + * Does NOT add to the collection (the reducer's job). + * @param entity The entity to add. It must have an id. + * @param collection The entity collection + * @param [mergeStrategy] Track by default. Don't track if is MergeStrategy.IgnoreChanges. + * If not specified, implementation supplies a default strategy. + */ + trackAddOne( + entity: T, + collection: EntityCollection, + mergeStrategy?: MergeStrategy + ): EntityCollection; + + /** + * Track multiple entities before removing them with the intention of deleting them on the server. + * Does NOT remove from the collection (the reducer's job). + * @param keys The primary keys of the entities to delete. + * @param collection The entity collection + * @param [mergeStrategy] Track by default. Don't track if is MergeStrategy.IgnoreChanges. + */ + trackDeleteMany( + keys: (number | string)[], + collection: EntityCollection, + mergeStrategy?: MergeStrategy + ): EntityCollection; + + /** + * Track an entity before it is removed with the intention of deleting it on the server. + * Does NOT remove from the collection (the reducer's job). + * @param key The primary key of the entity to delete. + * @param collection The entity collection + * @param [mergeStrategy] Track by default. Don't track if is MergeStrategy.IgnoreChanges. + */ + trackDeleteOne( + key: number | string, + collection: EntityCollection, + mergeStrategy?: MergeStrategy + ): EntityCollection; + + /** + * Track multiple entities before updating them in the collection. + * Does NOT update the collection (the reducer's job). + * @param updates The entities to update. + * @param collection The entity collection + * @param [mergeStrategy] Track by default. Don't track if is MergeStrategy.IgnoreChanges. + */ + trackUpdateMany( + updates: Update[], + collection: EntityCollection, + mergeStrategy?: MergeStrategy + ): EntityCollection; + + /** + * Track an entity before updating it in the collection. + * Does NOT update the collection (the reducer's job). + * @param update The entity to update. + * @param collection The entity collection + * @param [mergeStrategy] Track by default. Don't track if is MergeStrategy.IgnoreChanges. + */ + trackUpdateOne( + update: Update, + collection: EntityCollection, + mergeStrategy?: MergeStrategy + ): EntityCollection; + + /** + * Track multiple entities before upserting (adding and updating) them to the collection. + * Does NOT update the collection (the reducer's job). + * @param entities The entities to add or update. They must be complete entities with ids. + * @param collection The entity collection + * @param [mergeStrategy] Track by default. Don't track if is MergeStrategy.IgnoreChanges. + */ + trackUpsertMany( + entities: T[], + collection: EntityCollection, + mergeStrategy?: MergeStrategy + ): EntityCollection; + + /** + * Track an entity before upsert (adding and updating) it to the collection. + * Does NOT update the collection (the reducer's job). + * @param entities The entity to add or update. It must be a complete entity with its id. + * @param collection The entity collection + * @param [mergeStrategy] Track by default. Don't track if is MergeStrategy.IgnoreChanges. + */ + trackUpsertOne( + entity: T, + collection: EntityCollection, + mergeStrategy?: MergeStrategy + ): EntityCollection; + // #endregion track + + // #region undo + /** + * Revert the unsaved changes for all collection. + * Harmless when there are no entity changes to undo. + * @param collection The entity collection + */ + undoAll(collection: EntityCollection): EntityCollection; + + /** + * Revert the unsaved changes for the given entities. + * Harmless when there are no entity changes to undo. + * @param entityOrIdList The entities to revert or their ids. + * @param collection The entity collection + */ + undoMany( + entityOrIdList: (number | string | T)[], + collection: EntityCollection + ): EntityCollection; + + /** + * Revert the unsaved changes for the given entity. + * Harmless when there are no entity changes to undo. + * @param entityOrId The entity to revert or its id. + * @param collection The entity collection + */ + undoOne( + entityOrId: number | string | T, + collection: EntityCollection + ): EntityCollection; + // #endregion undo +} diff --git a/modules/data/src/reducers/entity-collection-creator.ts b/modules/data/src/reducers/entity-collection-creator.ts new file mode 100644 index 0000000000..97aa62c54d --- /dev/null +++ b/modules/data/src/reducers/entity-collection-creator.ts @@ -0,0 +1,44 @@ +import { Injectable, Optional } from '@angular/core'; + +import { EntityCollection } from './entity-collection'; +import { EntityDefinitionService } from '../entity-metadata/entity-definition.service'; + +@Injectable() +export class EntityCollectionCreator { + constructor( + @Optional() private entityDefinitionService?: EntityDefinitionService + ) {} + + /** + * Create the default collection for an entity type. + * @param entityName {string} entity type name + */ + create = EntityCollection>( + entityName: string + ): S { + const def = + this.entityDefinitionService && + this.entityDefinitionService.getDefinition( + entityName, + false /*shouldThrow*/ + ); + + const initialState = def && def.initialState; + + return (initialState || createEmptyEntityCollection(entityName)); + } +} + +export function createEmptyEntityCollection( + entityName?: string +): EntityCollection { + return { + entityName, + ids: [], + entities: {}, + filter: undefined, + loaded: false, + loading: false, + changeState: {}, + } as EntityCollection; +} diff --git a/modules/data/src/reducers/entity-collection-reducer-methods.ts b/modules/data/src/reducers/entity-collection-reducer-methods.ts new file mode 100644 index 0000000000..3883876711 --- /dev/null +++ b/modules/data/src/reducers/entity-collection-reducer-methods.ts @@ -0,0 +1,1247 @@ +import { Injectable } from '@angular/core'; + +import { Action } from '@ngrx/store'; +import { EntityAdapter, Dictionary, IdSelector, Update } from '@ngrx/entity'; + +import { merge } from 'rxjs/operators'; + +import { + ChangeStateMap, + ChangeType, + EntityCollection, +} from './entity-collection'; +import { EntityChangeTrackerBase } from './entity-change-tracker-base'; +import { defaultSelectId, toUpdateFactory } from '../utils/utilities'; +import { EntityAction } from '../actions/entity-action'; +import { EntityActionDataServiceError } from '../dataservices/data-service-error'; +import { EntityActionGuard } from '../actions/entity-action-guard'; +import { EntityChangeTracker } from './entity-change-tracker'; +import { EntityDefinition } from '../entity-metadata/entity-definition'; +import { EntityDefinitionService } from '../entity-metadata/entity-definition.service'; +import { EntityOp } from '../actions/entity-op'; +import { MergeStrategy } from '../actions/merge-strategy'; +import { UpdateResponseData } from '../actions/update-response-data'; + +/** + * Map of {EntityOp} to reducer method for the operation. + * If an operation is missing, caller should return the collection for that reducer. + */ +export interface EntityCollectionReducerMethodMap { + [method: string]: ( + collection: EntityCollection, + action: EntityAction + ) => EntityCollection; +} + +/** + * Base implementation of reducer methods for an entity collection. + */ +export class EntityCollectionReducerMethods { + protected adapter: EntityAdapter; + protected guard: EntityActionGuard; + /** True if this collection tracks unsaved changes */ + protected isChangeTracking: boolean; + + /** Extract the primary key (id); default to `id` */ + selectId: IdSelector; + + /** + * Track changes to entities since the last query or save + * Can revert some or all of those changes + */ + entityChangeTracker: EntityChangeTracker; + + /** + * Convert an entity (or partial entity) into the `Update` object + * `id`: the primary key and + * `changes`: the entity (or partial entity of changes). + */ + protected toUpdate: (entity: Partial) => Update; + + /** + * Dictionary of the {EntityCollectionReducerMethods} for this entity type, + * keyed by the {EntityOp} + */ + readonly methods: EntityCollectionReducerMethodMap = { + [EntityOp.CANCEL_PERSIST]: this.cancelPersist.bind(this), + + [EntityOp.QUERY_ALL]: this.queryAll.bind(this), + [EntityOp.QUERY_ALL_ERROR]: this.queryAllError.bind(this), + [EntityOp.QUERY_ALL_SUCCESS]: this.queryAllSuccess.bind(this), + + [EntityOp.QUERY_BY_KEY]: this.queryByKey.bind(this), + [EntityOp.QUERY_BY_KEY_ERROR]: this.queryByKeyError.bind(this), + [EntityOp.QUERY_BY_KEY_SUCCESS]: this.queryByKeySuccess.bind(this), + + [EntityOp.QUERY_LOAD]: this.queryLoad.bind(this), + [EntityOp.QUERY_LOAD_ERROR]: this.queryLoadError.bind(this), + [EntityOp.QUERY_LOAD_SUCCESS]: this.queryLoadSuccess.bind(this), + + [EntityOp.QUERY_MANY]: this.queryMany.bind(this), + [EntityOp.QUERY_MANY_ERROR]: this.queryManyError.bind(this), + [EntityOp.QUERY_MANY_SUCCESS]: this.queryManySuccess.bind(this), + + [EntityOp.SAVE_ADD_MANY]: this.saveAddMany.bind(this), + [EntityOp.SAVE_ADD_MANY_ERROR]: this.saveAddManyError.bind(this), + [EntityOp.SAVE_ADD_MANY_SUCCESS]: this.saveAddManySuccess.bind(this), + + [EntityOp.SAVE_ADD_ONE]: this.saveAddOne.bind(this), + [EntityOp.SAVE_ADD_ONE_ERROR]: this.saveAddOneError.bind(this), + [EntityOp.SAVE_ADD_ONE_SUCCESS]: this.saveAddOneSuccess.bind(this), + + [EntityOp.SAVE_DELETE_MANY]: this.saveDeleteMany.bind(this), + [EntityOp.SAVE_DELETE_MANY_ERROR]: this.saveDeleteManyError.bind(this), + [EntityOp.SAVE_DELETE_MANY_SUCCESS]: this.saveDeleteManySuccess.bind(this), + + [EntityOp.SAVE_DELETE_ONE]: this.saveDeleteOne.bind(this), + [EntityOp.SAVE_DELETE_ONE_ERROR]: this.saveDeleteOneError.bind(this), + [EntityOp.SAVE_DELETE_ONE_SUCCESS]: this.saveDeleteOneSuccess.bind(this), + + [EntityOp.SAVE_UPDATE_MANY]: this.saveUpdateMany.bind(this), + [EntityOp.SAVE_UPDATE_MANY_ERROR]: this.saveUpdateManyError.bind(this), + [EntityOp.SAVE_UPDATE_MANY_SUCCESS]: this.saveUpdateManySuccess.bind(this), + + [EntityOp.SAVE_UPDATE_ONE]: this.saveUpdateOne.bind(this), + [EntityOp.SAVE_UPDATE_ONE_ERROR]: this.saveUpdateOneError.bind(this), + [EntityOp.SAVE_UPDATE_ONE_SUCCESS]: this.saveUpdateOneSuccess.bind(this), + + [EntityOp.SAVE_UPSERT_MANY]: this.saveUpsertMany.bind(this), + [EntityOp.SAVE_UPSERT_MANY_ERROR]: this.saveUpsertManyError.bind(this), + [EntityOp.SAVE_UPSERT_MANY_SUCCESS]: this.saveUpsertManySuccess.bind(this), + + [EntityOp.SAVE_UPSERT_ONE]: this.saveUpsertOne.bind(this), + [EntityOp.SAVE_UPSERT_ONE_ERROR]: this.saveUpsertOneError.bind(this), + [EntityOp.SAVE_UPSERT_ONE_SUCCESS]: this.saveUpsertOneSuccess.bind(this), + + // Do nothing on save errors except turn the loading flag off. + // See the ChangeTrackerMetaReducers + // Or the app could listen for those errors and do something + + /// cache only operations /// + + [EntityOp.ADD_ALL]: this.addAll.bind(this), + [EntityOp.ADD_MANY]: this.addMany.bind(this), + [EntityOp.ADD_ONE]: this.addOne.bind(this), + + [EntityOp.REMOVE_ALL]: this.removeAll.bind(this), + [EntityOp.REMOVE_MANY]: this.removeMany.bind(this), + [EntityOp.REMOVE_ONE]: this.removeOne.bind(this), + + [EntityOp.UPDATE_MANY]: this.updateMany.bind(this), + [EntityOp.UPDATE_ONE]: this.updateOne.bind(this), + + [EntityOp.UPSERT_MANY]: this.upsertMany.bind(this), + [EntityOp.UPSERT_ONE]: this.upsertOne.bind(this), + + [EntityOp.COMMIT_ALL]: this.commitAll.bind(this), + [EntityOp.COMMIT_MANY]: this.commitMany.bind(this), + [EntityOp.COMMIT_ONE]: this.commitOne.bind(this), + [EntityOp.UNDO_ALL]: this.undoAll.bind(this), + [EntityOp.UNDO_MANY]: this.undoMany.bind(this), + [EntityOp.UNDO_ONE]: this.undoOne.bind(this), + + [EntityOp.SET_CHANGE_STATE]: this.setChangeState.bind(this), + [EntityOp.SET_COLLECTION]: this.setCollection.bind(this), + [EntityOp.SET_FILTER]: this.setFilter.bind(this), + [EntityOp.SET_LOADED]: this.setLoaded.bind(this), + [EntityOp.SET_LOADING]: this.setLoading.bind(this), + }; + + constructor( + public entityName: string, + public definition: EntityDefinition, + /* + * Track changes to entities since the last query or save + * Can revert some or all of those changes + */ + entityChangeTracker?: EntityChangeTracker + ) { + this.adapter = definition.entityAdapter; + this.isChangeTracking = definition.noChangeTracking !== true; + this.selectId = definition.selectId; + + this.guard = new EntityActionGuard(entityName, this.selectId); + this.toUpdate = toUpdateFactory(this.selectId); + + this.entityChangeTracker = + entityChangeTracker || + new EntityChangeTrackerBase(this.adapter, this.selectId); + } + + /** Cancel a persistence operation */ + protected cancelPersist( + collection: EntityCollection + ): EntityCollection { + return this.setLoadingFalse(collection); + } + + // #region query operations + + protected queryAll(collection: EntityCollection): EntityCollection { + return this.setLoadingTrue(collection); + } + + protected queryAllError( + collection: EntityCollection, + action: EntityAction + ): EntityCollection { + return this.setLoadingFalse(collection); + } + + /** + * Merges query results per the MergeStrategy + * Sets loading flag to false and loaded flag to true. + */ + protected queryAllSuccess( + collection: EntityCollection, + action: EntityAction + ): EntityCollection { + const data = this.extractData(action); + const mergeStrategy = this.extractMergeStrategy(action); + return { + ...this.entityChangeTracker.mergeQueryResults( + data, + collection, + mergeStrategy + ), + loaded: true, + loading: false, + }; + } + + protected queryByKey( + collection: EntityCollection, + action: EntityAction + ): EntityCollection { + return this.setLoadingTrue(collection); + } + + protected queryByKeyError( + collection: EntityCollection, + action: EntityAction + ): EntityCollection { + return this.setLoadingFalse(collection); + } + + protected queryByKeySuccess( + collection: EntityCollection, + action: EntityAction + ): EntityCollection { + const data = this.extractData(action); + const mergeStrategy = this.extractMergeStrategy(action); + collection = + data == null + ? collection + : this.entityChangeTracker.mergeQueryResults( + [data], + collection, + mergeStrategy + ); + return this.setLoadingFalse(collection); + } + + protected queryLoad(collection: EntityCollection): EntityCollection { + return this.setLoadingTrue(collection); + } + + protected queryLoadError( + collection: EntityCollection, + action: EntityAction + ): EntityCollection { + return this.setLoadingFalse(collection); + } + + /** + * Replaces all entities in the collection + * Sets loaded flag to true, loading flag to false, + * and clears changeState for the entire collection. + */ + protected queryLoadSuccess( + collection: EntityCollection, + action: EntityAction + ): EntityCollection { + const data = this.extractData(action); + return { + ...this.adapter.addAll(data, collection), + loading: false, + loaded: true, + changeState: {}, + }; + } + + protected queryMany( + collection: EntityCollection, + action: EntityAction + ): EntityCollection { + return this.setLoadingTrue(collection); + } + + protected queryManyError( + collection: EntityCollection, + action: EntityAction + ): EntityCollection { + return this.setLoadingFalse(collection); + } + + protected queryManySuccess( + collection: EntityCollection, + action: EntityAction + ): EntityCollection { + const data = this.extractData(action); + const mergeStrategy = this.extractMergeStrategy(action); + return { + ...this.entityChangeTracker.mergeQueryResults( + data, + collection, + mergeStrategy + ), + loading: false, + }; + } + // #endregion query operations + + // #region save operations + + // #region saveAddMany + /** + * Save multiple new entities. + * If saving pessimistically, delay adding to collection until server acknowledges success. + * If saving optimistically; add immediately. + * @param collection The collection to which the entities should be added. + * @param action The action payload holds options, including whether the save is optimistic, + * and the data, which must be an array of entities. + * If saving optimistically, the entities must have their keys. + */ + protected saveAddMany( + collection: EntityCollection, + action: EntityAction + ): EntityCollection { + if (this.isOptimistic(action)) { + const entities = this.guard.mustBeEntities(action); // ensure the entity has a PK + const mergeStrategy = this.extractMergeStrategy(action); + collection = this.entityChangeTracker.trackAddMany( + entities, + collection, + mergeStrategy + ); + collection = this.adapter.addMany(entities, collection); + } + return this.setLoadingTrue(collection); + } + + /** + * Attempt to save new entities failed or timed-out. + * Action holds the error. + * If saved pessimistically, new entities are not in the collection and + * you may not have to compensate for the error. + * If saved optimistically, the unsaved entities are in the collection and + * you may need to compensate for the error. + */ + protected saveAddManyError( + collection: EntityCollection, + action: EntityAction + ): EntityCollection { + return this.setLoadingFalse(collection); + } + // #endregion saveAddMany + + // #region saveAddOne + /** + * Successfully saved new entities to the server. + * If saved pessimistically, add the entities from the server to the collection. + * If saved optimistically, the added entities are already in the collection. + * However, the server might have set or modified other fields (e.g, concurrency field), + * and may even return additional new entities. + * Therefore, upsert the entities in the collection with the returned values (if any) + * Caution: in a race, this update could overwrite unsaved user changes. + * Use pessimistic add to avoid this risk. + * Note: saveAddManySuccess differs from saveAddOneSuccess when optimistic. + * saveAddOneSuccess updates (not upserts) with the lone entity from the server. + * There is no effect if the entity is not already in cache. + * saveAddManySuccess will add an entity if it is not found in cache. + */ + protected saveAddManySuccess( + collection: EntityCollection, + action: EntityAction + ) { + // For pessimistic save, ensure the server generated the primary key if the client didn't send one. + const entities = this.guard.mustBeEntities(action); + const mergeStrategy = this.extractMergeStrategy(action); + if (this.isOptimistic(action)) { + collection = this.entityChangeTracker.mergeSaveUpserts( + entities, + collection, + mergeStrategy + ); + } else { + collection = this.entityChangeTracker.mergeSaveAdds( + entities, + collection, + mergeStrategy + ); + } + return this.setLoadingFalse(collection); + } + // #endregion saveAddMany + + // #region saveAddOne + /** + * Save a new entity. + * If saving pessimistically, delay adding to collection until server acknowledges success. + * If saving optimistically; add entity immediately. + * @param collection The collection to which the entity should be added. + * @param action The action payload holds options, including whether the save is optimistic, + * and the data, which must be an entity. + * If saving optimistically, the entity must have a key. + */ + protected saveAddOne( + collection: EntityCollection, + action: EntityAction + ): EntityCollection { + if (this.isOptimistic(action)) { + const entity = this.guard.mustBeEntity(action); // ensure the entity has a PK + const mergeStrategy = this.extractMergeStrategy(action); + collection = this.entityChangeTracker.trackAddOne( + entity, + collection, + mergeStrategy + ); + collection = this.adapter.addOne(entity, collection); + } + return this.setLoadingTrue(collection); + } + + /** + * Attempt to save a new entity failed or timed-out. + * Action holds the error. + * If saved pessimistically, the entity is not in the collection and + * you may not have to compensate for the error. + * If saved optimistically, the unsaved entity is in the collection and + * you may need to compensate for the error. + */ + protected saveAddOneError( + collection: EntityCollection, + action: EntityAction + ): EntityCollection { + return this.setLoadingFalse(collection); + } + + /** + * Successfully saved a new entity to the server. + * If saved pessimistically, add the entity from the server to the collection. + * If saved optimistically, the added entity is already in the collection. + * However, the server might have set or modified other fields (e.g, concurrency field) + * Therefore, update the entity in the collection with the returned value (if any) + * Caution: in a race, this update could overwrite unsaved user changes. + * Use pessimistic add to avoid this risk. + */ + protected saveAddOneSuccess( + collection: EntityCollection, + action: EntityAction + ) { + // For pessimistic save, ensure the server generated the primary key if the client didn't send one. + const entity = this.guard.mustBeEntity(action); + const mergeStrategy = this.extractMergeStrategy(action); + if (this.isOptimistic(action)) { + const update: UpdateResponseData = this.toUpdate(entity); + // Always update the cache with added entity returned from server + collection = this.entityChangeTracker.mergeSaveUpdates( + [update], + collection, + mergeStrategy, + false /*never skip*/ + ); + } else { + collection = this.entityChangeTracker.mergeSaveAdds( + [entity], + collection, + mergeStrategy + ); + } + return this.setLoadingFalse(collection); + } + // #endregion saveAddOne + + // #region saveAddMany + // TODO MANY + // #endregion saveAddMany + + // #region saveDeleteOne + /** + * Delete an entity from the server by key and remove it from the collection (if present). + * If the entity is an unsaved new entity, remove it from the collection immediately + * and skip the server delete request. + * An optimistic save removes an existing entity from the collection immediately; + * a pessimistic save removes it after the server confirms successful delete. + * @param collection Will remove the entity with this key from the collection. + * @param action The action payload holds options, including whether the save is optimistic, + * and the data, which must be a primary key or an entity with a key; + * this reducer extracts the key from the entity. + */ + protected saveDeleteOne( + collection: EntityCollection, + action: EntityAction + ): EntityCollection { + const toDelete = this.extractData(action); + const deleteId = + typeof toDelete === 'object' + ? this.selectId(toDelete) + : (toDelete as string | number); + const change = collection.changeState[deleteId]; + // If entity is already tracked ... + if (change) { + if (change.changeType === ChangeType.Added) { + // Remove the added entity immediately and forget about its changes (via commit). + collection = this.adapter.removeOne(deleteId as string, collection); + collection = this.entityChangeTracker.commitOne(deleteId, collection); + // Should not waste effort trying to delete on the server because it can't be there. + action.payload.skip = true; + } else { + // Re-track it as a delete, even if tracking is turned off for this call. + collection = this.entityChangeTracker.trackDeleteOne( + deleteId, + collection + ); + } + } + + // If optimistic delete, track current state and remove immediately. + if (this.isOptimistic(action)) { + const mergeStrategy = this.extractMergeStrategy(action); + collection = this.entityChangeTracker.trackDeleteOne( + deleteId, + collection, + mergeStrategy + ); + collection = this.adapter.removeOne(deleteId as string, collection); + } + + return this.setLoadingTrue(collection); + } + + /** + * Attempt to delete the entity on the server failed or timed-out. + * Action holds the error. + * If saved pessimistically, the entity could still be in the collection and + * you may not have to compensate for the error. + * If saved optimistically, the entity is not in the collection and + * you may need to compensate for the error. + */ + protected saveDeleteOneError( + collection: EntityCollection, + action: EntityAction + ): EntityCollection { + return this.setLoadingFalse(collection); + } + + /** + * Successfully deleted entity on the server. The key of the deleted entity is in the action payload data. + * If saved pessimistically, if the entity is still in the collection it will be removed. + * If saved optimistically, the entity has already been removed from the collection. + */ + protected saveDeleteOneSuccess( + collection: EntityCollection, + action: EntityAction + ): EntityCollection { + const deleteId = this.extractData(action); + if (this.isOptimistic(action)) { + const mergeStrategy = this.extractMergeStrategy(action); + collection = this.entityChangeTracker.mergeSaveDeletes( + [deleteId], + collection, + mergeStrategy + ); + } else { + // Pessimistic: ignore mergeStrategy. Remove entity from the collection and from change tracking. + collection = this.adapter.removeOne(deleteId as string, collection); + collection = this.entityChangeTracker.commitOne(deleteId, collection); + } + return this.setLoadingFalse(collection); + } + // #endregion saveDeleteOne + + // #region saveDeleteMany + /** + * Delete multiple entities from the server by key and remove them from the collection (if present). + * Removes unsaved new entities from the collection immediately + * but the id is still sent to the server for deletion even though the server will not find that entity. + * Therefore, the server must be willing to ignore a delete request for an entity it cannot find. + * An optimistic save removes existing entities from the collection immediately; + * a pessimistic save removes them after the server confirms successful delete. + * @param collection Removes entities from this collection. + * @param action The action payload holds options, including whether the save is optimistic, + * and the data, which must be an array of primary keys or entities with a key; + * this reducer extracts the key from the entity. + */ + protected saveDeleteMany( + collection: EntityCollection, + action: EntityAction<(number | string | T)[]> + ): EntityCollection { + const deleteIds = this.extractData(action).map( + d => (typeof d === 'object' ? this.selectId(d) : (d as string | number)) + ); + deleteIds.forEach(deleteId => { + const change = collection.changeState[deleteId]; + // If entity is already tracked ... + if (change) { + if (change.changeType === ChangeType.Added) { + // Remove the added entity immediately and forget about its changes (via commit). + collection = this.adapter.removeOne(deleteId as string, collection); + collection = this.entityChangeTracker.commitOne(deleteId, collection); + // Should not waste effort trying to delete on the server because it can't be there. + action.payload.skip = true; + } else { + // Re-track it as a delete, even if tracking is turned off for this call. + collection = this.entityChangeTracker.trackDeleteOne( + deleteId, + collection + ); + } + } + }); + // If optimistic delete, track current state and remove immediately. + if (this.isOptimistic(action)) { + const mergeStrategy = this.extractMergeStrategy(action); + collection = this.entityChangeTracker.trackDeleteMany( + deleteIds, + collection, + mergeStrategy + ); + collection = this.adapter.removeMany(deleteIds as string[], collection); + } + return this.setLoadingTrue(collection); + } + + /** + * Attempt to delete the entities on the server failed or timed-out. + * Action holds the error. + * If saved pessimistically, the entities could still be in the collection and + * you may not have to compensate for the error. + * If saved optimistically, the entities are not in the collection and + * you may need to compensate for the error. + */ + protected saveDeleteManyError( + collection: EntityCollection, + action: EntityAction + ): EntityCollection { + return this.setLoadingFalse(collection); + } + + /** + * Successfully deleted entities on the server. The keys of the deleted entities are in the action payload data. + * If saved pessimistically, entities that are still in the collection will be removed. + * If saved optimistically, the entities have already been removed from the collection. + */ + protected saveDeleteManySuccess( + collection: EntityCollection, + action: EntityAction<(number | string)[]> + ): EntityCollection { + const deleteIds = this.extractData(action); + if (this.isOptimistic(action)) { + const mergeStrategy = this.extractMergeStrategy(action); + collection = this.entityChangeTracker.mergeSaveDeletes( + deleteIds, + collection, + mergeStrategy + ); + } else { + // Pessimistic: ignore mergeStrategy. Remove entity from the collection and from change tracking. + collection = this.adapter.removeMany(deleteIds as string[], collection); + collection = this.entityChangeTracker.commitMany(deleteIds, collection); + } + return this.setLoadingFalse(collection); + } + // #endregion saveDeleteMany + + // #region saveUpdateOne + /** + * Save an update to an existing entity. + * If saving pessimistically, update the entity in the collection after the server confirms success. + * If saving optimistically, update the entity immediately, before the save request. + * @param collection The collection to update + * @param action The action payload holds options, including if the save is optimistic, + * and the data which, must be an {Update} + */ + protected saveUpdateOne( + collection: EntityCollection, + action: EntityAction> + ): EntityCollection { + const update = this.guard.mustBeUpdate(action); + if (this.isOptimistic(action)) { + const mergeStrategy = this.extractMergeStrategy(action); + collection = this.entityChangeTracker.trackUpdateOne( + update, + collection, + mergeStrategy + ); + collection = this.adapter.updateOne(update, collection); + } + return this.setLoadingTrue(collection); + } + + /** + * Attempt to update the entity on the server failed or timed-out. + * Action holds the error. + * If saved pessimistically, the entity in the collection is in the pre-save state + * you may not have to compensate for the error. + * If saved optimistically, the entity in the collection was updated + * and you may need to compensate for the error. + */ + protected saveUpdateOneError( + collection: EntityCollection, + action: EntityAction + ): EntityCollection { + return this.setLoadingFalse(collection); + } + + /** + * Successfully saved the updated entity to the server. + * If saved pessimistically, update the entity in the collection with data from the server. + * If saved optimistically, the entity was already updated in the collection. + * However, the server might have set or modified other fields (e.g, concurrency field) + * Therefore, update the entity in the collection with the returned value (if any) + * Caution: in a race, this update could overwrite unsaved user changes. + * Use pessimistic update to avoid this risk. + * @param collection The collection to update + * @param action The action payload holds options, including if the save is optimistic, and + * the update data which, must be an UpdateResponse that corresponds to the Update sent to the server. + * You must include an UpdateResponse even if the save was optimistic, + * to ensure that the change tracking is properly reset. + */ + protected saveUpdateOneSuccess( + collection: EntityCollection, + action: EntityAction> + ): EntityCollection { + const update = this.guard.mustBeUpdateResponse(action); + const isOptimistic = this.isOptimistic(action); + const mergeStrategy = this.extractMergeStrategy(action); + collection = this.entityChangeTracker.mergeSaveUpdates( + [update], + collection, + mergeStrategy, + isOptimistic /*skip unchanged if optimistic */ + ); + return this.setLoadingFalse(collection); + } + // #endregion saveUpdateOne + + // #region saveUpdateMany + /** + * Save updated entities. + * If saving pessimistically, update the entities in the collection after the server confirms success. + * If saving optimistically, update the entities immediately, before the save request. + * @param collection The collection to update + * @param action The action payload holds options, including if the save is optimistic, + * and the data which, must be an array of {Update}. + */ + protected saveUpdateMany( + collection: EntityCollection, + action: EntityAction[]> + ): EntityCollection { + const updates = this.guard.mustBeUpdates(action); + if (this.isOptimistic(action)) { + const mergeStrategy = this.extractMergeStrategy(action); + collection = this.entityChangeTracker.trackUpdateMany( + updates, + collection, + mergeStrategy + ); + collection = this.adapter.updateMany(updates, collection); + } + return this.setLoadingTrue(collection); + } + + /** + * Attempt to update entities on the server failed or timed-out. + * Action holds the error. + * If saved pessimistically, the entities in the collection are in the pre-save state + * you may not have to compensate for the error. + * If saved optimistically, the entities in the collection were updated + * and you may need to compensate for the error. + */ + protected saveUpdateManyError( + collection: EntityCollection, + action: EntityAction + ): EntityCollection { + return this.setLoadingFalse(collection); + } + + /** + * Successfully saved the updated entities to the server. + * If saved pessimistically, the entities in the collection will be updated with data from the server. + * If saved optimistically, the entities in the collection were already updated. + * However, the server might have set or modified other fields (e.g, concurrency field) + * Therefore, update the entity in the collection with the returned values (if any) + * Caution: in a race, this update could overwrite unsaved user changes. + * Use pessimistic update to avoid this risk. + * @param collection The collection to update + * @param action The action payload holds options, including if the save is optimistic, + * and the data which, must be an array of UpdateResponse. + * You must include an UpdateResponse for every Update sent to the server, + * even if the save was optimistic, to ensure that the change tracking is properly reset. + */ + protected saveUpdateManySuccess( + collection: EntityCollection, + action: EntityAction[]> + ): EntityCollection { + const updates = this.guard.mustBeUpdateResponses(action); + const isOptimistic = this.isOptimistic(action); + const mergeStrategy = this.extractMergeStrategy(action); + collection = this.entityChangeTracker.mergeSaveUpdates( + updates, + collection, + mergeStrategy, + false /* never skip */ + ); + return this.setLoadingFalse(collection); + } + // #endregion saveUpdateMany + + // #region saveUpsertOne + /** + * Save a new or existing entity. + * If saving pessimistically, delay adding to collection until server acknowledges success. + * If saving optimistically; add immediately. + * @param collection The collection to which the entity should be upserted. + * @param action The action payload holds options, including whether the save is optimistic, + * and the data, which must be a whole entity. + * If saving optimistically, the entity must have its key. + */ + protected saveUpsertOne( + collection: EntityCollection, + action: EntityAction + ): EntityCollection { + if (this.isOptimistic(action)) { + const entity = this.guard.mustBeEntity(action); // ensure the entity has a PK + const mergeStrategy = this.extractMergeStrategy(action); + collection = this.entityChangeTracker.trackUpsertOne( + entity, + collection, + mergeStrategy + ); + collection = this.adapter.upsertOne(entity, collection); + } + return this.setLoadingTrue(collection); + } + + /** + * Attempt to save new or existing entity failed or timed-out. + * Action holds the error. + * If saved pessimistically, new or updated entity is not in the collection and + * you may not have to compensate for the error. + * If saved optimistically, the unsaved entities are in the collection and + * you may need to compensate for the error. + */ + protected saveUpsertOneError( + collection: EntityCollection, + action: EntityAction + ): EntityCollection { + return this.setLoadingFalse(collection); + } + + /** + * Successfully saved new or existing entities to the server. + * If saved pessimistically, add the entities from the server to the collection. + * If saved optimistically, the added entities are already in the collection. + * However, the server might have set or modified other fields (e.g, concurrency field) + * Therefore, update the entities in the collection with the returned values (if any) + * Caution: in a race, this update could overwrite unsaved user changes. + * Use pessimistic add to avoid this risk. + */ + protected saveUpsertOneSuccess( + collection: EntityCollection, + action: EntityAction + ) { + // For pessimistic save, ensure the server generated the primary key if the client didn't send one. + const entity = this.guard.mustBeEntity(action); + const mergeStrategy = this.extractMergeStrategy(action); + // Always update the cache with upserted entities returned from server + collection = this.entityChangeTracker.mergeSaveUpserts( + [entity], + collection, + mergeStrategy + ); + return this.setLoadingFalse(collection); + } + // #endregion saveUpsertOne + + // #region saveUpsertMany + /** + * Save multiple new or existing entities. + * If saving pessimistically, delay adding to collection until server acknowledges success. + * If saving optimistically; add immediately. + * @param collection The collection to which the entities should be upserted. + * @param action The action payload holds options, including whether the save is optimistic, + * and the data, which must be an array of whole entities. + * If saving optimistically, the entities must have their keys. + */ + protected saveUpsertMany( + collection: EntityCollection, + action: EntityAction + ): EntityCollection { + if (this.isOptimistic(action)) { + const entities = this.guard.mustBeEntities(action); // ensure the entity has a PK + const mergeStrategy = this.extractMergeStrategy(action); + collection = this.entityChangeTracker.trackUpsertMany( + entities, + collection, + mergeStrategy + ); + collection = this.adapter.upsertMany(entities, collection); + } + return this.setLoadingTrue(collection); + } + + /** + * Attempt to save new or existing entities failed or timed-out. + * Action holds the error. + * If saved pessimistically, new entities are not in the collection and + * you may not have to compensate for the error. + * If saved optimistically, the unsaved entities are in the collection and + * you may need to compensate for the error. + */ + protected saveUpsertManyError( + collection: EntityCollection, + action: EntityAction + ): EntityCollection { + return this.setLoadingFalse(collection); + } + + /** + * Successfully saved new or existing entities to the server. + * If saved pessimistically, add the entities from the server to the collection. + * If saved optimistically, the added entities are already in the collection. + * However, the server might have set or modified other fields (e.g, concurrency field) + * Therefore, update the entities in the collection with the returned values (if any) + * Caution: in a race, this update could overwrite unsaved user changes. + * Use pessimistic add to avoid this risk. + */ + protected saveUpsertManySuccess( + collection: EntityCollection, + action: EntityAction + ) { + // For pessimistic save, ensure the server generated the primary key if the client didn't send one. + const entities = this.guard.mustBeEntities(action); + const mergeStrategy = this.extractMergeStrategy(action); + // Always update the cache with upserted entities returned from server + collection = this.entityChangeTracker.mergeSaveUpserts( + entities, + collection, + mergeStrategy + ); + return this.setLoadingFalse(collection); + } + // #endregion saveUpsertMany + + // #endregion save operations + + // #region cache-only operations + + /** + * Replaces all entities in the collection + * Sets loaded flag to true. + * Merges query results, preserving unsaved changes + */ + protected addAll( + collection: EntityCollection, + action: EntityAction + ): EntityCollection { + const entities = this.guard.mustBeEntities(action); + return { + ...this.adapter.addAll(entities, collection), + loading: false, + loaded: true, + changeState: {}, + }; + } + + protected addMany( + collection: EntityCollection, + action: EntityAction + ): EntityCollection { + const entities = this.guard.mustBeEntities(action); + const mergeStrategy = this.extractMergeStrategy(action); + collection = this.entityChangeTracker.trackAddMany( + entities, + collection, + mergeStrategy + ); + return this.adapter.addMany(entities, collection); + } + + protected addOne( + collection: EntityCollection, + action: EntityAction + ): EntityCollection { + const entity = this.guard.mustBeEntity(action); + const mergeStrategy = this.extractMergeStrategy(action); + collection = this.entityChangeTracker.trackAddOne( + entity, + collection, + mergeStrategy + ); + return this.adapter.addOne(entity, collection); + } + + protected removeMany( + collection: EntityCollection, + action: EntityAction + ): EntityCollection { + // payload must be entity keys + const keys = this.guard.mustBeKeys(action) as string[]; + const mergeStrategy = this.extractMergeStrategy(action); + collection = this.entityChangeTracker.trackDeleteMany( + keys, + collection, + mergeStrategy + ); + return this.adapter.removeMany(keys, collection); + } + + protected removeOne( + collection: EntityCollection, + action: EntityAction + ): EntityCollection { + // payload must be entity key + const key = this.guard.mustBeKey(action) as string; + const mergeStrategy = this.extractMergeStrategy(action); + collection = this.entityChangeTracker.trackDeleteOne( + key, + collection, + mergeStrategy + ); + return this.adapter.removeOne(key, collection); + } + + protected removeAll( + collection: EntityCollection, + action: EntityAction + ): EntityCollection { + return { + ...this.adapter.removeAll(collection), + loaded: false, // Only REMOVE_ALL sets loaded to false + loading: false, + changeState: {}, // Assume clearing the collection and not trying to delete all entities + }; + } + + protected updateMany( + collection: EntityCollection, + action: EntityAction[]> + ): EntityCollection { + // payload must be an array of `Updates`, not entities + const updates = this.guard.mustBeUpdates(action); + const mergeStrategy = this.extractMergeStrategy(action); + collection = this.entityChangeTracker.trackUpdateMany( + updates, + collection, + mergeStrategy + ); + return this.adapter.updateMany(updates, collection); + } + + protected updateOne( + collection: EntityCollection, + action: EntityAction> + ): EntityCollection { + // payload must be an `Update`, not an entity + const update = this.guard.mustBeUpdate(action); + const mergeStrategy = this.extractMergeStrategy(action); + collection = this.entityChangeTracker.trackUpdateOne( + update, + collection, + mergeStrategy + ); + return this.adapter.updateOne(update, collection); + } + + protected upsertMany( + collection: EntityCollection, + action: EntityAction + ): EntityCollection { + // `, not entities + // v6+: payload must be an array of T + const entities = this.guard.mustBeEntities(action); + const mergeStrategy = this.extractMergeStrategy(action); + collection = this.entityChangeTracker.trackUpsertMany( + entities, + collection, + mergeStrategy + ); + return this.adapter.upsertMany(entities, collection); + } + + protected upsertOne( + collection: EntityCollection, + action: EntityAction + ): EntityCollection { + // `, not an entity + // v6+: payload must be a T + const entity = this.guard.mustBeEntity(action); + const mergeStrategy = this.extractMergeStrategy(action); + collection = this.entityChangeTracker.trackUpsertOne( + entity, + collection, + mergeStrategy + ); + return this.adapter.upsertOne(entity, collection); + } + + protected commitAll(collection: EntityCollection) { + return this.entityChangeTracker.commitAll(collection); + } + + protected commitMany( + collection: EntityCollection, + action: EntityAction + ) { + return this.entityChangeTracker.commitMany( + this.extractData(action), + collection + ); + } + + protected commitOne( + collection: EntityCollection, + action: EntityAction + ) { + return this.entityChangeTracker.commitOne( + this.extractData(action), + collection + ); + } + + protected undoAll(collection: EntityCollection) { + return this.entityChangeTracker.undoAll(collection); + } + + protected undoMany( + collection: EntityCollection, + action: EntityAction + ) { + return this.entityChangeTracker.undoMany( + this.extractData(action), + collection + ); + } + + protected undoOne(collection: EntityCollection, action: EntityAction) { + return this.entityChangeTracker.undoOne( + this.extractData(action), + collection + ); + } + + /** Dangerous: Completely replace the collection's ChangeState. Use rarely and wisely. */ + protected setChangeState( + collection: EntityCollection, + action: EntityAction> + ) { + const changeState = this.extractData(action); + return collection.changeState === changeState + ? collection + : { ...collection, changeState }; + } + + /** + * Dangerous: Completely replace the collection. + * Primarily for testing and rehydration from local storage. + * Use rarely and wisely. + */ + protected setCollection( + collection: EntityCollection, + action: EntityAction> + ) { + const newCollection = this.extractData(action); + return collection === newCollection ? collection : newCollection; + } + + protected setFilter( + collection: EntityCollection, + action: EntityAction + ): EntityCollection { + const filter = this.extractData(action); + return collection.filter === filter + ? collection + : { ...collection, filter }; + } + + protected setLoaded( + collection: EntityCollection, + action: EntityAction + ): EntityCollection { + const loaded = this.extractData(action) === true || false; + return collection.loaded === loaded + ? collection + : { ...collection, loaded }; + } + + protected setLoading( + collection: EntityCollection, + action: EntityAction + ): EntityCollection { + return this.setLoadingFlag(collection, this.extractData(action)); + } + + protected setLoadingFalse( + collection: EntityCollection + ): EntityCollection { + return this.setLoadingFlag(collection, false); + } + + protected setLoadingTrue( + collection: EntityCollection + ): EntityCollection { + return this.setLoadingFlag(collection, true); + } + + /** Set the collection's loading flag */ + protected setLoadingFlag(collection: EntityCollection, loading: boolean) { + loading = loading === true ? true : false; + return collection.loading === loading + ? collection + : { ...collection, loading }; + } + // #endregion Cache-only operations + + // #region helpers + /** Safely extract data from the EntityAction payload */ + protected extractData(action: EntityAction): D { + return (action.payload && action.payload.data) as D; + } + + /** Safely extract MergeStrategy from EntityAction. Set to IgnoreChanges if collection itself is not tracked. */ + protected extractMergeStrategy(action: EntityAction) { + // If not tracking this collection, always ignore changes + return this.isChangeTracking + ? action.payload && action.payload.mergeStrategy + : MergeStrategy.IgnoreChanges; + } + + protected isOptimistic(action: EntityAction) { + return action.payload && action.payload.isOptimistic === true; + } + + // #endregion helpers +} + +/** + * Creates {EntityCollectionReducerMethods} for a given entity type. + */ +@Injectable() +export class EntityCollectionReducerMethodsFactory { + constructor(private entityDefinitionService: EntityDefinitionService) {} + + /** Create the {EntityCollectionReducerMethods} for the named entity type */ + create(entityName: string): EntityCollectionReducerMethodMap { + const definition = this.entityDefinitionService.getDefinition( + entityName + ); + const methodsClass = new EntityCollectionReducerMethods( + entityName, + definition + ); + + return methodsClass.methods; + } +} diff --git a/modules/data/src/reducers/entity-collection-reducer-registry.ts b/modules/data/src/reducers/entity-collection-reducer-registry.ts new file mode 100644 index 0000000000..29ed2d166a --- /dev/null +++ b/modules/data/src/reducers/entity-collection-reducer-registry.ts @@ -0,0 +1,89 @@ +import { Inject, Injectable, Optional } from '@angular/core'; +import { ActionReducer, compose, MetaReducer } from '@ngrx/store'; + +import { EntityAction } from '../actions/entity-action'; +import { EntityCollection } from './entity-collection'; +import { ENTITY_COLLECTION_META_REDUCERS } from './constants'; +import { + EntityCollectionReducer, + EntityCollectionReducerFactory, +} from './entity-collection-reducer'; + +/** A hash of EntityCollectionReducers */ +export interface EntityCollectionReducers { + [entity: string]: EntityCollectionReducer; +} + +/** + * Registry of entity types and their previously-constructed reducers. + * Can create a new CollectionReducer, which it registers for subsequent use. + */ +@Injectable() +export class EntityCollectionReducerRegistry { + protected entityCollectionReducers: EntityCollectionReducers = {}; + private entityCollectionMetaReducer: MetaReducer< + EntityCollection, + EntityAction + >; + + constructor( + private entityCollectionReducerFactory: EntityCollectionReducerFactory, + @Optional() + @Inject(ENTITY_COLLECTION_META_REDUCERS) + entityCollectionMetaReducers?: MetaReducer[] + ) { + this.entityCollectionMetaReducer = compose.apply( + null, + entityCollectionMetaReducers || [] + ) as any; + } + + /** + * Get the registered EntityCollectionReducer for this entity type or create one and register it. + * @param entityName Name of the entity type for this reducer + */ + getOrCreateReducer(entityName: string): EntityCollectionReducer { + let reducer: EntityCollectionReducer = this.entityCollectionReducers[ + entityName + ]; + + if (!reducer) { + reducer = this.entityCollectionReducerFactory.create(entityName); + reducer = this.registerReducer(entityName, reducer); + this.entityCollectionReducers[entityName] = reducer; + } + return reducer; + } + + /** + * Register an EntityCollectionReducer for an entity type + * @param entityName - the name of the entity type + * @param reducer - reducer for that entity type + * + * Examples: + * registerReducer('Hero', myHeroReducer); + * registerReducer('Villain', myVillainReducer); + */ + registerReducer( + entityName: string, + reducer: EntityCollectionReducer + ): EntityCollectionReducer { + reducer = this.entityCollectionMetaReducer(reducer as any); + return (this.entityCollectionReducers[entityName.trim()] = reducer); + } + + /** + * Register a batch of EntityCollectionReducers. + * @param reducers - reducers to merge into existing reducers + * + * Examples: + * registerReducers({ + * Hero: myHeroReducer, + * Villain: myVillainReducer + * }); + */ + registerReducers(reducers: EntityCollectionReducers) { + const keys = reducers ? Object.keys(reducers) : []; + keys.forEach(key => this.registerReducer(key, reducers[key])); + } +} diff --git a/modules/data/src/reducers/entity-collection-reducer.ts b/modules/data/src/reducers/entity-collection-reducer.ts new file mode 100644 index 0000000000..6753cb29a8 --- /dev/null +++ b/modules/data/src/reducers/entity-collection-reducer.ts @@ -0,0 +1,30 @@ +import { Injectable } from '@angular/core'; + +import { EntityAction } from '../actions/entity-action'; +import { EntityCollection } from './entity-collection'; +import { EntityCollectionReducerMethodsFactory } from './entity-collection-reducer-methods'; + +export type EntityCollectionReducer = ( + collection: EntityCollection, + action: EntityAction +) => EntityCollection; + +/** Create a default reducer for a specific entity collection */ +@Injectable() +export class EntityCollectionReducerFactory { + constructor(private methodsFactory: EntityCollectionReducerMethodsFactory) {} + + /** Create a default reducer for a collection of entities of T */ + create(entityName: string): EntityCollectionReducer { + const methods = this.methodsFactory.create(entityName); + + /** Perform Actions against a particular entity collection in the EntityCache */ + return function entityCollectionReducer( + collection: EntityCollection, + action: EntityAction + ): EntityCollection { + const reducerMethod = methods[action.payload.entityOp]; + return reducerMethod ? reducerMethod(collection, action) : collection; + }; + } +} diff --git a/modules/data/src/reducers/entity-collection.ts b/modules/data/src/reducers/entity-collection.ts new file mode 100644 index 0000000000..c0fc0c3047 --- /dev/null +++ b/modules/data/src/reducers/entity-collection.ts @@ -0,0 +1,45 @@ +import { EntityState, Dictionary } from '@ngrx/entity'; + +/** Types of change in a ChangeState instance */ +export enum ChangeType { + /** The entity has not changed from its last known server state. */ + Unchanged = 0, + /** The entity was added to the collection */ + Added, + /** The entity is scheduled for delete and was removed from the collection */ + Deleted, + /** The entity in the collection was updated */ + Updated, +} + +/** + * Change state for an entity with unsaved changes; + * an entry in an EntityCollection.changeState map + */ +export interface ChangeState { + changeType: ChangeType; + originalValue?: T | undefined; +} + +/** + * Map of entity primary keys to entity ChangeStates. + * Each entry represents an entity with unsaved changes. + */ +export type ChangeStateMap = Dictionary>; + +/** + * Data and information about a collection of entities of a single type. + * EntityCollections are maintained in the EntityCache within the ngrx store. + */ +export interface EntityCollection extends EntityState { + /** Name of the entity type for this collection */ + entityName: string; + /** A map of ChangeStates, keyed by id, for entities with unsaved changes */ + changeState: ChangeStateMap; + /** The user's current collection filter pattern */ + filter?: string; + /** true if collection was ever filled by QueryAll; forced false if cleared */ + loaded: boolean; + /** true when a query or save operation is in progress */ + loading: boolean; +} diff --git a/modules/data/src/selectors/entity-cache-selector.ts b/modules/data/src/selectors/entity-cache-selector.ts new file mode 100644 index 0000000000..732e361f73 --- /dev/null +++ b/modules/data/src/selectors/entity-cache-selector.ts @@ -0,0 +1,36 @@ +import { + Inject, + Injectable, + InjectionToken, + Optional, + FactoryProvider, +} from '@angular/core'; +import { + createFeatureSelector, + createSelector, + MemoizedSelector, +} from '@ngrx/store'; +import { EntityCache } from '../reducers/entity-cache'; +import { + ENTITY_CACHE_NAME, + ENTITY_CACHE_NAME_TOKEN, +} from '../reducers/constants'; + +export const ENTITY_CACHE_SELECTOR_TOKEN = new InjectionToken< + MemoizedSelector +>('@ngrx/data/entity-cache-selector'); + +export const entityCacheSelectorProvider: FactoryProvider = { + provide: ENTITY_CACHE_SELECTOR_TOKEN, + useFactory: createEntityCacheSelector, + deps: [[new Optional(), ENTITY_CACHE_NAME_TOKEN]], +}; + +export type EntityCacheSelector = MemoizedSelector; + +export function createEntityCacheSelector( + entityCacheName?: string +): MemoizedSelector { + entityCacheName = entityCacheName || ENTITY_CACHE_NAME; + return createFeatureSelector(entityCacheName); +} diff --git a/modules/data/src/selectors/entity-selectors$.ts b/modules/data/src/selectors/entity-selectors$.ts new file mode 100644 index 0000000000..4c8684386c --- /dev/null +++ b/modules/data/src/selectors/entity-selectors$.ts @@ -0,0 +1,133 @@ +import { Inject, Injectable } from '@angular/core'; + +import { + createFeatureSelector, + createSelector, + Selector, + Store, +} from '@ngrx/store'; +import { Actions } from '@ngrx/effects'; +import { Dictionary } from '@ngrx/entity'; + +import { Observable } from 'rxjs'; +import { filter, shareReplay } from 'rxjs/operators'; + +import { EntityAction } from '../actions/entity-action'; +import { OP_ERROR } from '../actions/entity-op'; +import { ofEntityType } from '../actions/entity-action-operators'; +import { + ENTITY_CACHE_SELECTOR_TOKEN, + EntityCacheSelector, +} from './entity-cache-selector'; +import { EntitySelectors } from './entity-selectors'; +import { EntityCache } from '../reducers/entity-cache'; +import { + EntityCollection, + ChangeStateMap, +} from '../reducers/entity-collection'; +import { EntityCollectionCreator } from '../reducers/entity-collection-creator'; +import { EntitySelectorsFactory } from './entity-selectors'; + +/** + * The selector observable functions for entity collection members. + */ +export interface EntitySelectors$ { + /** Name of the entity collection for these selectors$ */ + readonly entityName: string; + + /** Observable of the collection as a whole */ + readonly collection$: Observable | Store; + + /** Observable of count of entities in the cached collection. */ + readonly count$: Observable | Store; + + /** Observable of all entities in the cached collection. */ + readonly entities$: Observable | Store; + + /** Observable of actions related to this entity type. */ + readonly entityActions$: Observable; + + /** Observable of the map of entity keys to entities */ + readonly entityMap$: Observable> | Store>; + + /** Observable of error actions related to this entity type. */ + readonly errors$: Observable; + + /** Observable of the filter pattern applied by the entity collection's filter function */ + readonly filter$: Observable | Store; + + /** Observable of entities in the cached collection that pass the filter function */ + readonly filteredEntities$: Observable | Store; + + /** Observable of the keys of the cached collection, in the collection's native sort order */ + readonly keys$: Observable | Store; + + /** Observable true when the collection has been loaded */ + readonly loaded$: Observable | Store; + + /** Observable true when a multi-entity query command is in progress. */ + readonly loading$: Observable | Store; + + /** ChangeState (including original values) of entities with unsaved changes */ + readonly changeState$: + | Observable> + | Store>; +} + +/** Creates observable EntitySelectors$ for entity collections. */ +@Injectable() +export class EntitySelectors$Factory { + /** Observable of the EntityCache */ + entityCache$: Observable; + + /** Observable of error EntityActions (e.g. QUERY_ALL_ERROR) for all entity types */ + entityActionErrors$: Observable; + + constructor( + private store: Store, + private actions: Actions, + @Inject(ENTITY_CACHE_SELECTOR_TOKEN) + private selectEntityCache: EntityCacheSelector + ) { + // This service applies to the cache in ngrx/store named `cacheName` + this.entityCache$ = this.store.select(this.selectEntityCache); + this.entityActionErrors$ = actions.pipe( + filter( + (ea: EntityAction) => + ea.payload && + ea.payload.entityOp && + ea.payload.entityOp.endsWith(OP_ERROR) + ), + shareReplay(1) + ); + } + + /** + * Creates an entity collection's selectors$ observables for this factory's store. + * `selectors$` are observable selectors of the cached entity collection. + * @param entityName - is also the name of the collection. + * @param selectors - selector functions for this collection. + **/ + create = EntitySelectors$>( + entityName: string, + selectors: EntitySelectors + ): S$ { + const selectors$: { [prop: string]: any } = { + entityName, + }; + + Object.keys(selectors).forEach(name => { + if (name.startsWith('select')) { + // strip 'select' prefix from the selector fn name and append `$` + // Ex: 'selectEntities' => 'entities$' + const name$ = name[6].toLowerCase() + name.substr(7) + '$'; + selectors$[name$] = this.store.select((selectors)[name]); + } + }); + selectors$.entityActions$ = this.actions.pipe(ofEntityType(entityName)); + selectors$.errors$ = this.entityActionErrors$.pipe( + ofEntityType(entityName) + ); + return selectors$ as S$; + } +} diff --git a/modules/data/src/selectors/entity-selectors.ts b/modules/data/src/selectors/entity-selectors.ts new file mode 100644 index 0000000000..67bd11652a --- /dev/null +++ b/modules/data/src/selectors/entity-selectors.ts @@ -0,0 +1,302 @@ +import { Inject, Injectable, Optional } from '@angular/core'; + +// Prod build requires `MemoizedSelector even though not used. +import { MemoizedSelector } from '@ngrx/store'; +import { createFeatureSelector, createSelector, Selector } from '@ngrx/store'; +import { Dictionary } from '@ngrx/entity'; + +import { Observable } from 'rxjs'; + +import { EntityCache } from '../reducers/entity-cache'; +import { + ENTITY_CACHE_SELECTOR_TOKEN, + EntityCacheSelector, + createEntityCacheSelector, +} from './entity-cache-selector'; +import { ENTITY_CACHE_NAME } from '../reducers/constants'; +import { + EntityCollection, + ChangeStateMap, +} from '../reducers/entity-collection'; +import { EntityCollectionCreator } from '../reducers/entity-collection-creator'; +import { EntityFilterFn } from '../entity-metadata/entity-filters'; +import { EntityMetadata } from '../entity-metadata/entity-metadata'; + +/** + * The selector functions for entity collection members, + * Selects from the entity collection to the collection member + * Contrast with {EntitySelectors}. + */ +export interface CollectionSelectors { + readonly [selector: string]: any; + + /** Count of entities in the cached collection. */ + readonly selectCount: Selector, number>; + + /** All entities in the cached collection. */ + readonly selectEntities: Selector, T[]>; + + /** Map of entity keys to entities */ + readonly selectEntityMap: Selector, Dictionary>; + + /** Filter pattern applied by the entity collection's filter function */ + readonly selectFilter: Selector, string>; + + /** Entities in the cached collection that pass the filter function */ + readonly selectFilteredEntities: Selector, T[]>; + + /** Keys of the cached collection, in the collection's native sort order */ + readonly selectKeys: Selector, string[] | number[]>; + + /** True when the collection has been fully loaded. */ + readonly selectLoaded: Selector, boolean>; + + /** True when a multi-entity query command is in progress. */ + readonly selectLoading: Selector, boolean>; + + /** ChangeState (including original values) of entities with unsaved changes */ + readonly selectChangeState: Selector, ChangeStateMap>; +} + +/** + * The selector functions for entity collection members, + * Selects from store root, through EntityCache, to the entity collection member + * Contrast with {CollectionSelectors}. + */ +export interface EntitySelectors { + /** Name of the entity collection for these selectors */ + readonly entityName: string; + + readonly [name: string]: MemoizedSelector, any> | string; + + /** The cached EntityCollection itself */ + readonly selectCollection: MemoizedSelector>; + + /** Count of entities in the cached collection. */ + readonly selectCount: MemoizedSelector; + + /** All entities in the cached collection. */ + readonly selectEntities: MemoizedSelector; + + /** The EntityCache */ + readonly selectEntityCache: MemoizedSelector; + + /** Map of entity keys to entities */ + readonly selectEntityMap: MemoizedSelector>; + + /** Filter pattern applied by the entity collection's filter function */ + readonly selectFilter: MemoizedSelector; + + /** Entities in the cached collection that pass the filter function */ + readonly selectFilteredEntities: MemoizedSelector; + + /** Keys of the cached collection, in the collection's native sort order */ + readonly selectKeys: MemoizedSelector; + + /** True when the collection has been fully loaded. */ + readonly selectLoaded: MemoizedSelector; + + /** True when a multi-entity query command is in progress. */ + readonly selectLoading: MemoizedSelector; + + /** ChangeState (including original values) of entities with unsaved changes */ + readonly selectChangeState: MemoizedSelector>; +} + +/** Creates EntitySelector functions for entity collections. */ +@Injectable() +export class EntitySelectorsFactory { + private entityCollectionCreator: EntityCollectionCreator; + private selectEntityCache: EntityCacheSelector; + + constructor( + @Optional() entityCollectionCreator?: EntityCollectionCreator, + @Optional() + @Inject(ENTITY_CACHE_SELECTOR_TOKEN) + selectEntityCache?: EntityCacheSelector + ) { + this.entityCollectionCreator = + entityCollectionCreator || new EntityCollectionCreator(); + this.selectEntityCache = + selectEntityCache || createEntityCacheSelector(ENTITY_CACHE_NAME); + } + + /** + * Create the NgRx selector from the store root to the named collection, + * e.g. from Object to Heroes. + * @param entityName the name of the collection + */ + createCollectionSelector< + T = any, + C extends EntityCollection = EntityCollection + >(entityName: string) { + const getCollection = (cache: EntityCache = {}) => + ( + (cache[entityName] || + this.entityCollectionCreator.create(entityName)) + ); + return createSelector(this.selectEntityCache, getCollection); + } + + /////// createCollectionSelectors ////////// + + // Based on @ngrx/entity/state_selectors.ts + + // tslint:disable:unified-signatures + // createCollectionSelectors(metadata) overload + /** + * Creates entity collection selectors from metadata. + * @param metadata - EntityMetadata for the collection. + * May be partial but much have `entityName`. + */ + createCollectionSelectors< + T, + S extends CollectionSelectors = CollectionSelectors + >(metadata: EntityMetadata): S; + + // tslint:disable:unified-signatures + // createCollectionSelectors(entityName) overload + /** + * Creates default entity collection selectors for an entity type. + * Use the metadata overload for additional collection selectors. + * @param entityName - name of the entity type + */ + createCollectionSelectors< + T, + S extends CollectionSelectors = CollectionSelectors + >(entityName: string): S; + + // createCollectionSelectors implementation + createCollectionSelectors< + T, + S extends CollectionSelectors = CollectionSelectors + >(metadataOrName: EntityMetadata | string): S { + const metadata = + typeof metadataOrName === 'string' + ? { entityName: metadataOrName } + : metadataOrName; + const selectKeys = (c: EntityCollection) => c.ids; + const selectEntityMap = (c: EntityCollection) => c.entities; + + const selectEntities: Selector, T[]> = createSelector( + selectKeys, + selectEntityMap, + (keys: (number | string)[], entities: Dictionary): T[] => + keys.map(key => entities[key] as T) + ); + + const selectCount: Selector, number> = createSelector( + selectKeys, + keys => keys.length + ); + + // EntityCollection selectors that go beyond the ngrx/entity/EntityState selectors + const selectFilter = (c: EntityCollection) => c.filter; + + const filterFn = metadata.filterFn; + const selectFilteredEntities: Selector, T[]> = filterFn + ? createSelector( + selectEntities, + selectFilter, + (entities: T[], pattern: any): T[] => filterFn(entities, pattern) + ) + : selectEntities; + + const selectLoaded = (c: EntityCollection) => c.loaded; + const selectLoading = (c: EntityCollection) => c.loading; + const selectChangeState = (c: EntityCollection) => c.changeState; + + // Create collection selectors for each `additionalCollectionState` property. + // These all extend from `selectCollection` + const extra = metadata.additionalCollectionState || {}; + const extraSelectors: { + [name: string]: Selector, any>; + } = {}; + Object.keys(extra).forEach(k => { + extraSelectors['select' + k[0].toUpperCase() + k.slice(1)] = ( + c: EntityCollection + ) => (c)[k]; + }); + + return { + selectCount, + selectEntities, + selectEntityMap, + selectFilter, + selectFilteredEntities, + selectKeys, + selectLoaded, + selectLoading, + selectChangeState, + ...extraSelectors, + } as S; + } + + /////// create ////////// + + // create(metadata) overload + /** + * Creates the store-rooted selectors for an entity collection. + * {EntitySelectors$Factory} turns them into selectors$. + * + * @param metadata - EntityMetadata for the collection. + * May be partial but much have `entityName`. + * + * Based on ngrx/entity/state_selectors.ts + * Differs in that these selectors select from the NgRx store root, + * through the collection, to the collection members. + */ + create = EntitySelectors>( + metadata: EntityMetadata + ): S; + + // create(entityName) overload + /** + * Creates the default store-rooted selectors for an entity collection. + * {EntitySelectors$Factory} turns them into selectors$. + * Use the metadata overload for additional collection selectors. + * + * @param entityName - name of the entity type. + * + * Based on ngrx/entity/state_selectors.ts + * Differs in that these selectors select from the NgRx store root, + * through the collection, to the collection members. + */ + create = EntitySelectors>( + // tslint:disable-next-line:unified-signatures + entityName: string + ): S; + + // createCollectionSelectors implementation + create = EntitySelectors>( + metadataOrName: EntityMetadata | string + ): S { + const metadata = + typeof metadataOrName === 'string' + ? { entityName: metadataOrName } + : metadataOrName; + const entityName = metadata.entityName; + const selectCollection: Selector< + Object, + EntityCollection + > = this.createCollectionSelector(entityName); + const collectionSelectors = this.createCollectionSelectors(metadata); + + const entitySelectors: { + [name: string]: Selector, any>; + } = {}; + Object.keys(collectionSelectors).forEach(k => { + entitySelectors[k] = createSelector( + selectCollection, + collectionSelectors[k] + ); + }); + + return { + entityName, + selectCollection, + selectEntityCache: this.selectEntityCache, + ...entitySelectors, + } as S; + } +} diff --git a/modules/data/src/utils/correlation-id-generator.ts b/modules/data/src/utils/correlation-id-generator.ts new file mode 100644 index 0000000000..16d7b96d12 --- /dev/null +++ b/modules/data/src/utils/correlation-id-generator.ts @@ -0,0 +1,23 @@ +import { Injectable } from '@angular/core'; + +/** + * Generates a string id beginning 'CRID', + * followed by a monotonically increasing integer for use as a correlation id. + * As they are produced locally by a singleton service, + * these ids are guaranteed to be unique only + * for the duration of a single client browser instance. + * Ngrx entity dispatcher query and save methods call this service to generate default correlation ids. + * Do NOT use for entity keys. + */ +@Injectable() +export class CorrelationIdGenerator { + /** Seed for the ids */ + protected seed = 0; + /** Prefix of the id, 'CRID; */ + protected prefix = 'CRID'; + /** Return the next correlation id */ + next() { + this.seed += 1; + return this.prefix + this.seed; + } +} diff --git a/modules/data/src/utils/default-logger.ts b/modules/data/src/utils/default-logger.ts new file mode 100644 index 0000000000..be1579eb53 --- /dev/null +++ b/modules/data/src/utils/default-logger.ts @@ -0,0 +1,23 @@ +import { Injectable } from '@angular/core'; +import { Logger } from './interfaces'; + +@Injectable() +export class DefaultLogger implements Logger { + error(message?: any, extra?: any) { + if (message) { + extra ? console.error(message, extra) : console.error(message); + } + } + + log(message?: any, extra?: any) { + if (message) { + extra ? console.log(message, extra) : console.log(message); + } + } + + warn(message?: any, extra?: any) { + if (message) { + extra ? console.warn(message, extra) : console.warn(message); + } + } +} diff --git a/modules/data/src/utils/default-pluralizer.ts b/modules/data/src/utils/default-pluralizer.ts new file mode 100644 index 0000000000..94b24ca438 --- /dev/null +++ b/modules/data/src/utils/default-pluralizer.ts @@ -0,0 +1,65 @@ +import { Inject, Injectable, Optional } from '@angular/core'; +import { EntityPluralNames, PLURAL_NAMES_TOKEN } from './interfaces'; + +const uncountable = [ + // 'sheep', + // 'fish', + // 'deer', + // 'moose', + // 'rice', + // 'species', + 'equipment', + 'information', + 'money', + 'series', +]; + +@Injectable() +export class DefaultPluralizer { + pluralNames: EntityPluralNames = {}; + + constructor( + @Optional() + @Inject(PLURAL_NAMES_TOKEN) + pluralNames: EntityPluralNames[] + ) { + // merge each plural names object + if (pluralNames) { + pluralNames.forEach(pn => this.registerPluralNames(pn)); + } + } + + /** + * Pluralize a singular name using common English language pluralization rules + * Examples: "company" -> "companies", "employee" -> "employees", "tax" -> "taxes" + */ + pluralize(name: string) { + const plural = this.pluralNames[name]; + if (plural) { + return plural; + } + // singular and plural are the same + if (uncountable.indexOf(name.toLowerCase()) >= 0) { + return name; + // vowel + y + } else if (/[aeiou]y$/.test(name)) { + return name + 's'; + // consonant + y + } else if (name.endsWith('y')) { + return name.substr(0, name.length - 1) + 'ies'; + // endings typically pluralized with 'es' + } else if (/[s|ss|sh|ch|x|z]$/.test(name)) { + return name + 'es'; + } else { + return name + 's'; + } + } + + /** + * Register a mapping of entity type name to the entity name's plural + * @param pluralNames {EntityPluralNames} plural names for entity types + */ + registerPluralNames(pluralNames: EntityPluralNames): void { + this.pluralNames = { ...this.pluralNames, ...(pluralNames || {}) }; + } +} diff --git a/modules/data/src/utils/guid-fns.ts b/modules/data/src/utils/guid-fns.ts new file mode 100644 index 0000000000..2ae6898672 --- /dev/null +++ b/modules/data/src/utils/guid-fns.ts @@ -0,0 +1,77 @@ +/* +Client-side id-generators + +These GUID utility functions are not used by ngrx-data itself at this time. +They are included as candidates for generating persistable correlation ids if that becomes desirable. +They are also safe for generating unique entity ids on the client. + +Note they produce 32-character hexadecimal UUID strings, +not the 128-bit representation found in server-side languages and databases. + +These utilities are experimental and may be withdrawn or replaced in future. +*/ + +/** + * Creates a Universally Unique Identifier (AKA GUID) + */ +export function getUuid() { + // The original implementation is based on this SO answer: + // http://stackoverflow.com/a/2117523/200253 + return 'xxxxxxxxxx4xxyxxxxxxxxxxxxxx'.replace(/[xy]/g, function(c) { + // tslint:disable-next-line:no-bitwise + const r = (Math.random() * 16) | 0, + // tslint:disable-next-line:no-bitwise + v = c === 'x' ? r : (r & 0x3) | 0x8; + return v.toString(16); + }); +} + +/** Alias for getUuid(). Compare with getGuidComb(). */ +export function getGuid() { + return getUuid(); +} + +/** + * Creates a sortable, pseudo-GUID (globally unique identifier) + * whose trailing 6 bytes (12 hex digits) are time-based + * Start either with the given getTime() value, seedTime, + * or get the current time in ms. + * + * @param seed {number} - optional seed for reproducible time-part + */ +export function getGuidComb(seed?: number) { + // Each new Guid is greater than next if more than 1ms passes + // See http://thatextramile.be/blog/2009/05/using-the-guidcomb-identifier-strategy + // Based on breeze.core.getUuid which is based on this StackOverflow answer + // http://stackoverflow.com/a/2117523/200253 + // + // Convert time value to hex: n.toString(16) + // Make sure it is 6 bytes long: ('00'+ ...).slice(-12) ... from the rear + // Replace LAST 6 bytes (12 hex digits) of regular Guid (that's where they sort in a Db) + // + // Play with this in jsFiddle: http://jsfiddle.net/wardbell/qS8aN/ + const timePart = ('00' + (seed || new Date().getTime()).toString(16)).slice( + -12 + ); + return ( + 'xxxxxxxxxx4xxyxxx'.replace(/[xy]/g, function(c) { + // tslint:disable:no-bitwise + const r = (Math.random() * 16) | 0, + v = c === 'x' ? r : (r & 0x3) | 0x8; + return v.toString(16); + }) + timePart + ); +} + +// Sort comparison value that's good enough +export function guidComparer(l: string, r: string) { + const l_low = l.slice(-12); + const r_low = r.slice(-12); + return l_low !== r_low + ? l_low < r_low + ? -1 + : +(l_low !== r_low) + : l < r + ? -1 + : +(l !== r); +} diff --git a/modules/data/src/utils/interfaces.ts b/modules/data/src/utils/interfaces.ts new file mode 100644 index 0000000000..aad00107e3 --- /dev/null +++ b/modules/data/src/utils/interfaces.ts @@ -0,0 +1,22 @@ +import { InjectionToken } from '@angular/core'; + +export abstract class Logger { + abstract error(message?: any, ...optionalParams: any[]): void; + abstract log(message?: any, ...optionalParams: any[]): void; + abstract warn(message?: any, ...optionalParams: any[]): void; +} + +/** + * Mapping of entity type name to its plural + */ +export interface EntityPluralNames { + [entityName: string]: string; +} + +export const PLURAL_NAMES_TOKEN = new InjectionToken( + '@ngrx/data/plural-names' +); + +export abstract class Pluralizer { + abstract pluralize(name: string): string; +} diff --git a/modules/data/src/utils/utilities.ts b/modules/data/src/utils/utilities.ts new file mode 100644 index 0000000000..06eb0c97f0 --- /dev/null +++ b/modules/data/src/utils/utilities.ts @@ -0,0 +1,55 @@ +import { IdSelector, Update } from '@ngrx/entity'; + +/** + * Default function that returns the entity's primary key (pkey). + * Assumes that the entity has an `id` pkey property. + * Returns `undefined` if no entity or `id`. + * Every selectId fn must return `undefined` when it cannot produce a full pkey. + */ +export function defaultSelectId(entity: any) { + return entity == null ? undefined : entity.id; +} + +/** + * Flatten first arg if it is an array + * Allows fn with ...rest signature to be called with an array instead of spread + * Example: + * ``` + * // See entity-action-operators.ts + * const persistOps = [EntityOp.QUERY_ALL, EntityOp.ADD, ...]; + * actions.pipe(ofEntityOp(...persistOps)) // works + * actions.pipe(ofEntityOp(persistOps)) // also works + * ``` + * */ +export function flattenArgs(args?: any[]): T[] { + if (args == null) { + return []; + } + if (Array.isArray(args[0])) { + const [head, ...tail] = args; + args = [...head, ...tail]; + } + return args; +} + +/** + * Return a function that converts an entity (or partial entity) into the `Update` + * whose `id` is the primary key and + * `changes` is the entity (or partial entity of changes). + */ +export function toUpdateFactory(selectId?: IdSelector) { + selectId = selectId || (defaultSelectId as IdSelector); + /** + * Convert an entity (or partial entity) into the `Update` + * whose `id` is the primary key and + * `changes` is the entity (or partial entity of changes). + * @param selectId function that returns the entity's primary key (id) + */ + return function toUpdate(entity: Partial): Update { + const id: any = selectId!(entity as T); + if (id == null) { + throw new Error('Primary key may not be null/undefined.'); + } + return entity && { id, changes: entity }; + }; +} diff --git a/modules/data/tsconfig-build.json b/modules/data/tsconfig-build.json new file mode 100644 index 0000000000..adb4f6ccf3 --- /dev/null +++ b/modules/data/tsconfig-build.json @@ -0,0 +1,35 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "declaration": true, + "stripInternal": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "strictPropertyInitialization": false, + "module": "es2015", + "moduleResolution": "node", + "noEmitOnError": false, + "noImplicitAny": true, + "noImplicitReturns": true, + "outDir": "../../dist/packages/data", + "paths": { + "@ngrx/store": ["../../dist/packages/store"], + "@ngrx/effects": ["../../dist/packages/effects"], + "@ngrx/entity": ["../../dist/packages/entity"] + }, + "rootDir": ".", + "sourceMap": true, + "inlineSources": true, + "lib": ["es2015", "dom"], + "target": "es2015", + "skipLibCheck": true + }, + "files": ["public_api.ts"], + "angularCompilerOptions": { + "annotateForClosureCompiler": true, + // Work around for issue: https://github.com/angular/angular/issues/22210 + "strictMetadataEmit": false, + "flatModuleOutFile": "index.js", + "flatModuleId": "@ngrx/data" + } +} diff --git a/modules/entity/src/index.ts b/modules/entity/src/index.ts index 4f819dd8d3..f6d374d424 100644 --- a/modules/entity/src/index.ts +++ b/modules/entity/src/index.ts @@ -6,4 +6,6 @@ export { Update, EntityMap, Predicate, + IdSelector, + Comparer, } from './models'; diff --git a/tools/defaults.bzl b/tools/defaults.bzl index aaa16f3cae..c7a5277c1d 100644 --- a/tools/defaults.bzl +++ b/tools/defaults.bzl @@ -16,6 +16,7 @@ NGRX_SCOPED_PACKAGES = ["@ngrx/%s" % p for p in [ "effects", "entity", "router-store", + "data", "schematics", "store-devtools", ]]