Repository: reduxjs/redux-toolkit
Pull request #444 (2020-03-26 01:03:30)

Add dictionary support to createEntityAdapter many methods
#### Overview
This PR allows a user to pass in an object with the shape of `{ 1: { id: 1, other: 'value' }` directly to the `upsertMany`, `addMany`, and `setAll` methods of an entity adapter. This ultimately makes integration with a normalization library like [normalizr](https://github.com/paularmstrong/normalizr) much simpler.

~~Todo~~
- [x] Review `updateMany`
- [x] Review tests
- [x] Expand type tests


phryneas: Looks good to me.

Patch:
diff --git a/src/entities/models.ts b/src/entities/models.ts
index 4a569617..b5fbb651 100644
--- a/src/entities/models.ts
+++ b/src/entities/models.ts
@@ -57,16 +57,22 @@ export interface EntityStateAdapter<T> {
     action: PayloadAction<T>
   ): S
 
-  addMany<S extends EntityState<T>>(state: PreventAny<S, T>, entities: T[]): S
   addMany<S extends EntityState<T>>(
     state: PreventAny<S, T>,
-    entities: PayloadAction<T[]>
+    entities: T[] | Record<EntityId, T>
+  ): S
+  addMany<S extends EntityState<T>>(
+    state: PreventAny<S, T>,
+    entities: PayloadAction<T[] | Record<EntityId, T>>
   ): S
 
-  setAll<S extends EntityState<T>>(state: PreventAny<S, T>, entities: T[]): S
   setAll<S extends EntityState<T>>(
     state: PreventAny<S, T>,
-    entities: PayloadAction<T[]>
+    entities: T[] | Record<EntityId, T>
+  ): S
+  setAll<S extends EntityState<T>>(
+    state: PreventAny<S, T>,
+    entities: PayloadAction<T[] | Record<EntityId, T>>
   ): S
 
   removeOne<S extends EntityState<T>>(state: PreventAny<S, T>, key: EntityId): S
@@ -112,11 +118,11 @@ export interface EntityStateAdapter<T> {
 
   upsertMany<S extends EntityState<T>>(
     state: PreventAny<S, T>,
-    entities: T[]
+    entities: T[] | Record<EntityId, T>
   ): S
   upsertMany<S extends EntityState<T>>(
     state: PreventAny<S, T>,
-    entities: PayloadAction<T[]>
+    entities: PayloadAction<T[] | Record<EntityId, T>>
   ): S
 }
 
diff --git a/src/entities/sorted_state_adapter.ts b/src/entities/sorted_state_adapter.ts
index 93c6e5e7..4315308a 100644
--- a/src/entities/sorted_state_adapter.ts
+++ b/src/entities/sorted_state_adapter.ts
@@ -3,7 +3,8 @@ import {
   IdSelector,
   Comparer,
   EntityStateAdapter,
-  Update
+  Update,
+  EntityId
 } from './models'
 import { createStateOperator } from './state_adapter'
 import { createUnsortedStateAdapter } from './unsorted_state_adapter'
@@ -23,7 +24,14 @@ export function createSortedStateAdapter<T>(
     return addManyMutably([entity], state)
   }
 
-  function addManyMutably(newModels: T[], state: R): void {
+  function addManyMutably(
+    newModels: T[] | Record<EntityId, T>,
+    state: R
+  ): void {
+    if (!Array.isArray(newModels)) {
+      newModels = Object.values(newModels)
+    }
+
     const models = newModels.filter(
       model => !(selectIdValue(model, selectId) in state.entities)
     )
@@ -33,7 +41,10 @@ export function createSortedStateAdapter<T>(
     }
   }
 
-  function setAllMutably(models: T[], state: R): void {
+  function setAllMutably(models: T[] | Record<EntityId, T>, state: R): void {
+    if (!Array.isArray(models)) {
+      models = Object.values(models)
+    }
     state.entities = {}
     state.ids = []
 
@@ -74,7 +85,14 @@ export function createSortedStateAdapter<T>(
     return upsertManyMutably([entity], state)
   }
 
-  function upsertManyMutably(entities: T[], state: R): void {
+  function upsertManyMutably(
+    entities: T[] | Record<EntityId, T>,
+    state: R
+  ): void {
+    if (!Array.isArray(entities)) {
+      entities = Object.values(entities)
+    }
+
     const added: T[] = []
     const updated: Update<T>[] = []
 
diff --git a/src/entities/unsorted_state_adapter.ts b/src/entities/unsorted_state_adapter.ts
index 7386d9a8..8d41f242 100644
--- a/src/entities/unsorted_state_adapter.ts
+++ b/src/entities/unsorted_state_adapter.ts
@@ -24,13 +24,21 @@ export function createUnsortedStateAdapter<T>(
     state.entities[key] = entity
   }
 
-  function addManyMutably(entities: T[], state: R): void {
+  function addManyMutably(entities: T[] | Record<EntityId, T>, state: R): void {
+    if (!Array.isArray(entities)) {
+      entities = Object.values(entities)
+    }
+
     for (const entity of entities) {
       addOneMutably(entity, state)
     }
   }
 
-  function setAllMutably(entities: T[], state: R): void {
+  function setAllMutably(entities: T[] | Record<EntityId, T>, state: R): void {
+    if (!Array.isArray(entities)) {
+      entities = Object.values(entities)
+    }
+
     state.ids = []
     state.entities = {}
 
@@ -123,7 +131,14 @@ export function createUnsortedStateAdapter<T>(
     return upsertManyMutably([entity], state)
   }
 
-  function upsertManyMutably(entities: T[], state: R): void {
+  function upsertManyMutably(
+    entities: T[] | Record<EntityId, T>,
+    state: R
+  ): void {
+    if (!Array.isArray(entities)) {
+      entities = Object.values(entities)
+    }
+
     const added: T[] = []
     const updated: Update<T>[] = []
 


Test patch:
diff --git a/src/entities/sorted_state_adapter.test.ts b/src/entities/sorted_state_adapter.test.ts
index 8b45a788..104375a3 100644
--- a/src/entities/sorted_state_adapter.test.ts
+++ b/src/entities/sorted_state_adapter.test.ts
@@ -85,6 +85,24 @@ describe('Sorted State Adapter', () => {
     })
   })
 
+  it('should let you add many entities to the state from a dictionary', () => {
+    const withOneEntity = adapter.addOne(state, TheGreatGatsby)
+
+    const withManyMore = adapter.addMany(withOneEntity, {
+      [AClockworkOrange.id]: AClockworkOrange,
+      [AnimalFarm.id]: AnimalFarm
+    })
+
+    expect(withManyMore).toEqual({
+      ids: [AClockworkOrange.id, AnimalFarm.id, TheGreatGatsby.id],
+      entities: {
+        [TheGreatGatsby.id]: TheGreatGatsby,
+        [AClockworkOrange.id]: AClockworkOrange,
+        [AnimalFarm.id]: AnimalFarm
+      }
+    })
+  })
+
   it('should remove existing and add new ones on setAll', () => {
     const withOneEntity = adapter.addOne(state, TheGreatGatsby)
 
@@ -102,6 +120,23 @@ describe('Sorted State Adapter', () => {
     })
   })
 
+  it('should remove existing and add new ones on setAll when passing in a dictionary', () => {
+    const withOneEntity = adapter.addOne(state, TheGreatGatsby)
+
+    const withAll = adapter.setAll(withOneEntity, {
+      [AClockworkOrange.id]: AClockworkOrange,
+      [AnimalFarm.id]: AnimalFarm
+    })
+
+    expect(withAll).toEqual({
+      ids: [AClockworkOrange.id, AnimalFarm.id],
+      entities: {
+        [AClockworkOrange.id]: AClockworkOrange,
+        [AnimalFarm.id]: AnimalFarm
+      }
+    })
+  })
+
   it('should remove existing and add new ones on addAll (deprecated)', () => {
     const withOneEntity = adapter.addOne(state, TheGreatGatsby)
 
@@ -380,4 +415,25 @@ describe('Sorted State Adapter', () => {
       }
     })
   })
+
+  it('should let you upsert many entities in the state when passing in a dictionary', () => {
+    const firstChange = { title: 'Zack' }
+    const withMany = adapter.setAll(state, [TheGreatGatsby])
+
+    const withUpserts = adapter.upsertMany(withMany, {
+      [TheGreatGatsby.id]: { ...TheGreatGatsby, ...firstChange },
+      [AClockworkOrange.id]: AClockworkOrange
+    })
+
+    expect(withUpserts).toEqual({
+      ids: [AClockworkOrange.id, TheGreatGatsby.id],
+      entities: {
+        [TheGreatGatsby.id]: {
+          ...TheGreatGatsby,
+          ...firstChange
+        },
+        [AClockworkOrange.id]: AClockworkOrange
+      }
+    })
+  })
 })
diff --git a/src/entities/unsorted_state_adapter.test.ts b/src/entities/unsorted_state_adapter.test.ts
index c4448337..87a4ad62 100644
--- a/src/entities/unsorted_state_adapter.test.ts
+++ b/src/entities/unsorted_state_adapter.test.ts
@@ -69,6 +69,24 @@ describe('Unsorted State Adapter', () => {
     })
   })
 
+  it('should let you add many entities to the state from a dictionary', () => {
+    const withOneEntity = adapter.addOne(state, TheGreatGatsby)
+
+    const withManyMore = adapter.addMany(withOneEntity, {
+      [AClockworkOrange.id]: AClockworkOrange,
+      [AnimalFarm.id]: AnimalFarm
+    })
+
+    expect(withManyMore).toEqual({
+      ids: [TheGreatGatsby.id, AClockworkOrange.id, AnimalFarm.id],
+      entities: {
+        [TheGreatGatsby.id]: TheGreatGatsby,
+        [AClockworkOrange.id]: AClockworkOrange,
+        [AnimalFarm.id]: AnimalFarm
+      }
+    })
+  })
+
   it('should remove existing and add new ones on setAll', () => {
     const withOneEntity = adapter.addOne(state, TheGreatGatsby)
 
@@ -86,6 +104,23 @@ describe('Unsorted State Adapter', () => {
     })
   })
 
+  it('should remove existing and add new ones on setAll when passing in a dictionary', () => {
+    const withOneEntity = adapter.addOne(state, TheGreatGatsby)
+
+    const withAll = adapter.setAll(withOneEntity, {
+      [AClockworkOrange.id]: AClockworkOrange,
+      [AnimalFarm.id]: AnimalFarm
+    })
+
+    expect(withAll).toEqual({
+      ids: [AClockworkOrange.id, AnimalFarm.id],
+      entities: {
+        [AClockworkOrange.id]: AClockworkOrange,
+        [AnimalFarm.id]: AnimalFarm
+      }
+    })
+  })
+
   it('should let you add remove an entity from the state', () => {
     const withOneEntity = adapter.addOne(state, TheGreatGatsby)
 
@@ -230,14 +265,11 @@ describe('Unsorted State Adapter', () => {
 
     /*
       Original code failed with a mish-mash of values, like:
-
       {
         ids: [ 'c' ],
         entities: { b: { id: 'b', title: 'First' }, c: { id: 'c' } }
       }
-
       We now expect that only 'c' will be left:
-
       { 
         ids: [ 'c' ], 
         entities: { c: { id: 'c', title: 'First' } } 
@@ -299,4 +331,25 @@ describe('Unsorted State Adapter', () => {
       }
     })
   })
+
+  it('should let you upsert many entities in the state when passing in a dictionary', () => {
+    const firstChange = { title: 'Zack' }
+    const withMany = adapter.setAll(state, [TheGreatGatsby])
+
+    const withUpserts = adapter.upsertMany(withMany, {
+      [TheGreatGatsby.id]: { ...TheGreatGatsby, ...firstChange },
+      [AClockworkOrange.id]: AClockworkOrange
+    })
+
+    expect(withUpserts).toEqual({
+      ids: [TheGreatGatsby.id, AClockworkOrange.id],
+      entities: {
+        [TheGreatGatsby.id]: {
+          ...TheGreatGatsby,
+          ...firstChange
+        },
+        [AClockworkOrange.id]: AClockworkOrange
+      }
+    })
+  })
 })
diff --git a/type-tests/files/createEntityAdapter.typetest.ts b/type-tests/files/createEntityAdapter.typetest.ts
index c53a29ff..5e00b95e 100644
--- a/type-tests/files/createEntityAdapter.typetest.ts
+++ b/type-tests/files/createEntityAdapter.typetest.ts
@@ -41,8 +41,12 @@ function extractReducers<T>(
   })
 
   expectType<ActionCreatorWithPayload<Entity>>(slice.actions.addOne)
-  expectType<ActionCreatorWithPayload<Entity[]>>(slice.actions.addMany)
-  expectType<ActionCreatorWithPayload<Entity[]>>(slice.actions.setAll)
+  expectType<ActionCreatorWithPayload<Entity[] | Record<string, Entity>>>(
+    slice.actions.addMany
+  )
+  expectType<ActionCreatorWithPayload<Entity[] | Record<string, Entity>>>(
+    slice.actions.setAll
+  )
   expectType<ActionCreatorWithPayload<EntityId>>(slice.actions.removeOne)
   expectType<ActionCreatorWithPayload<EntityId[]>>(slice.actions.removeMany)
   expectType<ActionCreatorWithoutPayload>(slice.actions.removeAll)
@@ -51,7 +55,9 @@ function extractReducers<T>(
     slice.actions.updateMany
   )
   expectType<ActionCreatorWithPayload<Entity>>(slice.actions.upsertOne)
-  expectType<ActionCreatorWithPayload<Entity[]>>(slice.actions.upsertMany)
+  expectType<ActionCreatorWithPayload<Entity[] | Record<string, Entity>>>(
+    slice.actions.upsertMany
+  )
 }
 
 /**