From a68ab2aba5fe8c178eac31fb55f5201d5a09b64b Mon Sep 17 00:00:00 2001 From: markostanimirovic Date: Fri, 21 Mar 2025 01:28:12 +0100 Subject: [PATCH 1/2] feat(signals): add upsert entity updaters --- modules/signals/entities/spec/mocks.ts | 7 +- .../spec/updaters/upsert-entities.spec.ts | 256 ++++++++++++++++++ .../spec/updaters/upsert-entity.spec.ts | 198 ++++++++++++++ modules/signals/entities/src/helpers.ts | 13 +- modules/signals/entities/src/index.ts | 2 + .../entities/src/updaters/upsert-entities.ts | 52 ++++ .../entities/src/updaters/upsert-entity.ts | 47 ++++ .../signals/signal-store/entity-management.md | 66 +++-- 8 files changed, 612 insertions(+), 29 deletions(-) create mode 100644 modules/signals/entities/spec/updaters/upsert-entities.spec.ts create mode 100644 modules/signals/entities/spec/updaters/upsert-entity.spec.ts create mode 100644 modules/signals/entities/src/updaters/upsert-entities.ts create mode 100644 modules/signals/entities/src/updaters/upsert-entity.ts diff --git a/modules/signals/entities/spec/mocks.ts b/modules/signals/entities/spec/mocks.ts index fba50b6d44..6f5a7de666 100644 --- a/modules/signals/entities/spec/mocks.ts +++ b/modules/signals/entities/spec/mocks.ts @@ -1,4 +1,9 @@ -export type User = { id: number; firstName: string; lastName: string }; +export type User = { + id: number; + firstName: string; + lastName: string; + age?: number; +}; export type Todo = { _id: string; text: string; completed: boolean }; export const user1: User = { id: 1, firstName: 'John', lastName: 'Doe' }; diff --git a/modules/signals/entities/spec/updaters/upsert-entities.spec.ts b/modules/signals/entities/spec/updaters/upsert-entities.spec.ts new file mode 100644 index 0000000000..d4d88bfead --- /dev/null +++ b/modules/signals/entities/spec/updaters/upsert-entities.spec.ts @@ -0,0 +1,256 @@ +import { patchState, signalStore, type } from '@ngrx/signals'; +import { entityConfig, upsertEntities, withEntities } from '../../src'; +import { Todo, todo1, todo2, todo3, User, user1, user2, user3 } from '../mocks'; +import { selectTodoId as selectId } from '../helpers'; + +const user1WithAge: User = { + ...user1, + age: 40, +}; +const newUser1WithoutAge: User = { + ...user2, + id: user1WithAge.id, +}; +const expectedUser1: User = { + ...user2, + id: user1WithAge.id, + age: user1WithAge.age, +}; + +describe('upsertEntities', () => { + it('adds entities if they do not exist', () => { + const Store = signalStore({ protectedState: false }, withEntities()); + const store = new Store(); + + patchState(store, upsertEntities([user1])); + + expect(store.entityMap()).toEqual({ 1: user1 }); + expect(store.ids()).toEqual([1]); + expect(store.entities()).toEqual([user1]); + + patchState(store, upsertEntities([user2, user3])); + + expect(store.entityMap()).toEqual({ 1: user1, 2: user2, 3: user3 }); + expect(store.ids()).toEqual([1, 2, 3]); + expect(store.entities()).toEqual([user1, user2, user3]); + }); + + it('updates entities if they already exist', () => { + const Store = signalStore({ protectedState: false }, withEntities()); + const store = new Store(); + + patchState( + store, + upsertEntities([user1WithAge, user2, { ...user2, lastName: 'Hendrix' }]) + ); + + expect(store.entityMap()).toEqual({ + 1: user1WithAge, + 2: { ...user2, lastName: 'Hendrix' }, + }); + expect(store.ids()).toEqual([1, 2]); + expect(store.entities()).toEqual([ + user1WithAge, + { ...user2, lastName: 'Hendrix' }, + ]); + + patchState( + store, + upsertEntities([user3, newUser1WithoutAge]), + upsertEntities([] as User[]) + ); + + expect(store.entityMap()).toEqual({ + 1: expectedUser1, + 2: { ...user2, lastName: 'Hendrix' }, + 3: user3, + }); + expect(store.ids()).toEqual([1, 2, 3]); + expect(store.entities()).toEqual([ + expectedUser1, + { ...user2, lastName: 'Hendrix' }, + user3, + ]); + }); + + it('adds entities to the specified collection if they do not exist', () => { + const Store = signalStore( + { protectedState: false }, + withEntities({ + entity: type(), + collection: 'user', + }) + ); + const store = new Store(); + + patchState( + store, + upsertEntities([user1, user2], { collection: 'user' }), + upsertEntities([user3], { collection: 'user' }) + ); + + expect(store.userEntityMap()).toEqual({ 1: user1, 2: user2, 3: user3 }); + expect(store.userIds()).toEqual([1, 2, 3]); + expect(store.userEntities()).toEqual([user1, user2, user3]); + }); + + it('updates entities to the specified collection if they already exist', () => { + const Store = signalStore( + { protectedState: false }, + withEntities({ + entity: type(), + collection: 'user', + }) + ); + const store = new Store(); + + patchState( + store, + upsertEntities([user1WithAge, user3, newUser1WithoutAge], { + collection: 'user', + }) + ); + + patchState( + store, + upsertEntities([] as User[], { collection: 'user' }), + upsertEntities([user3, user2, { ...user3, firstName: 'Jimmy' }], { + collection: 'user', + }) + ); + + expect(store.userEntityMap()).toEqual({ + 1: expectedUser1, + 3: { ...user3, firstName: 'Jimmy' }, + 2: user2, + }); + expect(store.userIds()).toEqual([1, 3, 2]); + expect(store.userEntities()).toEqual([ + expectedUser1, + { ...user3, firstName: 'Jimmy' }, + user2, + ]); + }); + + it('adds entities with a custom id if they do not exist', () => { + const Store = signalStore({ protectedState: false }, withEntities()); + const store = new Store(); + + patchState(store, upsertEntities([todo2, todo3], { selectId })); + + expect(store.entityMap()).toEqual({ y: todo2, z: todo3 }); + expect(store.ids()).toEqual(['y', 'z']); + expect(store.entities()).toEqual([todo2, todo3]); + + patchState( + store, + upsertEntities([todo1], { selectId }), + upsertEntities([] as Todo[], { selectId }) + ); + + expect(store.entityMap()).toEqual({ y: todo2, z: todo3, x: todo1 }); + expect(store.ids()).toEqual(['y', 'z', 'x']); + expect(store.entities()).toEqual([todo2, todo3, todo1]); + }); + + it('updates entities with a custom id if they already exist', () => { + const Store = signalStore({ protectedState: false }, withEntities()); + const store = new Store(); + + patchState( + store, + upsertEntities([todo1], { selectId }), + upsertEntities([todo2, { ...todo1, text: 'Signals' }], { selectId }), + upsertEntities([] as Todo[], { selectId }) + ); + + patchState( + store, + upsertEntities([] as Todo[], { selectId }), + upsertEntities([todo3, todo2, { ...todo2, text: 'NgRx' }, todo1], { + selectId, + }) + ); + + expect(store.entityMap()).toEqual({ + x: todo1, + y: { ...todo2, text: 'NgRx' }, + z: todo3, + }); + expect(store.ids()).toEqual(['x', 'y', 'z']); + expect(store.entities()).toEqual([ + todo1, + { ...todo2, text: 'NgRx' }, + todo3, + ]); + }); + + it('adds entities with a custom id to the specified collection if they do not exist', () => { + const Store = signalStore( + { protectedState: false }, + withEntities({ + entity: type(), + collection: 'todo', + }) + ); + const store = new Store(); + + patchState( + store, + upsertEntities([todo3, todo2], { + collection: 'todo', + selectId, + }) + ); + + expect(store.todoEntityMap()).toEqual({ z: todo3, y: todo2 }); + expect(store.todoIds()).toEqual(['z', 'y']); + expect(store.todoEntities()).toEqual([todo3, todo2]); + + patchState( + store, + upsertEntities([todo1], { collection: 'todo', selectId }), + upsertEntities([] as Todo[], { collection: 'todo', selectId }) + ); + + expect(store.todoEntityMap()).toEqual({ z: todo3, y: todo2, x: todo1 }); + expect(store.todoIds()).toEqual(['z', 'y', 'x']); + expect(store.todoEntities()).toEqual([todo3, todo2, todo1]); + }); + + it('updates entities with a custom id to the specified collection if they already exist', () => { + const todoConfig = entityConfig({ + entity: type(), + collection: 'todo', + selectId, + }); + + const Store = signalStore( + { protectedState: false }, + withEntities(todoConfig) + ); + const store = new Store(); + + patchState( + store, + upsertEntities( + [todo2, { ...todo2, text: 'NgRx' }, todo3, todo2], + todoConfig + ), + upsertEntities([] as Todo[], todoConfig), + upsertEntities([todo3, { ...todo3, text: 'NgRx' }, todo1], todoConfig) + ); + + expect(store.todoEntityMap()).toEqual({ + y: todo2, + z: { ...todo3, text: 'NgRx' }, + x: todo1, + }); + expect(store.todoIds()).toEqual(['y', 'z', 'x']); + expect(store.todoEntities()).toEqual([ + todo2, + { ...todo3, text: 'NgRx' }, + todo1, + ]); + }); +}); diff --git a/modules/signals/entities/spec/updaters/upsert-entity.spec.ts b/modules/signals/entities/spec/updaters/upsert-entity.spec.ts new file mode 100644 index 0000000000..c17c9e9488 --- /dev/null +++ b/modules/signals/entities/spec/updaters/upsert-entity.spec.ts @@ -0,0 +1,198 @@ +import { patchState, signalStore, type } from '@ngrx/signals'; +import { entityConfig, upsertEntity, withEntities } from '../../src'; +import { Todo, todo1, todo2, User, user1, user2, user3 } from '../mocks'; +import { selectTodoId as selectId } from '../helpers'; + +const user2WithAge: User = { + ...user2, + age: 30, +}; +const newUser2WithoutAge: User = { + ...user3, + id: user2WithAge.id, +}; +const expectedUser2: User = { + ...user3, + id: user2WithAge.id, + age: user2WithAge.age, +}; + +describe('upsertEntity', () => { + it('adds entity if it does not exist', () => { + const Store = signalStore({ protectedState: false }, withEntities()); + const store = new Store(); + + patchState(store, upsertEntity(user1)); + + expect(store.entityMap()).toEqual({ 1: user1 }); + expect(store.ids()).toEqual([1]); + expect(store.entities()).toEqual([user1]); + + patchState(store, upsertEntity(user2)); + + expect(store.entityMap()).toEqual({ 1: user1, 2: user2 }); + expect(store.ids()).toEqual([1, 2]); + expect(store.entities()).toEqual([user1, user2]); + }); + + it('updates entity if it already exists', () => { + const Store = signalStore({ protectedState: false }, withEntities()); + const store = new Store(); + + patchState(store, upsertEntity(user1), upsertEntity(user2WithAge)); + patchState(store, upsertEntity(newUser2WithoutAge)); + + expect(store.entityMap()).toEqual({ + 1: user1, + 2: expectedUser2, + }); + expect(store.ids()).toEqual([1, 2]); + expect(store.entities()).toEqual([user1, expectedUser2]); + }); + + it('adds entity to the specified collection if it does not exist', () => { + const Store = signalStore( + { protectedState: false }, + withEntities({ + entity: type(), + collection: 'user', + }) + ); + const store = new Store(); + + patchState( + store, + upsertEntity(user1, { collection: 'user' }), + upsertEntity(user2, { collection: 'user' }) + ); + + expect(store.userEntityMap()).toEqual({ 1: user1, 2: user2 }); + expect(store.userIds()).toEqual([1, 2]); + expect(store.userEntities()).toEqual([user1, user2]); + }); + + it('updates entity to the specified collection if it already exists', () => { + const Store = signalStore( + { protectedState: false }, + withEntities({ + entity: type(), + collection: 'user', + }) + ); + const store = new Store(); + + patchState( + store, + upsertEntity(user1, { collection: 'user' }), + upsertEntity(user2WithAge, { collection: 'user' }), + upsertEntity({ ...user1, firstName: 'Jimmy' }, { collection: 'user' }), + upsertEntity(user3, { collection: 'user' }), + upsertEntity(newUser2WithoutAge, { collection: 'user' }) + ); + + expect(store.userEntityMap()).toEqual({ + 1: { ...user1, firstName: 'Jimmy' }, + 2: expectedUser2, + 3: user3, + }); + expect(store.userIds()).toEqual([1, 2, 3]); + expect(store.userEntities()).toEqual([ + { ...user1, firstName: 'Jimmy' }, + expectedUser2, + user3, + ]); + }); + + it('adds entity with a custom id if it does not exist', () => { + const Store = signalStore({ protectedState: false }, withEntities()); + const store = new Store(); + + patchState(store, upsertEntity(todo1, { selectId })); + + expect(store.entityMap()).toEqual({ x: todo1 }); + expect(store.ids()).toEqual(['x']); + expect(store.entities()).toEqual([todo1]); + + patchState(store, upsertEntity(todo2, { selectId })); + + expect(store.entityMap()).toEqual({ x: todo1, y: todo2 }); + expect(store.ids()).toEqual(['x', 'y']); + expect(store.entities()).toEqual([todo1, todo2]); + }); + + it('updates entity with a custom id if it already exists', () => { + const Store = signalStore({ protectedState: false }, withEntities()); + const store = new Store(); + + patchState( + store, + upsertEntity(todo1, { selectId }), + upsertEntity(todo2, { selectId }) + ); + patchState(store, upsertEntity({ ...todo2, text: 'NgRx' }, { selectId })); + + expect(store.entityMap()).toEqual({ + x: todo1, + y: { ...todo2, text: 'NgRx' }, + }); + expect(store.ids()).toEqual(['x', 'y']); + expect(store.entities()).toEqual([todo1, { ...todo2, text: 'NgRx' }]); + }); + + it('adds entity with a custom id to the specified collection if it does not exist', () => { + const Store = signalStore( + { protectedState: false }, + withEntities({ + entity: type(), + collection: 'todo', + }) + ); + const store = new Store(); + + patchState(store, upsertEntity(todo1, { collection: 'todo', selectId })); + + expect(store.todoEntityMap()).toEqual({ x: todo1 }); + expect(store.todoIds()).toEqual(['x']); + expect(store.todoEntities()).toEqual([todo1]); + + patchState(store, upsertEntity(todo2, { collection: 'todo', selectId })); + + expect(store.todoEntityMap()).toEqual({ x: todo1, y: todo2 }); + expect(store.todoIds()).toEqual(['x', 'y']); + expect(store.todoEntities()).toEqual([todo1, todo2]); + }); + + it('updates entity with a custom id to the specified collection if it already exists', () => { + const todoConfig = entityConfig({ + entity: type(), + collection: 'todo', + selectId, + }); + + const Store = signalStore( + { protectedState: false }, + withEntities(todoConfig) + ); + const store = new Store(); + + patchState( + store, + upsertEntity(todo1, todoConfig), + upsertEntity(todo2, todoConfig) + ); + patchState( + store, + upsertEntity({ ...todo2, text: 'Signals' }, todoConfig), + upsertEntity(todo1, todoConfig), + upsertEntity({ ...todo1, text: 'NgRx' }, todoConfig), + upsertEntity(todo2, todoConfig) + ); + + expect(store.todoEntityMap()).toEqual({ + x: { ...todo1, text: 'NgRx' }, + y: todo2, + }); + expect(store.todoIds()).toEqual(['x', 'y']); + expect(store.todoEntities()).toEqual([{ ...todo1, text: 'NgRx' }, todo2]); + }); +}); diff --git a/modules/signals/entities/src/helpers.ts b/modules/signals/entities/src/helpers.ts index 0ed58ccb4a..381f1703a6 100644 --- a/modules/signals/entities/src/helpers.ts +++ b/modules/signals/entities/src/helpers.ts @@ -106,12 +106,16 @@ export function addEntitiesMutably( export function setEntityMutably( state: EntityState, entity: any, - selectId: SelectEntityId + selectId: SelectEntityId, + replace = true ): DidMutate { const id = selectId(entity); if (state.entityMap[id]) { - state.entityMap[id] = entity; + state.entityMap[id] = replace + ? entity + : { ...state.entityMap[id], ...entity }; + return DidMutate.Entities; } @@ -124,12 +128,13 @@ export function setEntityMutably( export function setEntitiesMutably( state: EntityState, entities: any[], - selectId: SelectEntityId + selectId: SelectEntityId, + replace = true ): DidMutate { let didMutate = DidMutate.None; for (const entity of entities) { - const result = setEntityMutably(state, entity, selectId); + const result = setEntityMutably(state, entity, selectId, replace); if (didMutate === DidMutate.Both) { continue; diff --git a/modules/signals/entities/src/index.ts b/modules/signals/entities/src/index.ts index f6bdb94a50..14640eaa96 100644 --- a/modules/signals/entities/src/index.ts +++ b/modules/signals/entities/src/index.ts @@ -9,6 +9,8 @@ export { setAllEntities } from './updaters/set-all-entities'; export { updateEntity } from './updaters/update-entity'; export { updateEntities } from './updaters/update-entities'; export { updateAllEntities } from './updaters/update-all-entities'; +export { upsertEntity } from './updaters/upsert-entity'; +export { upsertEntities } from './updaters/upsert-entities'; export { entityConfig } from './entity-config'; export { diff --git a/modules/signals/entities/src/updaters/upsert-entities.ts b/modules/signals/entities/src/updaters/upsert-entities.ts new file mode 100644 index 0000000000..ca08302d47 --- /dev/null +++ b/modules/signals/entities/src/updaters/upsert-entities.ts @@ -0,0 +1,52 @@ +import { PartialStateUpdater } from '@ngrx/signals'; +import { + EntityId, + EntityState, + NamedEntityState, + SelectEntityId, +} from '../models'; +import { + cloneEntityState, + getEntityIdSelector, + getEntityStateKeys, + getEntityUpdaterResult, + setEntitiesMutably, +} from '../helpers'; + +export function upsertEntities( + entities: Entity[] +): PartialStateUpdater>; +export function upsertEntities( + entities: Entity[], + config: { collection: Collection; selectId: SelectEntityId> } +): PartialStateUpdater>; +export function upsertEntities< + Entity extends { id: EntityId }, + Collection extends string +>( + entities: Entity[], + config: { collection: Collection } +): PartialStateUpdater>; +export function upsertEntities( + entities: Entity[], + config: { selectId: SelectEntityId> } +): PartialStateUpdater>; +export function upsertEntities( + entities: any[], + config?: { collection?: string; selectId?: SelectEntityId } +): PartialStateUpdater | NamedEntityState> { + const selectId = getEntityIdSelector(config); + const stateKeys = getEntityStateKeys(config); + + return (state) => { + const clonedState = cloneEntityState(state, stateKeys); + const didMutate = setEntitiesMutably( + clonedState, + entities, + selectId, + false + ); + + return getEntityUpdaterResult(clonedState, stateKeys, didMutate); + }; +} diff --git a/modules/signals/entities/src/updaters/upsert-entity.ts b/modules/signals/entities/src/updaters/upsert-entity.ts new file mode 100644 index 0000000000..2016093928 --- /dev/null +++ b/modules/signals/entities/src/updaters/upsert-entity.ts @@ -0,0 +1,47 @@ +import { PartialStateUpdater } from '@ngrx/signals'; +import { + EntityId, + EntityState, + NamedEntityState, + SelectEntityId, +} from '../models'; +import { + cloneEntityState, + getEntityIdSelector, + getEntityStateKeys, + getEntityUpdaterResult, + setEntityMutably, +} from '../helpers'; + +export function upsertEntity( + entity: Entity +): PartialStateUpdater>; +export function upsertEntity( + entity: Entity, + config: { collection: Collection; selectId: SelectEntityId> } +): PartialStateUpdater>; +export function upsertEntity< + Entity extends { id: EntityId }, + Collection extends string +>( + entity: Entity, + config: { collection: Collection } +): PartialStateUpdater>; +export function upsertEntity( + entity: Entity, + config: { selectId: SelectEntityId> } +): PartialStateUpdater>; +export function upsertEntity( + entity: any, + config?: { collection?: string; selectId?: SelectEntityId } +): PartialStateUpdater | NamedEntityState> { + const selectId = getEntityIdSelector(config); + const stateKeys = getEntityStateKeys(config); + + return (state) => { + const clonedState = cloneEntityState(state, stateKeys); + const didMutate = setEntityMutably(clonedState, entity, selectId, false); + + return getEntityUpdaterResult(clonedState, stateKeys, didMutate); + }; +} diff --git a/projects/ngrx.io/content/guide/signals/signal-store/entity-management.md b/projects/ngrx.io/content/guide/signals/signal-store/entity-management.md index 7685a911bb..1e1b31b10f 100644 --- a/projects/ngrx.io/content/guide/signals/signal-store/entity-management.md +++ b/projects/ngrx.io/content/guide/signals/signal-store/entity-management.md @@ -86,30 +86,6 @@ If the entity collection has entities with the same IDs, they are not overridden patchState(store, addEntities([todo1, todo2])); ``` -### `setEntity` - -Adds or replaces an entity in the collection. - -```ts -patchState(store, setEntity(todo)); -``` - -### `setEntities` - -Adds or replaces multiple entities in the collection. - -```ts -patchState(store, setEntities([todo1, todo2])); -``` - -### `setAllEntities` - -Replaces the current entity collection with the provided collection. - -```ts -patchState(store, setAllEntities([todo1, todo2, todo3])); -``` - ### `updateEntity` Updates an entity in the collection by ID. Supports partial updates. No error is thrown if an entity doesn't exist. @@ -179,6 +155,48 @@ patchState( ); ``` +### `setEntity` + +Adds or replaces an entity in the collection. + +```ts +patchState(store, setEntity(todo)); +``` + +### `setEntities` + +Adds or replaces multiple entities in the collection. + +```ts +patchState(store, setEntities([todo1, todo2])); +``` + +### `setAllEntities` + +Replaces the current entity collection with the provided collection. + +```ts +patchState(store, setAllEntities([todo1, todo2, todo3])); +``` + +### `upsertEntity` + +Adds or updates an entity in the collection. +When updating, it does not replace the existing entity but merges it with the provided one. + +```ts +patchState(store, upsertEntity(todo)); +``` + +### `upsertEntities` + +Adds or updates multiple entities in the collection. +When updating, it does not replace existing entities but merges them with the provided ones. + +```ts +patchState(store, upsertEntities([todo1, todo2])); +``` + ### `removeEntity` Removes an entity from the collection by ID. No error is thrown if an entity doesn't exist. From 337a3340d6abdd5784948ccf81f66f776d4bc8ff Mon Sep 17 00:00:00 2001 From: markostanimirovic Date: Sat, 29 Mar 2025 03:57:55 +0100 Subject: [PATCH 2/2] docs(signals): apply CR suggestions --- .../content/guide/signals/signal-store/entity-management.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/projects/ngrx.io/content/guide/signals/signal-store/entity-management.md b/projects/ngrx.io/content/guide/signals/signal-store/entity-management.md index 1e1b31b10f..b8ce8817e9 100644 --- a/projects/ngrx.io/content/guide/signals/signal-store/entity-management.md +++ b/projects/ngrx.io/content/guide/signals/signal-store/entity-management.md @@ -183,6 +183,8 @@ patchState(store, setAllEntities([todo1, todo2, todo3])); Adds or updates an entity in the collection. When updating, it does not replace the existing entity but merges it with the provided one. +Only the properties provided in the updated entity are merged with the existing entity. +Properties not present in the updated entity remain unchanged. ```ts patchState(store, upsertEntity(todo)); @@ -192,6 +194,8 @@ patchState(store, upsertEntity(todo)); Adds or updates multiple entities in the collection. When updating, it does not replace existing entities but merges them with the provided ones. +Only the properties provided in updated entities are merged with existing entities. +Properties not present in updated entities remain unchanged. ```ts patchState(store, upsertEntities([todo1, todo2]));