UNPKG

feathers-graph-populate

Version:

Add lightning fast, GraphQL-like populates to your FeathersJS API.

346 lines (301 loc) 10.2 kB
import type { Application, HookContext, Params } from '@feathersjs/feathers' import _get from 'lodash/get.js' import _has from 'lodash/has.js' import _isEmpty from 'lodash/isEmpty.js' import _isEqual from 'lodash/isEqual.js' import _isFunction from 'lodash/isFunction.js' import _merge from 'lodash/merge.js' import _set from 'lodash/set.js' import _uniqBy from 'lodash/uniqBy.js' import type { AnyData, ChainedParamsOptions, CumulatedRequestResult, GraphPopulateParams, PopulateObject, ShallowPopulateOptions, } from '../types' const requiredIncludeAttrs = ['service', 'nameAs', 'asArray', 'params'] const isDynamicParams = (params) => { if (!params) return false if (Array.isArray(params)) { return params.some((p) => isDynamicParams(p)) } else { return !_isEmpty(params) || _isFunction(params) } } export const shouldCatchOnError = ( options: Partial<Pick<ShallowPopulateOptions, 'catchOnError'>>, include: PopulateObject, ): boolean => { if (include.catchOnError !== undefined) return !!include.catchOnError if (options.catchOnError !== undefined) return !!options.catchOnError return false } export const assertIncludes = (includes: PopulateObject[]): void => { includes.forEach((include) => { // Create default `asArray` property if (!_has(include, 'asArray')) { include.asArray = true } // Create default `params` property if (!_has(include, 'params')) { include.params = {} } // Create default `requestPerItem` property if (!_has(include, 'requestPerItem')) { include.requestPerItem = !_has(include, 'keyHere') && !_has(include, 'keyThere') } const isDynamic = isDynamicParams(include.params) const requiredAttrs = isDynamic ? requiredIncludeAttrs : [...requiredIncludeAttrs, 'keyHere', 'keyThere'] requiredAttrs.forEach((attr) => { if (!_has(include, attr)) { throw new Error( 'shallowPopulate hook: Every `include` must contain `service`, `nameAs` and (`keyHere` and `keyThere`) or `params` properties', ) } }) // if is dynamicParams and `keyHere` is defined, also `keyThere` must be defined if ( isDynamic && Object.keys(include).filter((key) => key === 'keyHere' || key === 'keyThere').length === 1 ) { throw new Error( 'shallowPopulate hook: Every `include` with attribute `KeyHere` or `keyThere` also needs the other attribute defined', ) } if (include.requestPerItem && (_has(include, 'keyHere') || _has(include, 'keyThere'))) { throw new Error( 'shallowPopulate hook: The attributes `keyHere` and `keyThere` are useless when you set `requestPerItem: true`. You should remove these properties', ) } }) const uniqueNameAs = _uniqBy(includes, 'nameAs') if (uniqueNameAs.length !== includes.length) { throw new Error('shallowPopulate hook: Every `ìnclude` must have a unique `nameAs` property') } } export const chainedParams = async ( paramsArr: GraphPopulateParams, context: HookContext, target: any, options: ChainedParamsOptions = {}, ): Promise<Params> => { if (!paramsArr) return undefined if (!Array.isArray(paramsArr)) paramsArr = [paramsArr] const { thisKey, skipWhenUndefined } = options const resultingParams: Params = {} for (let i = 0, n = paramsArr.length; i < n; i++) { let params = paramsArr[i] if (_isFunction(params)) { params = thisKey == null ? // @ts-expect-error todo params(resultingParams, context, target) : params.call(thisKey, resultingParams, context, target) params = await Promise.resolve(params) } if (!params && skipWhenUndefined) return undefined if (params !== resultingParams) _merge(resultingParams, params) } return resultingParams } export async function makeCumulatedRequest( app: Application, include: PopulateObject, dataMap: AnyData, context: HookContext, ): Promise<CumulatedRequestResult> { const { keyHere, keyThere } = include let params = { paginate: false } as Params if (_has(include, 'keyHere') && _has(include, 'keyThere')) { const keyVals = dataMap[keyHere] let keysHere = Object.keys(keyVals) || [] keysHere = keysHere.map((k) => keyVals[k].key) Object.assign(params, { query: { [keyThere]: { $in: keysHere } } }) } const paramsFromInclude = Array.isArray(include.params) ? include.params : [include.params] const service = app.service(include.service) const target = { path: include.service, service, } params = await chainedParams([params, ...paramsFromInclude], context, target) // modify params let query = params.query || {} query = Object.assign({}, query) // remove $skip to prevent unintended results and regard it afterwards if (query.$skip) { delete query.$skip } // remove $limit to prevent unintended results and regard it afterwards if (query.$limit) { delete query.$limit } // if $select hasn't ${keyThere} add it and delete it afterwards if (query.$select && !query.$select.includes(keyThere)) { query.$select = [...query.$select, keyThere] } const response = await service.find(Object.assign({}, params, { query })) return { include, params, response, } } export async function makeRequestPerItem( item: AnyData, app: Application, include: PopulateObject, context: HookContext, ): Promise<void> { const { nameAs, asArray } = include const paramsFromInclude = Array.isArray(include.params) ? include.params : [include.params] const paramsOptions = { thisKey: item, skipWhenUndefined: true, } const service = app.service(include.service) const target = { path: include.service, service, } const params = await chainedParams( [{ paginate: false } as Params, ...paramsFromInclude], context, target, paramsOptions, ) if (!params) { asArray ? _set(item, nameAs, []) : _set(item, nameAs, null) return } const response = await service.find(params) const relatedItems = response.data || response if (asArray) { _set(item, nameAs, relatedItems) } else { const relatedItem = relatedItems.length > 0 ? relatedItems[0] : null _set(item, nameAs, relatedItem) } } export function setItems( data: AnyData[], include: PopulateObject, params: Params, response: { data: AnyData[] } | AnyData[], ): void { const relatedItems = Array.isArray(response) ? response : response.data const { nameAs, keyThere, asArray } = include data.forEach((item) => { const keyHere = _get(item, include.keyHere) as (string | number) | (string | number)[] if (keyHere !== undefined) { if (Array.isArray(keyHere)) { if (!asArray) { const items = getRelatedItems(keyHere[0], relatedItems, include, params) if (items !== undefined) { _set(item, nameAs, items) } } else { _set(item, nameAs, getRelatedItems(keyHere, relatedItems, include, params)) } } else { const items = getRelatedItems(keyHere, relatedItems, include, params) if (items !== undefined) { _set(item, nameAs, items) } } } }) if (params.query.$select && !params.query.$select.includes(keyThere)) { relatedItems.forEach((item) => { delete item[keyThere] }) } } type GetRelatedItemsResult = AnyData | AnyData[] | undefined export function getRelatedItems( ids: (string | number) | (string | number)[], relatedItems: AnyData[], include: PopulateObject, params: Params, ): GetRelatedItemsResult { const { keyThere, asArray } = include const skip = _get(params, 'query.$skip', 0) const limit = _get(params, 'query.$limit', Math.max) ids = [].concat(ids || []) let skipped = 0 let itemOrItems: GetRelatedItemsResult = asArray ? [] : undefined let isDone = false for (let i = 0, n = relatedItems.length; i < n; i++) { if (isDone) { break } const currentItem = relatedItems[i] for (let j = 0, m = ids.length; j < m; j++) { const id = ids[j] let currentId // Allow populating on nested array of objects like key[0].name, key[1].name // If keyThere includes a dot, we're looking for a nested prop. This checks if that nested prop is an array. // If it's an array, we assume it to be an array of objects. // It splits the key only on the first dot which allows populating on nested keys inside the array of objects. if ( keyThere.includes('.') && Array.isArray(currentItem[keyThere.slice(0, keyThere.indexOf('.'))]) ) { // The name of the array is everything leading up to the first dot. const arrayName = keyThere.split('.')[0] // The rest will be handed to getByDot as the path to the prop const nestedProp = keyThere.slice(keyThere.indexOf('.') + 1) // Map over the array to grab each nestedProp's value. currentId = (currentItem[arrayName] as AnyData[]).map((nestedItem) => { const keyThereVal = _get(nestedItem, nestedProp) return keyThereVal }) } else { const keyThereVal = _get(currentItem, keyThere) currentId = keyThereVal } if (asArray) { if ((Array.isArray(currentId) && currentId.includes(id)) || _isEqual(currentId, id)) { if (skipped < skip) { skipped++ continue } (itemOrItems as AnyData[]).push(currentItem) if (itemOrItems.length >= limit) { isDone = true break } } } else { if (_isEqual(currentId, id)) { if (skipped < skip) { skipped++ continue } itemOrItems = currentItem isDone = true break } } } } return itemOrItems } export function mapDataWithId<T extends AnyData>( byKeyHere: T, key: string, keyHere: string, current: AnyData, ): T { byKeyHere[key][keyHere] = byKeyHere[key][keyHere] || { key: keyHere, vals: [], } byKeyHere byKeyHere[key][keyHere].vals.push(current) return byKeyHere }