From 08c4656a8a3d9186156692e071790bbbf7f5a0ff Mon Sep 17 00:00:00 2001 From: xbaun Date: Tue, 9 Jun 2020 22:40:38 +0200 Subject: [PATCH] fix(entity-store): improve type safety of entity store upsert operations (#439) Co-authored-by: xbaun Co-authored-by: Xaver Baun --- BREAKING_CHANGES.md | 33 +++ .../src/app/cart/state/cart.service.ts | 19 +- .../app/products/state/products.service.ts | 4 +- docs/docs/additional/events.mdx | 69 ++--- docs/docs/entities/entity-store.mdx | 19 +- .../src/lib/ng-entity.service.ts | 42 +-- libs/akita/src/__tests__/classBased.spec.ts | 6 +- .../src/__tests__/runStoreAction.spec.ts | 42 +-- libs/akita/src/__tests__/upsert.spec.ts | 76 +++-- libs/akita/src/lib/entityStore.ts | 124 +++++--- libs/akita/src/lib/index.ts | 2 +- libs/akita/src/lib/runStoreAction.ts | 266 ++++++++---------- libs/akita/src/lib/types.ts | 4 +- 13 files changed, 385 insertions(+), 321 deletions(-) diff --git a/BREAKING_CHANGES.md b/BREAKING_CHANGES.md index 495bb7c7..d9e44b0b 100644 --- a/BREAKING_CHANGES.md +++ b/BREAKING_CHANGES.md @@ -5,6 +5,39 @@ - Updated `QueryEntity` typing of `select` / `get` methods to respect `undefined` entity values. - Remove deprecated array utils functions. - Remove deprecated `exclude` option from persist state. Use `include` with a callback option. +- The `upsert` operator of EntityStore now requires an explicit entity initialization callback if the entity is not + existing to guarantee a type safe entity store after execution. + +```ts +// Before +store.upsert([2, 3], (entity) => ({ isOpen: !entity.isOpen })); +``` + +```ts +// After +store.upsert( + [2, 3], + (entity) => ({ isOpen: !(entity?.isOpen ?? true) }), + (id, newState) => ({ id, ...newState, enabled: true }) +); +``` + +- The `runStoreAction` is rewritten as well to support type safe entity operations: + +```ts +// Before +runStoreAction('books', StoreActions.UpsertEntities, { + payload: { + data: { title: 'New Title' }, + entityIds: [2, 3], + }, +}); +``` + +```ts +// After +runEntityStoreAction(BooksStore, EntityStoreAction.UpsertEntities, (upsert) => upsert([2, 3], { title: 'New Title' }, (id, newState) => ({ id, ...newState, price: 0 }))); +``` ## 4.0.0 diff --git a/apps/angular-ecommerce/src/app/cart/state/cart.service.ts b/apps/angular-ecommerce/src/app/cart/state/cart.service.ts index e101dd1e..81812c3d 100644 --- a/apps/angular-ecommerce/src/app/cart/state/cart.service.ts +++ b/apps/angular-ecommerce/src/app/cart/state/cart.service.ts @@ -8,12 +8,19 @@ export class CartService { constructor(private cartStore: CartStore) {} add(product: Product, quantity: number) { - this.cartStore.upsert(product.id, { - title: product.title, - price: product.additionalData.price, - total: product.additionalData.price * quantity, - quantity - }); + this.cartStore.upsert( + product.id, + { + title: product.title, + price: product.additionalData.price, + total: product.additionalData.price * quantity, + quantity, + }, + (id, newState) => ({ + productId: id, + ...newState, + }) + ); } remove(productId: ID) { diff --git a/apps/angular-ecommerce/src/app/products/state/products.service.ts b/apps/angular-ecommerce/src/app/products/state/products.service.ts index 02bc8f2f..24178a39 100644 --- a/apps/angular-ecommerce/src/app/products/state/products.service.ts +++ b/apps/angular-ecommerce/src/app/products/state/products.service.ts @@ -13,11 +13,11 @@ export class ProductsService { getAll(term: string, filters) { return this.http .get(`${API}/products`, { params: { term, ...filters } }) - .pipe(tap(products => this.productsStore.set(products))); + .pipe(tap((products) => this.productsStore.set(products))); } getProduct(id: ID) { - return this.http.get(`${API}/product/${id}`).pipe(tap(product => this.productsStore.upsert(id, product))); + return this.http.get(`${API}/product/${id}`).pipe(tap((product) => this.productsStore.upsert(id, product, (id, product) => ({ id, ...product })))); } updateFilters(filters) { diff --git a/docs/docs/additional/events.mdx b/docs/docs/additional/events.mdx index 0345a796..ccbc6393 100644 --- a/docs/docs/additional/events.mdx +++ b/docs/docs/additional/events.mdx @@ -7,49 +7,30 @@ One of the recurring requests we got, was to simplify and improve the experience To make it easier for you, we’ve added a new API method — `runStoreAction`: ```ts -import { runStoreAction, StoreActions } from '@datorama/akita'; - -runStoreAction('books', StoreActions.Update, { - payload: { - data: { filter: 'New filter' } - } -}); - -runStoreAction('books', StoreActions.UpdateEntities, { - payload: { - data: { title: 'New title' }, - entityIds: 2 - } -}); - -runStoreAction('books', StoreActions.SetEntities, { - payload: { - data: [{ id: 1 }, { id: 2 }] - } -}); - -runStoreAction('books', StoreActions.AddEntities, { - payload: { - data: { id: 1 } - } -}); - -runStoreAction('books', StoreActions.UpsertEntities, { - payload: { - data: { title: 'Another title' }, - entityIds: [2, 3] - } -}); - -runStoreAction('books', StoreActions.UpsertEntities, { - payload: { - data: [ - { id: 3, title: 'Updated title' }, - { id: 4, title: 'Original title' } - ] - } -}); -``` +import { runStoreAction, StoreActions, runEntityStoreAction, EntityStoreActions } from '@datorama/akita'; + +runStoreAction(BooksStore, StoreAction.Update, update => update({ filter: 'COMPLETE' })); + +runEntityStoreAction(BooksStore, EntityStoreAction.SetEntities, set => set([ + { id: 1 }, + { id: 2 } +])); + +runEntityStoreAction(BooksStore, EntityStoreAction.AddEntities, add => add({ id: 1 })); -The `runStoreAction()` takes the store’s name, an action and a payload, and updates the store based on that information. So now, for example, you can get these parameters from your socket connection and update any store you want. +runEntityStoreAction(BooksStore, EntityStoreAction.UpdateEntities, update => update(2, { title: 'New title' })); + +runEntityStoreAction(BooksStore, EntityStoreAction.RemoveEntities, remove => remove(2)); + +runEntityStoreAction(BooksStore, EntityStoreAction.UpsertEntities, upsert => upsert([2, 3], + { title: 'New Title' }, (id, newState) => ({ id, ...newState, price: 0 }))); + +runEntityStoreAction(BooksStore, EntityStoreAction.UpsertManyEntities, upsertMany => upsertMany([ + { id: 2, title: 'New title', price: 0 }, + { id: 4, title: 'Another title', price: 0 }, +)); +``` +The `runStoreAction()` and `runEntityStoreAction()` takes the store’s class, the store action to perform and an operation +callback. The first argument of the operation callback is the store operator specified by the action. You can determine +these parameters from your socket connection and update any store you want. diff --git a/docs/docs/entities/entity-store.mdx b/docs/docs/entities/entity-store.mdx index f8d3f727..0a78c1c6 100644 --- a/docs/docs/entities/entity-store.mdx +++ b/docs/docs/entities/entity-store.mdx @@ -2,7 +2,7 @@ title: Entity Store --- -For the most part, the stores you'll require in your applications will be entity stores. You can think of an entity store as a table in a database, where each table represents a flat collection of entities. +For the most part, the stores you'll require in your applications will be entity stores. You can think of an entity store as a table in a database, where each table represents a flat collection of entities. Akita's `EntityStore` simplifies the process, giving you everything you need to manage it. Let's see how we can use it to create a `todos` table, i.e., an `EntityStore` managing a `Todo` object: @@ -15,7 +15,7 @@ export interface TodosState extends EntityState { } @StoreConfig({ name: 'todos' }) export class TodosStore extends EntityStore { - constructor() { + constructor() { super() ; } } @@ -112,7 +112,7 @@ Add an entity or entities to the store: ```ts // Add multiple store.add([Entity, Entity]); -// Add one +// Add one store.add(Entity); // Prepend @@ -170,9 +170,18 @@ store.remove(); Insert or update an entity. Creates a new entity when no entity matches the `id`; otherwise, it performs an update: ```ts -store.upsert(id, { isOpen: true }); +store.upsert(id, { isOpen: true }, (id, newState) => ({ id, ...newState }); +store.upsert(id, (oldState) => ({ isOpen: !(oldState?.isOpen ?? true) }), (id, newState) => ({ id, ...newState }); ``` +The first argument is the entity to be inserted or updated, the second contains the new entity state, and the last +argument is the callback for creating a new entity if it does not exist. + +:::warning +The initializing callback parameter can be omitted, but then type safety of entities cannot be guaranteed. Use +this if all state properties are declared optional or if manually type check for `undefined`. +::: + ### `upsertMany()` Insert or update multiple entities. Creates a new entity when no entity matches the `id`; otherwise, it performs an update: @@ -207,4 +216,4 @@ Update the store's `error` state: ```ts store.setError(error); -``` \ No newline at end of file +``` diff --git a/libs/akita-ng-entity-service/src/lib/ng-entity.service.ts b/libs/akita-ng-entity-service/src/lib/ng-entity.service.ts index e8017d7c..ac3d7e1f 100644 --- a/libs/akita-ng-entity-service/src/lib/ng-entity.service.ts +++ b/libs/akita-ng-entity-service/src/lib/ng-entity.service.ts @@ -10,7 +10,7 @@ import { defaultConfig, mergeDeep, NG_ENTITY_SERVICE_CONFIG, NgEntityServiceGlob import { isID } from './helpers'; import { errorAction, successAction } from './action-factory'; -export const mapResponse = (config?: HttpConfig) => map(res => (config && !!config.mapResponseFn ? config.mapResponseFn(res) : res)); +export const mapResponse = (config?: HttpConfig) => map((res) => (config && !!config.mapResponseFn ? config.mapResponseFn(res) : res)); export class NgEntityService extends EntityService { baseUrl: string | undefined; @@ -97,7 +97,7 @@ export class NgEntityService extends EntityService< method, loading: true, entityId, - storeName: this.store.storeName + storeName: this.store.storeName, }); return this.http.request(method, url, conf).pipe( @@ -105,7 +105,7 @@ export class NgEntityService extends EntityService< tap((data: any) => { if (!conf.skipWrite) { if (isSingle) { - this.store.upsert(entityId, data); + this.store.upsert(entityId, data, (id, newState) => ({ id, ...newState })); } else { if (conf.append) { this.store.add(data); @@ -120,16 +120,16 @@ export class NgEntityService extends EntityService< this.dispatchSuccess({ method, payload: data, - successMsg: conf.successMsg + successMsg: conf.successMsg, }); }), - catchError(error => this.handleError(method, error, conf.errorMsg)), + catchError((error) => this.handleError(method, error, conf.errorMsg)), finalize(() => { this.loader.dispatch({ method, loading: false, entityId, - storeName: this.store.storeName + storeName: this.store.storeName, }); }) ); @@ -149,7 +149,7 @@ export class NgEntityService extends EntityService< this.loader.dispatch({ method, loading: true, - storeName: this.store.storeName + storeName: this.store.storeName, }); const configWithBody = { ...config, ...{ body: entity } }; @@ -163,15 +163,15 @@ export class NgEntityService extends EntityService< this.dispatchSuccess({ method, payload: responseEntity, - successMsg: config && config.successMsg + successMsg: config && config.successMsg, }); }), - catchError(error => this.handleError(method, error, config && config.errorMsg)), + catchError((error) => this.handleError(method, error, config && config.errorMsg)), finalize(() => { this.loader.dispatch({ method, loading: false, - storeName: this.store.storeName + storeName: this.store.storeName, }); }) ); @@ -192,30 +192,30 @@ export class NgEntityService extends EntityService< method, loading: true, entityId: id, - storeName: this.store.storeName + storeName: this.store.storeName, }); const configWithBody = { ...config, ...{ body: entity } }; return this.http.request(method, url, configWithBody).pipe( mapResponse(config), - tap(responseEntity => { + tap((responseEntity) => { if (!config || (config && !config.skipWrite)) { this.store.update(id, responseEntity as any); } this.dispatchSuccess({ method, payload: responseEntity, - successMsg: config && config.successMsg + successMsg: config && config.successMsg, }); }), - catchError(error => this.handleError(method, error, config && config.errorMsg)), + catchError((error) => this.handleError(method, error, config && config.errorMsg)), finalize(() => { this.loader.dispatch({ method, loading: false, entityId: id, - storeName: this.store.storeName + storeName: this.store.storeName, }); }) ) as Observable; @@ -236,28 +236,28 @@ export class NgEntityService extends EntityService< method, loading: true, entityId: id, - storeName: this.store.storeName + storeName: this.store.storeName, }); return this.http.request(method, url, config).pipe( mapResponse(config), - tap(res => { + tap((res) => { if (!config || (config && !config.skipWrite)) { this.store.remove(id); } this.dispatchSuccess({ method, payload: res, - successMsg: config && config.successMsg + successMsg: config && config.successMsg, }); }), - catchError(error => this.handleError(method, error, config && config.errorMsg)), + catchError((error) => this.handleError(method, error, config && config.errorMsg)), finalize(() => { this.loader.dispatch({ method, loading: false, entityId: id, - storeName: this.store.storeName + storeName: this.store.storeName, }); }) ) as Observable; @@ -338,7 +338,7 @@ export class NgEntityService extends EntityService< this.dispatchError({ method, errorMsg, - payload: error + payload: error, }); return throwError(error); diff --git a/libs/akita/src/__tests__/classBased.spec.ts b/libs/akita/src/__tests__/classBased.spec.ts index 4ce93362..911a8439 100644 --- a/libs/akita/src/__tests__/classBased.spec.ts +++ b/libs/akita/src/__tests__/classBased.spec.ts @@ -18,13 +18,13 @@ class TodosStore extends EntityStore {} const store = new TodosStore(); describe('Class Based', () => { - it('should instantiate new Todo if not exists', function() { - store.upsert(1, { title: 'new title' }, { baseClass: Todo }); + it('should instantiate new Todo if not exists', function () { + store.upsert(1, { title: 'new title' }, (id, newState) => ({ id, ...newState, completed: false }), { baseClass: Todo }); expect(store._value().entities[1]).toBeInstanceOf(Todo); expect(store._value().entities[1].title).toBe('new title'); expect(store._value().entities[1].completed).toBe(false); expect(store._value().entities[1].id).toBe(1); - store.upsert(1, { title: 'new title2' }, { baseClass: Todo }); + store.upsert(1, { title: 'new title2' }, (id, newState) => ({ id, ...newState, completed: false }), { baseClass: Todo }); expect(store._value().entities[1]).toBeInstanceOf(Todo); expect(store._value().entities[1].title).toBe('new title2'); expect(store._value().entities[1].completed).toBe(false); diff --git a/libs/akita/src/__tests__/runStoreAction.spec.ts b/libs/akita/src/__tests__/runStoreAction.spec.ts index ddfc68c1..6bff37cc 100644 --- a/libs/akita/src/__tests__/runStoreAction.spec.ts +++ b/libs/akita/src/__tests__/runStoreAction.spec.ts @@ -1,50 +1,38 @@ import { BooksStore, TestBook, TestBooksState } from './booksStore'; -import { runStoreAction, StoreActions } from '../lib/runStoreAction'; +import { EntityStoreAction, getEntityStore, runEntityStoreAction, runStoreAction, StoreAction } from '../lib/runStoreAction'; import { createMockEntities } from './mocks'; describe('runStoreAction', () => { it('should run store actions', () => { const store = new BooksStore(); - runStoreAction('books', StoreActions.SetEntities, { payload: { data: createMockEntities() } }); + runEntityStoreAction(BooksStore, EntityStoreAction.SetEntities, (set) => set(createMockEntities())); expect(store._value().ids.length).toBe(2); - runStoreAction('books', StoreActions.AddEntities, { payload: { data: createMockEntities(10, 12) } }); + runEntityStoreAction(BooksStore, EntityStoreAction.AddEntities, (add) => add(createMockEntities(10, 12))); expect(store._value().ids.length).toBe(4); - runStoreAction('books', StoreActions.UpdateEntities, { - payload: { - data: { title: 'New title' }, - entityIds: 2 - } - }); + runEntityStoreAction(BooksStore, EntityStoreAction.UpdateEntities, (update) => update(2, { title: 'New title' })); expect(store._value().entities[2].title).toBe('New title'); - runStoreAction('books', StoreActions.UpsertEntities, { - payload: { - data: { title: 'Another title' }, - entityIds: [2, 3] - } - }); - expect(store._value().entities[2].title).toBe('Another title'); - expect(store._value().entities[3].title).toBe('Another title'); + runEntityStoreAction(BooksStore, EntityStoreAction.UpsertEntities, (upsert) => upsert([2, 3], { title: 'Another title 2' }, (id, newState) => ({ id, ...newState, price: 0 }))); + expect(store._value().entities[2].title).toBe('Another title 2'); + expect(store._value().entities[3].title).toBe('Another title 2'); expect(store._value().ids.length).toBe(5); - runStoreAction('books', StoreActions.UpsertEntities, { - payload: { - data: [ - { id: 2, title: 'New title' }, - { id: 4, title: 'Another title' } - ] - } - }); + runEntityStoreAction(BooksStore, EntityStoreAction.UpsertManyEntities, (upsertMany) => + upsertMany([ + { id: 2, title: 'New title', price: 0 }, + { id: 4, title: 'Another title', price: 0 }, + ]) + ); expect(store._value().entities[2].title).toBe('New title'); expect(store._value().ids.length).toBe(6); - runStoreAction('books', StoreActions.RemoveEntities, { payload: { entityIds: 1 } }); + runEntityStoreAction(BooksStore, EntityStoreAction.RemoveEntities, (remove) => remove(1)); expect(store._value().entities[1]).toBeUndefined(); - runStoreAction('books', StoreActions.Update, { payload: { data: { filter: 'COMPLETE' } } }); + runStoreAction(BooksStore, StoreAction.Update, (update) => update({ filter: 'COMPLETE' })); expect(store._value().filter).toBe('COMPLETE'); }); }); diff --git a/libs/akita/src/__tests__/upsert.spec.ts b/libs/akita/src/__tests__/upsert.spec.ts index 84d93127..4910de57 100644 --- a/libs/akita/src/__tests__/upsert.spec.ts +++ b/libs/akita/src/__tests__/upsert.spec.ts @@ -5,6 +5,7 @@ import { EntityStore } from '../lib/entityStore'; interface Article { id?: ID; title: string; + author: string; addedAt?: string; moreInfo?: { description: string; @@ -20,28 +21,52 @@ const store = new ArticlesStore(); describe('upsert', () => { it('should add if not exist - one', () => { - store.upsert(1, { title: 'new title' }); + store.upsert(1, { title: 'new title' }, (id, newState) => ({ ...newState, author: 'new author' })); expect(store._value().entities[1].title).toEqual('new title'); + expect(store._value().entities[1].author).toEqual('new author'); + expect(store._value().entities[1].id).toBe(1); + store.remove(); + }); + + it('should add if not exist - one (empty object passed to update callback)', () => { + let testNewState: Article | {}; + store.upsert( + 1, + (newState) => { + testNewState = newState; + return { + title: 'new title', + }; + }, + (id, newState) => ({ ...newState, author: 'new author' }) + ); + expect(store._value().entities[1].title).toEqual('new title'); + expect(store._value().entities[1].author).toEqual('new author'); + expect(Object.keys(testNewState).length === 0 && Object.getPrototypeOf(testNewState).constructor === Object.prototype.constructor).toBeTruthy(); expect(store._value().entities[1].id).toBe(1); store.remove(); }); it('should add if not exist - many', () => { - store.upsert([2, 3], { title: 'new title' }); + store.upsert([2, 3], { title: 'new title' }, (id, newState) => ({ ...newState, author: 'new author' })); expect(store._value().entities[2].title).toEqual('new title'); + expect(store._value().entities[2].author).toEqual('new author'); expect(store._value().entities[2].id).toBe(2); expect(store._value().entities[3].title).toEqual('new title'); + expect(store._value().entities[3].author).toEqual('new author'); expect(store._value().entities[3].id).toBe(3); expect(store._value().ids.length).toBe(2); store.remove(); }); it('should update if exist', () => { - store.add([{ id: 1, title: '' }]); - store.upsert(1, { title: 'new title' }); + store.add([{ id: 1, title: '', author: '' }]); + store.upsert(1, { title: 'new title' }, (id, newState) => ({ ...newState, author: 'new author2' })); expect(store._value().entities[1].title).toEqual('new title'); - store.upsert(1, { title: 'new title2' }); + expect(store._value().entities[1].author).toEqual(''); + store.upsert(1, { title: 'new title2' }, (id, newState) => ({ ...newState, author: 'new author3' })); expect(store._value().entities[1].title).toEqual('new title2'); + expect(store._value().entities[1].author).toEqual(''); expect(store._value().ids.length).toBe(1); store.remove(); }); @@ -49,18 +74,19 @@ describe('upsert', () => { describe('UpsertMany', () => { it('should support array of entities', () => { const data = [ - { id: 1, title: '1', moreInfo: { description: 'desc1' } }, + { id: 1, title: '1', author: '1', moreInfo: { description: 'desc1' } }, { id: 2, title: '2', - moreInfo: { description: 'desc2' } - } + author: '2', + moreInfo: { description: 'desc2' }, + }, ]; store.set(data); expect(store._value().ids.length).toBe(2); const baseData: Article[] = [ - { id: 1, title: '1' }, - { id: 2, title: '2' } + { id: 1, title: '1', author: '1' }, + { id: 2, title: '2', author: '2' }, ]; store.upsertMany(baseData); @@ -68,49 +94,53 @@ describe('upsert', () => { expect(store._value().entities[1]).toEqual({ id: 1, title: '1', - moreInfo: { description: 'desc1' } + author: '1', + moreInfo: { description: 'desc1' }, }); - store.upsertMany([{ id: 1, title: '12', moreInfo: { description: 'desc1' } }]); + store.upsertMany([{ id: 1, title: '12', author: '12', moreInfo: { description: 'desc1' } }]); expect(store._value().entities[1]).toEqual({ id: 1, title: '12', - moreInfo: { description: 'desc1' } + author: '12', + moreInfo: { description: 'desc1' }, }); store.remove(); }); it('should support hooks', () => { - ArticlesStore.prototype.akitaPreCheckEntity = function(entity: Article) { + ArticlesStore.prototype.akitaPreCheckEntity = function (entity: Article) { return { ...entity, - id: 11 + id: 11, }; }; - ArticlesStore.prototype.akitaPreAddEntity = function(entity: Article) { + ArticlesStore.prototype.akitaPreAddEntity = function (entity: Article) { return { ...entity, - addedAt: '2019-05-04' + addedAt: '2019-05-04', }; }; - ArticlesStore.prototype.akitaPreUpdateEntity = function(pre: Article, next: Article) { + ArticlesStore.prototype.akitaPreUpdateEntity = function (pre: Article, next: Article) { return { ...next, - title: 'BLA!!' + title: 'BLA!!', }; }; - store.upsertMany([{ title: '1' }]); + store.upsertMany([{ title: '1', author: '1' }]); expect(store._value().entities[11]).toEqual({ id: 11, title: '1', - addedAt: '2019-05-04' + author: '1', + addedAt: '2019-05-04', }); - store.upsertMany([{ title: '1', moreInfo: { description: 'bla bla' } }]); + store.upsertMany([{ title: '1', author: '1', moreInfo: { description: 'bla bla' } }]); expect(store._value().entities[11]).toEqual({ id: 11, title: 'BLA!!', + author: '1', addedAt: '2019-05-04', - moreInfo: { description: 'bla bla' } + moreInfo: { description: 'bla bla' }, }); }); }); diff --git a/libs/akita/src/lib/entityStore.ts b/libs/akita/src/lib/entityStore.ts index 83933ed6..3f43d012 100644 --- a/libs/akita/src/lib/entityStore.ts +++ b/libs/akita/src/lib/entityStore.ts @@ -1,7 +1,21 @@ +import { getEntity } from './getEntity'; import { isEmpty } from './isEmpty'; import { SetEntities, setEntities } from './setEntities'; import { Store } from './store'; -import { Constructor, EntityState, EntityUICreateFn, IDS, OrArray, StateWithActive, UpdateEntityPredicate, UpdateStateCallback, getEntityType, getIDType } from './types'; +import { + Constructor, + EntityState, + EntityUICreateFn, + IDS, + OrArray, + StateWithActive, + UpdateEntityPredicate, + UpdateStateCallback, + getEntityType, + getIDType, + CreateStateCallback, + UpsertStateCallback, +} from './types'; import { getActiveEntities, SetActiveOptions } from './getActiveEntities'; import { addEntities, AddEntitiesOptions } from './addEntities'; import { coerceArray } from './coerceArray'; @@ -74,13 +88,13 @@ export class EntityStore { + this._setState((state) => { const newState = setEntities({ state, entities, idKey: this.idKey, preAddEntity: this.akitaPreAddEntity, - isNativePreAdd + isNativePreAdd, }); if (isUndefined(options.activeId) === false) { @@ -120,7 +134,7 @@ export class EntityStore (idsOrFnOrState as UpdateEntityPredicate)(this.entities[id])); + ids = this.ids.filter((id) => (idsOrFnOrState as UpdateEntityPredicate)(this.entities[id])); } else { // If it's nil we want all of them ids = isNil(idsOrFnOrState) ? this.ids : coerceArray(idsOrFnOrState as OrArray); @@ -184,48 +198,76 @@ export class EntityStore + this._setState((state) => updateEntities({ idKey: this.idKey, ids, preUpdateEntity: this.akitaPreUpdateEntity, state, newStateOrFn, - producerFn: this._producerFn + producerFn: this._producerFn, }) ); this.entityActions.next({ type: EntityActions.Update, ids }); } + /** + * + * Create or update. + * + * Warning: By omitting the initializing callback parameter onCreate(), the type safety of entities cannot be guaranteed. + * + * @example + * + * store.upsert(1, { active: true }); + * store.upsert([2, 3], { active: true }); + * store.upsert([2, 3], entity => ({ isOpen: !(entity?.isOpen ?? true) })) + * + */ + upsert>(ids: OrArray, newState: UpsertStateCallback | NewEntityType, options?: { baseClass?: Constructor }): void; /** * * Create or update * * @example * - * store.upsert(1, { active: true }) - * store.upsert([2, 3], { active: true }) - * store.upsert([2, 3], entity => ({ isOpen: !entity.isOpen})) + * store.upsert(1, { active: true }, (id, newState) => ({ id, ...newState, enabled: true })); + * store.upsert([2, 3], { active: true }, (id, newState) => ({ id, ...newState, enabled: true })); + * store.upsert([2, 3], entity => ({ isOpen: !(entity?.isOpen ?? true) }), (id, newState) => ({ id, ...newState, enabled: true })); * */ + upsert>( + ids: OrArray, + newState: UpsertStateCallback | NewEntityType, + onCreate: CreateStateCallback, + options?: { baseClass?: Constructor } + ): void; @transaction() - upsert(ids: OrArray, newState: Partial | EntityType | UpdateStateCallback | EntityType[], options: { baseClass?: Constructor } = {}) { + upsert>( + ids: OrArray, + newState: UpsertStateCallback | NewEntityType, + onCreate?: CreateStateCallback | { baseClass?: Constructor }, + options: { baseClass?: Constructor } = {} + ) { const toArray = coerceArray(ids); - const predicate = isUpdate => id => hasEntity(this.entities, id) === isUpdate; - const isClassBased = isFunction(options.baseClass); + const predicate = (isUpdate) => (id) => hasEntity(this.entities, id) === isUpdate; + const baseClass = isFunction(onCreate) ? options.baseClass : onCreate ? onCreate.baseClass : undefined; + const isClassBased = isFunction(baseClass); + const updateIds = toArray.filter(predicate(true)); - const newEntities = toArray.filter(predicate(false)).map(id => { - let entity = isFunction(newState) ? newState({} as EntityType) : newState; - const withId = { ...(entity as EntityType), [this.idKey]: id }; + const newEntities = toArray.filter(predicate(false)).map((id) => { + const newStateObj = typeof newState === 'function' ? newState({}) : newState; + const entity = isFunction(onCreate) ? onCreate(id, newStateObj) : newStateObj; + const withId = { ...entity, [this.idKey]: id }; if (isClassBased) { - return new options.baseClass(withId); + return new baseClass(withId); } return withId; }); // it can be any of the three types - this.update(updateIds as any, newState as any); + this.update(updateIds, newState as UpdateStateCallback); this.add(newEntities); isDev() && logAction('Upsert Entity'); } @@ -270,14 +312,14 @@ export class EntityStore ({ + this._setState((state) => ({ ...state, ids: addedIds.length ? [...state.ids, ...addedIds] : state.ids, entities: { ...state.entities, - ...updatedEntities + ...updatedEntities, }, - loading: !!options.loading + loading: !!options.loading, })); updatedIds.length && this.entityActions.next({ type: EntityActions.Update, ids: updatedIds }); @@ -306,12 +348,12 @@ export class EntityStore ({ + this._setState((state) => ({ ...state, entities: { ...state.entities, - ...replaced - } + ...replaced, + }, })); } @@ -329,13 +371,13 @@ export class EntityStore ({ + this._setState((state) => ({ ...state, // Change the entities reference so that selectAll emit entities: { - ...state.entities + ...state.entities, }, - ids + ids, })); } @@ -363,7 +405,7 @@ export class EntityStore idsOrFn(this.entities[entityId])); + ids = this.ids.filter((entityId) => idsOrFn(this.entities[entityId])); } else { ids = idPassed ? coerceArray(idsOrFn) : null; } @@ -410,7 +452,7 @@ export class EntityStore>(ids: T) { const toArray = coerceArray(ids); if (isEmpty(toArray)) return; - const everyExist = toArray.every(id => this.active.indexOf(id) > -1); + const everyExist = toArray.every((id) => this.active.indexOf(id) > -1); if (everyExist) return; isDev() && setAction('Add Active', ids); - this._setState(state => { + this._setState((state) => { /** Protect against case that one of the items in the array exist */ const uniques = Array.from(new Set([...(state.active as IDType[]), ...toArray])); return { ...state, - active: uniques + active: uniques, }; }); } @@ -458,14 +500,14 @@ export class EntityStore>(ids: T) { const toArray = coerceArray(ids); if (isEmpty(toArray)) return; - const someExist = toArray.some(id => this.active.indexOf(id) > -1); + const someExist = toArray.some((id) => this.active.indexOf(id) > -1); if (!someExist) return; isDev() && setAction('Remove Active', ids); - this._setState(state => { + this._setState((state) => { return { ...state, - active: Array.isArray(state.active) ? state.active.filter(currentId => toArray.indexOf(currentId) === -1) : null + active: Array.isArray(state.active) ? state.active.filter((currentId) => toArray.indexOf(currentId) === -1) : null, }; }); } @@ -481,7 +523,7 @@ export class EntityStore>(ids: T) { const toArray = coerceArray(ids); - const filterExists = remove => id => this.active.includes(id) === remove; + const filterExists = (remove) => (id) => this.active.includes(id) === remove; const remove = toArray.filter(filterExists(true)); const add = toArray.filter(filterExists(false)); this.removeActive(remove); @@ -555,10 +597,10 @@ export class EntityStore) { - this._setState(state => { + this._setState((state) => { return { ...state, - active: ids + active: ids, }; }); } @@ -567,17 +609,17 @@ export class EntityStore { + const createFn = (id) => { const current = this.entities[id]; const ui = isFunc ? this.ui._akitaCreateEntityFn(current) : this.ui._akitaCreateEntityFn; return { [this.idKey]: current[this.idKey], - ...ui + ...ui, }; }; if (add) { - uiEntities = this.ids.filter(id => isUndefined(this.ui.entities[id])).map(createFn); + uiEntities = this.ids.filter((id) => isUndefined(this.ui.entities[id])).map(createFn); } else { uiEntities = ids.map(createFn); } diff --git a/libs/akita/src/lib/index.ts b/libs/akita/src/lib/index.ts index 71b45ac9..0bcf35e7 100644 --- a/libs/akita/src/lib/index.ts +++ b/libs/akita/src/lib/index.ts @@ -55,7 +55,7 @@ export { SelectAllOptionsA, SelectAllOptionsB, SelectAllOptionsC, SelectAllOptio export { __stores__ } from './stores'; export { isDev, enableAkitaProdMode, __DEV__ } from './env'; export { isNotBrowser } from './root'; -export { runStoreAction, StoreActions } from './runStoreAction'; +export { runStoreAction, runEntityStoreAction, getStore, getEntityStore } from './runStoreAction'; export { arrayUpdate } from './arrayUpdate'; export { arrayAdd } from './arrayAdd'; export { arrayUpsert } from './arrayUpsert'; diff --git a/libs/akita/src/lib/runStoreAction.ts b/libs/akita/src/lib/runStoreAction.ts index 505b4549..f16736e3 100644 --- a/libs/akita/src/lib/runStoreAction.ts +++ b/libs/akita/src/lib/runStoreAction.ts @@ -1,182 +1,154 @@ -import { __stores__ } from './stores'; -import { IDS } from './types'; -import { AddEntitiesOptions } from './addEntities'; import { EntityStore } from './entityStore'; -import { SetEntities } from './setEntities'; -import { isNil } from './isNil'; import { AkitaError } from './errors'; +import { isNil } from './isNil'; +import { Store } from './store'; +import { configKey } from './storeConfig'; +import { __stores__ } from './stores'; +import { Constructor } from './types'; -export enum StoreActions { - Update, - AddEntities, - SetEntities, - UpdateEntities, - RemoveEntities, - UpsertEntities +export enum StoreAction { + Update = 'UPDATE', } -interface RunStoreActionSetEntities { - payload: { - data: SetEntities; - }; -} +const StoreActionMapping = { + [StoreAction.Update]: 'update', +}; -interface RunStoreActionAddEntities { - payload: { - data: Entity[] | Entity; - params?: AddEntitiesOptions; - }; +export enum EntityStoreAction { + Update = 'UPDATE', + AddEntities = 'ADD_ENTITIES', + SetEntities = 'SET_ENTITIES', + UpdateEntities = 'UPDATE_ENTITIES', + RemoveEntities = 'REMOVE_ENTITIES', + UpsertEntities = 'UPSERT_ENTITIES', + UpsertManyEntities = 'UPSERT_MANY_ENTITIES', } -interface RunStoreActionUpdateEntities { - payload: { - data: Partial; - entityIds: IDS; - }; -} +const EntityStoreActionMapping = { + [EntityStoreAction.Update]: 'update', + [EntityStoreAction.AddEntities]: 'add', + [EntityStoreAction.SetEntities]: 'set', + [EntityStoreAction.UpdateEntities]: 'update', + [EntityStoreAction.RemoveEntities]: 'remove', + [EntityStoreAction.UpsertEntities]: 'upsert', + [EntityStoreAction.UpsertManyEntities]: 'upsertMany', +}; + +/** + * Get a {@link Store} from the global store registry. + * @param storeClass The {@link Store} class to return the instance. + */ +export function getStore, S = TStore extends Store ? T : never>(storeClass: Constructor): TStore { + const store = __stores__[storeClass[configKey]['storeName']] as TStore; -interface RunStoreActionRemoveEntities { - payload: { - entityIds: IDS; - }; + if (isNil(store)) { + throw new AkitaError(`${store} doesn't exist`); + } + + return store; } -interface RunStoreActionUpsertEntities { - payload: { - data: Partial[] | Partial; - entityIds?: IDS; - }; +/** + * Get a {@link EntityStore} from the global store registry. + * @param storeClass The {@link EntityStore} class to return the instance. + */ +export function getEntityStore, S = TEntityStore extends EntityStore ? T : never>(storeClass: Constructor): TEntityStore { + return getStore(storeClass as Constructor>) as TEntityStore; } -interface RunStoreActionUpdate { - payload: { - data: Partial; - }; +/** + * @example + * + * runStoreAction(BooksStore, StoreAction.Update, update => update({ filter: 'COMPLETE' })); + * + */ +export function runStoreAction, S = TStore extends Store ? T : never>( + storeClass: Constructor, + action: StoreAction.Update, + operation: (operator: TStore['update']) => void +); +export function runStoreAction, S = TStore extends Store ? T : never>( + storeClass: Constructor, + action: StoreAction, + operation: (operator: TStore[keyof TStore] & Function) => void +) { + const store = getStore(storeClass); + operation(store[StoreActionMapping[action]].bind(store)); } /** - * @example + * @example + * + * runEntityStoreAction(BooksStore, EntityStoreAction.SetEntities, set => set([{ id: 1 }, { id: 2 }])); * - * runStoreAction('books', StoreActions.Update, { - * payload: { - * data: { filter: 'New filter' } - * } - * }); */ -export function runStoreAction(storeName: string, action: StoreActions.Update, params: RunStoreActionUpdate); +export function runEntityStoreAction, S = TEntityStore extends EntityStore ? T : never>( + storeClass: Constructor, + action: EntityStoreAction.SetEntities, + operation: (operator: TEntityStore['set']) => void +); /** - * @example + * @example + * + * runEntityStoreAction(BooksStore, EntityStoreAction.AddEntities, add => add({ id: 1 })); * - * runStoreAction('books', StoreActions.RemoveEntities, { - * payload: { - * entityIds: 2 - * } - * }); */ -export function runStoreAction(storeName: string, action: StoreActions.RemoveEntities, params: RunStoreActionRemoveEntities); +export function runEntityStoreAction, S = TEntityStore extends EntityStore ? T : never>( + storeClass: Constructor, + action: EntityStoreAction.AddEntities, + operation: (operator: TEntityStore['add']) => void +); /** - * @example + * @example + * + * runEntityStoreAction(BooksStore, EntityStoreAction.UpdateEntities, update => update(2, { title: 'New title' })); * - * runStoreAction('books', StoreActions.UpdateEntities, { - * payload: { - * data: { title: 'New title' }, - * entityIds: 2 - * } - * }); */ -export function runStoreAction(storeName: string, action: StoreActions.UpdateEntities, params: RunStoreActionUpdateEntities); +export function runEntityStoreAction, S = TEntityStore extends EntityStore ? T : never>( + storeClass: Constructor, + action: EntityStoreAction.UpdateEntities, + operation: (operator: TEntityStore['update']) => void +); /** - * @example + * @example + * + * runEntityStoreAction(BooksStore, EntityStoreAction.RemoveEntities, remove => remove(2)); * - * runStoreAction('books', StoreActions.SetEntities, { - * payload: { - * data: [{ id: 1 }, { id: 2 }] - * } - * }); */ -export function runStoreAction(storeName: string, action: StoreActions.SetEntities, params: RunStoreActionSetEntities); +export function runEntityStoreAction, S = TEntityStore extends EntityStore ? T : never>( + storeClass: Constructor, + action: EntityStoreAction.RemoveEntities, + operation: (operator: TEntityStore['remove']) => void +); /** - * @example + * @example + * + * runEntityStoreAction(BooksStore, EntityStoreAction.UpsertEntities, upsert => upsert([2, 3], { title: 'New Title' }, (id, newState) => ({ id, ...newState, price: 0 }))); * - * runStoreAction('books', StoreActions.AddEntities, { - * payload: { - * data: { id: 1 } - * } - * }); */ -export function runStoreAction(storeName: string, action: StoreActions.AddEntities, params: RunStoreActionAddEntities); +export function runEntityStoreAction, S = TEntityStore extends EntityStore ? T : never>( + storeClass: Constructor, + action: EntityStoreAction.UpsertEntities, + operation: (operator: TEntityStore['upsert']) => void +); /** - * @example + * @example * - * runStoreAction('books', StoreActions.UpsertEntities, { - * payload: { - * data: { title: 'New Title' }, - * entityIds: [1, 2] - * } - * }); - * runStoreAction('books', StoreActions.UpsertEntities, { - * payload: { - * data: [{ id: 2, title: 'New Title' }, { id: 3, title: 'Another title'}], - * } - * }); + * runEntityStoreAction(BooksStore, EntityStoreAction.UpsertManyEntities, upsertMany => upsertMany([ + * { id: 2, title: 'New title', price: 0 }, + * { id: 4, title: 'Another title', price: 0 }, + * )); */ -export function runStoreAction(storeName: string, action: StoreActions.UpsertEntities, params: RunStoreActionUpsertEntities); -export function runStoreAction( - storeName: string, - action: StoreActions, - params: - | RunStoreActionSetEntities - | RunStoreActionAddEntities - | RunStoreActionRemoveEntities - | RunStoreActionUpdateEntities - | RunStoreActionUpsertEntities +export function runEntityStoreAction, S = TEntityStore extends EntityStore ? T : never>( + storeClass: Constructor, + action: EntityStoreAction.UpsertManyEntities, + operation: (operator: TEntityStore['upsertMany']) => void +); +export function runEntityStoreAction, S = TEntityStore extends EntityStore ? T : never>( + storeClass: Constructor, + action: EntityStoreAction, + operation: (operator: TEntityStore[keyof TEntityStore] & Function) => void ) { - const store = __stores__[storeName]; - - if (isNil(store)) { - throw new AkitaError(`${storeName} doesn't exist`); - } - - switch (action) { - case StoreActions.SetEntities: { - const { payload } = params as RunStoreActionSetEntities; - (store as EntityStore).set(payload.data); - return; - } - case StoreActions.AddEntities: { - const { payload } = params as RunStoreActionAddEntities; - (store as EntityStore).add(payload.data, payload.params); - return; - } - - case StoreActions.UpdateEntities: { - const { payload } = params as RunStoreActionUpdateEntities; - (store as EntityStore).update(payload.entityIds, payload.data); - return; - } - - case StoreActions.RemoveEntities: { - const { payload } = params as RunStoreActionRemoveEntities; - (store as EntityStore).remove(payload.entityIds); - return; - } - - case StoreActions.UpsertEntities: { - const { payload } = params as RunStoreActionUpsertEntities; - if (payload.entityIds) { - (store as EntityStore).upsert(payload.entityIds, payload.data); - } else if (Array.isArray(payload.data)) { - (store as EntityStore).upsertMany(payload.data); - } else { - (store as EntityStore).upsertMany([payload.data]); - } - return; - } - - case StoreActions.Update: { - const { payload } = params as RunStoreActionUpdate; - (store as EntityStore).update(payload.data); - return; - } - } + const store = getEntityStore(storeClass); + operation(store[EntityStoreActionMapping[action]].bind(store)); } diff --git a/libs/akita/src/lib/types.ts b/libs/akita/src/lib/types.ts index a83f8b60..2c96aeb7 100644 --- a/libs/akita/src/lib/types.ts +++ b/libs/akita/src/lib/types.ts @@ -33,7 +33,9 @@ export interface SelectOptions extends SortByOptions { } export type StateWithActive = State & (ActiveState | MultiActiveState); -export type UpdateStateCallback = (state: State) => Partial | void; +export type UpdateStateCallback = Partial> = (state: State) => NewState | void; +export type UpsertStateCallback = Partial> = (state: State | {}) => NewState; +export type CreateStateCallback, IDType> = (id: IDType, newState: NewState) => State; export type UpdateEntityPredicate = (entity: E) => boolean; export type ID = number | string; export type IDS = ID | ID[];