Skip to content

Commit

Permalink
fix(entity-store): improve type safety of entity store upsert operati…
Browse files Browse the repository at this point in the history
…ons (salesforce#439)

Co-authored-by: xbaun <[email protected]>
Co-authored-by: Xaver Baun <[email protected]>
  • Loading branch information
3 people authored Jun 9, 2020
1 parent a53dfba commit 08c4656
Show file tree
Hide file tree
Showing 13 changed files with 385 additions and 321 deletions.
33 changes: 33 additions & 0 deletions BREAKING_CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
19 changes: 13 additions & 6 deletions apps/angular-ecommerce/src/app/cart/state/cart.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,11 @@ export class ProductsService {
getAll(term: string, filters) {
return this.http
.get<BaseProduct[]>(`${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<Product>(`${API}/product/${id}`).pipe(tap(product => this.productsStore.upsert(id, product)));
return this.http.get<Product>(`${API}/product/${id}`).pipe(tap((product) => this.productsStore.upsert(id, product, (id, product) => ({ id, ...product }))));
}

updateFilters(filters) {
Expand Down
69 changes: 25 additions & 44 deletions docs/docs/additional/events.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
19 changes: 14 additions & 5 deletions docs/docs/entities/entity-store.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -15,7 +15,7 @@ export interface TodosState extends EntityState<Todo, number> { }

@StoreConfig({ name: 'todos' })
export class TodosStore extends EntityStore<TodosState> {
constructor() {
constructor() {
super() ;
}
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -207,4 +216,4 @@ Update the store's `error` state:
```ts
store.setError(error);
```
```
42 changes: 21 additions & 21 deletions libs/akita-ng-entity-service/src/lib/ng-entity.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = <T>(config?: HttpConfig<T>) => map(res => (config && !!config.mapResponseFn ? config.mapResponseFn(res) : res));
export const mapResponse = <T>(config?: HttpConfig<T>) => map((res) => (config && !!config.mapResponseFn ? config.mapResponseFn(res) : res));

export class NgEntityService<S extends EntityState = any> extends EntityService<S> {
baseUrl: string | undefined;
Expand Down Expand Up @@ -97,15 +97,15 @@ export class NgEntityService<S extends EntityState = any> extends EntityService<
method,
loading: true,
entityId,
storeName: this.store.storeName
storeName: this.store.storeName,
});

return this.http.request(method, url, conf).pipe(
mapResponse(conf),
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);
Expand All @@ -120,16 +120,16 @@ export class NgEntityService<S extends EntityState = any> 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,
});
})
);
Expand All @@ -149,7 +149,7 @@ export class NgEntityService<S extends EntityState = any> extends EntityService<
this.loader.dispatch({
method,
loading: true,
storeName: this.store.storeName
storeName: this.store.storeName,
});

const configWithBody = { ...config, ...{ body: entity } };
Expand All @@ -163,15 +163,15 @@ export class NgEntityService<S extends EntityState = any> 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,
});
})
);
Expand All @@ -192,30 +192,30 @@ export class NgEntityService<S extends EntityState = any> 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<T>;
Expand All @@ -236,28 +236,28 @@ export class NgEntityService<S extends EntityState = any> 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<T>;
Expand Down Expand Up @@ -338,7 +338,7 @@ export class NgEntityService<S extends EntityState = any> extends EntityService<
this.dispatchError({
method,
errorMsg,
payload: error
payload: error,
});

return throwError(error);
Expand Down
6 changes: 3 additions & 3 deletions libs/akita/src/__tests__/classBased.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,13 @@ class TodosStore extends EntityStore<any, Todo> {}
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);
Expand Down
Loading

0 comments on commit 08c4656

Please sign in to comment.