UNPKG

@imqueue/graphql-dependency

Version:

Cross-service GraphQL dependencies for underlying @imqueue services

537 lines 20.5 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.Dependency = exports.GraphQLDependency = void 0; const helpers_1 = require("./helpers"); /** * Class GraphQLDependency * Implements dependency relationship descriptions between different * GraphQLObjectType entities, providing possibility of optimal data * fetching during GraphQL queries resolution. * * Glossary: * - Initializer: async routine required to fill up * specific entity set. Can block deps loading * before resolution if init fields are a part of * dep loading process * - Loader: async routine required to load dependency * itself by a given filter using a given request * fields. * * It implements cascade loading - analyze request fields against * initial result and build load chains for the same type * with merging all fields for the same type and load * only missing objects on each iteration. Loading are performed * as bulk operations using defined bulk data-loaders and initializers. * It will try to perform as parallel as possible depending on the dependencies * definition between objects. */ class GraphQLDependency { type; /** * Creates dependency entity registering it with internal registry * for further use. Use this method to construct entities as far as * it will guarantee there is only one instance created for particular * GraphQL object type, which it wraps on app initialization. * * @param {GraphQLObjectType} type * @return {GraphQLDependency<TResultType>} */ static create(type) { let dep = GraphQLDependency.deps.get(type); if (!dep) { dep = new GraphQLDependency(type); GraphQLDependency.deps.set(type, dep); } return dep; } /** * Checks if a given filter arg is empty or not * * @access private * @param {any} filter * @return {boolean} */ static isEmptyArg(filter) { if (Array.isArray(filter) && filter.length) { return false; } if (filter && typeof filter === 'object') { for (const prop of Object.keys(filter)) { if (Array.isArray(filter[prop])) { if (filter[prop].length) { return false; } } else if (filter[prop]) { return false; } } return true; } return !filter; } static deps = new Map(); loader; init; initFields; options = new Map(); initFieldNames = []; /** * Class constructor * @constructor * @param {GraphQLObjectType} type - associated GraphQL type */ constructor(type) { this.type = type; } // noinspection JSUnusedGlobalSymbols /** * Defines a loader for this particular dependency. This * usually must be a bulk loading function accepting input of * filters and returning appropriate data result type. * * Usually this can be defined close to the entity definition itself * and signal that the entity may be a part of dependent structure for * other top-level entities in the queries. * * @example * ```typescript * Dependency(User).defineLoader(async <User[]>( * context: any, * filter: FiltersInput, * fields?: FieldsMapInput, * ) => (await context.user.listUser(filter, fields)).data); * ``` * * @param {DataLoader<T>} loader * @return {GraphQLDependency} */ defineLoader(loader) { this.loader = loader; return this; } // noinspection JSUnusedGlobalSymbols /** * Defines an async initializer for this particular entity. Initializers are * usually used to perform async routines required to pre-fill entity * data before any other dependencies for this entity are loaded. * * Note: ite performs better when initializer fields are provided. * * @example * ```typescript * Dependency(UserType).defineInitializer( * async <User[]>( * context: any, * result: User[], * ) => { * // do init stuff appending extra fields to * // result set... * // this will block deps loading related to * // initializer fields or will block all deps loading * // if initializer fields are not specified * }, * User.getFields().orderId, * User.getFields().shipmentIds, * ); * ``` * * @param {DataInitializer} initializer - async init routine to be used as * entity initializer * @param {...GraphQLField} [fields] - list of initializer fields it is * linked to [optional]. * @return {GraphQLDependency} */ defineInitializer(initializer, ...fields) { this.init = initializer; this.initFields = fields; return this; } // noinspection JSUnusedGlobalSymbols /** * Defines dependencies for this entity. * Usually it lays along with the entity definition itself and describes * how the particular entity should be fully resolved. * * @example * ```typescript * Dependency(CompanyType).require(UserType, () => ({ * as: CompanyType.getFields().employees * filter: { [User.getFields().companyId.name]: Company.getFields().id } * }), () => ({ * as: Company.getFields().owner, * filter: { [UserType.getFields().id.name]: Company.getFields().ownerId } * })); * ``` * * @param {GraphQLObjectType} child - child entity this entity depends on * @param {DependencyOptions} options - options for dependency description * @return {GraphQLDependency} */ require(child, ...options) { this.options.set((0, exports.Dependency)(child), options); return this; } // noinspection JSUnusedGlobalSymbols /** * Performs actual work on loading all entities current entity depends on. * This will load all dependent entities using pre-defined bulk loaders * and dependency configurations which were set by defineInitializer(), * defineLoader() and require() calls on app startup. Loading is usually * performed at runtime on particular graphql queries. * * During the execution it will analyze and call those bulk loaders * which are required to pre-fill user data with related dependent * structures. If some loaders may be need to called several times it * will request only missing parts, those which was already pre-loaded * would be re-used from previous load calls. * * Finally, this will be processed as: * 1. Scan fields to identify which deps loaders requested to call * 2. Scan deps and underlying types across fields and merge all * similar-type request fields into minimal complete fields map * definition * 3. Scan and call for proper initializers and deps loaders in * particular order * 4. Re-map loaded data to result according requested by user fields * 5. Return modified result * * @example * ```typescript * async buildResolutionCache( * source: any, * args: any, * context: any, * info: GraphQLResolveInfo, * ) => { // imagine we are inside some query resolver * const fields = fieldsMap(info); * const userData = await context.userService.listAll(); * * return await Dependency(User).load(userData, context, fields); * } * ``` * * @param {any} source - source data object, usually obtained by some * initial service call inside particular query * resolver * @param {any} context - execution context (usually passed to graphql * resolver) * @param {any} fields - requested fields as map object (usually can be * obtained from GraphQLResolveInfo object passed * to a query resolver and constructed using * graphql-fields-map#fieldsMap() routine) * @return {Promise<any>} */ async load(source, context, fields) { if (!fields) { // nothing to do, as long as load fields are not specified return source; } this.initFieldNames = (this.initFields || []) .map(field => field().name); (0, helpers_1.ensureIds)(fields); const cache = this.buildResolutionCache(fields, source); return await this.incrementalLoad(source, context, fields, cache); } /** * Resolves dependencies required to be loaded for a given user request * identified by given request fields. Stores resolution result * under given result map or will initializes new one and returns it. * Result map usually is used by a recursive calls, so should not be passed * on a top-level dependency call. * * @access private * @param {any} fields - user requested fields * @param {any} [source] - cached data if any * @param {ResolutionCache} [cache] - resolution map to store result in * @return {ResolutionCache} - resolution map and max call priority */ buildResolutionCache(fields, source, cache = new Map()) { const graphqlFields = this.type.getFields(); if (source) { const cacheData = cache.get(this.type) || {}; cacheData.fields = Object.assign(cacheData.fields || {}, fields); cacheData.data = (0, helpers_1.makeCachedData)(source, cacheData.data || {}); cacheData.calls = {}; cache.set(this.type, cacheData); } for (const field of Object.keys(fields)) { if (!(fields[field] && graphqlFields[field])) { // we are skipping scalars or non-nested deps // as deps only nested objects always continue; } const type = (0, helpers_1.gqlType)(graphqlFields[field]); const dep = GraphQLDependency.deps.get(type); if (dep) { const cacheData = cache.get(type) || {}; const src = this.childSource(source, field); cacheData.fields = Object.assign(cacheData.fields || {}, fields[field]); cacheData.data = (0, helpers_1.makeCachedData)(src, cacheData.data || {}); cacheData.calls = {}; cache.set(type, cacheData); // this field is a part of our dependency dep.buildResolutionCache(fields[field], src, cache); } } return cache; } /** * Collects and returns call arguments for dependency loader from a given * source data using dependency filtering argument options * * @access private * @param {any} source - data source object * @param {DependencyFilterOptions} filter - filter arguments lookup opts * @param {ResolutionCache} cache - resolution cache * @return {any} - call filtering argument */ makeCallArgs(source, filter, cache) { if (!source) { throw new TypeError('Broken call chain, source expected to be value!'); } const arg = {}; const src = Array.isArray(source) ? source : [source]; for (const prop of Object.keys(filter)) { arg[prop] = [...new Set(src.reduce((res, item) => { res.push(...(Array.isArray(item[filter[prop].name]) ? item[filter[prop].name] : [item[filter[prop].name]])); return res; }, []))].filter(val => !!val); // for id filters - check against cached data to not load // anything being already loaded if (prop === 'id') { arg[prop] = arg[prop].filter((id) => !(cache && cache.data && cache.data[id])); } } return arg; } /** * Performs recursive incremental load of dependent data and map results to * a given source object then returns it * * @access private * @param {any} source * @param {any} context * @param {any} fields * @param {ResolutionCache} cache * @return {any} */ async incrementalLoad(source, context, fields, cache) { if (!source) { return source; } let promises = []; const gqlFields = this.type.getFields(); const children = []; if (this.init) { if (this.waitForInit(fields, gqlFields)) { await this.requestInitializer(source, context, fields, cache); } else { promises.push(this.requestInitializer(source, context, fields, cache)); } } for (const field of Object.keys(fields)) { if (!(fields[field] && gqlFields[field])) { // we are skipping scalars or non-nested deps // as deps only nested objects always continue; } const type = (0, helpers_1.gqlType)(gqlFields[field]); const dep = GraphQLDependency.deps.get(type); if (dep) { children.push({ field, dep }); if (!dep.loader) { continue; } const options = this.options.get(dep) || []; for (let option of options) { if (typeof option === 'function') { option = option(); } promises.push(this.requestLoader(source, context, option, dep, cache)); } } } if (promises.length) { // this level dependencies await Promise.all(promises); } // children recursive load if (children && children.length) { promises = []; for (const child of children) { if (!fields[child.field]) { continue; } const src = this.childSource(source, child.field); promises.push(child.dep.incrementalLoad(src, context, fields[child.field], cache)); } if (promises.length) { // next level dependencies as recursive call await Promise.all(promises); } } return source; } /** * Builds and returns next callable level of source object for child * dependencies * * @access private * @param {any} source * @param {string} field * @return {any[]} */ childSource(source, field) { if (!source) { return []; } const src = Array.isArray(source) ? source : [source]; if (!field) { return src; } const childSource = []; for (const item of src) { if (item[field]) { // we may have list, object or scalar in data under the given // field, so we have to take it into account as far as we // expect plain list of objects to be returned if (Array.isArray(item[field])) { childSource.push(...item[field]); } else { childSource.push(item[field]); } } } return childSource; } /** * Checks if given user requested fields need to be initialized before * any dependency for current load level to be called (this usually * occurs when dependency relies on initializer dependent fields) * * @access private * @param {any} fields * @param {GraphQLFieldMap<any, any, any>>} [gqlFields] * @return {boolean} */ waitForInit(fields, gqlFields = this.type.getFields()) { if (!this.init) { // there is no initializer defined, so nothing to wait for return false; } if (!this.initFields) { // we do not know if some fields which are filled by // initializer are required by some deps, so - we will wait // to be safe return true; } for (const field of Object.keys(fields)) { if (!(fields[field] && gqlFields[field])) { continue; } const found = (0, helpers_1.checkDepInit)(this.initFieldNames, GraphQLDependency.deps.get((0, helpers_1.gqlType)(gqlFields[field]))); if (typeof found !== 'undefined') { return found; } } return false; } /** * Performs initializer call and maps it's result to a given data source, * returning modified source object. * * @access private * @param {any} source - data source object * @param {any} context - GraphQL request context * @param {any} fields - user request fields map * @param {ResolutionCache} cache - request resolution cache * @return {any} */ async requestInitializer(source, context, fields, cache) { const key = (0, helpers_1.hash)(this.type, helpers_1.ResolveMethod.INITIALIZER, fields); const thisCache = cache.get(this.type); let initData; if (thisCache && thisCache.calls[key]) { initData = thisCache.calls[key]; } else if (this.init) { initData = await this.init(context, source, fields); if (thisCache) { thisCache.calls[key] = initData; } } if (!initData) { return source; } const src = Array.isArray(source) ? source : [source]; for (const item of src) { if (!(item && item.id)) { continue; } Object.assign(item, initData[item.id]); } return source; } /** * Performs recursive dependency loading and updates source object * with loaded data * * @access private * @param {any} source - data source object * @param {any} context - graphql request context object * @param {DependencyOptions} option - dependency options config * @param {GraphQLDependency<any>} dep - loaded dependency object itself * @param {ResolutionCache} cache - Resolution cache object * @return {any} */ async requestLoader(source, context, option, dep, cache) { const depCache = cache.get(dep.type); if (!depCache) { return source; } const filter = this.makeCallArgs(source, option.filter, depCache); if (GraphQLDependency.isEmptyArg(filter)) { // nothing to load, so just make sure we can map existing // data from the resolution cache return (0, helpers_1.mapDependencyData)(source, depCache.data, option); } const key = (0, helpers_1.hash)(dep.type, helpers_1.ResolveMethod.LOADER, filter); if (!(depCache && depCache.calls[key])) { const data = (await dep.loader(context, filter, depCache.fields)) .reduce((res, next) => { res[next.id] = next; return res; }, {}); depCache.calls[key] = data; Object.assign(depCache.data, data); } return (0, helpers_1.mapDependencyData)(source, depCache.data, option); } } exports.GraphQLDependency = GraphQLDependency; /** * Imitates static constructor for GraphQLDependency class. Actually it is * a short-hand alias to GraphQLDependency.create * * @see GraphQLDependency#create * * @example * ```typescript * Dependency(UserType).require(CompanyType, { * as: UserType.getFields().company, * filter: { // filter is used for company data loader * [CompanyType.getFields().id.name]: UserType.getFields().companyId, * }, * }); * ``` * * @param {GraphQLObjectType} * @return {GraphQLDependency} */ exports.Dependency = GraphQLDependency.create; //# sourceMappingURL=dependency.js.map