From f2d708b30b81fddad84b4064198861a555df5217 Mon Sep 17 00:00:00 2001 From: Mike Ryan Date: Fri, 28 Jul 2017 10:03:42 -0500 Subject: [PATCH] feat(entities): Introduce @ngrx/entities --- build/config.ts | 4 + modules/entity/README.md | 6 + modules/entity/index.ts | 7 + modules/entity/package.json | 22 ++ modules/entity/public_api.ts | 1 + modules/entity/rollup.config.js | 10 + modules/entity/spec/entity_state.spec.ts | 33 +++ modules/entity/spec/fixtures/book.ts | 21 ++ .../entity/spec/sorted_state_adapter.spec.ts | 234 ++++++++++++++++++ modules/entity/spec/state_selectors.spec.ts | 57 +++++ .../spec/unsorted_state_adapter.spec.ts | 205 +++++++++++++++ modules/entity/src/create_adapter.ts | 30 +++ modules/entity/src/entity_state.ts | 20 ++ modules/entity/src/index.ts | 2 + modules/entity/src/models.ts | 54 ++++ modules/entity/src/sorted_state_adapter.ts | 132 ++++++++++ modules/entity/src/state_adapter.ts | 16 ++ modules/entity/src/state_selectors.ts | 27 ++ modules/entity/src/unsorted_state_adapter.ts | 93 +++++++ modules/entity/tsconfig-build.json | 33 +++ package.json | 1 + yarn.lock | 82 ++---- 22 files changed, 1032 insertions(+), 58 deletions(-) create mode 100644 modules/entity/README.md create mode 100644 modules/entity/index.ts create mode 100644 modules/entity/package.json create mode 100644 modules/entity/public_api.ts create mode 100644 modules/entity/rollup.config.js create mode 100644 modules/entity/spec/entity_state.spec.ts create mode 100644 modules/entity/spec/fixtures/book.ts create mode 100644 modules/entity/spec/sorted_state_adapter.spec.ts create mode 100644 modules/entity/spec/state_selectors.spec.ts create mode 100644 modules/entity/spec/unsorted_state_adapter.spec.ts create mode 100644 modules/entity/src/create_adapter.ts create mode 100644 modules/entity/src/entity_state.ts create mode 100644 modules/entity/src/index.ts create mode 100644 modules/entity/src/models.ts create mode 100644 modules/entity/src/sorted_state_adapter.ts create mode 100644 modules/entity/src/state_adapter.ts create mode 100644 modules/entity/src/state_selectors.ts create mode 100644 modules/entity/src/unsorted_state_adapter.ts create mode 100644 modules/entity/tsconfig-build.json diff --git a/build/config.ts b/build/config.ts index 29e2da865b..fdda5e781c 100644 --- a/build/config.ts +++ b/build/config.ts @@ -25,4 +25,8 @@ export const packages: PackageDescription[] = [ name: 'store-devtools', hasTestingModule: false, }, + { + name: 'entity', + hasTestingModule: false, + }, ]; diff --git a/modules/entity/README.md b/modules/entity/README.md new file mode 100644 index 0000000000..d26531d598 --- /dev/null +++ b/modules/entity/README.md @@ -0,0 +1,6 @@ +@ngrx/entity +======= + +The sources for this package are in the main [ngrx/platform](https://github.com/ngrx/platform) repo. Please file issues and pull requests against that repo. + +License: MIT diff --git a/modules/entity/index.ts b/modules/entity/index.ts new file mode 100644 index 0000000000..637e1cf2bf --- /dev/null +++ b/modules/entity/index.ts @@ -0,0 +1,7 @@ +/** + * DO NOT EDIT + * + * This file is automatically generated at build + */ + +export * from './public_api'; diff --git a/modules/entity/package.json b/modules/entity/package.json new file mode 100644 index 0000000000..791b17138f --- /dev/null +++ b/modules/entity/package.json @@ -0,0 +1,22 @@ +{ + "name": "@ngrx/entity", + "version": "4.0.1", + "description": "Common utilities for entity reducers", + "module": "@ngrx/entity.es5.js", + "es2015": "@ngrx/entity.js", + "main": "bundles/entity.umd.js", + "typings": "entity.d.ts", + "repository": { + "type": "git", + "url": "https://github.com/ngrx/platform.git" + }, + "authors": [ + "Mike Ryan" + ], + "license": "MIT", + "peerDependencies": { + "@angular/core": "^4.0.0", + "@ngrx/store": "^4.0.0", + "rxjs": "^5.0.0" + } +} diff --git a/modules/entity/public_api.ts b/modules/entity/public_api.ts new file mode 100644 index 0000000000..cba1843545 --- /dev/null +++ b/modules/entity/public_api.ts @@ -0,0 +1 @@ +export * from './src/index'; diff --git a/modules/entity/rollup.config.js b/modules/entity/rollup.config.js new file mode 100644 index 0000000000..bba59155ea --- /dev/null +++ b/modules/entity/rollup.config.js @@ -0,0 +1,10 @@ +export default { + entry: './dist/entity/@ngrx/entity.es5.js', + dest: './dist/entity/bundles/entity.umd.js', + format: 'umd', + exports: 'named', + moduleName: 'ngrx.entity', + globals: { + '@ngrx/store': 'ngrx.store' + } +} diff --git a/modules/entity/spec/entity_state.spec.ts b/modules/entity/spec/entity_state.spec.ts new file mode 100644 index 0000000000..bd61dc9678 --- /dev/null +++ b/modules/entity/spec/entity_state.spec.ts @@ -0,0 +1,33 @@ +import { createEntityAdapter, EntityAdapter } from '../src'; +import { BookModel } from './fixtures/book'; + +describe('Entity State', () => { + let adapter: EntityAdapter; + + beforeEach(() => { + adapter = createEntityAdapter({ + selectId: (book: BookModel) => book.id, + }); + }); + + it('should let you get the initial state', () => { + const initialState = adapter.getInitialState(); + + expect(initialState).toEqual({ + ids: [], + entities: {}, + }); + }); + + it('should let you provide additional initial state properties', () => { + const additionalProperties = { isHydrated: true }; + + const initialState = adapter.getInitialState(additionalProperties); + + expect(initialState).toEqual({ + ...additionalProperties, + ids: [], + entities: {}, + }); + }); +}); diff --git a/modules/entity/spec/fixtures/book.ts b/modules/entity/spec/fixtures/book.ts new file mode 100644 index 0000000000..a2ffcacfdc --- /dev/null +++ b/modules/entity/spec/fixtures/book.ts @@ -0,0 +1,21 @@ +const deepFreeze = require('deep-freeze'); + +export interface BookModel { + id: string; + title: string; +} + +export const AClockworkOrange: BookModel = deepFreeze({ + id: 'aco', + title: 'A Clockwork Orange', +}); + +export const AnimalFarm: BookModel = deepFreeze({ + id: 'af', + title: 'Animal Farm', +}); + +export const TheGreatGatsby: BookModel = deepFreeze({ + id: 'tgg', + title: 'The Great Gatsby', +}); diff --git a/modules/entity/spec/sorted_state_adapter.spec.ts b/modules/entity/spec/sorted_state_adapter.spec.ts new file mode 100644 index 0000000000..eaef6dc797 --- /dev/null +++ b/modules/entity/spec/sorted_state_adapter.spec.ts @@ -0,0 +1,234 @@ +import { EntityStateAdapter, EntityState } from '../src/models'; +import { createEntityAdapter } from '../src/create_adapter'; +import { + BookModel, + TheGreatGatsby, + AClockworkOrange, + AnimalFarm, +} from './fixtures/book'; + +describe('Sorted State Adapter', () => { + let adapter: EntityStateAdapter; + let state: EntityState; + + beforeEach(() => { + adapter = createEntityAdapter({ + selectId: (book: BookModel) => book.id, + sort: (a, b) => a.title.localeCompare(b.title), + }); + + state = { ids: [], entities: {} }; + }); + + it('should let you add one entity to the state', () => { + const withOneEntity = adapter.addOne(TheGreatGatsby, state); + + expect(withOneEntity).toEqual({ + ids: [TheGreatGatsby.id], + entities: { + [TheGreatGatsby.id]: TheGreatGatsby, + }, + }); + }); + + it('should not change state if you attempt to re-add an entity', () => { + const withOneEntity = adapter.addOne(TheGreatGatsby, state); + + const readded = adapter.addOne(TheGreatGatsby, withOneEntity); + + expect(readded).toEqual(withOneEntity); + }); + + it('should let you add many entities to the state', () => { + const withOneEntity = adapter.addOne(TheGreatGatsby, state); + + const withManyMore = adapter.addMany( + [AClockworkOrange, AnimalFarm], + withOneEntity + ); + + expect(withManyMore).toEqual({ + ids: [AClockworkOrange.id, AnimalFarm.id, TheGreatGatsby.id], + entities: { + [TheGreatGatsby.id]: TheGreatGatsby, + [AClockworkOrange.id]: AClockworkOrange, + [AnimalFarm.id]: AnimalFarm, + }, + }); + }); + + it('should let you add all entities to the state', () => { + const withOneEntity = adapter.addOne(TheGreatGatsby, state); + + const withAll = adapter.addAll( + [AClockworkOrange, AnimalFarm], + withOneEntity + ); + + 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(TheGreatGatsby, state); + + const withoutOne = adapter.removeOne(TheGreatGatsby.id, state); + + expect(withoutOne).toEqual({ + ids: [], + entities: {}, + }); + }); + + it('should let you remove many entities from the state', () => { + const withAll = adapter.addAll( + [TheGreatGatsby, AClockworkOrange, AnimalFarm], + state + ); + + const withoutMany = adapter.removeMany( + [TheGreatGatsby.id, AClockworkOrange.id], + withAll + ); + + expect(withoutMany).toEqual({ + ids: [AnimalFarm.id], + entities: { + [AnimalFarm.id]: AnimalFarm, + }, + }); + }); + + it('should let you remove all entities from the state', () => { + const withAll = adapter.addAll( + [TheGreatGatsby, AClockworkOrange, AnimalFarm], + state + ); + + const withoutAll = adapter.removeAll(withAll); + + expect(withoutAll).toEqual({ + ids: [], + entities: {}, + }); + }); + + it('should let you update an entity in the state', () => { + const withOne = adapter.addOne(TheGreatGatsby, state); + const changes = { title: 'A New Hope' }; + + const withUpdates = adapter.updateOne( + { + id: TheGreatGatsby.id, + changes, + }, + withOne + ); + + expect(withUpdates).toEqual({ + ids: [TheGreatGatsby.id], + entities: { + [TheGreatGatsby.id]: { + ...TheGreatGatsby, + ...changes, + }, + }, + }); + }); + + it('should not change state if you attempt to update an entity that has not been added', () => { + const withUpdates = adapter.updateOne( + { + id: TheGreatGatsby.id, + changes: { title: 'A New Title' }, + }, + state + ); + + expect(withUpdates).toEqual(state); + }); + + it('should let you update the id of entity', () => { + const withOne = adapter.addOne(TheGreatGatsby, state); + const changes = { id: 'A New Id' }; + + const withUpdates = adapter.updateOne( + { + id: TheGreatGatsby.id, + changes, + }, + withOne + ); + + expect(withUpdates).toEqual({ + ids: [changes.id], + entities: { + [changes.id]: { + ...TheGreatGatsby, + ...changes, + }, + }, + }); + }); + + it('should resort correctly if the id and sort key update', () => { + const withOne = adapter.addAll( + [TheGreatGatsby, AnimalFarm, AClockworkOrange], + state + ); + const changes = { id: 'A New Id', title: AnimalFarm.title }; + + const withUpdates = adapter.updateOne( + { + id: TheGreatGatsby.id, + changes, + }, + withOne + ); + + expect(withUpdates).toEqual({ + ids: [AClockworkOrange.id, changes.id, AnimalFarm.id], + entities: { + [AClockworkOrange.id]: AClockworkOrange, + [changes.id]: { + ...TheGreatGatsby, + ...changes, + }, + [AnimalFarm.id]: AnimalFarm, + }, + }); + }); + + it('should let you update many entities in the state', () => { + const firstChange = { title: 'Zack' }; + const secondChange = { title: 'Aaron' }; + const withMany = adapter.addAll([TheGreatGatsby, AClockworkOrange], state); + + const withUpdates = adapter.updateMany( + [ + { id: TheGreatGatsby.id, changes: firstChange }, + { id: AClockworkOrange.id, changes: secondChange }, + ], + withMany + ); + + expect(withUpdates).toEqual({ + ids: [AClockworkOrange.id, TheGreatGatsby.id], + entities: { + [TheGreatGatsby.id]: { + ...TheGreatGatsby, + ...firstChange, + }, + [AClockworkOrange.id]: { + ...AClockworkOrange, + ...secondChange, + }, + }, + }); + }); +}); diff --git a/modules/entity/spec/state_selectors.spec.ts b/modules/entity/spec/state_selectors.spec.ts new file mode 100644 index 0000000000..0e0a9cb55b --- /dev/null +++ b/modules/entity/spec/state_selectors.spec.ts @@ -0,0 +1,57 @@ +import { createEntityAdapter, EntityAdapter, EntityState } from '../src'; +import { EntitySelectors } from '../src/models'; +import { + BookModel, + AClockworkOrange, + AnimalFarm, + TheGreatGatsby, +} from './fixtures/book'; + +describe('Entity State', () => { + interface State { + books: EntityState; + } + + let adapter: EntityAdapter; + let selectors: EntitySelectors; + let state: State; + + beforeEach(() => { + adapter = createEntityAdapter({ + selectId: (book: BookModel) => book.id, + }); + + state = { + books: adapter.addAll( + [AClockworkOrange, AnimalFarm, TheGreatGatsby], + adapter.getInitialState() + ), + }; + + selectors = adapter.getSelectors((state: State) => state.books); + }); + + it('should create a selector for selecting the ids', () => { + const ids = selectors.selectIds(state); + + expect(ids).toEqual(state.books.ids); + }); + + it('should create a selector for selecting the entities', () => { + const entities = selectors.selectEntities(state); + + expect(entities).toEqual(state.books.entities); + }); + + it('should create a selector for selecting the list of models', () => { + const models = selectors.selectAll(state); + + expect(models).toEqual([AClockworkOrange, AnimalFarm, TheGreatGatsby]); + }); + + it('should create a selector for selecting the count of models', () => { + const total = selectors.selectTotal(state); + + expect(total).toEqual(3); + }); +}); diff --git a/modules/entity/spec/unsorted_state_adapter.spec.ts b/modules/entity/spec/unsorted_state_adapter.spec.ts new file mode 100644 index 0000000000..2a9d1176a8 --- /dev/null +++ b/modules/entity/spec/unsorted_state_adapter.spec.ts @@ -0,0 +1,205 @@ +import { EntityStateAdapter, EntityState } from '../src/models'; +import { createEntityAdapter } from '../src/create_adapter'; +import { + BookModel, + TheGreatGatsby, + AClockworkOrange, + AnimalFarm, +} from './fixtures/book'; + +describe('Unsorted State Adapter', () => { + let adapter: EntityStateAdapter; + let state: EntityState; + + beforeEach(() => { + adapter = createEntityAdapter({ + selectId: (book: BookModel) => book.id, + }); + + state = { ids: [], entities: {} }; + }); + + it('should let you add one entity to the state', () => { + const withOneEntity = adapter.addOne(TheGreatGatsby, state); + + expect(withOneEntity).toEqual({ + ids: [TheGreatGatsby.id], + entities: { + [TheGreatGatsby.id]: TheGreatGatsby, + }, + }); + }); + + it('should not change state if you attempt to re-add an entity', () => { + const withOneEntity = adapter.addOne(TheGreatGatsby, state); + + const readded = adapter.addOne(TheGreatGatsby, withOneEntity); + + expect(readded).toEqual(withOneEntity); + }); + + it('should let you add many entities to the state', () => { + const withOneEntity = adapter.addOne(TheGreatGatsby, state); + + const withManyMore = adapter.addMany( + [AClockworkOrange, AnimalFarm], + withOneEntity + ); + + expect(withManyMore).toEqual({ + ids: [TheGreatGatsby.id, AClockworkOrange.id, AnimalFarm.id], + entities: { + [TheGreatGatsby.id]: TheGreatGatsby, + [AClockworkOrange.id]: AClockworkOrange, + [AnimalFarm.id]: AnimalFarm, + }, + }); + }); + + it('should let you add all entities to the state', () => { + const withOneEntity = adapter.addOne(TheGreatGatsby, state); + + const withAll = adapter.addAll( + [AClockworkOrange, AnimalFarm], + withOneEntity + ); + + 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(TheGreatGatsby, state); + + const withoutOne = adapter.removeOne(TheGreatGatsby.id, state); + + expect(withoutOne).toEqual({ + ids: [], + entities: {}, + }); + }); + + it('should let you remove many entities from the state', () => { + const withAll = adapter.addAll( + [TheGreatGatsby, AClockworkOrange, AnimalFarm], + state + ); + + const withoutMany = adapter.removeMany( + [TheGreatGatsby.id, AClockworkOrange.id], + withAll + ); + + expect(withoutMany).toEqual({ + ids: [AnimalFarm.id], + entities: { + [AnimalFarm.id]: AnimalFarm, + }, + }); + }); + + it('should let you remove all entities from the state', () => { + const withAll = adapter.addAll( + [TheGreatGatsby, AClockworkOrange, AnimalFarm], + state + ); + + const withoutAll = adapter.removeAll(withAll); + + expect(withoutAll).toEqual({ + ids: [], + entities: {}, + }); + }); + + it('should let you update an entity in the state', () => { + const withOne = adapter.addOne(TheGreatGatsby, state); + const changes = { title: 'A New Hope' }; + + const withUpdates = adapter.updateOne( + { + id: TheGreatGatsby.id, + changes, + }, + withOne + ); + + expect(withUpdates).toEqual({ + ids: [TheGreatGatsby.id], + entities: { + [TheGreatGatsby.id]: { + ...TheGreatGatsby, + ...changes, + }, + }, + }); + }); + + it('should not change state if you attempt to update an entity that has not been added', () => { + const withUpdates = adapter.updateOne( + { + id: TheGreatGatsby.id, + changes: { title: 'A New Title' }, + }, + state + ); + + expect(withUpdates).toEqual(state); + }); + + it('should let you update the id of entity', () => { + const withOne = adapter.addOne(TheGreatGatsby, state); + const changes = { id: 'A New Id' }; + + const withUpdates = adapter.updateOne( + { + id: TheGreatGatsby.id, + changes, + }, + withOne + ); + + expect(withUpdates).toEqual({ + ids: [changes.id], + entities: { + [changes.id]: { + ...TheGreatGatsby, + ...changes, + }, + }, + }); + }); + + it('should let you update many entities in the state', () => { + const firstChange = { title: 'First Change' }; + const secondChange = { title: 'Second Change' }; + const withMany = adapter.addAll([TheGreatGatsby, AClockworkOrange], state); + + const withUpdates = adapter.updateMany( + [ + { id: TheGreatGatsby.id, changes: firstChange }, + { id: AClockworkOrange.id, changes: secondChange }, + ], + withMany + ); + + expect(withUpdates).toEqual({ + ids: [TheGreatGatsby.id, AClockworkOrange.id], + entities: { + [TheGreatGatsby.id]: { + ...TheGreatGatsby, + ...firstChange, + }, + [AClockworkOrange.id]: { + ...AClockworkOrange, + ...secondChange, + }, + }, + }); + }); +}); diff --git a/modules/entity/src/create_adapter.ts b/modules/entity/src/create_adapter.ts new file mode 100644 index 0000000000..90467c639c --- /dev/null +++ b/modules/entity/src/create_adapter.ts @@ -0,0 +1,30 @@ +import { createSelector } from '@ngrx/store'; +import { + EntityDefinition, + Comparer, + IdSelector, + EntityAdapter, +} from './models'; +import { createInitialStateFactory } from './entity_state'; +import { createSelectorsFactory } from './state_selectors'; +import { createSortedStateAdapter } from './sorted_state_adapter'; +import { createUnsortedStateAdapter } from './unsorted_state_adapter'; + +export function createEntityAdapter(options: { + selectId: IdSelector; + sort?: false | Comparer; +}): EntityAdapter { + const { selectId, sort }: EntityDefinition = { sort: false, ...options }; + + const stateFactory = createInitialStateFactory(); + const selectorsFactory = createSelectorsFactory(); + const stateAdapter = sort + ? createSortedStateAdapter(selectId, sort) + : createUnsortedStateAdapter(selectId); + + return { + ...stateFactory, + ...selectorsFactory, + ...stateAdapter, + }; +} diff --git a/modules/entity/src/entity_state.ts b/modules/entity/src/entity_state.ts new file mode 100644 index 0000000000..aab8653b49 --- /dev/null +++ b/modules/entity/src/entity_state.ts @@ -0,0 +1,20 @@ +import { EntityState } from './models'; + +export function getInitialEntityState(): EntityState { + return { + ids: [], + entities: {}, + }; +} + +export function createInitialStateFactory() { + function getInitialState(): EntityState; + function getInitialState( + additionalState: S + ): EntityState & S; + function getInitialState(additionalState: any = {}): any { + return Object.assign(getInitialEntityState(), additionalState); + } + + return { getInitialState }; +} diff --git a/modules/entity/src/index.ts b/modules/entity/src/index.ts new file mode 100644 index 0000000000..2635a4dbbc --- /dev/null +++ b/modules/entity/src/index.ts @@ -0,0 +1,2 @@ +export { createEntityAdapter } from './create_adapter'; +export { EntityState, EntityAdapter } from './models'; diff --git a/modules/entity/src/models.ts b/modules/entity/src/models.ts new file mode 100644 index 0000000000..a0a4068b64 --- /dev/null +++ b/modules/entity/src/models.ts @@ -0,0 +1,54 @@ +export type Comparer = { + (a: T, b: T): number; +}; + +export type IdSelector = { + (model: T): string; +}; + +export type Dictionary = { + [id: string]: T; +}; + +export type Update = { + id: string; + changes: Partial; +}; + +export interface EntityState { + ids: string[]; + entities: Dictionary; +} + +export interface EntityDefinition { + selectId: IdSelector; + sort: false | Comparer; +} + +export interface EntityStateAdapter { + addOne>(entity: T, state: S): S; + addMany>(entities: T[], state: S): S; + addAll>(entities: T[], state: S): S; + + removeOne>(key: string, state: S): S; + removeMany>(keys: string[], state: S): S; + removeAll>(state: S): S; + + updateOne>(update: Update, state: S): S; + updateMany>(updates: Update[], state: S): S; +} + +export type EntitySelectors = { + selectIds: (state: V) => string[]; + selectEntities: (state: V) => Dictionary; + selectAll: (state: V) => T[]; + selectTotal: (state: V) => number; +}; + +export interface EntityAdapter extends EntityStateAdapter { + getInitialState(): EntityState; + getInitialState(state: S): EntityState & S; + getSelectors( + selectState: (state: V) => EntityState + ): EntitySelectors; +} diff --git a/modules/entity/src/sorted_state_adapter.ts b/modules/entity/src/sorted_state_adapter.ts new file mode 100644 index 0000000000..53bdcca992 --- /dev/null +++ b/modules/entity/src/sorted_state_adapter.ts @@ -0,0 +1,132 @@ +import { + EntityState, + IdSelector, + Comparer, + Dictionary, + EntityStateAdapter, + Update, +} from './models'; +import { createStateOperator } from './state_adapter'; +import { createUnsortedStateAdapter } from './unsorted_state_adapter'; + +export function createSortedStateAdapter( + selectId: IdSelector, + sort: Comparer +): EntityStateAdapter { + type R = EntityState; + + const { removeOne, removeMany, removeAll } = createUnsortedStateAdapter( + selectId + ); + + function addOneMutably(entity: T, state: R): void { + const key = selectId(entity); + const index = state.ids.indexOf(key); + + if (index !== -1) { + return; + } + + const insertAt = findTargetIndex(state, entity); + state.ids.splice(insertAt, 0, key); + state.entities[key] = entity; + } + + function addManyMutably(newModels: T[], state: R): void { + for (let index in newModels) { + addOneMutably(newModels[index], state); + } + } + + function addAllMutably(models: T[], state: R): void { + const sortedModels = models.sort(sort); + + state.entities = {}; + state.ids = sortedModels.map(model => { + const id = selectId(model); + state.entities[id] = model; + return id; + }); + } + + function updateOneMutably(update: Update, state: R): void { + const index = state.ids.indexOf(update.id); + + if (index === -1) { + return; + } + + const original = state.entities[update.id]; + const updated: T = Object.assign({}, original, update.changes); + const updatedKey = selectId(updated); + const result = sort(original, updated); + + if (result === 0) { + if (updatedKey !== update.id) { + delete state.entities[update.id]; + state.ids[index] = updatedKey; + } + + state.entities[updatedKey] = updated; + + return; + } + + state.ids.splice(index, 1); + state.ids.splice(findTargetIndex(state, updated), 0, updatedKey); + + if (updatedKey !== update.id) { + delete state.entities[update.id]; + } + + state.entities[updatedKey] = updated; + } + + function updateManyMutably(updates: Update[], state: R): void { + for (let index in updates) { + updateOneMutably(updates[index], state); + } + } + + function findTargetIndex( + state: R, + model: T, + left = 0, + right = state.ids.length - 1 + ) { + if (right === -1) { + return 0; + } + + let middle: number; + + while (true) { + middle = Math.floor((left + right) / 2); + + const result = sort(state.entities[state.ids[middle]], model); + + if (result === 0) { + return middle; + } else if (result < 0) { + left = middle + 1; + } else { + right = middle - 1; + } + + if (left > right) { + return state.ids.length - 1; + } + } + } + + return { + removeOne, + removeMany, + removeAll, + addOne: createStateOperator(addOneMutably), + updateOne: createStateOperator(updateOneMutably), + addAll: createStateOperator(addAllMutably), + addMany: createStateOperator(addManyMutably), + updateMany: createStateOperator(updateManyMutably), + }; +} diff --git a/modules/entity/src/state_adapter.ts b/modules/entity/src/state_adapter.ts new file mode 100644 index 0000000000..6a6b24f744 --- /dev/null +++ b/modules/entity/src/state_adapter.ts @@ -0,0 +1,16 @@ +import { EntityState, EntityStateAdapter } from './models'; + +export function createStateOperator( + mutator: (arg: R, state: EntityState) => void +) { + return function operation>(arg: R, state: S): S { + const clonedEntityState: EntityState = { + ids: [...state.ids], + entities: { ...state.entities }, + }; + + mutator(arg, clonedEntityState); + + return Object.assign({}, state, clonedEntityState); + }; +} diff --git a/modules/entity/src/state_selectors.ts b/modules/entity/src/state_selectors.ts new file mode 100644 index 0000000000..260edfb91e --- /dev/null +++ b/modules/entity/src/state_selectors.ts @@ -0,0 +1,27 @@ +import { createSelector } from '@ngrx/store'; +import { EntityState, EntitySelectors } from './models'; + +export function createSelectorsFactory() { + return { + getSelectors( + selectState: (state: V) => EntityState + ): EntitySelectors { + const selectIds = (state: EntityState) => state.ids; + const selectEntities = (state: EntityState) => state.entities; + const selectAll = createSelector( + selectIds, + selectEntities, + (ids, entities) => ids.map(id => entities[id]) + ); + + const selectTotal = createSelector(selectIds, ids => ids.length); + + return { + selectIds: createSelector(selectState, selectIds), + selectEntities: createSelector(selectState, selectEntities), + selectAll: createSelector(selectState, selectAll), + selectTotal: createSelector(selectState, selectTotal), + }; + }, + }; +} diff --git a/modules/entity/src/unsorted_state_adapter.ts b/modules/entity/src/unsorted_state_adapter.ts new file mode 100644 index 0000000000..afc6980055 --- /dev/null +++ b/modules/entity/src/unsorted_state_adapter.ts @@ -0,0 +1,93 @@ +import { EntityState, EntityStateAdapter, IdSelector, Update } from './models'; +import { createStateOperator } from './state_adapter'; + +export function createUnsortedStateAdapter( + selectId: IdSelector +): EntityStateAdapter { + type R = EntityState; + + function addOneMutably(entity: T, state: R): void { + const key = selectId(entity); + const index = state.ids.indexOf(key); + + if (index !== -1) { + return; + } + + state.ids.push(key); + state.entities[key] = entity; + } + + function addManyMutably(entities: T[], state: R): void { + for (let index in entities) { + addOneMutably(entities[index], state); + } + } + + function addAllMutably(entities: T[], state: R): void { + state.ids = []; + state.entities = {}; + + addManyMutably(entities, state); + } + + function removeOneMutably(key: string, state: R): void { + const index = state.ids.indexOf(key); + + if (index === -1) { + return; + } + + state.ids.splice(index, 1); + delete state.entities[key]; + } + + function removeManyMutably(keys: string[], state: R): void { + for (let index in keys) { + removeOneMutably(keys[index], state); + } + } + + function removeAll(state: S): S { + return Object.assign({}, state, { + ids: [], + entities: {}, + }); + } + + function updateOneMutably(update: Update, state: R): void { + const index = state.ids.indexOf(update.id); + + if (index === -1) { + return; + } + + const original = state.entities[update.id]; + const updated: T = Object.assign({}, original, update.changes); + const newKey = selectId(updated); + + if (newKey !== update.id) { + state.ids[index] = newKey; + delete state.entities[update.id]; + } + + state.entities[newKey] = updated; + } + + function updateManyMutably(updates: Update[], state: R): void { + for (let index in updates) { + updateOneMutably(updates[index], state); + } + } + + return { + removeAll, + addOne: createStateOperator(addOneMutably), + addMany: createStateOperator(addManyMutably), + addAll: createStateOperator(addAllMutably), + updateOne: createStateOperator(updateOneMutably), + updateMany: createStateOperator(updateManyMutably), + removeOne: createStateOperator(removeOneMutably), + removeMany: createStateOperator(removeManyMutably), + }; +} diff --git a/modules/entity/tsconfig-build.json b/modules/entity/tsconfig-build.json new file mode 100644 index 0000000000..bdba0a3200 --- /dev/null +++ b/modules/entity/tsconfig-build.json @@ -0,0 +1,33 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "declaration": true, + "stripInternal": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "module": "es2015", + "moduleResolution": "node", + "noEmitOnError": false, + "noImplicitAny": true, + "noImplicitReturns": true, + "outDir": "../../dist/packages/entity", + "paths": { + "@ngrx/store": ["../../dist/packages/store"] + }, + "rootDir": ".", + "sourceMap": true, + "inlineSources": true, + "lib": ["es2015", "dom"], + "target": "es2015", + "skipLibCheck": true + }, + "files": [ + "public_api.ts" + ], + "angularCompilerOptions": { + "annotateForClosureCompiler": true, + "strictMetadataEmit": true, + "flatModuleOutFile": "index.js", + "flatModuleId": "@ngrx/entity" + } +} \ No newline at end of file diff --git a/package.json b/package.json index d599b95b2f..967636461e 100644 --- a/package.json +++ b/package.json @@ -69,6 +69,7 @@ "core-js": "^2.4.1", "coveralls": "^2.13.0", "cpy-cli": "^1.0.1", + "deep-freeze": "^0.0.1", "fs-extra": "^2.1.2", "glob": "^7.1.1", "hammerjs": "^2.0.8", diff --git a/yarn.lock b/yarn.lock index eb745e27aa..123cdf2402 100644 --- a/yarn.lock +++ b/yarn.lock @@ -194,18 +194,14 @@ version "2.0.29" resolved "https://registry.yarnpkg.com/@types/minimatch/-/minimatch-2.0.29.tgz#5002e14f75e2d71e564281df0431c8c1b4a2a36a" -"@types/node@*": - version "8.0.10" - resolved "https://registry.yarnpkg.com/@types/node/-/node-8.0.10.tgz#12efec9183b072d5f951cf86395a4c780f868a17" +"@types/node@*", "@types/node@^7.0.5": + version "7.0.34" + resolved "https://registry.yarnpkg.com/@types/node/-/node-7.0.34.tgz#eed5c95291a9dddff6b9f5a72ca342b1e72f0ba2" "@types/node@^6.0.46": version "6.0.68" resolved "https://registry.yarnpkg.com/@types/node/-/node-6.0.68.tgz#0c43b6b8b9445feb86a0fbd3457e3f4bc591e66d" -"@types/node@^7.0.5": - version "7.0.34" - resolved "https://registry.yarnpkg.com/@types/node/-/node-7.0.34.tgz#eed5c95291a9dddff6b9f5a72ca342b1e72f0ba2" - "@types/ora@^0.3.31": version "0.3.31" resolved "https://registry.yarnpkg.com/@types/ora/-/ora-0.3.31.tgz#1a4bf16bd62ec2764b8f40b0e2f4d85c21292f83" @@ -595,14 +591,14 @@ babel-types@^6.18.0, babel-types@^6.23.0: lodash "^4.2.0" to-fast-properties "^1.0.1" -babylon@^6.11.0, babylon@^6.13.0, babylon@^6.15.0: - version "6.16.1" - resolved "https://registry.yarnpkg.com/babylon/-/babylon-6.16.1.tgz#30c5a22f481978a9e7f8cdfdf496b11d94b404d3" - -babylon@^6.17.4: +babylon@^6.11.0, babylon@^6.15.0, babylon@^6.17.4: version "6.17.4" resolved "https://registry.yarnpkg.com/babylon/-/babylon-6.17.4.tgz#3e8b7402b88d22c3423e137a1577883b15ff869a" +babylon@^6.13.0: + version "6.16.1" + resolved "https://registry.yarnpkg.com/babylon/-/babylon-6.16.1.tgz#30c5a22f481978a9e7f8cdfdf496b11d94b404d3" + backo2@1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/backo2/-/backo2-1.0.2.tgz#31ab1ac8b129363463e35b3ebb69f4dfcfba7947" @@ -1337,22 +1333,7 @@ conventional-changelog-writer@^1.1.0: split "^1.0.0" through2 "^2.0.0" -conventional-changelog@^1.1.3: - version "1.1.4" - resolved "https://registry.yarnpkg.com/conventional-changelog/-/conventional-changelog-1.1.4.tgz#108bc750c2a317e200e2f9b413caaa1f8c7efa3b" - dependencies: - conventional-changelog-angular "^1.3.4" - conventional-changelog-atom "^0.1.0" - conventional-changelog-codemirror "^0.1.0" - conventional-changelog-core "^1.9.0" - conventional-changelog-ember "^0.2.6" - conventional-changelog-eslint "^0.1.0" - conventional-changelog-express "^0.1.0" - conventional-changelog-jquery "^0.1.0" - conventional-changelog-jscs "^0.1.0" - conventional-changelog-jshint "^0.1.0" - -conventional-changelog@^1.1.4: +conventional-changelog@^1.1.3, conventional-changelog@^1.1.4: version "1.1.4" resolved "https://registry.npmjs.org/conventional-changelog/-/conventional-changelog-1.1.4.tgz#108bc750c2a317e200e2f9b413caaa1f8c7efa3b" dependencies: @@ -1741,6 +1722,10 @@ deep-freeze-strict@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/deep-freeze-strict/-/deep-freeze-strict-1.1.1.tgz#77d0583ca24a69be4bbd9ac2fae415d55523e5b0" +deep-freeze@^0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/deep-freeze/-/deep-freeze-0.0.1.tgz#3a0b0005de18672819dfd38cd31f91179c893e84" + default-require-extensions@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/default-require-extensions/-/default-require-extensions-1.0.0.tgz#f37ea15d3e13ffd9b437d33e1a75b5fb97874cb8" @@ -2885,14 +2870,10 @@ husky@^0.14.3: normalize-path "^1.0.0" strip-indent "^2.0.0" -iconv-lite@0.4.15: +iconv-lite@0.4.15, iconv-lite@~0.4.13: version "0.4.15" resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.15.tgz#fe265a218ac6a57cfe854927e9d04c19825eddeb" -iconv-lite@~0.4.13: - version "0.4.18" - resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.18.tgz#23d8656b16aae6742ac29732ea8f0336a4789cf2" - icss-replace-symbols@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/icss-replace-symbols/-/icss-replace-symbols-1.1.0.tgz#06ea6f83679a7749e386cfe1fe812ae5db223ded" @@ -3250,14 +3231,14 @@ istanbul-instrumenter-loader@^2.0.0: loader-utils "^0.2.16" object-assign "^4.1.0" -istanbul-lib-coverage@^1.0.0, istanbul-lib-coverage@^1.0.0-alpha, istanbul-lib-coverage@^1.0.0-alpha.0, istanbul-lib-coverage@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/istanbul-lib-coverage/-/istanbul-lib-coverage-1.0.1.tgz#f263efb519c051c5f1f3343034fc40e7b43ff212" - -istanbul-lib-coverage@^1.1.1: +istanbul-lib-coverage@^1.0.0, istanbul-lib-coverage@^1.0.0-alpha, istanbul-lib-coverage@^1.0.0-alpha.0, istanbul-lib-coverage@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/istanbul-lib-coverage/-/istanbul-lib-coverage-1.1.1.tgz#73bfb998885299415c93d38a3e9adf784a77a9da" +istanbul-lib-coverage@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/istanbul-lib-coverage/-/istanbul-lib-coverage-1.0.1.tgz#f263efb519c051c5f1f3343034fc40e7b43ff212" + istanbul-lib-hook@^1.0.0: version "1.0.4" resolved "https://registry.yarnpkg.com/istanbul-lib-hook/-/istanbul-lib-hook-1.0.4.tgz#1919debbc195807880041971caf9c7e2be2144d6" @@ -4944,14 +4925,10 @@ punycode@^1.2.4, punycode@^1.4.1: version "1.4.1" resolved "https://registry.yarnpkg.com/punycode/-/punycode-1.4.1.tgz#c0d5a63b2718800ad8e1eb0fa5269c84dd41845e" -q@1.4.1: +q@1.4.1, q@^1.1.2, q@^1.4.1: version "1.4.1" resolved "https://registry.yarnpkg.com/q/-/q-1.4.1.tgz#55705bcd93c5f3673530c2c2cbc0c2b3addc286e" -q@^1.1.2, q@^1.4.1: - version "1.5.0" - resolved "https://registry.yarnpkg.com/q/-/q-1.5.0.tgz#dd01bac9d06d30e6f219aecb8253ee9ebdc308f1" - qjobs@^1.1.4: version "1.1.5" resolved "https://registry.yarnpkg.com/qjobs/-/qjobs-1.1.5.tgz#659de9f2cf8dcc27a1481276f205377272382e73" @@ -6043,7 +6020,7 @@ tmp@0.0.24: version "0.0.24" resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.0.24.tgz#d6a5e198d14a9835cc6f2d7c3d9e302428c8cf12" -tmp@0.0.28: +tmp@0.0.28, tmp@0.0.x: version "0.0.28" resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.0.28.tgz#172735b7f614ea7af39664fa84cf0de4e515d120" dependencies: @@ -6055,7 +6032,7 @@ tmp@0.0.30: dependencies: os-tmpdir "~1.0.1" -tmp@0.0.x, tmp@^0.0.31: +tmp@^0.0.31: version "0.0.31" resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.0.31.tgz#8f38ab9438e17315e5dbd8b3657e8bfb277ae4a7" dependencies: @@ -6345,14 +6322,10 @@ uuid@^2.0.1, uuid@^2.0.2: version "2.0.3" resolved "https://registry.yarnpkg.com/uuid/-/uuid-2.0.3.tgz#67e2e863797215530dff318e5bf9dcebfd47b21a" -uuid@^3.0.0: +uuid@^3.0.0, uuid@^3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.0.1.tgz#6544bba2dfda8c1cf17e629a3a305e2bb1fee6c1" -uuid@^3.0.1: - version "3.1.0" - resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.1.0.tgz#3dd3d3e790abc24d7b0d3a034ffababe28ebbc04" - v8flags@^2.0.11: version "2.0.11" resolved "https://registry.yarnpkg.com/v8flags/-/v8flags-2.0.11.tgz#bca8f30f0d6d60612cc2c00641e6962d42ae6881" @@ -6622,20 +6595,13 @@ write-pkg@^3.0.1: sort-keys "^2.0.0" write-json-file "^2.2.0" -ws@1.1.1: +ws@1.1.1, ws@^1.0.1: version "1.1.1" resolved "https://registry.yarnpkg.com/ws/-/ws-1.1.1.tgz#082ddb6c641e85d4bb451f03d52f06eabdb1f018" dependencies: options ">=0.0.5" ultron "1.0.x" -ws@^1.0.1: - version "1.1.4" - resolved "https://registry.yarnpkg.com/ws/-/ws-1.1.4.tgz#57f40d036832e5f5055662a397c4de76ed66bf61" - dependencies: - options ">=0.0.5" - ultron "1.0.x" - wtf-8@1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/wtf-8/-/wtf-8-1.0.0.tgz#392d8ba2d0f1c34d1ee2d630f15d0efb68e1048a"