import DataLoader from 'dataloader'; export type AutoHandledBatchFn = (keys: ReadonlyArray) => PromiseLike> | ArrayLike>; export type OrderFn = (ids: ReadonlyArray, values: (V | Error)[]) => (V | Error)[] interface LoadEntityOptions extends Omit, 'batch'> { id?: string; list?: boolean, loadKeyName?: keyof V; batch?: AutoHandledBatchFn; fetch?: (key: K) => Promise; order?: OrderFn; disablePrime?: boolean; } interface EntityLoaderOptions { primeKey?: keyof V } export class EntityLoader { private listLoaders: Map> = new Map>(); private loaders: Map> = new Map>(); constructor(private readonly options?: EntityLoaderOptions) {} loaderName>(options: Options) { return options.loadKeyName ? options.loadKeyName as string : options.id ? options.id : this.constructor.name; } loadEntityList>(options: Options) { const loaderName = this.loaderName(options); const cachedLoader = this.listLoaders.get(loaderName); if (cachedLoader) { return cachedLoader; } if (!options.batch && !options.fetch) { throw new Error('You must provide either batch or fetch function'); } const loader = this.createLoader(options); this.loaders.set(loaderName, loader); return loader; } loadEntity>(options: Options) { const loaderName = this.loaderName(options); const cachedLoader = this.loaders.get(loaderName); if (cachedLoader) { return cachedLoader; } if (!options.batch && !options.fetch) { throw new Error('You must provide either batch or fetch function'); } const loader = this.createLoader(options); const _options = this.options; const _loaders = this.loaders; const _originalLoader = loader.load.bind(loader); const load: DataLoader['load'] = async function(key: K) { const result = await _originalLoader(key); if (_options?.primeKey) { // fill cache for other loaders _loaders.forEach((loader, key) => { if (key !== loaderName && !options.disablePrime) { const primeKey = _options?.primeKey; if (result && primeKey && result[primeKey]) { const entityPrimeKey = result[primeKey]; loader.prime(entityPrimeKey as any, result); } } }); } return result; }; loader.load = load; this.loaders.set(loaderName, loader); return loader; } protected createLoader>(options: Options) { return new DataLoader(async (ids) => { // handle batch if (options.batch) { return this.handleBatch(options.batch, ids, options); } const fetchFn = options.fetch; if (!fetchFn) { throw new Error('You must provide either batch or fetch function'); } return this.handleBatch((ids) => { return ids.map(id => fetchFn(id)); }, ids, options); }, { ...options, batch: true, }); } protected async handleBatch( batchFn: AutoHandledBatchFn, ids: ReadonlyArray, options?: Pick, 'loadKeyName' | 'order'> ): Promise> { const batchValues = batchFn(ids); let values: Array = []; if (Array.isArray(batchValues)) { // Make sure the error is returned values = await Promise.all(batchValues.map(batchValue => { return batchValue.catch((e: Error) => e); })); } else { values = await batchValues as Array; } if (options && options.order) { return options.order(ids, values); } if (options && options.loadKeyName) { return this.handleOrder(options.loadKeyName, ids, values); } return values; } protected handleOrder(loadKeyName: keyof V, ids: ReadonlyArray, values: (V | Error)[]) { return ids.map((id, index) => { const foundValue = values.find((value: (V | Error) & {[key: string]: any}) => { return (!(value instanceof Error) && value[loadKeyName] === id); }); if (foundValue) { return foundValue; } if (values[index] instanceof Error) { return values[index]; } return new Error(`Loader entity id ${id} not found`); }); } }