diff --git a/.prettierrc b/.prettierrc
index fab40020..7a29c24c 100644
--- a/.prettierrc
+++ b/.prettierrc
@@ -1,5 +1,5 @@
{
- "printWidth": 80,
+ "printWidth": 140,
"singleQuote": true,
"tabWidth": 2
}
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 29dab6ef..e96eb387 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,13 +5,18 @@ Please look there.
**_This_** Changelog covers changes to the repository and the demo applications.
-
+
+# 0.6.2 (2018-06-26)
+
+* Significantly refactored for ngrx-data `6.0.2-beta.7`.
+
+
# 0.6.1 (2018-05-25)
* Refactored for EntityAction operators as required by Beta 6
* Add example of extending EntityDataServices with custom `HeroDataService` as described in `entity-dataservice.md` (#151).
-
+
# 0.6.0 (2018-05-08)
@@ -30,7 +35,7 @@ which still works.
-
+
# 0.2.13 (2018-05-04)
@@ -46,7 +51,7 @@ The downside is that "Go to definition" takes you to the `d.ts` file rather than
That's inconvenient. But the benefit is that the routine build process reflects what apps will experience.
This is the approach followed by the Angular CLI's library support.
-
+
# 0.2.12 (2018-03-25)
@@ -55,19 +60,19 @@ This is the approach followed by the Angular CLI's library support.
* Opted not to force trailing slash because as contrary to the goal of putting dev
in complete control of url generation. May re-evaluate that decision later.
-
+
# 0.2.11 (2018-03-19)
* demonstrates `HttpResourceUrls` setting in config (new in beta.2)
-
+
# 0.2.10 (2018-03-15)
* Update ngPackagr from -rc to v.2.2
-
+
# 0.2.9 (2018-03-14)
@@ -77,13 +82,13 @@ This is the approach followed by the Angular CLI's library support.
None of these changes should break anything or interfere with creating the library package.
-
+
# 0.2.8 (2018-03-12)
* Update app to align with app in ngrx-data-lab (much cleaner)
-
+
# 0.2.7 (2018-03-09)
@@ -101,7 +106,7 @@ This `VillainEditor` also shows
Depends on alpha.14
-
+
# 0.2.6 (2018-03-05)
@@ -121,7 +126,7 @@ Sample revised
* Those components now start with the cached version of the `getAll()` results.
Press refresh or toggle the datasource to trigger a new `getAll()`
-
+
# 0.2.5 (2018-03-05)
@@ -129,19 +134,19 @@ Add HeroesComponent tests to illustrate how one might write test components. Exp
Requires Alpha.11
-
+
# 0.2.4 (2018-02-26)
App refactors based on learnings from our Angular Awesome workshop.
-
+
# 0.2.3 (2018-02-24)
Adapt to alpha.10
-
+
# 0.2.2 (2018-02-23)
@@ -151,7 +156,7 @@ Revises the demo app and updates the docs to conform to alpha.9
* Updates the `EntityMetadata`
* Adds `HeroesV1Component` to illustrate using `EntityCollectionServiceFactory` directly w/o `HeroService`.
-
+
# 0.2.1 (2018-02-19)
@@ -171,7 +176,7 @@ Revises the demo app and updates the docs to conform to alpha.9
Should not affect the builds of the _ngrx-data lib packages_!
-
+
# 0.2.0 (2018-02-13)
@@ -185,7 +190,7 @@ you must upgrade _ngrx_ to v5.1 or later,
because the reducer uses the "upsert" feature, new in `@ngrx/entity` v5.1,
for `QUERY_ONE_SUCCESS` and `QUERY_MANY_SUCCESS`.
-
+
# 0.1.0 (2018-02-04)
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 011406f5..db328b2b 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -1,6 +1,6 @@
# Contributing to angular-ngrx-data
-This project welcomes contributions and suggestions. Most contributions require you to agree to a
+This project welcomes contributions and suggestions. Most contributions require you to agree to a
Contributor License Agreement (CLA) declaring that you have the right to, and actually do, grant us
the rights to use your contribution. For details, visit https://cla.microsoft.com.
@@ -12,34 +12,38 @@ This project has adopted the [Microsoft Open Source Code of Conduct](https://ope
For more information see the [Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) or
contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with any additional questions or comments.
- - [Code of Conduct](#coc)
- - [Issues and Bugs](#issue)
- - [Feature Requests](#feature)
- - [Submission Guidelines](#submit)
+* [Code of Conduct](#coc)
+* [Issues and Bugs](#issue)
+* [Feature Requests](#feature)
+* [Submission Guidelines](#submit)
+
+## Code of Conduct
-## Code of Conduct
Help us keep this project open and inclusive. Please read and follow our [Code of Conduct](https://opensource.microsoft.com/codeofconduct/).
-## Found an Issue?
+## Found an Issue?
+
If you find a bug in the source code or a mistake in the documentation, you can help us by
[submitting an issue](#submit-issue) to the GitHub Repository. Even better, you can
[submit a Pull Request](#submit-pr) with a fix.
-## Want a Feature?
-You can *request* a new feature by [submitting an issue](#submit-issue) to the GitHub
-Repository. If you would like to *implement* a new feature, please submit an issue with
+## Want a Feature?
+
+You can _request_ a new feature by [submitting an issue](#submit-issue) to the GitHub
+Repository. If you would like to _implement_ a new feature, please submit an issue with
a proposal for your work first, to be sure that we can use it.
* **Small Features** can be crafted and directly [submitted as a Pull Request](#submit-pr).
-## Submission Guidelines
+## Submission Guidelines
+
+### Submitting an Issue
-### Submitting an Issue
Before you submit an issue, search the archive, maybe your question was already answered.
If your issue appears to be a bug, and hasn't been reported, open a new issue.
Help us to maximize the effort we can spend fixing issues and adding new
-features, by not reporting duplicate issues. Providing the following information will increase the
+features, by not reporting duplicate issues. Providing the following information will increase the
chances of your issue being dealt with quickly:
* **Overview of the Issue** - if an error is being thrown a non-minified stack trace helps
@@ -53,7 +57,8 @@ chances of your issue being dealt with quickly:
You can file new issues by providing the above information at the corresponding repository's issues link: https://github.com/[organization-name]/[repository-name]/issues/new].
-### Submitting a Pull Request (PR)
+### Submitting a Pull Request (PR)
+
Before you submit your Pull Request (PR) consider the following guidelines:
* Search the repository (https://github.com/[organization-name]/[repository-name]/pulls) for an open or closed PR
@@ -65,6 +70,7 @@ Before you submit your Pull Request (PR) consider the following guidelines:
* Push your fork to GitHub:
* In GitHub, create a pull request
* If we suggest changes then:
+
* Make the required updates.
* Rebase your fork and force push to your GitHub repository (this will update your Pull Request):
diff --git a/docs/entity-actions.md b/docs/entity-actions.md
index 60c46d16..6f7f0fff 100644
--- a/docs/entity-actions.md
+++ b/docs/entity-actions.md
@@ -20,17 +20,19 @@ export interface EntityAction extends Action {
readonly entityName: string;
readonly op: EntityOp;
readonly payload?: P;
- readonly label?: string;
+ readonly tag?: string;
error?: Error;
+ skip?: boolean
}
```
-* `type` - action name, typically generated from the `label` and the `op`
+* `type` - action name, typically generated from the `tag` and the `op`
* `entityName` - the name of the entity type
* `op` - the name of an entity operation
* `payload?` - the message data for the action.
-* `label?` - the label to use within the generated type. If not specified, the `entityName` is the label.
+* `tag?` - the tag to use within the generated type. If not specified, the `entityName` is the tag.
* `error?` - an unexpected action processing error.
+* `skip?` - true if downstream consumers should skip processing the action.
The `type` is the only property required by _ngrx_. It is a string that uniquely identifies the action among the set of all the types of actions that can be dispatched to the store.
@@ -48,13 +50,17 @@ that the _ngrx-data_ library can perform.
The `payload` is conceptually the body of the message.
Its type and content should fit the requirements of the operation to be performed.
-The optional `label` appears in the generated `type` text when the `EntityActionFactory` creates this `EntityAction`.
+The optional `tag` appears in the generated `type` text when the `EntityActionFactory` creates this `EntityAction`.
-The `entityName` is the default label that appears between brackets in the formatted `type`,
+The `entityName` is the default tag that appears between brackets in the formatted `type`,
e.g., `'[Hero] ngrx-data/query-all'`.
The `error` property indicates that something went wrong while processing the action. [See more below](#action-error).
+The `skip` property tells downstream action receivers that they should skip the usual action processing.
+This flag is usually missing and is implicitly false.
+[See more below](#action-skip).
+
## _EntityAction_ consumers
The _ngrx-data_ library ignores the `Action.type`.
@@ -72,7 +78,7 @@ You can create an `EntityAction` by hand if you wish.
The _ngrx-data_ library considers _any action_ with an `entityName` and `op` properties to be an `EntityAction`.
The `EntityActionFactory.create()` method helps you create a consistently well-formed `EntityAction` instance
-whose `type` is a string composed from the `label` (the `entityName` by default) and the `op`.
+whose `type` is a string composed from the `tag` (the `entityName` by default) and the `op`.
For example, the default generated `Action.type` for the operation that queries the server for all heroes is `'[Hero] ngrx-data/query-all'`.
@@ -84,7 +90,7 @@ For example, the default generated `Action.type` for the operation that queries
Note that **_each entity type has its own \_unique_ `Action` for each operation\_**, as if you had created them individually by hand.
-## Labeling the EntityAction
+## Tagging the EntityAction
A well-formed action `type` can tell the reader what changed and
who changed it.
@@ -95,10 +101,10 @@ So you can get the same behavior from several different actions,
each with its own informative `type`, as long as they share the same
`entityName` and `entityOp`.
-The optional `label` parameter of the `EntityActionFactory.create()` method makes
+The optional `tag` parameter of the `EntityActionFactory.create()` method makes
it easy to produce meaningful _EntityActions_.
-You don't have to specify a label. The `entityName` is the default label that appears between brackets in the formatted `type`,
+You don't have to specify a tag. The `entityName` is the default tag that appears between brackets in the formatted `type`,
e.g., `'[Hero] ngrx-data/query-all'`.
Here's an example that uses the injectable `EntityActionFactory` to construct the default "query all heroes" action.
@@ -118,11 +124,11 @@ Thanks to the _ngrx-data **effects**_, this produces _two_ actions in the log, t
[Hero] ngrx-data/query-all-success
```
-This default `entityName` label identifies the action's target entity collection.
+This default `entityName` tag identifies the action's target entity collection.
But you can't understand the _context_ of the action from these log entries. You don't know who dispatched the action or why.
The action `type` is too generic.
-You can create a more informative action by providing a label that
+You can create a more informative action by providing a tag that
better describes what is happening and also make it easier to find
where that action is dispatched by your code.
@@ -164,7 +170,7 @@ store.dispatch(action);
It triggers the HTTP request via _ngrx-data effects_, as in the previous examples.
Just be aware that _ngrx-data effects_ uses the `EntityActionFactory` to create the second, success Action.
-Without the `label` property, it produces a generic success action.
+Without the `tag` property, it produces a generic success action.
The log of the two action types will look like this:
@@ -209,7 +215,7 @@ That's a lot of code to write, test, and maintain.
With the help of _ngrx-data_, you don't write any of it.
_Ngrx-data_ creates the _actions_ and the _dispatchers_, _reducers_, and _effects_ that respond to those actions.
-
+
## _EntityAction.error_
@@ -229,7 +235,25 @@ The `EntityEffects` will see that such an action has an error and will return th
> This is the only way we've found to prevent a bad action from getting through the effect and triggering an HTTP request.
-
+
+
+## _EntityAction.skip_
+
+The `skip` property tells downstream action receivers that they should skip the usual action processing.
+This flag is usually missing and is implicitly false.
+
+The ngrx-data sets `skip=true` when you try to delete a new entity that has not been saved.
+When the `EntityEffects.persist$` method sees this flag set true on the `EntityAction` envelope,
+it skips the HTTP request and dispatches an appropriate `_SUCCESS` action with the
+original request payload.
+
+This feature allows ngrx-data to avoid making a DELETE request when you try to delete an entity
+that has been added to the collection but not saved.
+Such a request would have failed on the server because there is no such entity to delete.
+
+See the [`EntityChangeTracker`](entity-change-tracker.md) page for more about change tracking.
+
+
## EntityCache-level actions
diff --git a/docs/entity-change-tracker.md b/docs/entity-change-tracker.md
index 9c6ef09f..39809847 100644
--- a/docs/entity-change-tracker.md
+++ b/docs/entity-change-tracker.md
@@ -1,51 +1,269 @@
# EntityChangeTracker
-Can add the current value of a cached entity in that entity's
-`collection.originalValues` map and later _revert_ the entity to
-this _original value_.
+Ngrx-data tracks entity changes that haven't yet been saved on the server.
+It also preserves "original values" for these changes and you can revert them with _undo actions_.
-This feature is most valuable during _optimistic_ saves that
-add, update, and delete entity data in the remote storage (e.g., a database).
+Change-tracking and _undo_ are important for applications that make _optimistic saves_.
-## Change tracking and optimistic saves
+## Optimistic versus Pessimistic save
-The `EntityActions` whose operation names end in `_OPTIMISTIC` begin
-an _optimistic_ save.
-The default [`EntityCollectionReducer`](entity-reducer.md) _immediately_ updates the cached collection _before_ sending the HTTP request to do the same on the server.
+An _optimistic save_ stores a new or changed entity in the cache _before making a save request to the server_.
+It also removes an entity from the store _before making a delete request to the server_.
-The operation is _optimistic_ because it is presumed to succeed more often than not.
-The _pessimistic_ versions of these actions _do not update_ the cached collection
-_until_ the server responds.
+> The `EntityActions` whose operation names end in `_OPTIMISTIC` start
+> an _optimistic_ save.
+
+Many apps are easier to build when saves are "optimistic" because
+the changes are immediately available to application code that is watching collection selectors.
+The app doesn't have to wait for confirmation that the entity operation succeeded on the server.
+
+A _pessimistic save_ doesn't update the store until the server until the server confirms that the save succeeded,
+which ngrx-data then turns into a "SUCCESS" action that updates the collection.
+With a _pessimistic_ save, the changes won't be available in the store
+
+This confirmation cycle can (and usually will) take significant time and the app has to account for that gap somehow.
+The app could "freeze" the UX (perhaps with a modal spinner and navigation guards) until the confirmation cycle completes.
+That's tricky code to write and race conditions are inevitable.
+And it's difficult to hide this gap from the user and keep the user experience responsive.
+
+This isn't a problem with optimistic saves because the changed data are immediately available in the store.
+
+> The developer always has the option to wait for confirmation of an optimistic save.
+> But the changed entity data will be in the store during that wait.
### Save errors
-If the server request timeouts or the server rejects the save request,
-the _nrgx_ reducer is called with an error action ending in `_ERROR`.
+The downside of optimistic save is that _the save could fail_ for many reasons including lost connection,
+timeout, or rejection by the server.
-**The reducer does nothing with save errors.**
+When the client or server rejects the save request,
+the _nrgx_ `EntityEffect.persist$` dispatches an error action ending in `_ERROR`.
-There is no harm if the operation was _pessimistic_.
-The collection had not been updated so there is no obvious inconsistency with the state
-of the entity on the server.
+**The default entity reducer methods do nothing with save errors.**
+
+There is no issue if the operation was _pessimistic_.
+The collection had not been updated so there is no obvious inconsistency between the state
+of the entity in the collection and on the server.
+
+It the operation was _optimistic_, the entity in the cached collection has been added, removed, or updated.
+The entity and the collection are no longer consistent with the state on the server.
-It the operation was _optimistic_, the entity in the cached collection has been updated and is no longer
-consistent with the entity's state on the server.
That may be a problem for your application.
-You might prefer that the entity be restored to a known server state,
-such as the state of the entity when it was last queried or successfully saved.
+If the save fails, the entity in cache no longer accurately reflects the state of the entity on the server.
+While that can happen for other reasons (e.g., a different user changed the same data),
+when you get a save error, you're almost certainly out-of-sync and should be able to do something about it.
+
+Change tracking gives the developer the option to respond to a server error
+by dispatching an _undo action_ for the entity (or entities) and
+thereby reverting the entity (or entities) to the last known server state.
+
+_Undo_ is NOT automatic.
+You may have other save error recovery strategies that preserve the user's
+unsaved changes.
+It is up to you if and when to dispatch one of the `UNDO_...` actions.
+
+## Change Tracking
+
+The ngrx-data tracks an entity's change-state in the collection's `changeState` property.
+
+When change tracking is enabled (the default), the `changeState` is a _primary key to_ `changeState` _map_.
+
+> You can disable change tracking for an individual action or the collection as a whole as
+> described [below](#enable-change-tracking).
+
+### _ChangeState_
+
+A `changeState` map adheres to the following interface
+
+```
+export interface ChangeState {
+ changeType: ChangeType;
+ originalValue: T | undefined;
+}
+
+export enum ChangeType {
+ Unchanged, // the entity has not been changed.
+ Added, // the entity was added to the collection
+ Updated, // the entity in the collection was updated
+ Deleted, // the entity is scheduled for delete and was removed from collection.
+}
+```
+
+A _ChangeState_ describes an entity that changed since its last known server value.
+The `changeType` property tells you how it changed.
+
+> `Unchanged` is an _implied_ state.
+> Only changed entities are recorded in the collection's `changeState` property.
+> If an entity's key is not present, assume it is `Unchanged` and has not changed since it was last
+> retrieved from or successfully saved to the server.
+
+The _original value_ is the last known value from the server.
+The `changeState` object holds an entity's _original value_ for _two_ of these states: _Updated_ and _Deleted_.
+For an _Unchanged_ entity, the current value is the original value so there is no need to duplicate it.
+There could be no original value for an entity this is added to the collection but no yet saved.
+
+## EntityActions and change tracking.
+
+The collection is created with an empty `changeState` map.
+
+### Recording a change state
+
+Many _EntityOp_ reducer methods will record an entity's change state.
+Once an entity is recorded in the `changeState`, its `changeType` and `originalValue` generally do not change.
+Once "added", "deleted" or "updated", an entity stays
+that way until committed or undone.
+
+Delete (remove) is a special case with special rules.
+[See below](#delete).
+
+Here are the most important `EntityOps` that record an entity in the `changeState` map:
+
+```
+// Optimistic save operations
+SAVE_ADD_ONE_OPTIMISTIC
+SAVE_DELETE_ONE_OPTIMISTIC
+SAVE_UPDATE_ONE_OPTIMISTIC
+
+// Cache operations
+ADD_ONE
+ADD_MANY
+REMOVE_ONE
+REMOVE_MANY
+UPDATE_ONE
+UPDATE_MANY
+UPSERT_ONE
+UPSERT_MANY
+```
+
+### Removing an entity from the _changeState_ map.
+
+An entity which has no entry in the `ChangeState` map is presumed to be unchanged.
+
+The _commit_ and _undo_ operations remove entries from the `ChangeState` which means, in effect, that they are "unchanged."
+
+The **commit** operations simply remove entities from the `changeState`.
+They have no other effect on the collection.
+
+The [**undo** operations](#undo) replace entities in the collection based on
+information in the `changeState` map, reverting them their last known server-side state, and removing them from the `changeState` map.
+These entities become "unchanged."
+
+An entity ceases to be in a changed state when the server returns a new version of the entity.
+Operations that put that entity in the store also remove it from the `changeState` map.
+
+Here are the operations that remove one or more specified entities from the `changeState` map.
+
+```
+QUERY_BY_KEY_SUCCESS
+QUERY_MANY_SUCCESS
+SAVE_ADD_ONE_SUCCESS
+SAVE_ADD_ONE_OPTIMISTIC_SUCCESS,
+SAVE_DELETE_ONE_SUCCESS
+SAVE_DELETE_ONE_OPTIMISTIC_SUCCESS
+SAVE_UPDATE_ONE_SUCCESS
+SAVE_UPDATE_ONE_OPTIMISTIC_SUCCESS
+COMMIT_ONE
+COMMIT_MANY
+UNDO_ONE
+UNDO_MANY
+```
+
+### Operations that clear the _changeState_ map.
+
+The `EntityOps` that replace or remove every entity in the collection also reset the `changeState` to an empty object.
+All entities in the collection (if any) become "unchanged".
+
+```
+ADD_ALL
+QUERY_ALL_SUCCESS
+REMOVE_ALL
+COMMIT_ALL
+UNDO_ALL
+```
+
+Two of these may surprise you.
+
+1. `ADD_ALL` is interpreted as a cache load from a known state.
+ These entities are presumed _unchanged_.
+ If you have a different intent, use `ADD_MANY`.
+
+2. `REMOVE_ALL` is interpreted as a cache clear with nothing to save. If you have a different intent, use _removeMany_.
+
+You can (re)set the `changeState` to anything with `EntityOp.SET_CHANGE_STATE`.
+
+This is a super-powerful operation that you should rarely perform.
+It's most useful if you've created your own entity action and are
+modifying the collection in some unique way.
+
+
+
+## _Undo_ (revert) an unsaved change
+
+You have many options for handling an optimistic save error.
+One of them is to revert the change to the entity's last known state on the server by dispatching an _undo_ action.
+
+There are three _undo_ `EntityOps` that revert entities:
+`UNDO_ONE`, `UNDO_MANY` and `UNDO_ALL`.
+
+For `UNDO_ONE` and `UNDO_MANY`, the id(s) of the entities to revert are in the action payload.
+
+`UNDO_ALL` reverts every entity in the `changeState` map.
+
+Each entity is reverted as follows:
+
+* `ADDED` - Remove from the collection and discard
+
+* `DELETED` - Add the _original value_ of the removed entity to the collection.
+ If the collection is sorted, it will be moved into place.
+ If unsorted, it's added to the end of the collection.
+
+* `UPDATED` - Update the collection with the entity's _original value_.
+
+If you try to undo/revert an entity whose id is not in the `changeState` map, the action is silently ignored.
+
+
+
+### Deleting/removing entities
+
+There are special change tracking rules for deleting/removing an entity from the collection
+
+#### Added entities
+
+When you remove or delete an "added" entity, the change tracker removes the entity from the `changeState` map because there is no server state to which such an entity could be restored.
+
+The reducer methods that delete and remove entities should immediately remove an _added entity_ from the collection.
+
+> The default delete and remove reducer methods remove these entities immediately.
+
+They should not send HTTP DELETE requests to the server because these entities do not exist on the server.
+
+> The default `EntityEffects.persist$` effect does not make HTTP DELETE requests for these entities.
+
+#### Updated entities
-If the _ChangeTracker_ had captured that state in the `entityCollection.originalValues` _before_ the entity was changed in cache,
-the app could tell the tracker to revert the entity to that state when it
-sees an HTTP save error.
+An entity registered in the `changeState` map as "updated"
+is reclassified as "deleted".
+Its `originalValue` stays the same.
+Undoing the change will restore the entity to the collection in its pre-update state.
-### ChangeTracker _MetaReducers_
+
-The _ngrx-data_ library has optional [_MetaReducers_](entity-reducer.md#collection-meta-reducers)
-that can track the entity state before an optimistic save and revert the entity
-to it state when last queried or successfully saved.
+### Enabling and disabling change tracking
-These MetaReducers are included by default.
+You can opt-out of change tracking for a collection by setting the collection's `enableChangeTracking` flag to `false` in its `entityMetadata`.
+When `false`, ngrx-data does not track any changes for this collection
+and the `EntityCollection.changeState` property remains an empty object.
->You can remove or replace them during `NgrxDataModule` configuration.
+You can also turnoff change tracking for a specific, cache-only action by choosing one of the
+"no-tracking" `EntityOps`. They all end in "\_NO_TRACK".
-_More on all of this soon_
+```
+ADD_ONE_NO_TRACK
+ADD_MANY_NO_TRACK
+REMOVE_ONE_NO_TRACK
+REMOVE_MANY_NO_TRACK
+UPDATE_ONE_NO_TRACK
+UPDATE_MANY_NO_TRACK
+UPSERT_ONE_NO_TRACK
+UPSERT_MANY_NO_TRACK
+```
diff --git a/docs/entity-collection-service.md b/docs/entity-collection-service.md
index 647b7e93..09ebfed1 100644
--- a/docs/entity-collection-service.md
+++ b/docs/entity-collection-service.md
@@ -141,7 +141,7 @@ Rather than expect a result from the command,
you subscribe to a _selector$_ property that reflects
the effects of the command. If the command did something you care about, a _selector$_ property should be able to tell you about it.
-
+
## _EntityServiceFactory_
diff --git a/docs/entity-collection.md b/docs/entity-collection.md
index 2db98eaf..06067bad 100644
--- a/docs/entity-collection.md
+++ b/docs/entity-collection.md
@@ -3,16 +3,16 @@
The _ngrx-data_ library maintains a _cache_ (`EntityCache`) of
_entity collections_ for each _entity type_ in the _ngrx store_.
-An _entity_collection_ implements the [`EntityCollection` interface](../lib/src/reducers/entity-reducer.ts).
+An _entity_collection_ implements the [`EntityCollection` interface](../lib/src/reducers/entity-reducer.ts).
-| Property | Meaning |
-| ---------- |------------------------------------------|
-| `ids` | Primary key values in default sort order |
-| `entities` | Map of primary key to entity data values |
-| `filter` | The user's filtering criteria |
-| `loaded` | Whether collection was filled by QueryAll; forced false after clear |
-| `loading` | Whether currently waiting for query results to arrive from the server |
-| `originalValues` | When [change-tracking](change-tracker.md) is enabled, the original values of unsaved entities |
+| Property | Meaning |
+| ------------- | -------------------------------------------------------------------------------------------- |
+| `ids` | Primary key values in default sort order |
+| `entities` | Map of primary key to entity data values |
+| `filter` | The user's filtering criteria |
+| `loaded` | Whether collection was filled by QueryAll; forced false after clear |
+| `loading` | Whether currently waiting for query results to arrive from the server |
+| `changeState` | When [change-tracking](change-tracker.md) is enabled, the `ChangeStates` of unsaved entities |
-You can extend an entity types with _additional properties_ via
+You can extend an entity types with _additional properties_ via
[entity metadata](entity-metadata.md#additional-collection-state).
diff --git a/docs/entity-dataservice.md b/docs/entity-dataservice.md
index 61ce437f..c4156112 100644
--- a/docs/entity-dataservice.md
+++ b/docs/entity-dataservice.md
@@ -77,7 +77,7 @@ get the collection resource name.
The [_Entity Metadata_](entity-metadata.md#plurals) guide
explains how to configure the default `Pluralizer` .
-
+
### Configure the _DefaultDataService_
@@ -133,13 +133,7 @@ It only overrides what it really needs.
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
-import {
- EntityCollectionDataService,
- DefaultDataService,
- HttpUrlGenerator,
- Logger,
- QueryParams
-} from 'ngrx-data';
+import { EntityCollectionDataService, DefaultDataService, HttpUrlGenerator, Logger, QueryParams } from 'ngrx-data';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
@@ -147,19 +141,13 @@ import { Hero } from '../../core';
@Injectable()
export class HeroDataService extends DefaultDataService {
- constructor(
- http: HttpClient,
- httpUrlGenerator: HttpUrlGenerator,
- logger: Logger
- ) {
+ constructor(http: HttpClient, httpUrlGenerator: HttpUrlGenerator, logger: Logger) {
super('Hero', http, httpUrlGenerator);
logger.log('Created custom Hero EntityDataService');
}
getAll(): Observable {
- return super
- .getAll()
- .pipe(map(heroes => heroes.map(hero => this.mapHero(hero))));
+ return super.getAll().pipe(map(heroes => heroes.map(hero => this.mapHero(hero))));
}
getById(id: string | number): Observable {
@@ -167,9 +155,7 @@ export class HeroDataService extends DefaultDataService {
}
getWithQuery(params: string | QueryParams): Observable {
- return super
- .getWithQuery(params)
- .pipe(map(heroes => heroes.map(hero => this.mapHero(hero))));
+ return super.getWithQuery(params).pipe(map(heroes => heroes.map(hero => this.mapHero(hero))));
}
private mapHero(hero: Hero): Hero {
diff --git a/docs/entity-metadata.md b/docs/entity-metadata.md
index 6ee81a2b..4850e07e 100644
--- a/docs/entity-metadata.md
+++ b/docs/entity-metadata.md
@@ -66,7 +66,7 @@ class LazyModule {
}
```
-
+
## Metadata Properties
@@ -100,7 +100,7 @@ Importantly, the default [_entity dataservice_](docs/entity-dataservice.md) crea
> Of course the proper plural of "hero" is "hero**es**", not "hero**s**".
> You'll see how to correct this problem [below](#plurals).
-
+
### _filterFn_
@@ -139,7 +139,7 @@ export function nameAndSayingFilter(entities: Villain[], pattern: string) {
}
```
-
+
### _selectId_
@@ -158,7 +158,7 @@ The `selectorId` function is this:
selectId: (villain: Villain) => villain.key;
```
-
+
### _sortComparer_
@@ -186,7 +186,7 @@ Run the demo app and try changing existing hero names or adding new heroes.
Your app can call the `selectKey` selector to see the collection's `ids` property, which returns an array of the collection's primary key values in sorted order.
-
+
### _entityDispatcherOptions_
@@ -200,7 +200,7 @@ If the caller doesn't specify, the dispatcher chooses based on default options.
The _default_ defaults are the safe ones: _optimistic_ for delete and _pessimistic_ for add and update.
You can override those choices here.
-
+
### _additionalCollectionState_
@@ -222,7 +222,7 @@ The property values become the initial collection values for those properties wh
The _ngrx-data_ library generates selectors for these properties but has no way to update them. You'll have to create or extend the existing reducers to do that yourself.
-
+
## Pluralizing the entity name
diff --git a/docs/entity-reducer.md b/docs/entity-reducer.md
index 4267d0c4..93a1a6ba 100644
--- a/docs/entity-reducer.md
+++ b/docs/entity-reducer.md
@@ -2,7 +2,7 @@
The _Entity Reducer_ is the _master reducer_ for all entity collections in the stored entity cache.
-
+
The library doesn't have a named _entity reducer_ type.
Rather it relies on the **`EntityReducerFactory.create()`** method to produce that reducer,
@@ -36,7 +36,7 @@ If it can't find a reducer for the entity type, it [creates one](#collection-red
of the injected `EntityCollectionReducerFactory`, and registers that reducer
so it can use it again next time.
-
+
### Register custom reducers
@@ -47,7 +47,7 @@ You can register several custom reducers at the same time
by calling `EntityReducerFactory.registerReducer(reducerMap)` where
the `reducerMap` is a hash of reducers, keyed by _entity-type-name_.
-
+
## Default _EntityCollectionReducer_
@@ -76,7 +76,7 @@ cache altering operations performed by the default _entity collection reducer_.
The [`EntityCollectionReducerFactory`](../lib/src/reducers/entity-collection-reducer.ts`) and its tests are the authority on how the default reducer actually works.
-
+
## Initializing collection state
@@ -95,7 +95,7 @@ the creator returns an `EntityCollection`.
The _entity reducer_ then passes the new collection in the `state` argument of the _entity collection reducer_.
-
+
## Customizing entity reducer behavior
@@ -109,7 +109,7 @@ providing a custom alternative to the [`EntityReducerFactory`](#reducer-factory)
But quite often you'd like to extend a _collection reducer_ with some additional reducer logic that runs before or after.
-
+
## EntityCache-level actions
@@ -142,7 +142,7 @@ in error-recovery or "what-if" scenarios.
If you want to create and reduce additional, cache-wide actions,
consider the _EntityCache MetaReducer_, described in the next section.
-
+
## _MetaReducers_
@@ -167,7 +167,7 @@ Ngrx-data supports two levels of MetaReducer
1. _EntityCache MetaReducer_, scoped to the entire entity cache
1. _EntityCollection MetaReducer_, scoped to a particular collection.
-
+
### Entity Cache _MetaReducers_
@@ -184,7 +184,7 @@ An _EntityCache MetaReducer_ reducer must satisfy three requirements:
> We intend to explain how in a documentation update.
> For now, see the `ngrx-data.module.spec.ts` for examples.
-
+
### Entity Collection _MetaReducers_
diff --git a/docs/faq.md b/docs/faq.md
index 69dfdd5d..ccde94e1 100644
--- a/docs/faq.md
+++ b/docs/faq.md
@@ -1,6 +1,6 @@
# Ngrx-data FAQs
-
+
## You said I'd never write an action. But what if ...
@@ -14,7 +14,7 @@ You can customize almost anything, both at the single entity-type level and for
But you ONLY do so when you want to do something unusual … and that, by definition, is not boilerplate.
-
+
## What is an _entity_?
@@ -33,7 +33,7 @@ The application's **_entity model_** is the set of all entity types in your appl
In some definitions, the _entity type_ and _entity model_ describe both the data and the _logic_ that govern that data such as data integrity rules (e.g., validations) and behaviors (e.g., calculations). The _current version_ of _ngrx-data_ library is unaware of entity logic beyond what is strictly necessary to persist entity data values.
-
+
## Is _ngrx-data_ the answer for everything?
@@ -61,7 +61,7 @@ They are still worth managing with _ngrx_.
It bears repeating: the _ngrx-data_ library is good for
querying, caching, and saving _entity data_ ... and that's it.
-
+
## What is _ngrx_?
@@ -73,7 +73,7 @@ querying, caching, and saving _entity data_ ... and that's it.
[@ngrx/effects](https://github.com/ngrx/platform/blob/master/docs/effects/README.md), and
[@ngrx/entity](https://github.com/ngrx/platform/blob/master/docs/entity/README.md).
-
+
## How is _ngrx-data_ different from _@ngrx/entity_?
@@ -99,7 +99,7 @@ The store, the actions, the adapter, and the entity collections remain visible a
The fixes and enhancements in future _@ngrx/entity_ versions flow through _ngrx-data_ to your application.
-
+
## What is _redux_?
@@ -137,7 +137,7 @@ It differs most significantly in replacing _events_ with _observables_.
_Ngrx_ relies on
[RxJS Observables](#rxjs) to listen for store events, select those that matter, and push the selected object(s) to your application.
-
+
## What is _state_?
@@ -156,7 +156,7 @@ You replace them with new objects, created through a merge of the previous prope
Arrays are completely replaced with you add, remove, or replace any of their items.
-
+
## What are _RxJS Observables_
@@ -164,7 +164,7 @@ Arrays are completely replaced with you add, remove, or replace any of their ite
Many Angular APIs produce _RxJS Observables_ so programming "reactively" with _Observables_ is familiar to many Angular developers. Search the web for many helpful resources on _RxJS_.
-
+
## What's wrong with code generation?
diff --git a/docs/limitations.md b/docs/limitations.md
index e292123b..7c3b44e2 100644
--- a/docs/limitations.md
+++ b/docs/limitations.md
@@ -2,7 +2,7 @@
The _ngrx-data_ library lacks many capabilities of a [full-featured entity management](#alternatives) system.
-You may be able to work-around some of the limitations without too much effort,
+You may be able to work-around some of the limitations without too much effort,
particularly when the shortcomings are a problem for just a few entity types.
This page lists many of the serious limitations we've recognized ourselves.
@@ -17,8 +17,8 @@ We could use your help.
## Deep entity cloning
-This library (like the [@ngrx/entity library](https://github.com/ngrx/platform/tree/master/docs/entity)
-on which it depends) assumes that entity property values
+This library (like the [@ngrx/entity library](https://github.com/ngrx/platform/tree/master/docs/entity)
+on which it depends) assumes that entity property values
are simple data types such as strings, numbers, and dates.
Nothing enforces that assumption.
@@ -26,8 +26,8 @@ Many web APIs return entity data with complex properties.
A property value could be a _value type_ (e.g., a _money type_ that combines a currency indicator and an amount).
It could have a nested structure (e.g., an address).
-This library shallow-clones the entity data in the collections.
-It doesn't clone complex, nested, or array properties.
+This library shallow-clones the entity data in the collections.
+It doesn't clone complex, nested, or array properties.
You'll have to do the deep equality tests and cloning yourself _before_ asking _ngrx-data_ to save data.
## Non-normalized server responses
@@ -45,11 +45,11 @@ This library lacks the tools to help you disaggregate and normalize server respo
Entities are often related to each other via _foreign keys_.
These relationships can be represented as a directed graph, often with cycles.
-This library tacitly assumes that entities of different types are unrelated.
+This library tacitly assumes that entities of different types are unrelated.
It is completely unaware of _relationships_ and _foreign keys_ that may be implicit in the entity data.
-It's up to you to make something out of those relationships and keys.
+It's up to you to make something out of those relationships and keys.
-It's not easy to represent relationships.
+It's not easy to represent relationships.
A `Customer` entity could have a one-to-many relationship with `Order` entities.
The `Order` entity has an `order.customer` property whose value is the primary key
@@ -74,7 +74,7 @@ as entities enter and leave the cache.
There will be long chains of navigations (`Customer <-> Order <-> <-> LineItem <-> Product <-> Supplier`).
How should these be implemented?
-_Observable selector_ properties seem logical but thorny performance traps
+_Observable selector_ properties seem logical but thorny performance traps
await.
## Client-side primary key generation
@@ -90,14 +90,14 @@ You can create new records offline or recover if your connection to the server
breaks inconveniently during the save.
It's easy to generate a new _guid_ (or _uuid_) key.
-It's much harder to generate integer or semantic keys because
+It's much harder to generate integer or semantic keys because
you need a foolproof way to enforce uniqueness.
Server-supplied keys greatly complicate maintenance of a cache of inter-related entities.
You'll have to find a way to hold the related entities together until you can save them.
Temporary-key generation is one approach. It requires complex key-fixup logic
-to replace the temporary keys in _foreign key properties_
+to replace the temporary keys in _foreign key properties_
with the server-supplied permanent keys.
## Transactions
@@ -110,7 +110,7 @@ The library doesn't support that yet.
## Data integrity rules
-Entities are often governed by intra- and inter-entity validation rules.
+Entities are often governed by intra- and inter-entity validation rules.
The `Customer.name` property may be required.
The `Order.shipDate` must be after the `Order.orderDate`.
The parent `Order` of a `LineItem` may have to exist.
@@ -122,7 +122,8 @@ It would be great if the library knew about the rules (in `EntityMetadata`?), ra
These might be features in a future version of this library.
-
+
+
## Server/client entity mapping
The representation of an entity on the server may be different than on the client.
@@ -144,7 +145,7 @@ But maybe you can't connect to the server.
Where do you keep the user's pending, unsaved changes?
One approach is to keep track of pending changes, either in a change-tracker or in the entity data themselves.
-Then you might be able to use one of the
+Then you might be able to use one of the
[_cache-only_ commands](../lib/src/entity-metadata/entity-commands.ts) to
put hold the entity in cache while you waited for restored connectivity.
@@ -165,18 +166,18 @@ while adding your own entity actions and _ngrx effects_ for these operations.
## No explicit _SAVE_UPSERT_
The default `EntityEffects` supports saving a new or existing entity but does not have an explicit
-SAVE_UPSERT action that would _official_ save
+SAVE*UPSERT action that would \_official* save
an entity which might be either new or existing.
You may be able to add a new entity with `SAVE_UPDATE` or `SAVE_UPDATE_ONE_OPTIMISTIC`,
-because the `EntityCollectionReducer` implements these actions
+because the `EntityCollectionReducer` implements these actions
by calling the collection `upsertOne()` method.
Do this _only_ if your server supports _upsert-with-PUT_ requests.
## No request concurrency checking
-The user saves a new `Customer`, followed by a query for all customers.
+The user saves a new `Customer`, followed by a query for all customers.
It the new customer in the query response?
`Ngrx-data` does not coordinate save and query requests and does not guarantee order of responses.
@@ -186,11 +187,10 @@ Here's some pseudo-code that might do that for the previous example:
```javascript
// add new customer, then query all customers
-customerService.addEntity(newCustomer)
- .pipe(
- concatMap(() => customerService.queryAll())
- )
- .subscribe(custs => this.customers = custs);
+customerService
+ .addEntity(newCustomer)
+ .pipe(concatMap(() => customerService.queryAll()))
+ .subscribe(custs => (this.customers = custs));
```
The same reasoning applies to _any_ request that must follow in a precise sequence.
@@ -208,7 +208,7 @@ What's the actual address in the database? What's the address in the user's cach
It could be any of the three addresses depending on when the server saw them and when the responses arrived.
You cannot know.
-Many applications maintain a concurrency property that guards against updating an entity
+Many applications maintain a concurrency property that guards against updating an entity
that was updated by someone else.
The `ngrx-data` library is unaware of this protocol.
You'll have to manage concurrency yourself.
@@ -231,7 +231,8 @@ This library's `getWithQuery()` command takes a query specification in the form
There is no apparatus for composing queries or sending them to the server except as a query string.
-
+
+
## An alternative to _ngrx-data_
[BreezeJS](http://www.getbreezenow.com/breezejs) is a free, open source,
@@ -241,5 +242,5 @@ Many Angular (and AngularJS) applications use _Breeze_ today.
It's not the library for you if you **_require_** a small library that adheres to _reactive_, _immutable_, _redux-like_ principles.
->Disclosure: one of the _ngrx-data_ authors, Ward Bell,
-is an original core Breeze contributor.
+> Disclosure: one of the _ngrx-data_ authors, Ward Bell,
+> is an original core Breeze contributor.
diff --git a/docs/publishing-checklist.md b/docs/publishing-checklist.md
index a11e1fdd..0178b914 100644
--- a/docs/publishing-checklist.md
+++ b/docs/publishing-checklist.md
@@ -3,9 +3,9 @@
Only a few of us are authorized to publish the npm package.
Here is our checklist.
-1. Confirm that the library builds cleanly and that the demo app can use it in production with `npm run build-all`.
+1. Run `npm run build-all` to confirm that the library builds cleanly and that the demo app can use it in production.
-1. Smoke-test the production app by running `lite-server --baseDir="dist/app"` which runs the prod app in port 3000.
+1. Run `lite-server --baseDir="dist/app"`to smoke-test the production app. It runs the prod app in port 3000.
1. Run `ng lint`. Should be clean.
diff --git a/lib/CHANGELOG.md b/lib/CHANGELOG.md
index 8ae459a6..30f47d05 100644
--- a/lib/CHANGELOG.md
+++ b/lib/CHANGELOG.md
@@ -1,8 +1,614 @@
# Angular ngrx-data library ChangeLog
-
+
-# 6.0.1-beta.6 (2018-05-24)
+# 6.0.2-beta.7 (2018-06-26)
+
+This **major release** is primarily about the new, _change tracking_ feature,
+which makes _optimistic saves_ a viable choice (possibly the preferred choice),
+now that you can recover from a save error by undoing (reverting) to the last known state of the entities on the server.
+
+This new feature provoked a cascade of changes, most of them related to change tracking.
+
+**Many of them are breaking changes**.
+Please pay close attention to the details of this release notification.
+
+## Features
+
+The new features that we think will be most widely appreciated are:
+
+* ChangeTracking
+
+ * It makes optimistic saves a viable first choice
+ * It lets you accumulate unsaved-changes in cache and then save them together transactionally if your server supports that.
+
+* The `EntityService` query and save commands return an `Observable` result.
+
+* Multiple queries and saves can be in progress concurrently.
+
+* You can cancel long-running server requests with `EntityService.cancel(correlationId)`.
+
+* The `MergeQuerySet` enables bulk cache updates with multiple collection query results.
+
+Look for these features below.
+
+### Change Tracking
+
+EntityCollections, reducers, and selectors support change tracking and undo
+via the rewritten `EntityChangeTracker` and the
+new `EntityCollection.changeState` property which replaces the `originalValues` property.
+
+Previously change tracking and undo were an incomplete, alpha feature.
+
+The ngrx-data now tracks changes properly by default and you can **_undo unsaved changes_**, reverting to the last know state of entity (or entities) on the server.
+This gives the developer a good option for recovering from optimistic saves that failed.
+
+#### Effect on delete
+
+Thanks to change tracking, we can tell when an entity that you're about to delete has been added locally but not saved to the server.
+Ngrx-data now removes such entities immediately, even when the save is pessimistic, and silently side-steps the
+HTTP DELETE request, which has nothing to remove on the server and might 404.
+
+In order to tell `EntityEffects.persist$` to skip such deletes, we had to add a mutable `EntityAction.skip` flag to the `EntityAction`.
+
+The `skip` flag is `false` by default.
+Ngrx-data only sets it to `true` when the app tries to save the deletion of an added, unsaved entity.
+
+We're not thrilled about adding another mutable property to `EntityAction`.
+But we do not know of another way to tell `EntityEffects` to skip the HTTP DELETE request which might otherwise have produced an error.
+
+#### Disable tracking
+
+The new `EntityMetadata.noChangeTracking` flag, which is `false` by default,
+can be set `true` in the metadata for a collection.
+When `true`, ngrx-data does not track any changes for this collection
+and the `EntityCollection.changeState` property remains an empty object.
+
+You can also turnoff change tracking for a specific, cache-only action by choosing passing the `MergeStrategy.IgnoreTracking` as an option to the command.
+
+See [entity-change-tracker.md](../docs/entity-change-tracker.md) for discussion and details.
+
+### Other Features
+
+#### Dispatcher query and save methods return Observables
+
+The dispatcher query and save methods (`add`, `delete`, `update`, and `upsert`) used to return `void`.
+That was architecturally consistent with the CQRS pattern in which (c)ommands never return a result.
+Only (q)ueries in the guise of selectors returned values by way of Observables.
+
+The CQRS principle had to give way to practicality.
+Real apps often "wait" (asynchronously of course) until the save result becomes known.
+Ngrx-data saves are implemented with an ngrx _effect_ and
+effects decouple the HTTP request from the server result.
+It was difficult to know when a save operation completed, either successfully or with an error.
+
+In this release, **each of these methods return a terminating Observable of the operation result** which emits when the
+server responds and after the reducers have applied the result to the collection.
+
+> Cache-only commands, which are synchronous, continue to return `void`.
+
+Now you can subscribe to these observables to learn when the server request completed and
+to examine the result or error.
+
+```
+heroService.add(newHero).subscribe(
+ hero => ...,
+ error => ...
+);
+```
+
+> See the `EntityServices` tests for examples.
+
+This feature simplifies scenarios that used to be challenging.
+For example, you can query for the master record and then
+query for its related child records as in the following example.
+
+```
+heroService.getByKey(42)
+ .pipe(hero => {
+ sideKickService.getWithQuery({heroId: hero.id}),
+ map(sideKicks => {hero, sideKicks})
+ })
+ .subscribe((results: {hero: Hero, sideKicks: SideKicks}) => doSomething(results));
+```
+
+Of course you can stay true to CQRS and ignore these results.
+Your existing query and save command callers will continue to compile and run as before.
+
+This feature does introduce a _breaking change_ for those apps that create custom entity collection services
+and override the base methods.
+These overrides must now return an appropriate terminating Observable.
+
+#### Cancellation with the correlation id
+
+The ngrx-data associates the initiating action (e.g., `QUERY_ALL`) ngrx-data to the reply actions
+(e.g,. `QUERY_ALL_SUCCESS` and `QUERY_ALL_ERROR` with a _correlation id_,
+which it generates automatically.
+
+Alternatively you can specify the `correlationId` as an option.
+You might do so in order to cancel a long-running query.
+
+The `EntityService` (and dispatcher) offer a new `cancel` command that dispatches an EntityAction
+with the new `EntityOp.CANCEL-PERSIST`.
+
+You pass the command the correlation id for the action you want to cancel, along with an optional reason-to-cancel string.
+
+```
+// Too much time passes without a response. The user cancels
+heroCollectionService.cancel(correlationId, 'User canceled');
+```
+
+The `EntityCollectionReducer` responds to that action by turning off the loading flag
+(it would be on for a persistence operation that is still in-flight).
+
+The `EntityEffects.cancel$` effect watches for `EntityOp.CANCEL-PERSIST` actions and emits the correlation id.
+Meanwhile, the `EntityEffects.persist$` is processing the persistance EntityActions.
+It cancels an in-flight server request when it that `cancel$` has emitted
+the corresponding persistence action's correlation id.
+
+The observable returned by the original server request (e.g., `heroCollectionService.getAll(...)`)
+will emit an error with an instance of `PersistanceCanceled`, whose `message` property contains the cancellation reason.
+
+This `EntityService` test demonstrates.
+
+```
+// 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, 'PersistanceCanceled');
+ expect(error.message).toBe('Test cancel');
+ done();
+ }
+);
+
+heroCollectionService.cancel(correlationId, 'User canceled');
+```
+
+Note that cancelling a command may not stop the browser from making the HTTP request
+and it certainly can't stop the server from processing a request it received.
+
+It will prevent ngrx-data `EntityEffect` from creating and dispatching the success or failure
+actions that would otherwise update the entity cache.
+
+#### HTTP requests from `EntityCollectionService` query and save commands are now concurrent.
+
+The `EntityCollectionService` query and save commands (e.g `getAll()` and `add()`) produce EntityActions that are handled by the `EntityEffects.persist$` effect.
+
+`EntityEffects.persist$` now uses `mergeMap` so that multiple HTTP requests may be in-flight concurrently.
+
+The `persist$` method previously used `concatMap, forcing each request to wait until the previous request finished.
+
+This change may improve performance for some apps.
+
+> It may also break an app that relied on strictly sequential requests.
+
+The `persist$` method used `concatMap` previously because there was no easy way to control the order of HTTP requests
+or know when a particular command updated the collection.
+
+Now, the command observable tells you when it completed and how.
+And if command A must complete before command B, you can pipe the command observables appropriately,
+as seen in the "hero/sidekick" example above.
+
+#### _EntityDispatcherDefaultOptions_ can be provided.
+
+Previously the default for options governing whether saves were optimistic or pessimistic was
+hardwired into the `EntityDispatcherFactory`.
+The defaults were deemed to be the safest values:
+
+```
+/** 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;
+```
+
+You could (and still can) change these options at the collection level in the entity metadata.
+But you couldn't change the _defaults for all collections_.
+
+Now the collection defaults are in the `EntityDispatcherDefaultOptions` service class, which is
+provided in the `NgrxDataWithoutEffectsModule`.
+
+Your app can provide an alternative to change these defaults.
+For example, you could make all save operations optimistic by default.
+
+```
+@Injectable()
+export class OptimisticDispatcherDefaultOptions {
+ optimisticAdd = true;
+ optimisticDelete = true;
+ optimisticUpdate = true;
+}
+
+@NgModule({
+ imports: [ NgrxDataModule.forRoot({...}) ],
+ providers: [
+ { provide: EntityDispatcherDefaultOptions, useClass: OptimisticDispatcherDefaultOptions },
+ ...
+ ]
+})
+export class EntityStoreModule {}
+```
+
+#### Removed _OPTIMISTIC..._ EntityOps in favor of a flag (breaking change)
+
+The number of EntityOps and corresponding `EntityCollectionReducer` methods have been growing (see below).
+Getting rid of the `OPTIMISTIC` ops is a welcome step in the other direction and makes reducer logic
+a bit simpler.
+
+You can still choose an optimistic save with the action payload's `isOptimistic` option.
+The dispatcher defaults (see `EntityDispatcherDefaultOptions`) have not changed.
+If you don't specify `isOptimistic`, it defaults to `false` for _adds_ and _updates_ and `true` for _deletes_.
+
+#### `EntityAction` properties moved to the payload (breaking change)
+
+The properties on the `EntityAction` was also getting out of hand.
+Had we continued the trend, the `MergeStrategy` and the `isOptimistic` flag would have
+joined `entityName`, `op`, `tag` (FKA `label`), `skip`, and `error` as properties of the `EntityAction`.
+Future options would mean more action properties and more complicated EntityAction creation.
+
+This was a bad trend. From the beginning we have been uncomfortable with adding any properties to the action
+as ngrx actions out-of-the-box are just a _type_ and an optional _payload_.
+
+Now almost all properties have moved to the payload.
+
+```
+// entity-action.ts
+export interface EntityAction extends Action {
+ readonly type: string;
+ readonly payload: EntityActionPayload
;
+}
+
+export interface EntityActionPayload
{
+ readonly entityName: string;
+ readonly op: EntityOp;
+ readonly data?: P;
+ readonly correlationId?: any;
+ readonly isOptimistic?: boolean;
+ readonly mergeStrategy?: MergeStrategy;
+ readonly tag?: string;
+
+ // Mutable.
+ error?: Error;
+ skip?: boolean;
+}
+```
+
+#### New _QUERY_LOAD_ EntityOp
+
+`QUERY_ALL` used to fetch all entities of a collection and `QUERY_ALL_SUCCESS`
+reset the collection with those entities.
+That's not always correct.
+Now that you can have unsaved changes, including unsaved adds and deletes,
+querying for all entities should be able to leave those pending changes in place and
+merely update the collection.
+
+That's how `QUERY_ALL...` behaves as of this release.
+The `QUERY_ALL_SUCCESS` **merges** entities with the collection, based on the
+change tracking
+
+Yet there is still a need to _both_ clear the collection _and_ reinitialize if with all fetched entities.
+While you could achieve this with two actions, `REMOVE_ALL` and `QUERY_ALL`,
+the new `QUERY_LOAD...` does both more efficiently.
+
+The new `QUERY_LOAD...` resets entity data, clears change tracking data, and
+sets the loading (false) and loaded (true) flags.
+
+#### Added `SET_COLLECTION` EntityOp to completely replace the collection.
+
+Good for testing and rehydrating collection from local storage.
+Dangerous. Use wisely and rarely.
+
+#### Added `EntityCacheAction.MERGE_QUERY_SET`
+
+The new `EntityCacheAction.MERGE_QUERY_SET` action and corresponding `MergeQuerySet(EntityQuerySet)` ActionCreator class can merge query results
+from multiple collections into the EntityCache using the `upsert` entity collection reducer method.
+These collections all update at the same time before selectors fire.
+
+This means that collection `selectors$` emit _after all collections have been updated_.
+
+Previously, you had to merge into each collection individually.
+Each collections `selectors$` would emit
+after each collection merge.
+That behavior could provoke an unfortunate race condition
+as when adding order line items before the parent order itself.
+
+> `EntityServices` tests demonstrate these points.
+
+#### Added `EntityServices.entityActionErrors$`
+
+An observable of **error** `EntityActions` (e.g. `QUERY_ALL_ERROR`) for all entity types.
+
+#### _CorrelationIdGenerator_ and _Guid_ utility functions
+
+Ngrx-data needs a `CorrelationIdGenerator` service to coordinate multiple EntityActions.
+
+The entity dispatcher save and query methods use it to generate correlation ids that
+associate a start action with its corresponding success or error action.
+
+The ngrx-data `CorrelationIdGenerator.next()` method produces a string
+consisting of 'CRID' (for "correlation **id**") plus an increasing integer.
+
+Correlation ids are unique for a single browser session only.
+Do not use for entity ids.
+Use the _GUID_ utilities to generate entity ids.
+
+You can replace this generator by providing an alternative implementation with a `next()` method
+that returns a value of any type that serves the purpose.
+
+The new GUID utility functions - `getGuid()`, `getUuid()`, and `getGuidComb()` - generate pseudo-GUID strings
+for client-side id generation.
+The `getGuidComb()` function produces sequential guids which are sortable and often nice to SQL databases.
+
+All three produce 32-character hexadecimal UUID strings, not the 128-bit representation found in server-side languages and databases. That's less than ideal but we don't have a better alternative at this time.
+
+> The 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.
+> These utilities are classified as _experimental_ and may be withdrawn or replaced in future.
+
+## Breaking Changes
+
+### Change-tracking-related
+
+The change tracking feature required replacement of the `EntityCollection.originalValues` with `EntityCollection.changeState`.
+
+The `originalValues` property is now the `originalValue` (singular) property of `changeState`,
+which also has a `changeType` property that tells you what kind of unsaved change is being tracked.
+
+The `EntitySelectors.selectOriginalValues` and `Selectors$.originalValues$` are replaced by
+`EntitySelectors.selectChangeState` and `Selectors$.selectChangeState$`
+
+The `EntityReducerMethods` have changed to include change tracking.
+This will not affect most apps but it does mean that actions which previously did not change the collection
+may do so now by virtue of updates to the collection's `changeState` value.
+
+The `EntityDataService.update()` method still takes an `Update` but it
+returns the update entity (or null) rather than an `Update`.
+
+**If you wrote a custom `EntityDataService`, you must change the result of your `update` method accordingly.**
+
+The `EntityEffects.persist$` now handles transformation of HTTP update results
+into the `Update` before creating the update "success" actions that are
+dispatched to the store.
+
+Moving this responsibility to the `EntityEffects` makes it a little easier to
+write custom `EntityDataServices`, which won't have to deal with it,
+while ensuring that the success action payload dispatched to the store
+arrives at the reducer with the information needed for change tracking.
+
+The app or its tests _might_ expect ngrx-data to make DELETE requests when processing SAVE_DELETE... actions
+for entities that were added to the collection but not yet saved.
+
+As discussed above, when change tracking is enabled, ngrx-data no longer makes DELETE requests when processing SAVE_DELETE... actions
+for entities that were added to the collection but not yet saved.
+
+It is possible that an app or its tests expected ngrx-data to make these DELETE requests. Please correct your code/tests accordingly.
+
+### Other Breaking Changes
+
+The ChangeTracking feature has such a profound effect on the library, involving necessary breaking changes, that the occasion seemed ripe to address longstanding problems and irritants in the architecture and names.
+These repairs include additional breaking changes.
+
+#### Custom `EntityCollectionService` constructor changed.
+
+Previously, when you wrote a custom `EntityCollectionService`, you injected the `EntityCollectionServiceFactory` and passed it to your constructor like this.
+
+```
+import { Injectable } from '@angular/core';
+import { EntityCollectionServiceBase, EntityCollectionServiceFactory } from 'ngrx-data';
+
+import { Hero } from '../core';
+
+@Injectable({ providedIn: 'root' })
+export class HeroesService extends EntityCollectionServiceBase {
+ constructor(serviceFactory: EntityCollectionServiceFactory) {
+ super('Hero', serviceFactory);
+ }
+ // ... your custom service logic ...
+}
+```
+
+This was weird.
+You don't expect to inject the factory that makes that thing into the constructor of that thing.
+
+In fact, `EntityCollectionServiceFactory` wasn't behaving like a service factory.
+It merely exposed _yet another unnamed factory_, which made the core elements necessary for the service.
+
+This version of ngrx-data makes that elements factory explicit (`EntityCollectionServiceElementsFactory`).
+
+Unfortunately, this breaks your custom services.
+You'll have to modify them to inject and use the elements factory instead of the colletion service factory.
+
+Here's the updated `HeroesService` example:
+
+```
+import { Injectable } from '@angular/core';
+import { EntityCollectionServiceBase, EntityCollectionServiceElementsFactory } from 'ngrx-data';
+
+import { Hero } from '../core';
+
+
+@Injectable({ providedIn: 'root' })
+export class HeroesService extends EntityCollectionServiceBase {
+ constructor(serviceElementsFactory: EntityCollectionServiceElementsFactory) {
+ super('Hero', serviceElementsFactory);
+ }
+ // ... your custom service logic ...
+}
+```
+
+#### Custom `EntityServices` constructor changed.
+
+Those app custom `EntityServices` classes that derive from the `EntityServicesBase` class
+must be modified to suit the constructor of the new `EntityServicesBase` class.
+
+This change simplifies construction of custom `EntityServices` classes
+and reduces the risk of future change to the base class constructor.
+As the base `EntityServices` class evolves it might need new dependencies.
+These new dependencies can be delivered by the injected `EntityServicesElements` service,
+which can grow without disturbing the application derived classes.
+
+This manner of insulating custom classes from future library changes
+follows the _elements_ pattern used by the `EntityCollectionService`, as described above.
+
+Here is a custom `AppEntityServices` written for the _previous ngrx-data release_.
+
+```
+import { Injectable } from '@angular/core';
+import { Store } from '@ngrx/store';
+import {
+ EntityCache,
+ EntityCollectionServiceFactory,
+ EntityServicesBase
+} from 'ngrx-data';
+
+import { HeroesService } from '../../heroes/heroes.service';
+import { VillainsService } from '../../villains/villains.service';
+
+@Injectable()
+export class AppEntityServices extends EntityServicesBase {
+ constructor(
+ public readonly store: Store,
+ public readonly entityCollectionServiceFactory: EntityCollectionServiceFactory,
+
+ // Inject custom services, register them with the EntityServices, and expose in API.
+ public readonly heroesService: HeroesService,
+ public readonly villainsService: VillainsService
+ ) {
+ super(store, entityCollectionServiceFactory);
+ this.registerEntityCollectionServices([heroesService, villainsService]);
+ }
+}
+```
+
+The updated `AppEntityServices` is slightly smaller with fewer imports.
+
+```
+import { Injectable } from '@angular/core';
+import { EntityServicesElements, EntityServicesBase } from 'ngrx-data';
+
+import { HeroesService } from '../../heroes/heroes.service';
+import { VillainsService } from '../../villains/villains.service';
+
+@Injectable()
+export class AppEntityServices extends EntityServicesBase {
+ constructor(
+ entityServicesElements: EntityServicesElements,
+
+ // Inject custom services, register them with the EntityServices, and expose in API.
+ public readonly heroesService: HeroesService,
+ public readonly villainsService: VillainsService
+ ) {
+ super(entityServicesElements);
+ this.registerEntityCollectionServices([heroesService, villainsService]);
+ }
+}
+```
+
+#### `EntityServices.store` and `.entityCollectionServiceFactory` no longer in the API.
+
+We could not see why these members should be part of the public `EntityServices` API.
+Removed them so we don't have to support them.
+
+A custom `EntityService` could inject them in its own constructor if need be.
+Developers can petition to re-expose them if they can offer good reasons.
+
+#### `EntityAction` properties moved to the payload.
+
+This change, described earlier, affects those developers who worked directly with `EntityAction` instances.
+
+#### `EntityAction.op` property renamed `entityOp`
+
+While moving entity action properties to `EntityAction.payload`, the `op` property was renamed `entityOp` for three reasons.
+
+1. The rename reduces the likelihood that a non-EntityAction payload has a similarly named property that
+ would cause ngrx-data to treat the action as an `EntityAction`.
+
+1. It should always have been called `entityOp` for consistency with its type as the value of the `EntityOp` enumeration.
+
+1. The timing is right because the relocation of properties to the payload is already a breaking change.
+
+#### _EntityCollectionService_ members only reference the collection.
+
+Formerly such services exposed the `entityCache`, the `store` and a `dispatch` method, all of which are outside of the `EntityCollection` targeted by the service.
+
+They've been removed from the `EntityCollectionService` API.
+Use `EntityServices` instead to access the `entityCache`, the `store`,
+a general dispatcher of `Action`, etc.
+
+#### Service dispatcher query and save methods must return an Observable
+
+The query and save commands should return an Observable as described above. This is a _breaking change_ for those apps that create custom entity collection services
+and override the base methods.
+Such overrides must now return an appropriate terminating Observable.
+
+#### `EntityEffects.persist$` uses `mergeMap` instead of `concatMap`.
+
+`EntityEffects.persist$` uses `mergeMap` so that multiple HTTP requests may be in-flight concurrently.
+
+Previously used `concatMap`, which meant that ngrx-data did not make a new HTTP request until the previous request finished.
+
+This change may break an app that counted upon strictly sequential HTTP requests.
+
+#### Renamed `EntityAction.label`.
+
+The word "label" is semantically too close to the word "type" in `Action.type` and easy to confuse with the type.
+"Tag" conveys the freedom and flexibility we're looking for.
+The EntityAction formatter will embed the "tag" in the type as it did the label.
+We hope that relatively few are affected by this renaming.
+
+More significantly, the tag (FKA label) is now part of the payload rather than a property of the action.
+
+#### `ADD_ALL` resets the loading (false) and loaded (true) flags.
+
+#### Deleted `MERGE_ENTITY_CACHE`.
+
+The cache action was never used by ngrx-data itself.
+It can be easily implemented with
+`ENTITY_CACHE_SET` and a little code to get the current entity cache state.
+
+#### Moved `SET_ENTITY_CACHE` under `EntityCacheAction.SET_ENTITY_CACHE`.
+
+#### Eliminated the `OPTIMISTIC` variations of the save `EntityOps`.
+
+These entityOps and their corresponding reducer methods are gone.
+Now there is only one _add_, _update_, and _delete_ operation.
+Their reducers behave pessimistically or
+optimistically based on the `isOptimistic` flag in the `EntityActionOptions` in the action payload.
+
+This change does not affect the primary application API and should break only those apps that delved below
+the ngrx-data surface such as apps that implement their own entity action reducers.
+
+#### _EntityReducerFactory_ is now _EntityCacheReducerFactory_ and _EntityCollectionReducerRegistry_
+
+The former `_EntityReducerFactory_` combined two purposes
+
+1. Creator of the reducer for actions applying to the `EntityCache` as a whole.
+2. Registry of the `EntityCollectionReducers` that apply to individual collections.
+
+The need for separating these concerns became apparent as we enriched the actions that apply to the entire `EntityCache`.
+
+This change breaks apps that registered collection reducers directly with the former `_EntityReducerFactory_`.
+Resolve by importing `EntityCollectionReducerRegistry` instead and calling the same registration methods on it.
+
+#### Renamed _DefaultEntityCollectionServiceFactory_ to _EntityCollectionServiceFactoryBase_.
+
+Renamed for consistence with other members of the _EntityServices_ family.
+A breaking change for the rare app that referenced this factory directly.
+
+#### Renamed _DefaultDispatcherOptions_ to _EntityDispatcherDefaultOptions_.
+
+Renamed for clarity.
+
+
+
+# 6.0.0-beta.6 (2018-05-24)
## _EntityActions_ replaced by _EntityAction operators_
@@ -52,9 +658,7 @@ export class NgrxDataToastService {
actions$
.where(ea => ea.op.endsWith(OP_SUCCESS) || ea.op.endsWith(OP_ERROR))
// this service never dies so no need to unsubscribe
- .subscribe(action =>
- toast.openSnackBar(`${action.entityName} action`, action.op)
- );
+ .subscribe(action => toast.openSnackBar(`${action.entityName} action`, action.op));
}
}
```
@@ -83,14 +687,9 @@ export class NgrxDataToastService {
// filter first for EntityActions with ofEntityOp() (those with an EntityOp)
ofEntityOp(),
// use filter() instead of where()
- filter(
- (ea: EntityAction) =>
- ea.op.endsWith(OP_SUCCESS) || ea.op.endsWith(OP_ERROR)
- )
+ filter((ea: EntityAction) => ea.op.endsWith(OP_SUCCESS) || ea.op.endsWith(OP_ERROR))
)
- .subscribe(action =>
- toast.openSnackBar(`${action.entityName} action`, action.op)
- );
+ .subscribe(action => toast.openSnackBar(`${action.entityName} action`, action.op));
}
}
```
@@ -114,9 +713,9 @@ Import it instead of `NgrxDataModule`, like this.
export class EntityAppModule {...}
```
-
+
-# 6.0.1-beta.5 (2018-05-23)
+# 6.0.0-beta.5 (2018-05-23)
* Update to `@ngrx v.6.0.1` (updated package.json)
@@ -125,7 +724,7 @@ export class EntityAppModule {...}
one that is both rare and easily worked around (see [ng-packager issue 901](https://github.com/dherges/ng-packagr/issues/901))
(note: these changes were committed directly to master in SHA ede9d (ede9d39d776752ba2e2fa03d48c06a7f0bbbbf30)).
-
+
# 6.0.0-beta.4 (2018-05-22)
@@ -136,7 +735,7 @@ export class EntityAppModule {...}
* Feature: Default reducer sets collection's `loaded` flag true in ADD\*ALL action. Used to only do so in QUERY_ALL_SUCCESS. This \_might be a **breaking change\*** for a very few.
-
+
# 6.0.0-beta.3 (2018-05-18)
@@ -162,7 +761,7 @@ customize it rather than simply replace a method in the `reducerMethods` diction
* refactored `DefaultLogger` for better console output.
* delete reducers now accept the entity as well as just the entity id
-
+
# 6.0.0-beta.2 (2018-05-08)
@@ -188,7 +787,7 @@ In a related change, the former `EntityService.entityCache$` selector has been r
See the _Entity Services_ doc (in the repo at `docs/entity-services.md`) for a discussion and
examples of this new class.
-
+
# 6.0.0-beta.1 (2018-05-08)
@@ -211,11 +810,11 @@ In a related change, the former `EntityService.entityCache$` selector has been r
-
+
# 1.0.0-beta.13 (2018-05-04)
-
+
Fix AOT build error (#145) by removing all barrels.
@@ -235,7 +834,7 @@ These fixes should not break library consumers because they cannot reach into th
* Refactoring of internal utils files to put the interfaces in their own file
* Update packages to the last v5 versions.
-
+
# 1.0.0-beta.11 (2018-04-29)
@@ -245,7 +844,7 @@ Add `EntityCacheSelector` which can be injected
Minor _break_ in signature of `EntitySelectorFactory$` constructor to use `EntityCacheSelector`
instead of `EntitySelectorFactory`
-
+
# 1.0.0-beta.10 (2018-04-22)
@@ -259,7 +858,7 @@ The **`related-entity-selectors.spec.ts`** demonstrates usage.
* break: shuffled methods and pruned members that shouldn't be exposed.
* break: certain create functions are now members of factories.
-
+
# 1.0.0-beta.9 (2018-04-14)
@@ -274,7 +873,7 @@ The **`related-entity-selectors.spec.ts`** demonstrates usage.
* break: `EntityActionGuard` completely overhauled. Will break the few who call it directly.
-
+
# 1.0.0-beta.8 (2018-04-14)
@@ -286,9 +885,10 @@ This makes it much easier to customize the reducer methods.
Formerly they were hidden within the `switch()`, which meant you had to effectively replace the
entire collection reducer if you wanted to change the behavior of a single action.
-The default collection reducer methods are in the `DefaultEntityCollectionReducerMethods`.
-This produces a map of `EntityOps` to entity collection reducer methods.
-These methods rely on the `DefaultEntityCollectionReducerMethods` class properties
+The default collection reducer methods are in the `EntityCollectionReducerMethods`.
+This produces a map (`EntityCollectionReducerMethodsMap`) of `EntityOps` to entity collection reducer methods.
+
+These methods rely on the `EntityCollectionReducerMethods` class members
that you can override.
Alternatively, you can replace any of them by name with your own method.
You could also add new "operations" and reducer methods to meet your custom needs.
@@ -297,7 +897,7 @@ You'll probably make such changes within a replacement implementation of the
`EntityCollectionReducerMethodsFactory`, an abstract class with _one_ simple method,
that is the injection token for a class that produces `EntityCollectionReducerMethods`.
-
+
# 1.0.0-beta.7 (2018-04-13)
@@ -309,14 +909,14 @@ that is the injection token for a class that produces `EntityCollectionReducerMe
may call the new logger with the error
* tests: repair a few
-
+
# 1.0.0-beta.6 (2018-04-12)
* refactor `DefaultDataService` to not use `pipe` static (gone in v6).
Simplified it and deleted (internal) `make-response-delay.ts` in the process.
-
+
# 1.0.0-beta.5 (2018-04-10)
@@ -335,21 +935,21 @@ that is the same as that in `EntityEffects`.
Also refactored the `EntityCollectionReducer` to be a little smarter when setting flags.
-
+
# 1.0.0-beta.4 (2018-04-09)
* Feature: add SET_LOADED and SET_LOADING entity ops
* RequestData.options is now optional
-
+
# 1.0.0-beta.3 (2018-03-24)
* Feature: enable replacement of `EntityEffects` via `@ngrx/effects` backdoor;
see `NgrxDataModule` and its new test.
-
+
# release 1.0.0-beta.2 (2018-03-19)
@@ -388,7 +988,7 @@ const defaultDataServiceConfig: DefaultDataServiceConfig = {
}
```
-
+
# release 1.0.0-beta.1 (2018-03-14)
@@ -396,25 +996,25 @@ WAHOO! We're in beta!
-
+
# release 1.0.0-alpha.18 (2018-03-14)
Fix error-logging bug in `EntityReducerFactory` (thanks Colin).
-
+
# release 1.0.0-alpha.17 (2018-03-13)
Remove circular refs within the library that caused `ng build --prod` to fail.
-
+
# release 1.0.0-alpha.16 (2018-03-10)
* Feature: EntitySelectors$.errors$ makes it easier to subscribe to entity action errors in a component.
-
+
# release 1.0.0-alpha.15 (2018-03-09)
@@ -422,7 +1022,7 @@ Remove circular refs within the library that caused `ng build --prod` to fail.
with `@ngrx/effects/Actions`.
This is a **Breaking Change**, although we expect low impact because the feature only became viable today.
-
+
# release 1.0.0-alpha.14 (2018-03-09)
@@ -430,7 +1030,7 @@ Remove circular refs within the library that caused `ng build --prod` to fail.
* Bug fix: DefaultDataService adds delay on server errors too (see `makeResponseDelay`).
* Corrected text of the type constants for the QUERY_BY_KEY actions.
-
+
# release 1.0.0-alpha.13 (2018-03-07)
@@ -442,13 +1042,13 @@ New Features:
* `entityCache$` observable selector on `EntityCollectionService` and `EntitySelectors$Factory` enable watching of the entire cache.
-
+
# release 1.0.0-alpha.12 (2018-03-05)
* EntityEffects.persist is now public, mostly for easier testing
-
+
# release 1.0.0-alpha.11 (2018-03-05)
@@ -461,7 +1061,7 @@ Small refactors for testability
* EntityEffects: expose `persistOps`
* EntityReducerFactory: new `getOrCreateReducer()` method.
-
+
# release 1.0.0-alpha.10 (2018-02-24)
@@ -479,7 +1079,7 @@ They also guard against collisions with your custom entity action types
The file renaming and restructuring is for easier reading.
Shouldn't affect applications which do not deep link into the library.
-
+
# release 1.0.0-alpha.9 (2018-02-23)
@@ -503,14 +1103,14 @@ See demo app's `HeroesService` and `VillainsService`.
It is useful for simple service creation
and when defining an entity service class with much smaller API service.
-
+
# release 1.0.0-alpha.8 (2018-02-19)
* renamed `EntityActions.filter` to `EntityActions.where`.
Fixes conflict with `import 'rxjs/add/operator/filter';` #97 [minor breaking change]
-
+
# release 1.0.0-alpha.7 (2018-02-19)
@@ -533,19 +1133,19 @@ and when defining an entity service class with much smaller API service.
* The previous save operations did not revert entities that failed to save.
The next version will revert those entities after a save error.
-
+
# release 1.0.0-alpha.6 (2018-02-14)
* Add DefaultDataService error handling and their tests
-
+
# release 1.0.0-alpha.5 (2018-02-14)
* Workaround redux tools replay bug
-
+
# release 1.0.0-alpha.4 (2018-02-13)
@@ -561,26 +1161,26 @@ you must upgrade _ngrx_ to v5.1 or later,
because the reducer uses the "upsert" feature, new in `@ngrx/entity` v5.1,
for `QUERY_ONE_SUCCESS` and `QUERY_MANY_SUCCESS`.
-
+
# 1.0.0-alpha.3 (2018-02-12)
* Added `EntityCollectionMetaReducer`s for easier customization of `EntityCollectionReducer` behavior.
* Documented entity reducers.
-
+
# 1.0.0-alpha.2 (2018-02-11)
Updated with more extension points
-
+
# 1.0.0-alpha.1 (2018-02-06)
Working release deployed to npm
-
+
# 1.0.0-alpha.0 (2018-02-04)
diff --git a/lib/package.json b/lib/package.json
index 982673de..310c6299 100644
--- a/lib/package.json
+++ b/lib/package.json
@@ -1,6 +1,6 @@
{
"name": "ngrx-data",
- "version": "6.0.0-beta.6",
+ "version": "6.0.2-beta.7",
"repository": "https://github.com/johnpapa/angular-ngrx-data.git",
"license": "MIT",
"peerDependencies": {
diff --git a/lib/src/actions/entity-action-factory.spec.ts b/lib/src/actions/entity-action-factory.spec.ts
new file mode 100644
index 00000000..2260eca9
--- /dev/null
+++ b/lib/src/actions/entity-action-factory.spec.ts
@@ -0,0 +1,159 @@
+import { EntityAction, EntityActionOptions, EntityActionPayload } from './entity-action';
+import { EntityOp } from './entity-op';
+import { EntityActionFactory } from './entity-action-factory';
+import { MergeStrategy } from './merge-strategy';
+import { 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, 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)).toThrow();
+ });
+
+ it('should throw if do not specify EntityOp', () => {
+ expect(() => factory.create({ entityName: 'Hero', entityOp: null })).toThrow();
+ });
+});
diff --git a/lib/src/actions/entity-action-factory.ts b/lib/src/actions/entity-action-factory.ts
new file mode 100644
index 00000000..3f6a9beb
--- /dev/null
+++ b/lib/src/actions/entity-action-factory.ts
@@ -0,0 +1,59 @@
+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
;
+
+ create
(
+ nameOrPayload: EntityActionPayload
| string,
+ entityOp?: EntityOp,
+ data?: P,
+ options?: EntityActionOptions
+ ): EntityAction
{
+ const payload: EntityActionPayload
=
+ typeof nameOrPayload === 'string' ? { ...(options || {}), entityName: nameOrPayload, entityOp, data } : nameOrPayload;
+
+ const { entityName, entityOp: op, tag } = payload;
+
+ if (!entityName) {
+ throw new Error('Missing entity name for new action');
+ }
+ if (op == null) {
+ throw new Error('Missing EntityOp for new action');
+ }
+ const type = this.formatActionType(op, 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/lib/src/actions/entity-action-guard.ts b/lib/src/actions/entity-action-guard.ts
index 0762d425..82f45bdd 100644
--- a/lib/src/actions/entity-action-guard.ts
+++ b/lib/src/actions/entity-action-guard.ts
@@ -1,99 +1,110 @@
import { EntityAction } from './entity-action';
import { IdSelector, Update } from '../utils/ngrx-entity-models';
+/**
+ * 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 selectId: IdSelector) {}
+ constructor(private entityName: string, private selectId: IdSelector) {}
/** Throw if the action payload is not an entity with a valid key */
- mustBeEntity(action: EntityAction) {
- const { entityName, payload, op, type } = action;
- if (!payload) {
- this.makeError(action, `should have a single entity.`);
+ mustBeEntity(action: EntityAction): T {
+ const data = this.extractData(action);
+ if (!data) {
+ this.throwError(action, `should have a single entity.`);
}
- const id = this.selectId(payload);
+ const id = this.selectId(data);
if (this.isNotKeyType(id)) {
- this.makeError(action, `has a missing or invalid entity key (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) {
- const { entityName, payload, op, type } = action;
- if (!Array.isArray(payload)) {
- this.makeError(action, `should be an array of entities`);
+ mustBeEntities(action: EntityAction): T[] {
+ const data = this.extractData(action);
+ if (!Array.isArray(data)) {
+ this.throwError(action, `should be an array of entities`);
}
- payload.forEach((entity, i) => {
+ 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.makeError(action, msg);
+ this.throwError(action, msg);
}
});
+ return data;
}
/** Throw if the action payload is not a single, valid key */
- mustBeKey(action: EntityAction) {
- const { entityName, payload, op, type } = action;
- if (!payload) {
+ mustBeKey(action: EntityAction): string | number {
+ const data = this.extractData(action);
+ if (!data) {
throw new Error(`should be a single entity key`);
}
- if (this.isNotKeyType(payload)) {
+ 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)[]>) {
- const { entityName, payload, op, type } = action;
- if (!Array.isArray(payload)) {
- this.makeError(action, `should be an array of entity keys (id)`);
+ mustBeKeys(action: EntityAction<(string | number)[]>): (string | number)[] {
+ const data = this.extractData(action);
+ if (!Array.isArray(data)) {
+ this.throwError(action, `should be an array of entity keys (id)`);
}
- payload.forEach((id, i) => {
+ data.forEach((id, i) => {
if (this.isNotKeyType(id)) {
- const msg = `${entityName} ', item ${i +
- 1}, is not a valid entity key (id)`;
- this.makeError(action, msg);
+ 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>) {
- const { entityName, payload, op, type } = action;
- if (!payload) {
- this.makeError(action, `should be a single entity update`);
+ mustBeUpdate(action: EntityAction>): Update {
+ const data = this.extractData(action);
+ if (!data) {
+ this.throwError(action, `should be a single entity update`);
}
- const { id, changes } = payload;
+ const { id, changes } = data;
const id2 = this.selectId(changes);
if (this.isNotKeyType(id) || this.isNotKeyType(id2)) {
- this.makeError(action, `has a missing or invalid entity key (id)`);
+ 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[]>) {
- const { entityName, payload, op, type } = action;
- if (!Array.isArray(payload)) {
- this.makeError(action, `should be an array of entity updates`);
+ mustBeUpdates(action: EntityAction[]>): Update[] {
+ const data = this.extractData(action);
+ if (!Array.isArray(data)) {
+ this.throwError(action, `should be an array of entity updates`);
}
- payload.forEach((item, i) => {
+ data.forEach((item, i) => {
const { id, changes } = item;
const id2 = this.selectId(changes);
if (this.isNotKeyType(id) || this.isNotKeyType(id2)) {
- this.makeError(
- action,
- `, item ${i + 1}, has a missing or invalid entity key (id)`
- );
+ 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 */
- isNotKeyType(id: any) {
+ private isNotKeyType(id: any) {
return typeof id !== 'string' && typeof id !== 'number';
}
- makeError(action: EntityAction, msg: string) {
- throw new Error(`Action "${action.type}" payload ${msg}`);
+ private throwError(action: EntityAction, msg: string): void {
+ throw new Error(`${this.entityName} EntityAction guard for "${action.type}": payload ${msg}`);
}
}
diff --git a/lib/src/actions/entity-action-operators.spec.ts b/lib/src/actions/entity-action-operators.spec.ts
index 3762475f..b54794d3 100644
--- a/lib/src/actions/entity-action-operators.spec.ts
+++ b/lib/src/actions/entity-action-operators.spec.ts
@@ -3,7 +3,8 @@ import { Actions } from '@ngrx/effects';
import { Subject } from 'rxjs';
-import { EntityAction, EntityActionFactory } from './entity-action';
+import { EntityAction } from './entity-action';
+import { EntityActionFactory } from './entity-action-factory';
import { EntityOp } from './entity-op';
import { ofEntityType, ofEntityOp } from './entity-action-operators';
@@ -24,15 +25,8 @@ describe('EntityAction Operators', () => {
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
- ),
+ villain_query_many: entityActionFactory.create('Villain', EntityOp.QUERY_MANY),
+ hero_delete: entityActionFactory.create('Hero', EntityOp.SAVE_DELETE_ONE, 42),
bar: ({ type: 'Bar', payload: 'bar' })
};
@@ -51,11 +45,7 @@ describe('EntityAction Operators', () => {
// 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
- ];
+ const expectedActions = [testActions.hero_query_all, testActions.villain_query_many, testActions.hero_delete];
dispatchTestActions();
expect(results).toEqual(expectedActions);
});
@@ -64,19 +54,14 @@ describe('EntityAction Operators', () => {
// EntityActions of one type
actions.pipe(ofEntityType('Hero')).subscribe(ea => results.push(ea));
- const expectedActions = [
- testActions.hero_query_all,
- testActions.hero_delete
- ];
+ 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));
+ actions.pipe(ofEntityType('Hero', 'Villain', 'Bar')).subscribe(ea => results.push(ea));
ofEntityTypeTest();
});
@@ -117,9 +102,7 @@ describe('EntityAction Operators', () => {
///////////////
it('#ofEntityOp with string args', () => {
- actions
- .pipe(ofEntityOp(EntityOp.QUERY_ALL, EntityOp.QUERY_MANY))
- .subscribe(ea => results.push(ea));
+ actions.pipe(ofEntityOp(EntityOp.QUERY_ALL, EntityOp.QUERY_MANY)).subscribe(ea => results.push(ea));
ofEntityOpTest();
});
@@ -142,20 +125,13 @@ describe('EntityAction Operators', () => {
// 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
- ];
+ 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
- ];
+ const expectedActions = [testActions.hero_query_all, testActions.villain_query_many];
dispatchTestActions();
expect(results).toEqual(expectedActions);
}
diff --git a/lib/src/actions/entity-action-operators.ts b/lib/src/actions/entity-action-operators.ts
index 063698f2..c7501736 100644
--- a/lib/src/actions/entity-action-operators.ts
+++ b/lib/src/actions/entity-action-operators.ts
@@ -16,29 +16,24 @@ import { flattenArgs } from '../utils/utilities';
* 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 `op` property
+ * 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 {
+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.op);
+ 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 => op === action.op);
+ return filter((action: EntityAction): action is T => action.payload && op === action.payload.entityOp);
default:
- return filter((action: EntityAction): action is T =>
- ops.some(entityOp => entityOp === action.op)
- );
+ return filter((action: EntityAction): action is T => {
+ const entityOp = action.payload && action.payload.entityOp;
+ return entityOp && ops.some(o => o === entityOp);
+ });
}
}
@@ -54,27 +49,20 @@ export function ofEntityOp(
* this.actions.pipe(ofEntityType(theChosen), ...)
* ```
*/
-export function ofEntityType(
- allowedEntityNames?: string[]
-): OperatorFunction;
-export function ofEntityType(
- ...allowedEntityNames: string[]
-): OperatorFunction;
-export function ofEntityType(
- ...allowedEntityNames: any[]
-): OperatorFunction {
+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.entityName);
+ 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 => name === action.entityName
- );
+ return filter((action: EntityAction): action is T => action.payload && name === action.payload.entityName);
default:
- return filter((action: EntityAction): action is T =>
- names.some(entityName => entityName === action.entityName)
- );
+ return filter((action: EntityAction): action is T => {
+ const entityName = action.payload && action.payload.entityName;
+ return entityName && names.some(n => n === entityName);
+ });
}
}
diff --git a/lib/src/actions/entity-action.spec.ts b/lib/src/actions/entity-action.spec.ts
deleted file mode 100644
index cc6e2a6e..00000000
--- a/lib/src/actions/entity-action.spec.ts
+++ /dev/null
@@ -1,73 +0,0 @@
-import { Action } from '@ngrx/store';
-
-import { EntityAction, EntityActionFactory } from './entity-action';
-import { EntityOp } from './entity-op';
-
-class Hero {
- id: number;
- name: string;
-}
-
-describe('EntityActionFactory', () => {
- let factory: EntityActionFactory;
-
- beforeEach(() => {
- factory = new EntityActionFactory();
- });
-
- it('should create expected EntityAction for named entity', () => {
- const hero: Hero = { id: 42, name: 'Francis' };
- const action = factory.create('Hero', EntityOp.ADD_ONE, hero);
- expect(action.entityName).toBe('Hero');
- expect(action.op).toBe(EntityOp.ADD_ONE);
- expect(action.payload).toBe(hero);
- });
-
- it('should create EntityAction from another EntityAction', () => {
- const hero: Hero = { id: 42, name: 'Francis' };
- const action1 = factory.create('Hero', EntityOp.ADD_ONE, hero);
- const action = factory.create(action1, EntityOp.SAVE_ADD_ONE);
- expect(action.entityName).toBe('Hero');
- expect(action.op).toBe(EntityOp.SAVE_ADD_ONE);
- // Forward's the payload to the new action.
- expect(action.payload).toBe(hero);
- });
-
- it('can suppress the payload when create EntityAction from another EntityAction', () => {
- const hero: Hero = { id: 42, name: 'Francis' };
- const action1 = factory.create('Hero', EntityOp.ADD_ONE, hero);
- const action = factory.create(action1, EntityOp.SAVE_ADD_ONE, undefined);
- expect(action.entityName).toBe('Hero');
- expect(action.op).toBe(EntityOp.SAVE_ADD_ONE);
- expect(action.payload).toBeUndefined();
- });
-
- it('should format type as expected with #formatActionTypeName()', () => {
- const action = factory.create('Hero', EntityOp.QUERY_ALL);
- const expectedFormat = factory.formatActionType(EntityOp.QUERY_ALL, 'Hero');
- expect(action.type).toBe(expectedFormat);
- });
-
- it('should format type with given label instead of the entity name', () => {
- const label = 'Hero - Label Test';
- const action = factory.create('Hero', EntityOp.QUERY_ALL, null, label);
- expect(action.type).toContain(label);
- });
-
- it('can re-format generated action.type with 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 entity name', () => {
- expect(() => factory.create(null)).toThrow();
- });
-
- it('should throw if do not specify EntityOp', () => {
- expect(() => factory.create('Hero')).toThrow();
- });
-});
diff --git a/lib/src/actions/entity-action.ts b/lib/src/actions/entity-action.ts
index f2a0c67d..f658e4f6 100644
--- a/lib/src/actions/entity-action.ts
+++ b/lib/src/actions/entity-action.ts
@@ -2,57 +2,44 @@ 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 entityName: string;
- readonly op: EntityOp;
- readonly payload?: P;
- /** The label to use in the action's type. The entityName if no label specified. */
- readonly label?: string;
- // The only mutable property because
- // it's the only way to stop downstream action processing
- /** The action was determined (usually by a reducer) to be in error and should not be processed. */
- error?: Error;
+ readonly payload: EntityActionPayload
;
}
-@Injectable()
-export class EntityActionFactory {
- create
(
- nameOrAction: string | EntityAction,
- op?: EntityOp,
- payload?: P,
- label?: string,
- error?: Error
- ): EntityAction
{
- let entityName: string;
+/** 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;
- if (typeof nameOrAction === 'string') {
- if (nameOrAction == null) {
- throw new Error('Missing entity name for new action');
- }
- if (op == null) {
- throw new Error('Missing EntityOp for new action');
- }
- entityName = nameOrAction.trim();
- } else {
- // is an EntityAction
- entityName = nameOrAction.entityName;
- label = label || nameOrAction.label;
- op = op || nameOrAction.op;
- if (arguments.length < 3) {
- payload = nameOrAction.payload;
- }
- }
- label = (label || entityName).trim();
- const type = this.formatActionType(op, label);
- return error
- ? { type, entityName, op, payload, label, error }
- : { type, entityName, op, payload, label };
- }
+ // 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;
- formatActionType(op: string, label: string) {
- return `[${label}] ${op}`;
- // return `${op} [${label}]`.toUpperCase(); // an alternative
- }
+ /**
+ * 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/lib/src/actions/entity-cache-action.ts b/lib/src/actions/entity-cache-action.ts
new file mode 100644
index 00000000..7c2565fd
--- /dev/null
+++ b/lib/src/actions/entity-cache-action.ts
@@ -0,0 +1,58 @@
+/*
+ * Actions dedicated to the EntityCache as a whole
+ */
+import { Action } from '@ngrx/store';
+
+import { EntityCache } from '../reducers/entity-cache';
+import { EntityActionOptions } from '../actions/entity-action';
+import { MergeStrategy } from '../actions/merge-strategy';
+
+export enum EntityCacheAction {
+ MERGE_QUERY_SET = 'ngrx-data/entity-cache/merge-query-set',
+ SET_ENTITY_CACHE = 'ngrx-data/entity-cache/set-cache'
+}
+
+/**
+ * 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[];
+}
+
+/**
+ * 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
+ */
+export class MergeQuerySet implements Action {
+ readonly payload: {
+ querySet: EntityCacheQuerySet;
+ mergeStrategy?: MergeStrategy;
+ };
+
+ readonly type = EntityCacheAction.MERGE_QUERY_SET;
+
+ constructor(querySet: EntityCacheQuerySet, mergeStrategy?: MergeStrategy) {
+ this.payload = {
+ querySet,
+ mergeStrategy: mergeStrategy === null ? MergeStrategy.PreserveChanges : mergeStrategy
+ };
+ }
+}
+
+/**
+ * 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.
+ */
+export class SetEntityCache implements Action {
+ readonly type = EntityCacheAction.SET_ENTITY_CACHE;
+ constructor(public readonly payload: EntityCache) {}
+}
diff --git a/lib/src/actions/entity-cache-actions.ts b/lib/src/actions/entity-cache-actions.ts
deleted file mode 100644
index ffc13d1d..00000000
--- a/lib/src/actions/entity-cache-actions.ts
+++ /dev/null
@@ -1,30 +0,0 @@
-/*
- * Actions dedicated to the EntityCache as a whole
- */
-import { Action } from '@ngrx/store';
-
-import { EntityCache } from '../reducers/entity-cache';
-
-export const MERGE_ENTITY_CACHE = 'ngrx-data/entity-cache/merge';
-export const SET_ENTITY_CACHE = 'ngrx-data/entity-cache/set';
-
-/**
- * Create cache merge operation that replaces the collections in cache with the
- * collections in the payload.
- * Dangerous but useful as when re-hydrating an EntityCache from local browser storage
- * or rolling selected collections back to a previously known state.
- */
-export class EntityCacheMerge implements Action {
- readonly type = MERGE_ENTITY_CACHE;
- constructor(public readonly payload: EntityCache) {}
-}
-
-/**
- * Create cache set operation that replaces the entire entity cache.
- * Dangerous but useful as when re-hydrating an EntityCache from local browser storage
- * when the application launches.
- */
-export class EntityCacheSet implements Action {
- readonly type = SET_ENTITY_CACHE;
- constructor(public readonly payload: EntityCache) {}
-}
diff --git a/lib/src/actions/entity-op.ts b/lib/src/actions/entity-op.ts
index 8aa4c6b6..695288d2 100644
--- a/lib/src/actions/entity-op.ts
+++ b/lib/src/actions/entity-op.ts
@@ -1,19 +1,19 @@
-/** "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';
-
// 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 {
- // Persisting Actions (more to come)
+ // Persistance operations
+ CANCEL_PERSIST = 'ngrx-data/cancel-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',
@@ -34,31 +34,44 @@ export enum EntityOp {
SAVE_UPDATE_ONE_SUCCESS = 'ngrx-data/save/update-one/success',
SAVE_UPDATE_ONE_ERROR = 'ngrx-data/save/update-one/error',
- SAVE_ADD_ONE_OPTIMISTIC = 'ngrx-data/save/add-one/optimistic',
- SAVE_ADD_ONE_OPTIMISTIC_ERROR = 'ngrx-data/save/add-one/optimistic/error',
- SAVE_ADD_ONE_OPTIMISTIC_SUCCESS = 'ngrx-data/save/add-one/optimistic/success',
-
- SAVE_DELETE_ONE_OPTIMISTIC = 'ngrx-data/save/delete-one/optimistic',
- SAVE_DELETE_ONE_OPTIMISTIC_SUCCESS = 'ngrx-data/save/delete-one/optimistic/success',
- SAVE_DELETE_ONE_OPTIMISTIC_ERROR = 'ngrx-data/save/delete-one/optimistic/error',
-
- SAVE_UPDATE_ONE_OPTIMISTIC = 'ngrx-data/save/update-one/optimistic',
- SAVE_UPDATE_ONE_OPTIMISTIC_SUCCESS = 'ngrx-data/save/update-one/optimistic/success',
- SAVE_UPDATE_ONE_OPTIMISTIC_ERROR = 'ngrx-data/save/update-one/optimistic/error',
-
- // Cache actions
+ // 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',
- REMOVE_ALL = 'ngrx-data/remove-all',
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/lib/src/actions/merge-strategy.ts b/lib/src/actions/merge-strategy.ts
new file mode 100644
index 00000000..3b9caed9
--- /dev/null
+++ b/lib/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/lib/src/dataservices/data-service-error.ts b/lib/src/dataservices/data-service-error.ts
index 406c22c3..fab1a641 100644
--- a/lib/src/dataservices/data-service-error.ts
+++ b/lib/src/dataservices/data-service-error.ts
@@ -1,17 +1,24 @@
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 HttpResponse error 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 {
- readonly message: string;
+ message: string;
+
constructor(public error: any, public requestData: RequestData) {
- this.message =
- (error.error && error.error.message) ||
- (error.message || (error.body && error.body.error) || error).toString();
+ this.message = (error.error && error.error.message) || (error.message || (error.body && error.body.error) || error).toString();
}
}
/** Payload for an EntityAction data service error such as QUERY_ALL_ERROR */
export interface EntityActionDataServiceError {
- originalAction: EntityAction;
error: DataServiceError;
+ originalAction: EntityAction;
}
diff --git a/lib/src/dataservices/default-data.service.spec.ts b/lib/src/dataservices/default-data.service.spec.ts
index 649a440e..5bb4aabf 100644
--- a/lib/src/dataservices/default-data.service.spec.ts
+++ b/lib/src/dataservices/default-data.service.spec.ts
@@ -1,24 +1,13 @@
import { TestBed } from '@angular/core/testing';
import { HttpClient, HttpResponse } from '@angular/common/http';
-import {
- HttpClientTestingModule,
- HttpTestingController
-} from '@angular/common/http/testing';
+import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
import { of } from 'rxjs';
-import {
- DefaultDataService,
- DefaultDataServiceFactory,
- DefaultDataServiceConfig
-} from './default-data.service';
+import { DefaultDataService, DefaultDataServiceFactory, DefaultDataServiceConfig } from './default-data.service';
import { DataServiceError } from './data-service-error';
-import {
- DefaultHttpUrlGenerator,
- EntityHttpResourceUrls,
- HttpUrlGenerator
-} from './http-url-generator';
+import { DefaultHttpUrlGenerator, EntityHttpResourceUrls, HttpUrlGenerator } from './http-url-generator';
import { Update } from '../utils/ngrx-entity-models';
class Hero {
@@ -102,16 +91,7 @@ describe('DefaultDataService', () => {
});
it('should return expected heroes (called once)', () => {
- service
- .getAll()
- .subscribe(
- heroes =>
- expect(heroes).toEqual(
- expectedHeroes,
- 'should return expected heroes'
- ),
- fail
- );
+ 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);
@@ -122,13 +102,7 @@ describe('DefaultDataService', () => {
});
it('should be OK returning no heroes', () => {
- service
- .getAll()
- .subscribe(
- heroes =>
- expect(heroes.length).toEqual(0, 'should have empty heroes array'),
- fail
- );
+ 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
@@ -137,16 +111,7 @@ describe('DefaultDataService', () => {
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
- );
+ 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()');
@@ -164,10 +129,7 @@ describe('DefaultDataService', () => {
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 instanceof DataServiceError).toBe(true, 'is DataServiceError');
expect(err.error.status).toEqual(404, 'has 404 status');
expect(err.message).toEqual(msg, 'has expected error message');
}
@@ -196,13 +158,7 @@ describe('DefaultDataService', () => {
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
- );
+ 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);
@@ -244,14 +200,7 @@ describe('DefaultDataService', () => {
it('should return expected selected heroes w/ object params', () => {
service
.getWithQuery({ name: 'B' })
- .subscribe(
- heroes =>
- expect(heroes).toEqual(
- expectedHeroes,
- 'should return expected heroes'
- ),
- fail
- );
+ .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
@@ -263,16 +212,7 @@ describe('DefaultDataService', () => {
});
it('should return expected selected heroes w/ string params', () => {
- service
- .getWithQuery('name=B')
- .subscribe(
- heroes =>
- expect(heroes).toEqual(
- expectedHeroes,
- 'should return expected heroes'
- ),
- fail
- );
+ 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
@@ -284,13 +224,7 @@ describe('DefaultDataService', () => {
});
it('should be OK returning no heroes', () => {
- service
- .getWithQuery({ name: 'B' })
- .subscribe(
- heroes =>
- expect(heroes.length).toEqual(0, 'should have empty heroes array'),
- fail
- );
+ 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
@@ -300,14 +234,10 @@ describe('DefaultDataService', () => {
const msg = 'deliberate 404 error';
service.getWithQuery({ name: 'B' }).subscribe(
- heroes =>
- fail('getWithQuery succeeded when expected it to fail with a 404'),
+ 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 instanceof DataServiceError).toBe(true, 'is DataServiceError');
expect(err.error.status).toEqual(404, 'has 404 status');
expect(err.message).toEqual(msg, 'has expected error message');
}
@@ -327,18 +257,10 @@ describe('DefaultDataService', () => {
it('should return expected hero with id', () => {
expectedHero = { id: 42, name: 'A' };
- service
- .add({ name: 'A' } as Hero)
- .subscribe(
- hero =>
- expect(hero).toEqual(expectedHero, 'should return expected hero'),
- fail
- );
+ service.add({ name: 'A' } as Hero).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
- );
+ const req = httpTestingController.expectOne(r => r.method === 'POST' && r.url === heroUrl);
// Respond with the expected hero
req.flush(expectedHero);
@@ -358,36 +280,20 @@ describe('DefaultDataService', () => {
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
- );
+ 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
- );
+ const req = httpTestingController.expectOne(r => r.method === 'DELETE' && r.url === heroUrlId1);
// 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
- );
+ 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
- );
+ const req = httpTestingController.expectOne(r => r.method === 'DELETE' && r.url === heroUrlId1);
// Respond with empty nonsense object
req.flush({});
@@ -432,24 +338,10 @@ describe('DefaultDataService', () => {
// The server makes the update AND updates the version concurrency property.
const expectedHero: Hero = { id: 1, name: 'B', version: 2 };
- // Service must return an Update
- const expectedUpdate: Update = { id: 1, changes: expectedHero };
-
- service
- .update(updateArg)
- .subscribe(
- updated =>
- expect(updated).toEqual(
- expectedUpdate,
- 'should return expected hero update'
- ),
- fail
- );
+ 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
- );
+ const req = httpTestingController.expectOne(r => r.method === 'PUT' && r.url === heroUrlId1);
// Respond with the expected hero
req.flush(expectedHero);
@@ -516,11 +408,7 @@ describe('DefaultDataServiceFactory', () => {
describe('(with config)', () => {
it('can create factory', () => {
const config: DefaultDataServiceConfig = { root: 'api' };
- const factory = new DefaultDataServiceFactory(
- http,
- httpUrlGenerator,
- config
- );
+ const factory = new DefaultDataServiceFactory(http, httpUrlGenerator, config);
const heroDS = factory.create('Hero');
expect(heroDS.name).toBe('Hero DefaultDataService');
});
@@ -536,11 +424,7 @@ describe('DefaultDataServiceFactory', () => {
}
}
};
- const factory = new DefaultDataServiceFactory(
- http,
- httpUrlGenerator,
- config
- );
+ const factory = new DefaultDataServiceFactory(http, httpUrlGenerator, config);
const heroDS = factory.create('Hero');
heroDS.getAll();
expect(http.get).toHaveBeenCalledWith(newHeroesUrl, undefined);
diff --git a/lib/src/dataservices/default-data.service.ts b/lib/src/dataservices/default-data.service.ts
index abc0f4fc..bd13dec4 100644
--- a/lib/src/dataservices/default-data.service.ts
+++ b/lib/src/dataservices/default-data.service.ts
@@ -1,9 +1,5 @@
import { Injectable, Optional } from '@angular/core';
-import {
- HttpClient,
- HttpErrorResponse,
- HttpParams
-} from '@angular/common/http';
+import { HttpClient, HttpErrorResponse, HttpParams } from '@angular/common/http';
import { Observable, of, throwError } from 'rxjs';
import { catchError, delay, map, tap, timeout } from 'rxjs/operators';
@@ -67,14 +63,7 @@ export class DefaultDataService implements EntityCollectionDataService {
) {
this._name = `${entityName} DefaultDataService`;
this.entityName = entityName;
- const {
- root = 'api',
- delete404OK = true,
- getDelay = 0,
- saveDelay = 0,
- timeout: to = 0
- } =
- config || {};
+ 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);
@@ -84,19 +73,18 @@ export class DefaultDataService implements EntityCollectionDataService {
}
add(entity: T): Observable {
- const entityOrError =
- entity || new Error(`No "${this.entityName}" entity to add`);
+ const entityOrError = entity || new Error(`No "${this.entityName}" entity to add`);
return this.execute('POST', this.entityUrl, entityOrError);
}
- delete(key: number | string): Observable {
+ delete(key: number | string): Observable {
let err: Error;
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 any)
+ map(result => key as number | string)
);
}
@@ -113,20 +101,14 @@ export class DefaultDataService implements EntityCollectionDataService {
}
getWithQuery(queryParams: QueryParams | string): Observable {
- const qParams =
- typeof queryParams === 'string'
- ? { fromString: queryParams }
- : { fromObject: queryParams };
+ 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> {
+ update(update: Update): Observable {
const id = update && update.id;
- const updateOrError =
- id == null
- ? new Error(`No "${this.entityName}" update data or id`)
- : update;
+ const updateOrError = id == null ? new Error(`No "${this.entityName}" update data or id`) : update;
return this.execute('PUT', this.entityUrl + id, updateOrError);
}
@@ -136,7 +118,7 @@ export class DefaultDataService implements EntityCollectionDataService {
data?: any, // data, error, or undefined/null
options?: any
): Observable {
- const req: RequestData = { method, url, options };
+ const req: RequestData = { method, url, data, options };
if (data instanceof Error) {
return this.handleError(req)(data);
@@ -168,19 +150,7 @@ export class DefaultDataService implements EntityCollectionDataService {
}
// N.B.: It must return an Update
case 'PUT': {
- const { id, changes } = data; // data must be Update
- result$ = this.http.put(url, changes, options).pipe(
- map(updated => {
- // Return Update with merged updated data (if any).
- // If no data from server,
- const noData = Object.keys(updated || {}).length === 0;
- // assume the server made no additional changes of its own and
- // append `unchanged: true` to the original payload.
- return noData
- ? { ...data, unchanged: true }
- : { id, changes: { ...changes, ...updated } };
- })
- );
+ result$ = this.http.put(url, data, options);
if (this.saveDelay) {
result$ = result$.pipe(delay(this.saveDelay));
}
@@ -209,11 +179,7 @@ export class DefaultDataService implements EntityCollectionDataService {
}
private handleDelete404(error: HttpErrorResponse, reqData: RequestData) {
- if (
- error.status === 404 &&
- reqData.method === 'DELETE' &&
- this.delete404OK
- ) {
+ if (error.status === 404 && reqData.method === 'DELETE' && this.delete404OK) {
return of({});
}
return undefined;
@@ -241,11 +207,6 @@ export class DefaultDataServiceFactory {
* @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
- );
+ return new DefaultDataService(entityName, this.http, this.httpUrlGenerator, this.config);
}
}
diff --git a/lib/src/dataservices/entity-data.service.spec.ts b/lib/src/dataservices/entity-data.service.spec.ts
index 436163d1..67d2b27b 100644
--- a/lib/src/dataservices/entity-data.service.spec.ts
+++ b/lib/src/dataservices/entity-data.service.spec.ts
@@ -4,20 +4,10 @@ import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
-import {
- createEntityDefinition,
- EntityDefinition
-} from '../entity-metadata/entity-definition';
-import {
- EntityMetadata,
- EntityMetadataMap,
- ENTITY_METADATA_TOKEN
-} from '../entity-metadata/entity-metadata';
-
-import {
- DefaultDataService,
- DefaultDataServiceFactory
-} from './default-data.service';
+import { createEntityDefinition, EntityDefinition } from '../entity-metadata/entity-definition';
+import { EntityMetadata, EntityMetadataMap, ENTITY_METADATA_TOKEN } from '../entity-metadata/entity-metadata';
+
+import { DefaultDataService, DefaultDataServiceFactory } from './default-data.service';
import { HttpUrlGenerator, EntityHttpResourceUrls } from './http-url-generator';
import { EntityDataService } from './entity-data.service';
@@ -40,8 +30,7 @@ export class Bazinga {
wow: string;
}
-export class BazingaDataService
- implements EntityCollectionDataService {
+export class BazingaDataService implements EntityCollectionDataService {
name: string;
// TestBed bug requires `@Optional` even though http is always provided.
@@ -67,7 +56,7 @@ export class BazingaDataService
getWithQuery(params: string | QueryParams): Observable {
return this.bazinga();
}
- update(update: Update): Observable> {
+ update(update: Update): Observable {
return this.bazinga();
}
@@ -81,10 +70,7 @@ export class BazingaDataService
providers: [BazingaDataService]
})
export class CustomDataServiceModule {
- constructor(
- entityDataService: EntityDataService,
- bazingaService: BazingaDataService
- ) {
+ constructor(entityDataService: EntityDataService, bazingaService: BazingaDataService) {
entityDataService.registerService('Bazinga', bazingaService);
}
}
@@ -101,9 +87,7 @@ class TestHttpUrlGenerator implements HttpUrlGenerator {
collectionResource(entityName: string, root: string): string {
return 'api/heroes/';
}
- registerHttpResourceUrls(
- entityHttpResourceUrls: EntityHttpResourceUrls
- ): void {}
+ registerHttpResourceUrls(entityHttpResourceUrls: EntityHttpResourceUrls): void {}
}
// endregion
@@ -132,7 +116,7 @@ describe('EntityDataService', () => {
expect(service).toBeDefined();
});
- it('can data service is a DefaultDataService by default', () => {
+ it('data service should be a DefaultDataService by default', () => {
const service = entityDataService.getService('Hero');
expect(service instanceof DefaultDataService).toBe(true);
});
diff --git a/lib/src/dataservices/entity-data.service.ts b/lib/src/dataservices/entity-data.service.ts
index 3190f6a0..67e19376 100644
--- a/lib/src/dataservices/entity-data.service.ts
+++ b/lib/src/dataservices/entity-data.service.ts
@@ -12,11 +12,11 @@ import { Update } from '../utils/ngrx-entity-models';
export interface EntityCollectionDataService {
readonly name: string;
add(entity: T): Observable;
- delete(id: any): Observable;
+ delete(id: number | string): Observable;
getAll(): Observable;
getById(id: any): Observable;
getWithQuery(params: QueryParams | string): Observable;
- update(update: Update): Observable>;
+ update(update: Update): Observable;
}
@Injectable()
diff --git a/lib/src/dataservices/interfaces.ts b/lib/src/dataservices/interfaces.ts
index ca0c006a..5b9bf0c3 100644
--- a/lib/src/dataservices/interfaces.ts
+++ b/lib/src/dataservices/interfaces.ts
@@ -3,6 +3,7 @@ export type HttpMethods = 'DELETE' | 'GET' | 'POST' | 'PUT';
export interface RequestData {
method: HttpMethods;
url: string;
+ data?: any;
options?: any;
}
diff --git a/lib/src/dataservices/persistence-result-handler.service.ts b/lib/src/dataservices/persistence-result-handler.service.ts
index 28170815..1429cb2b 100644
--- a/lib/src/dataservices/persistence-result-handler.service.ts
+++ b/lib/src/dataservices/persistence-result-handler.service.ts
@@ -3,12 +3,10 @@ import { Action } from '@ngrx/store';
import { Observable, of } from 'rxjs';
-import {
- DataServiceError,
- EntityActionDataServiceError
-} from './data-service-error';
-import { EntityAction, EntityActionFactory } from '../actions/entity-action';
-import { EntityOp, OP_ERROR, OP_SUCCESS } from '../actions/entity-op';
+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';
/**
@@ -16,14 +14,10 @@ import { Logger } from '../utils/interfaces';
*/
export abstract class PersistenceResultHandler {
/** Handle successful result of persistence operation for an action */
- abstract handleSuccess(action: EntityAction): (data: any) => Action;
+ abstract handleSuccess(originalAction: EntityAction): (data: any) => Action;
/** Handle error result of persistence operation for an action */
- abstract handleError(
- action: EntityAction
- ): (
- error: DataServiceError | Error
- ) => EntityAction;
+ abstract handleError(originalAction: EntityAction): (error: DataServiceError | Error) => EntityAction;
}
/**
@@ -31,39 +25,28 @@ export abstract class PersistenceResultHandler {
* specifically an EntityDataService
*/
@Injectable()
-export class DefaultPersistenceResultHandler
- implements PersistenceResultHandler {
- constructor(
- private logger: Logger,
- private entityActionFactory: EntityActionFactory
- ) {}
+export class DefaultPersistenceResultHandler implements PersistenceResultHandler {
+ constructor(private logger: Logger, private entityActionFactory: EntityActionFactory) {}
/** Handle successful result of persistence operation on an EntityAction */
- handleSuccess(action: EntityAction): (data: any) => Action {
- const successOp = (action.op + OP_SUCCESS);
- return (data: any) =>
- this.entityActionFactory.create(action as EntityAction, successOp, data);
+ 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(
- action: EntityAction
- ): (
- error: DataServiceError | Error
- ) => EntityAction {
- const errorOp = (action.op + OP_ERROR);
- return (error: DataServiceError | Error) => {
- if (error instanceof Error) {
- error = new DataServiceError(error, null);
- }
- this.logger.error(error);
- const errorAction = this.entityActionFactory.create<
- EntityActionDataServiceError
- >(action as EntityAction, errorOp, {
- error,
- originalAction: action as 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(originalAction, {
+ entityOp: errorOp,
+ data: errorData
});
- return errorAction;
+ return action;
};
}
}
diff --git a/lib/src/dispatchers/entity-commands.ts b/lib/src/dispatchers/entity-commands.ts
index 4c8e7ce3..9292a439 100644
--- a/lib/src/dispatchers/entity-commands.ts
+++ b/lib/src/dispatchers/entity-commands.ts
@@ -1,112 +1,143 @@
+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 ***/
+/** Commands that update the remote server. */
export interface EntityServerCommands {
/**
- * Save a new entity to remote storage.
- * Does not add to cache until save succeeds.
- * Ignored by cache-add if the entity is already in cache.
+ * 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, isOptimistic?: boolean): void;
+ add(entity: T, options?: EntityActionOptions): Observable;
/**
- * Removes entity from the cache by key (if it is in the cache).
- * and deletes entity from remote storage by key.
- * Does not restore to cache if the delete fails.
- * @param key The primary key of the entity to remove
+ * 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.
*/
- delete(key: number | string, isOptimistic?: boolean): void;
+ cancel(correlationId: any, reason?: string): void;
/**
- * Removes entity from the cache (if it is in the cache)
- * and deletes entity from remote storage by key.
- * Does not restore to cache if the delete fails.
- * @param entity The entity to remove
+ * 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(entity: T, isOptimistic?: boolean): void;
+ delete(entity: T, options?: EntityActionOptions): Observable;
/**
- * Query remote storage for all entities and
- * completely replace the cached collection with the queried entities.
+ * Dispatch action to delete entity from remote storage by key.
+ * @param key The primary key of the entity to remove
+ * @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.
+ * @returns A terminating Observable of the collection
+ * after server reports successful query or the query error.
+ * @see load()
*/
- getAll(): void;
+ getAll(options?: EntityActionOptions): Observable;
/**
- * Query remote storage for the entity with this primary key.
+ * 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 queried entities that are in the collection
+ * after server reports success or the query error.
*/
- getByKey(key: any): void;
+ getByKey(key: any, options?: EntityActionOptions): Observable;
/**
- * Query remote storage for the entities that satisfy a query expressed
- * with either a query parameter map or an HTTP URL query string.
+ * 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): void;
+ getWithQuery(queryParams: QueryParams | string, options?: EntityActionOptions): Observable;
/**
- * Save the updated entity (or partial entity) to remote storage.
- * Updates the cached entity after the save succeeds.
- * Update in cache is ignored if the entity's key is not found in cache.
+ * 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;
+
+ /**
+ * 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, isOptimistic?: boolean): void;
+ update(entity: Partial, options?: EntityActionOptions): Observable;
}
-/*** Cache-only commands that do not update remote storage ***/
+/*** 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[]): void;
+ 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): void;
+ 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[]): void;
+ addManyToCache(entities: T[], options?: EntityActionOptions): void;
/** Clear the cached entity collection */
- clearCache(): void;
+ 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): void;
+ 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): void;
+ 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[]): void;
+ 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)[]): void;
+ removeManyFromCache(keys: (number | string)[], options?: EntityActionOptions): void;
/**
* Update a cached entity directly.
@@ -115,7 +146,7 @@ export interface EntityCacheCommands {
* The update entity may be partial (but must have its key)
* in which case it patches the existing entity.
*/
- updateOneInCache(entity: Partial): void;
+ updateOneInCache(entity: Partial, options?: EntityActionOptions): void;
/**
* Update multiple cached entities directly.
@@ -124,7 +155,7 @@ export interface EntityCacheCommands {
* Update entities may be partial but must at least have their keys.
* such partial entities patch their cached counterparts.
*/
- updateManyInCache(entities: Partial[]): void;
+ updateManyInCache(entities: Partial[], options?: EntityActionOptions): void;
/**
* Insert or update a cached entity directly.
@@ -132,7 +163,7 @@ export interface EntityCacheCommands {
* 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;
+ upsertOneInCache(entity: Partial, options?: EntityActionOptions): void;
/**
* Insert or update multiple cached entities directly.
@@ -140,28 +171,20 @@ export interface EntityCacheCommands {
* 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[]): void;
+ upsertManyInCache(entities: Partial[], options?: EntityActionOptions): void;
/**
* Set the pattern that the collection's filter applies
* when using the `filteredEntities` selector.
*/
- setFilter(pattern: any): void;
+ setFilter(pattern: any, options?: EntityActionOptions): void;
/** Set the loaded flag */
- setLoaded(isLoaded: boolean): void;
+ setLoaded(isLoaded: boolean, options?: EntityActionOptions): void;
/** Set the loading flag */
- setLoading(isLoading: boolean): void;
+ setLoading(isLoading: boolean, options?: EntityActionOptions): void;
}
-/**
- * Interface for ngrx-data entity commands that
- * dispatch entity actions to the ngrx store.
- */
-export interface EntityCommands
- extends EntityServerCommands,
- EntityCacheCommands {}
-
-// TypeScript bug: have to export something real in JavaScript
-export const __dummy__: any = undefined;
+/** Commands that dispatch entity actions for a collection */
+export interface EntityCommands extends EntityServerCommands, EntityCacheCommands {}
diff --git a/lib/src/dispatchers/entity-dispatcher-base.ts b/lib/src/dispatchers/entity-dispatcher-base.ts
new file mode 100644
index 00000000..f3a2b788
--- /dev/null
+++ b/lib/src/dispatchers/entity-dispatcher-base.ts
@@ -0,0 +1,493 @@
+import { Action, createSelector, select, Store } from '@ngrx/store';
+
+import { Observable, of, throwError } from 'rxjs';
+import { filter, first, map, mergeMap, shareReplay, withLatestFrom } 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, 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 { EntityOp, OP_ERROR, OP_SUCCESS } from '../actions/entity-op';
+import { IdSelector, Update, UpdateData } from '../utils/ngrx-entity-models';
+import { MergeStrategy } from '../actions/merge-strategy';
+import { QueryParams } from '../dataservices/interfaces';
+
+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.
+ */
+ public 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)
+ * @param correlationId The correlation id for the corresponding EntityAction
+ * @param [reason] explains why canceled and by whom.
+ */
+ cancel(correlationId: any, reason?: string): void {
+ 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: 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);
+ }
+ 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)]),
+ 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;
+ }
+
+ /**
+ * 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)
+ );
+ }),
+ first(),
+ 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/lib/src/dispatchers/entity-dispatcher-default-options.ts b/lib/src/dispatchers/entity-dispatcher-default-options.ts
new file mode 100644
index 00000000..1690c904
--- /dev/null
+++ b/lib/src/dispatchers/entity-dispatcher-default-options.ts
@@ -0,0 +1,18 @@
+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;
+}
diff --git a/lib/src/dispatchers/entity-dispatcher-factory.ts b/lib/src/dispatchers/entity-dispatcher-factory.ts
index 97406b2b..3ddd63d9 100644
--- a/lib/src/dispatchers/entity-dispatcher-factory.ts
+++ b/lib/src/dispatchers/entity-dispatcher-factory.ts
@@ -1,36 +1,46 @@
-import { Injectable } from '@angular/core';
-import { Store } from '@ngrx/store';
+import { Inject, Injectable, OnDestroy } from '@angular/core';
+import { Action, Store, ScannedActionsSubject } from '@ngrx/store';
+import { Observable, Subscription } from 'rxjs';
+import { shareReplay } from 'rxjs/operators';
-import { EntityAction, EntityActionFactory } from '../actions/entity-action';
-import { EntityActionGuard } from '../actions/entity-action-guard';
-import { EntityOp } from '../actions/entity-op';
-import { QueryParams } from '../dataservices/interfaces';
-import { EntityCommands } from './entity-commands';
+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 {
- EntityDispatcher,
- EntityDispatcherBase,
- EntityDispatcherOptions
-} from './entity-dispatcher';
+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 { IdSelector, Update } from '../utils/ngrx-entity-models';
-import { defaultSelectId, toUpdateFactory } from '../utils/utilities';
+import { QueryParams } from '../dataservices/interfaces';
+/** Creates EntityDispatchers for entity collections */
@Injectable()
-export class EntityDispatcherFactory {
+export class EntityDispatcherFactory implements OnDestroy {
/**
- * Default dispatcher options.
- * These defaults are the safest values.
+ * Actions scanned by the store after it processed them with reducers.
+ * A replay observable of the most recent action reduced by the store.
*/
- defaultDispatcherOptions = {
- optimisticAdd: false,
- optimisticDelete: true,
- optimisticUpdate: false
- };
+ reducedActions$: Observable;
+ private raSubscription: Subscription;
constructor(
private entityActionFactory: EntityActionFactory,
- private store: Store
- ) {}
+ 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.
+ // Sure 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.
@@ -43,23 +53,26 @@ export class EntityDispatcherFactory {
* Usually acquired from `EntityDefinition` metadata.
*/
selectId: IdSelector = defaultSelectId,
- /** Options that influence dispatcher behavior such as whether
+ /** Defaults for options that influence dispatcher behavior such as whether
* `add()` is optimistic or pessimistic;
*/
- dispatcherOptions: Partial = {}
+ defaultOptions: Partial = {}
): EntityDispatcher {
- // merge w/ dispatcher options with defaults
- const options: EntityDispatcherOptions = Object.assign(
- {},
- this.defaultDispatcherOptions,
- dispatcherOptions
- );
+ // merge w/ defaultOptions with injected defaults
+ const options: EntityDispatcherDefaultOptions = { ...this.entityDispatcherDefaultOptions, ...defaultOptions };
return new EntityDispatcherBase(
entityName,
this.entityActionFactory,
this.store,
selectId,
- options
+ options,
+ this.reducedActions$,
+ this.entityCacheSelector,
+ this.correlationIdGenerator
);
}
+
+ ngOnDestroy() {
+ this.raSubscription.unsubscribe();
+ }
}
diff --git a/lib/src/dispatchers/entity-dispatcher.spec.ts b/lib/src/dispatchers/entity-dispatcher.spec.ts
index 8fa92bf3..eddfeab2 100644
--- a/lib/src/dispatchers/entity-dispatcher.spec.ts
+++ b/lib/src/dispatchers/entity-dispatcher.spec.ts
@@ -1,10 +1,16 @@
+import { Action } from '@ngrx/store';
+import { Subject } from 'rxjs';
+
+import { CorrelationIdGenerator } from '../utils/correlation-id-generator';
+import { EntityDispatcherDefaultOptions } from './entity-dispatcher-default-options';
import { defaultSelectId } from '../utils/utilities';
-import { EntityAction, EntityActionFactory } from '../actions/entity-action';
+import { EntityAction } from '../actions/entity-action';
+import { EntityActionFactory } from '../actions/entity-action-factory';
+import { createEntityCacheSelector } from '../selectors/entity-cache-selector';
+import { EntityDispatcher } from './entity-dispatcher';
+import { EntityDispatcherBase } from './entity-dispatcher-base';
import { EntityOp } from '../actions/entity-op';
-import { EntityDispatcher, EntityDispatcherBase } from './entity-dispatcher';
-import { EntityDispatcherFactory } from './entity-dispatcher-factory';
-import { EntityCommands } from './entity-commands';
-import { EntityDispatcherOptions } from './entity-dispatcher';
+import { MergeStrategy } from '../actions/merge-strategy';
import { Update } from '../utils/ngrx-entity-models';
class Hero {
@@ -13,35 +19,47 @@ class Hero {
saying?: string;
}
-const defaultDispatcherOptions = new EntityDispatcherFactory(null, null)
- .defaultDispatcherOptions;
+/** Store stub */
+class TestStore {
+ // only interested in calls to store.dispatch()
+ dispatch() {}
+ select() {}
+}
-describe('EntityDispatcher', () => {
- commandDispatchTest(entityDispatcherTestSetup);
+const defaultDispatcherOptions = new EntityDispatcherDefaultOptions();
- function entityDispatcherTestSetup() {
- // only interested in calls to store.dispatch()
- const testStore = jasmine.createSpyObj('store', ['dispatch']);
+describe('EntityDispatcher', () => {
+ commandDispatchTest(entityDispatcherSetup);
- const selectId = defaultSelectId;
+ 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,
- testStore,
+ store,
selectId,
- defaultDispatcherOptions
+ defaultDispatcherOptions,
+ scannedActions$, // scannedActions$ not used in these tests
+ entityCacheSelector, // entityCacheSelector not used in these tests
+ correlationIdGenerator
);
- return { dispatcher, testStore };
+ return { dispatcher, store };
}
});
///// Tests /////
-/** Test that implementer of EntityCommands dispatches properly */
-export function commandDispatchTest(
- setup: () => { dispatcher: EntityDispatcher; testStore: any }
-) {
+/**
+ * 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 };
@@ -51,79 +69,94 @@ export function commandDispatchTest(
beforeEach(() => {
const s = setup();
+ spyOn(s.store, 'dispatch').and.callThrough();
dispatcher = s.dispatcher;
- testStore = s.testStore;
+ 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) dispatches SAVE_ADD_ONE_OPTIMISTIC', () => {
+ it('#add(hero) can dispatch SAVE_ADD_ONE optimistically', () => {
const hero: Hero = { id: 42, name: 'test' };
- dispatcher.add(hero, /* isOptimistic */ true);
-
- expect(dispatchedAction().op).toBe(EntityOp.SAVE_ADD_ONE_OPTIMISTIC);
- expect(dispatchedAction().payload).toBe(hero);
+ 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_OPTIMISTIC for the id:42', () => {
+ it('#delete(42) dispatches SAVE_DELETE_ONE optimistically for the id:42', () => {
dispatcher.delete(42); // optimistic by default
-
- expect(dispatchedAction().op).toBe(EntityOp.SAVE_DELETE_ONE_OPTIMISTIC);
- expect(dispatchedAction().payload).toBe(42);
+ 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_OPTIMISTIC for the hero.id', () => {
+ 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
-
- expect(dispatchedAction().op).toBe(EntityOp.SAVE_DELETE_ONE_OPTIMISTIC);
- expect(dispatchedAction().payload).toBe(id);
+ const { entityOp, isOptimistic, data } = dispatchedAction().payload;
+ expect(entityOp).toBe(EntityOp.SAVE_DELETE_ONE);
+ expect(isOptimistic).toBe(true);
+ expect(data).toBe(42);
});
- it('#update(hero) dispatches SAVE_UPDATE_ONE_OPTIMISTIC with an update payload', () => {
+ 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);
-
- expect(dispatchedAction().op).toBe(EntityOp.SAVE_UPDATE_ONE_OPTIMISTIC);
- expect(dispatchedAction().payload).toEqual(expectedUpdate);
+ 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', () => {
+ it('#add(hero) dispatches SAVE_ADD pessimistically', () => {
const hero: Hero = { id: 42, name: 'test' };
dispatcher.add(hero); // pessimistic by default
-
- expect(dispatchedAction().op).toBe(EntityOp.SAVE_ADD_ONE);
- expect(dispatchedAction().payload).toBe(hero);
+ const { entityOp, isOptimistic, data } = dispatchedAction().payload;
+ expect(entityOp).toBe(EntityOp.SAVE_ADD_ONE);
+ expect(isOptimistic).toBe(false);
+ expect(data).toBe(hero);
});
- it('#delete(42) dispatches SAVE_DELETE for the id:42', () => {
- dispatcher.delete(42, /* isOptimistic */ false); // optimistic by default
-
- expect(dispatchedAction().op).toBe(EntityOp.SAVE_DELETE_ONE);
- expect(dispatchedAction().payload).toBe(42);
+ 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) dispatches SAVE_DELETE for the hero.id', () => {
+ 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
-
- expect(dispatchedAction().op).toBe(EntityOp.SAVE_DELETE_ONE);
- expect(dispatchedAction().payload).toBe(id);
+ 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', () => {
@@ -131,119 +164,187 @@ export function commandDispatchTest(
const expectedUpdate: Update = { id: 42, changes: hero };
dispatcher.update(hero); // pessimistic by default
-
- expect(dispatchedAction().op).toBe(EntityOp.SAVE_UPDATE_ONE);
- expect(dispatchedAction().payload).toEqual(expectedUpdate);
+ 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 for the Hero collection', () => {
+ it('#getAll() dispatches QUERY_ALL', () => {
dispatcher.getAll();
- expect(dispatchedAction().op).toBe(EntityOp.QUERY_ALL);
- expect(dispatchedAction().entityName).toBe('Hero');
+ 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);
- expect(dispatchedAction().op).toBe(EntityOp.QUERY_BY_KEY);
- expect(dispatchedAction().payload).toBe(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' });
- expect(dispatchedAction().op).toBe(EntityOp.QUERY_MANY);
- expect(dispatchedAction().entityName).toBe('Hero');
- expect(dispatchedAction().payload).toEqual({ name: 'B' }, 'params');
+ 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');
- expect(dispatchedAction().op).toBe(EntityOp.QUERY_MANY);
- expect(dispatchedAction().entityName).toBe('Hero');
- expect(dispatchedAction().payload).toEqual('name=B', 'params');
+ 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' }
- ];
+ const heroes: Hero[] = [{ id: 42, name: 'test 42' }, { id: 84, name: 'test 84', saying: 'howdy' }];
dispatcher.addAllToCache(heroes);
-
- expect(dispatchedAction().op).toBe(EntityOp.ADD_ALL);
- expect(dispatchedAction().payload).toBe(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');
+ });
- expect(dispatchedAction().op).toBe(EntityOp.ADD_ONE);
- expect(dispatchedAction().payload).toBe(hero);
+ 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' }
- ];
+ 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);
+ });
- expect(dispatchedAction().op).toBe(EntityOp.ADD_MANY);
- expect(dispatchedAction().payload).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');
+ });
- expect(dispatchedAction().op).toBe(EntityOp.REMOVE_ALL);
- expect(dispatchedAction().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);
-
- expect(dispatchedAction().op).toBe(EntityOp.REMOVE_ONE);
- expect(dispatchedAction().payload).toBe(id);
+ const { entityOp, data } = dispatchedAction().payload;
+ expect(entityOp).toBe(EntityOp.REMOVE_ONE);
+ expect(data).toBe(id);
});
- it('#removeOneFromCache(entity) dispatches REMOVE_ONE', () => {
+ it('#removeOneFromCache(key) can dispatch REMOVE_ONE and MergeStrategy.IgnoreChanges', () => {
const id = 42;
- const hero: Hero = { id, name: 'test' };
- dispatcher.removeOneFromCache(hero);
-
- expect(dispatchedAction().op).toBe(EntityOp.REMOVE_ONE);
- expect(dispatchedAction().payload).toBe(id);
+ 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);
+ });
- expect(dispatchedAction().op).toBe(EntityOp.REMOVE_MANY);
- expect(dispatchedAction().payload).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 heroes: Hero[] = [{ id: 42, name: 'test 42' }, { id: 84, name: 'test 84', saying: 'howdy' }];
const keys = heroes.map(h => h.id);
dispatcher.removeManyFromCache(heroes);
-
- expect(dispatchedAction().op).toBe(EntityOp.REMOVE_MANY);
- expect(dispatchedAction().payload).toEqual(keys);
+ const { entityOp, data } = dispatchedAction().payload;
+ expect(entityOp).toBe(EntityOp.REMOVE_MANY);
+ expect(data).toEqual(keys);
});
it('#toUpdate() helper method creates Update', () => {
@@ -257,48 +358,68 @@ export function commandDispatchTest(
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);
+ });
- expect(dispatchedAction().op).toBe(EntityOp.UPDATE_ONE);
- expect(dispatchedAction().payload).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] }
- ];
+ 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);
+ });
- expect(dispatchedAction().op).toBe(EntityOp.UPDATE_MANY);
- expect(dispatchedAction().payload).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: Partial = { id: 42, name: 'test' };
- const upsert = { id: 42, changes: hero };
+ const hero = { id: 42, name: 'test' };
dispatcher.upsertOneInCache(hero);
+ const { entityOp, data } = dispatchedAction().payload;
+ expect(entityOp).toBe(EntityOp.UPSERT_ONE);
+ expect(data).toEqual(hero);
+ });
- expect(dispatchedAction().op).toBe(EntityOp.UPSERT_ONE);
- expect(dispatchedAction().payload).toEqual(upsert);
+ 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: Partial[] = [
- { id: 42, name: 'test 42' },
- { id: 84, saying: 'ho ho ho' }
- ];
- const upserts = [
- { id: 42, changes: heroes[0] },
- { id: 84, changes: heroes[1] }
- ];
+ 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);
+ });
- expect(dispatchedAction().op).toBe(EntityOp.UPSERT_MANY);
- expect(dispatchedAction().payload).toEqual(upserts);
+ 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/lib/src/dispatchers/entity-dispatcher.ts b/lib/src/dispatchers/entity-dispatcher.ts
index c9fb48a3..e43760b0 100644
--- a/lib/src/dispatchers/entity-dispatcher.ts
+++ b/lib/src/dispatchers/entity-dispatcher.ts
@@ -1,24 +1,11 @@
-import { Injectable } from '@angular/core';
import { Action, Store } from '@ngrx/store';
-import { EntityAction, EntityActionFactory } from '../actions/entity-action';
+import { EntityAction, EntityActionOptions } from '../actions/entity-action';
import { EntityActionGuard } from '../actions/entity-action-guard';
-import { EntityOp } from '../actions/entity-op';
-import { QueryParams } from '../dataservices/interfaces';
import { EntityCommands } from './entity-commands';
import { EntityCache } from '../reducers/entity-cache';
+import { EntityOp } from '../actions/entity-op';
import { IdSelector, Update } from '../utils/ngrx-entity-models';
-import { defaultSelectId, toUpdateFactory } from '../utils/utilities';
-
-/**
- * Options controlling EntityDispatcher behavior
- * such as whether `add()` is optimistic or pessimistic
- */
-export interface EntityDispatcherOptions {
- optimisticAdd: boolean;
- optimisticDelete: boolean;
- optimisticUpdate: boolean;
-}
/**
* Dispatches Entity-related commands to effects and reducers
@@ -41,23 +28,28 @@ export interface EntityDispatcher extends EntityCommands {
/**
* Create an {EntityAction} for this entity type.
* @param op {EntityOp} the entity operation
- * @param payload the action payload
+ * @param [data] the action data
+ * @param [options] additional options
+ * @returns the EntityAction
*/
- createEntityAction(op: EntityOp, payload?: any): 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 payload the action payload
+ * @param [data] the action data
+ * @param [options] additional options
+ * @returns the dispatched EntityAction
*/
- createAndDispatch(op: EntityOp, payload?: any): void;
+ createAndDispatch
(op: EntityOp, data?: P, options?: EntityActionOptions): EntityAction
;
/**
- * Dispatch action to the store.
+ * Dispatch an Action to the store.
* @param action the Action
+ * @returns the dispatched Action
*/
- dispatch(action: Action): void;
+ dispatch(action: Action): Action;
/**
* Convert an entity (or partial entity) into the `Update` object
@@ -66,312 +58,11 @@ export interface EntityDispatcher extends EntityCommands {
toUpdate(entity: Partial): Update;
}
-export class EntityDispatcherBase implements EntityDispatcher {
- /**
- * Utility class with methods to validate EntityAction payloads.
- */
- guard: EntityActionGuard;
-
- /**
- * 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.
- */
- public options: EntityDispatcherOptions
- ) {
- this.guard = new EntityActionGuard(selectId);
- this.toUpdate = toUpdateFactory(selectId);
- }
-
- /**
- * Create an {EntityAction} for this entity type.
- * @param op {EntityOp} the entity operation
- * @param payload the action payload
- */
- createEntityAction(op: EntityOp, payload?: any): EntityAction {
- return this.entityActionFactory.create(this.entityName, op, payload);
- }
-
- /**
- * Create an {EntityAction} for this entity type and
- * dispatch it immediately to the store.
- * @param op {EntityOp} the entity operation
- * @param payload the action payload
- */
- createAndDispatch(op: EntityOp, payload?: any): void {
- const action = this.createEntityAction(op, payload);
- this.dispatch(action);
- }
-
- /**
- * Dispatch {EntityAction} to the store.
- * @param action the EntityAction
- */
- dispatch(action: Action): void {
- this.store.dispatch(action);
- }
-
- /**
- * Save a new entity to remote storage.
- * Does not add to cache until save succeeds.
- * Ignored by cache-add if the entity is already in cache.
- */
- add(entity: T, isOptimistic?: boolean): void {
- isOptimistic =
- isOptimistic != null ? isOptimistic : this.options.optimisticAdd;
- const op = isOptimistic
- ? EntityOp.SAVE_ADD_ONE_OPTIMISTIC
- : EntityOp.SAVE_ADD_ONE;
-
- const action = this.createEntityAction(op, entity);
- if (isOptimistic) {
- this.guard.mustBeEntity(action);
- }
- this.dispatch(action);
- }
-
- /**
- * Removes entity from the cache (if it is in the cache)
- * and deletes entity from remote storage by key.
- * Does not restore to cache if the delete fails.
- * @param entity The entity to remove
- */
- delete(entity: T, isOptimistic?: boolean): void;
-
- /**
- * Removes entity from the cache by key (if it is in the cache)
- * and deletes entity from remote storage by key.
- * Does not restore to cache if the delete fails.
- * @param key The primary key of the entity to remove
- */
- delete(key: number | string, isOptimistic?: boolean): void;
- delete(arg: (number | string) | T, isOptimistic?: boolean): void {
- const op = (isOptimistic != null
- ? isOptimistic
- : this.options.optimisticDelete)
- ? EntityOp.SAVE_DELETE_ONE_OPTIMISTIC
- : EntityOp.SAVE_DELETE_ONE;
- const key = this.getKey(arg);
- const action = this.createEntityAction(op, key);
- this.guard.mustBeKey(action);
- this.dispatch(action);
- }
-
- /**
- * Query remote storage for all entities and
- * completely replace the cached collection with the queried entities.
- */
- getAll(): void {
- this.createAndDispatch(EntityOp.QUERY_ALL);
- }
-
- /**
- * Query remote storage for the entity with this primary key.
- * If the server returns an entity,
- * merge it into the cached collection.
- */
- getByKey(key: any): void {
- this.createAndDispatch(EntityOp.QUERY_BY_KEY, key);
- }
-
- /**
- * 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.
- */
- getWithQuery(queryParams: QueryParams | string): void {
- this.createAndDispatch(EntityOp.QUERY_MANY, queryParams);
- }
-
- /**
- * Save the updated entity (or partial entity) to remote storage.
- * Updates the cached entity after the save succeeds.
- * Update in cache is ignored if the entity's key is not found in cache.
- * The update entity may be partial (but must have its key)
- * in which case it patches the existing entity.
- */
- update(entity: Partial, isOptimistic?: boolean): 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);
- const op = (isOptimistic != null
- ? isOptimistic
- : this.options.optimisticUpdate)
- ? EntityOp.SAVE_UPDATE_ONE_OPTIMISTIC
- : EntityOp.SAVE_UPDATE_ONE;
-
- const action = this.createEntityAction(op, update);
- this.guard.mustBeUpdate(action);
- this.dispatch(action);
- }
-
- /*** 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[]): void {
- this.createAndDispatch(EntityOp.ADD_ALL, 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.createAndDispatch(EntityOp.ADD_ONE, 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.createAndDispatch(EntityOp.ADD_MANY, entities);
- }
-
- /** Clear the cached entity collection */
- clearCache(): void {
- this.createAndDispatch(EntityOp.REMOVE_ALL);
- }
-
- /**
- * 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.createAndDispatch(EntityOp.REMOVE_ONE, this.getKey(arg));
- }
-
- /**
- * 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 {
- 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);
- }
-
- /**
- * 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
- const update: Update