import { Entities, Entity, EntityDefinition, EntityHandler, BakedEntityHandler, HandlerSetter, Constraints, ConditionFunc, Options } from 'interfaces'; import { union, pull, omit, mapValues, merge, forEach, pick, values, filter, clone, keys, forIn, memoize } from 'lodash'; import { createSelector } from 'reselect'; import { timeoutPromise } from './utils'; import extractEntityHandler from './extractEntityHandlers'; import Operation from './Operation'; import * as Actions from './Actions'; export default class EntityManager { // for internal _orderedListLastItemDocRefs: { [orderBy: string]: any // Array } _queriedListLastItemDocRefs: { [queryName: string]: any // Array } _fetchingPromises: { // 이미 fetching중인데 또 fetch하려 드는 경우를 위해 이용 [id: string]: Promise<'DONE'> } // functions _firestore: any; _errorHandler: (error:Error) => any; _runTransactionManager: (dispatch: any, getState: any, initialOperation: Operation, conditionFunc?: ConditionFunc) => Promise<'DONE'>; _getEntities: (state: Object) => Entities; _getEntity: (entities: Object) => Entity; // static getters getById: (state: Object) => Entity['byId']; getAllIds: (state: Object) => Entity['allIds']; getOrderedListMap: (state: Object) => Entity['orderedList']; getQueriedListMap: (state: Object) => Entity['queriedList']; // dynamic getters getItem: (id: string) => (state: Object) => any; getAllItemList: () => (state: Object) => Array; getOrderedList: (orderBy: string) => (state: Object) => Array; getQueriedList: (queryName: string) => (state: Object) => Array; // caching _entityType: string; _entityDefinition: EntityDefinition; _childHandlerSetters: Array // info _parents: { [parentEntityType: string]: { propKey: string, type: 'MULTIPLE' | 'SINGLE' } }; _constraints: { [propKey: string]: Constraints }; _options: Options; // handlers _handlers: { ADD: Array, UPDATE: Array, DELETE: Array, }; constructor( entityType: string, entityDefinition: EntityDefinition, getEntities: (state: Object) => Entities, errorHandler: (error: Error) => any, runTransactionManager: (dispatch: any, getState: any, initialOperation: Operation) => Promise<'DONE'>, firestore: any, options: Options /* RNFirestore?: *, */ ) { // initialize members this._orderedListLastItemDocRefs = {}; this._queriedListLastItemDocRefs = {}; this._fetchingPromises = {}; this._entityType = entityType; this._entityDefinition = entityDefinition; this._childHandlerSetters = []; this._firestore = firestore; this._getEntities = getEntities; this._getEntity = createSelector(getEntities, (entities: Entities) => entities[this._entityType]); this._errorHandler = errorHandler; this._runTransactionManager = runTransactionManager; this._parents = {}; this._constraints = { id: { unSettableOnUpdate: true, } }; this._options = options; this._handlers = { ADD: [], UPDATE: [], DELETE: [] }; // static getters this.getById = createSelector(this._getEntity, (entity: Entity) => entity.byId); this.getAllIds = createSelector(this._getEntity, (entity: Entity) => entity.allIds); this.getOrderedListMap = createSelector(this._getEntity, (entity: Entity) => entity.orderedList); this.getQueriedListMap = createSelector(this._getEntity, (entity: Entity) => entity.queriedList); // dynamic getters (using memoize) this.getItem = memoize(this._getItem.bind(this)); this.getAllItemList = memoize(this._getAllItemList.bind(this)); this.getQueriedList = memoize(this._getQueriedList.bind(this)); this.getOrderedList = memoize(this._getOrderedList.bind(this)); // bind methods to this this.addItem = this.addItem.bind(this); this.updateItem = this.updateItem.bind(this); this.deleteItem = this.deleteItem.bind(this); this.fetchItem = this.fetchItem.bind(this); this.fetchList = this.fetchList.bind(this); this.queryList = this.queryList.bind(this); // utils this.generateId = this.generateId.bind(this); // start compile this._compile(); } // inspecting _inspect() { console.log( `------------------------- ${this._entityType} -------------------------\n`, { _parents: this._parents, _constraints: this._constraints, _onAdd: this._handlers.ADD, _onUpdate: this._handlers.UPDATE, _onDelete: this._handlers.DELETE, } ); } // ==================== initialization phase ===================== // helpers _concatHandlers(selfHandlerSetters: Array, childHandlerSetters?: Array) { selfHandlerSetters.forEach((handlerSetter) => { this._handlers = { ADD: [...this._handlers.ADD, ...handlerSetter.onAdd], UPDATE: [...this._handlers.UPDATE, ...handlerSetter.onUpdate], DELETE: [...this._handlers.DELETE, ...handlerSetter.onDelete] }; }); if (childHandlerSetters) { this._childHandlerSetters = [...this._childHandlerSetters, ...childHandlerSetters]; } } // fill parents & fill constraints & set selfHandlers & prepare childHandlerSetters _compile() { for (const propKey in this._entityDefinition) { if (this._entityDefinition[propKey]) { // fill this._constraints const propValue = this._entityDefinition[propKey]; const { constraints } = propValue; this._constraints[propKey] = constraints; // fill this._parents if (propValue.type === 'PARENT_ID') { const { parentEntityType } = propValue; this._parents[parentEntityType] = { propKey, type: 'SINGLE' }; } else if (propValue.type === 'PARENT_IDS') { const { parentEntityType } = propValue; this._parents[parentEntityType] = { propKey, type: 'MULTIPLE' }; } } } // fill handlers (_onAdd, _onUpdate, _onDelete) const { selfHandlerSetters, childHandlerSetters } = extractEntityHandler(this._entityType, this._entityDefinition); this._concatHandlers(selfHandlerSetters, childHandlerSetters); } _getChildHandlerSetters() { return this._childHandlerSetters; } _setChildHandler(handlerSetter:HandlerSetter) { // check whether targetParent in entityHandler exists in this._parents const checkWhetherTargetParentExists = (entityHandler:EntityHandler) => { if (entityHandler.updatingParentType && !this._parents[entityHandler.updatingParentType]) { this._errorHandler(Error(`${this._entityType} needs EM.parentId or EM.parentIds of ${entityHandler.updatingParentType}`)); } }; handlerSetter.onAdd.forEach(entityHandler => checkWhetherTargetParentExists(entityHandler)); handlerSetter.onUpdate.forEach(entityHandler => checkWhetherTargetParentExists(entityHandler)); handlerSetter.onDelete.forEach(entityHandler => checkWhetherTargetParentExists(entityHandler)); // assign handlers this._concatHandlers([handlerSetter]); } // ==================== reducer ===================== _generateReducer() { const initialState: Entity = { byId: {}, allIds: [], orderedList: {}, queriedList: {}, }; return ( state: Entity = initialState, // flow type inference를 위해 type aliase를 만들지 않고 늘어뜨림 action: Actions.SuccessAddItem | Actions.SuccessUpdateItem | Actions.SuccessDeleteItem | Actions.SuccessFetchItemAction | Actions.SuccessFetchListAction | Actions.SuccessQueryListAction ): Entity => { if (action.payload && action.payload.entityType !== this._entityType) { return state; } // ---------- WRITES ---------- if ( action.type === Actions.SUCCESS_ADD_ITEM || action.type === Actions.SUCCESS_UPDATE_ITEM ) { const { payload: { id, entityItem } } = action; return { ...state, byId: { ...state.byId, [id]: { id, ...entityItem } }, allIds: union(state.allIds, [id]) }; } if (action.type === Actions.SUCCESS_DELETE_ITEM) { const { payload: { id, entityItem } } = action; return { ...state, byId: omit(state.byId, id), allIds: [...pull(state.allIds, id)], orderedList: mapValues(state.orderedList, ids => [...pull(ids, id)]), }; } // ---------- READS ---------- if (action.type === Actions.SUCCESS_FETCH_ITEM) { // duplicated code for readability though same with SUCCESS_ADD_ITEM, SUCCESS_UPDATE_ITEM const { payload: { id, entityItem } } = action; return { ...state, byId: { ...state.byId, [id]: entityItem }, allIds: union(state.allIds, [id]) }; } if (action.type === Actions.SUCCESS_FETCH_LIST) { const { payload: { orderBy, ids, itemListById, needResetList } } = action; let newById = {}; let newAllIds = []; if (needResetList && state.orderedList[orderBy]) { Object.values(state.byId) .filter( item => !(state.orderedList[orderBy].find(orderedItemId => orderedItemId === item.id)) ) .forEach(filteredItem => { newById[filteredItem.id] = filteredItem; newAllIds.push(filteredItem.id); }) } else { newById = state.byId; newAllIds = state.allIds; } return { ...state, byId: merge({}, newById, itemListById), allIds: union(newAllIds, ids), orderedList: { ...state.orderedList, [orderBy]: (needResetList) ? union([], ids) : union(state.orderedList[orderBy], ids) }, }; } if (action.type === Actions.SUCCESS_QUERY_LIST) { const { payload: { queryName, ids, itemListById, needResetList } } = action; let newById = {}; let newAllIds = []; if (needResetList && state.queriedList[queryName]) { Object.values(state.byId) .filter( item => !(state.queriedList[queryName].find(orderedItemId => orderedItemId === item.id)) ) .forEach(filteredItem => { newById[filteredItem.id] = filteredItem; newAllIds.push(filteredItem.id); }) } else { newById = state.byId; newAllIds = state.allIds; } return { ...state, byId: merge({}, newById, itemListById), allIds: union(newAllIds, ids), queriedList: { ...state.queriedList, [queryName]: (needResetList) ? union([], ids) : union(state.queriedList[queryName], ids) }, }; } return state; }; } // ==================== CRUD phase ==================== // operation must have parent id! _getBakedEntityHandlers(operation: Operation): Array { if (operation.requireRead) { // error for contributor console.error("Can't bake operation that has not been read yet.", operation); } const handlers = clone(this._handlers[operation.method]); const bakedEntityHandlers: Array = [] // TODO: forEach로 바꾸고 필요없는 건 bakedEntityHandlers에 넣지않는다! handlers.forEach((handler: EntityHandler) => { const parentType = handler.updatingParentType; let bakedEntityHandler: BakedEntityHandler = null; let beforeParentIds = []; let afterParentIds = []; let beforeParentItemSelectors = []; let afterParentItemSelectors = []; if (parentType) { const parentInfo = this._parents[parentType]; if (parentInfo.type === 'SINGLE') { // SINGLE parent const beforeParentId = operation.before[parentInfo.propKey]; beforeParentIds = beforeParentId ? [beforeParentId] : []; const afterParentId = operation.after[parentInfo.propKey]; afterParentIds = afterParentId ? [afterParentId] : []; } else { // MULTIPLE parents beforeParentIds = keys(operation.before[parentInfo.propKey]); afterParentIds = keys(operation.after[parentInfo.propKey]); } beforeParentItemSelectors = beforeParentIds.map(parentId => ({ entityType: parentType, id: parentId })); afterParentItemSelectors = afterParentIds.map(parentId => ({ entityType: parentType, id: parentId })); if (handler.checkNeedUpdatingParents && !handler.checkNeedUpdatingParents(operation, beforeParentItemSelectors, afterParentItemSelectors)) { // handler does not update parents beforeParentItemSelectors = []; afterParentItemSelectors = []; } else { // do nothing (update parents) } } else { // handler does not update parents beforeParentItemSelectors = []; afterParentItemSelectors = []; } bakedEntityHandlers.push({ childItemSelector: operation.itemSelector, beforeParentItemSelectors, afterParentItemSelectors, operationModifier: handler.operationModifier }); }); return bakedEntityHandlers; } // ==================== for consumer ===================== // ---------- Add ---------- addItem(entityItem: any) { // resolve with [id of addedItem] forEach(this._constraints, (constraints: Constraints, propKey: string) => { const { unSettableOnAdd, requiredOnAdd } = constraints; if (requiredOnAdd && entityItem[propKey] == null) { throw Error(`${propKey} is required to add item`); } else if (unSettableOnAdd && entityItem[propKey] != null) { throw Error(`${propKey} can't be set on add`); } }); return (dispatch: any, getState: any): Promise => { const entityType: string = this._entityType; const id: string = entityItem.id || this._firestore.collection(this._entityType).doc().id; const initialOperation = new Operation('ADD', { entityType, id }, { ...entityItem }); return this._runTransactionManager(dispatch, getState, initialOperation) .then((result) => { if (result === 'DONE') { return id; } return null; }); }; } // ---------- Update ---------- updateItem(id: string, partialEntityItem: Object, conditionFunc?: ConditionFunc) { forEach(this._constraints, (constraints: Constraints, propKey: string) => { const { unSettableOnUpdate, notNull } = constraints; if (unSettableOnUpdate && partialEntityItem[propKey] != null) { throw Error(`${this._entityType}.${propKey} can't be set on update`); } else if (notNull && (partialEntityItem[propKey] === null || partialEntityItem[propKey] === 'DELETE_FIELD')) { throw Error(`${this._entityType}.${propKey} can't be null`); } }); return (dispatch: any, getState: any): Promise<'DONE'> => { const entityType = this._entityType; const initialOperation = new Operation('UPDATE', { entityType, id }, partialEntityItem); return this._runTransactionManager(dispatch, getState, initialOperation, conditionFunc); }; } // ---------- Delete ---------- deleteItem(id: string) { return (dispatch: any, getState: any): Promise<'DONE'> => { const getEntities = (): Entities => this._getEntities(getState()); const entityType = this._entityType; const initialOperation = new Operation('DELETE', { entityType, id }); return this._runTransactionManager(dispatch, getState, initialOperation); }; } // Read fetchItem(id: string) { return (dispatch: any, getState: any): Promise => { // check if fetching promise exists already if (this._fetchingPromises[id]) { return this._fetchingPromises[id]; } const docRef = this._firestore.collection(this._entityType).doc(id); const fetchPromise = docRef .get() .then((doc) => { if (doc.exists) { const docData = doc.data(); dispatch(Actions.successFetchItem({ entityType: this._entityType, id }, (docData))); } delete this._fetchingPromises[id]; return 'DONE'; }); const reliableFetchPromise = Promise .race([ fetchPromise, timeoutPromise(this._options.fetchItemTimeout) ]) .then(result => { if (result === 'TIME_OUT') { delete this._fetchingPromises[id]; return this.fetchItem(id)(dispatch, getState); } return result; }) .catch(err => { delete this._fetchingPromises[id]; this._errorHandler(err); return this.fetchItem(id)(dispatch, getState); }); this._fetchingPromises[id] = reliableFetchPromise; return reliableFetchPromise; }; } fetchList( orderBy: string = '__NONE__', directionStr: 'asc'|'desc' = 'asc' ) { // fetchMore return (dispatch: any/* , getState: * */) => (limit: number = 5, startAt?: 'FROM_FIRST' | any) => { let listRef = this._firestore.collection(this._entityType); if (orderBy !== '__NONE__') { listRef = listRef.orderBy(orderBy, directionStr); } let needResetList = false; if (startAt === 'FROM_FIRST') { // reset anchor point needResetList = true; delete this._orderedListLastItemDocRefs[orderBy]; // do not .startAfter() } else if (startAt != null) { needResetList = true; listRef = listRef.startAt(startAt); } else if (this._orderedListLastItemDocRefs[orderBy]) { needResetList = false; listRef = listRef.startAfter(this._orderedListLastItemDocRefs[orderBy]); } listRef = listRef.limit(limit); return listRef.get().then((listSnapshot) => { const ids: Array = []; const itemListById: { [id: string]: any } = {}; listSnapshot.forEach((doc) => { ids.push(doc.id); itemListById[doc.id] = doc.data(); }); // set anchor (this._orderedListLastItemDocRefs) this._orderedListLastItemDocRefs[orderBy] = listSnapshot.docs[listSnapshot.docs.length - 1]; dispatch(Actions.successFetchList(this._entityType, orderBy, ids, itemListById, needResetList)); return 'DONE'; }); }; } /** * firestore's rule : 어떤 propKey에 대해 equality test ('==') where절이 있다면 그 propKey 대해 orderBy 불가능 */ queryList( queryName: string, filters: Array<[string, '==' | '<' | '<=' | '>' | '>=', any]> = [], orderBy?: string, directionStr: 'asc'|'desc' = 'asc' ) { // queryMore return (dispatch: any/* , getState: * */) => (limit: number = 5, startAt?: 'FROM_FIRST' | any) => { // console.log('params of queryList'); // console.log(queryName, filters, orderBy, directionStr, startAt); const collectionRef = this._firestore.collection(this._entityType); let listRef = collectionRef; let needResetList = false; filters.forEach((filter) => { listRef = listRef.where(...filter); }); // check orderable condition let orderable = true; if (!orderBy) orderable = false; for (let i = 0; i < filters.length; i += 1) { const filter = filters[i]; if (filter[1] === '==' && filter[0] === orderBy) { orderable = false; } } if (orderable) { listRef = listRef.orderBy(orderBy, directionStr); if (startAt === 'FROM_FIRST') { // reset anchor point needResetList = true; delete this._queriedListLastItemDocRefs[queryName]; // do not .startAfter() } else if (startAt != null) { needResetList = true; listRef = listRef.startAt(startAt); } else if (this._queriedListLastItemDocRefs[queryName]) { needResetList = false; listRef = listRef.startAfter(this._queriedListLastItemDocRefs[queryName]); } listRef = listRef.limit(limit); } return listRef.get().then((listSnapshot) => { const ids: Array = []; const itemListById: { [id: string]: any } = {}; const items = []; listSnapshot.forEach((doc) => { const item = doc.data(); const itemId = doc.id; items.push({ ...item, id: itemId }); ids.push(itemId); itemListById[itemId] = item; }); // set anchor (this._queriedListLastItemDocRefs) this._queriedListLastItemDocRefs[queryName] = listSnapshot.docs[listSnapshot.docs.length - 1]; dispatch(Actions.successQueryList(this._entityType, queryName, ids, itemListById, needResetList)); return items; }); }; } // getter generator들은 constructo에서 memoize된다! _getItem(id: string): (state: Object) => any { return createSelector( this.getById, (byId) => { const item = byId[id]; if (item) { return { ...item, id }; } return null; } ); } // helper function _getItemListWithIds(byId: { [id: string]: Object }, ids: string[]): Object[] { return ids.map(id => ({ ...byId[id], id })); } _getAllItemList(): (state: Object) => Array { return createSelector( this.getById, this.getAllIds, (byId, allIds) => this._getItemListWithIds(byId, allIds) ); } _getOrderedList(orderBy: string = 'id'): (state: Object) => Array { return createSelector( this.getById, this.getOrderedListMap, (byId, orderedList) => this._getItemListWithIds(byId, orderedList[orderBy] || []) ); } _getQueriedList(queryName: string): (state: Object) => Array { return createSelector( this.getById, this.getQueriedListMap, (byId, queriedList) => this._getItemListWithIds(byId, queriedList[queryName] || []) ); } // utils generateId(): string { return this._firestore.collection(this._entityType).doc().id; } }