UNPKG

feathers-graph-populate

Version:

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

705 lines (687 loc) 24.5 kB
var __create = Object.create; var __defProp = Object.defineProperty; var __getOwnPropDesc = Object.getOwnPropertyDescriptor; var __getOwnPropNames = Object.getOwnPropertyNames; var __getProtoOf = Object.getPrototypeOf; var __hasOwnProp = Object.prototype.hasOwnProperty; var __export = (target, all) => { for (var name in all) __defProp(target, name, { get: all[name], enumerable: true }); }; var __copyProps = (to, from, except, desc) => { if (from && typeof from === "object" || typeof from === "function") { for (let key of __getOwnPropNames(from)) if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); } return to; }; var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps( // If the importer is in node compatibility mode or this is not an ESM // file that has been converted to a CommonJS file using a Babel- // compatible transform (i.e. "__esModule" has not been set), then set // "default" to the CommonJS "module.exports" for node compatibility. isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target, mod )); var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); // src/index.ts var src_exports = {}; __export(src_exports, { default: () => initApp, definePopulates: () => definePopulates, getQuery: () => getQuery, graphPopulate: () => graphPopulate, paramsForServer: () => paramsForServer, paramsFromClient: () => paramsFromClient, populate: () => populate, populateUtil: () => populateUtil, shallowPopulate: () => shallowPopulate }); module.exports = __toCommonJS(src_exports); // src/hooks/graph-populate.hook.ts var import_get3 = __toESM(require("lodash/get.js")); var import_isEmpty2 = __toESM(require("lodash/isEmpty.js")); var import_merge2 = __toESM(require("lodash/merge.js")); // src/hooks/shallow-populate.hook.ts var import_get2 = __toESM(require("lodash/get.js")); var import_set2 = __toESM(require("lodash/set.js")); var import_has2 = __toESM(require("lodash/has.js")); // src/utils/shallow-populate.utils.ts var import_get = __toESM(require("lodash/get.js")); var import_has = __toESM(require("lodash/has.js")); var import_isEmpty = __toESM(require("lodash/isEmpty.js")); var import_isEqual = __toESM(require("lodash/isEqual.js")); var import_isFunction = __toESM(require("lodash/isFunction.js")); var import_merge = __toESM(require("lodash/merge.js")); var import_set = __toESM(require("lodash/set.js")); var import_uniqBy = __toESM(require("lodash/uniqBy.js")); var requiredIncludeAttrs = ["service", "nameAs", "asArray", "params"]; var isDynamicParams = (params) => { if (!params) return false; if (Array.isArray(params)) { return params.some((p) => isDynamicParams(p)); } else { return !(0, import_isEmpty.default)(params) || (0, import_isFunction.default)(params); } }; var shouldCatchOnError = (options, include) => { if (include.catchOnError !== void 0) return !!include.catchOnError; if (options.catchOnError !== void 0) return !!options.catchOnError; return false; }; var assertIncludes = (includes) => { includes.forEach((include) => { if (!(0, import_has.default)(include, "asArray")) { include.asArray = true; } if (!(0, import_has.default)(include, "params")) { include.params = {}; } if (!(0, import_has.default)(include, "requestPerItem")) { include.requestPerItem = !(0, import_has.default)(include, "keyHere") && !(0, import_has.default)(include, "keyThere"); } const isDynamic = isDynamicParams(include.params); const requiredAttrs = isDynamic ? requiredIncludeAttrs : [...requiredIncludeAttrs, "keyHere", "keyThere"]; requiredAttrs.forEach((attr) => { if (!(0, import_has.default)(include, attr)) { throw new Error( "shallowPopulate hook: Every `include` must contain `service`, `nameAs` and (`keyHere` and `keyThere`) or `params` properties" ); } }); 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 && ((0, import_has.default)(include, "keyHere") || (0, import_has.default)(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 = (0, import_uniqBy.default)(includes, "nameAs"); if (uniqueNameAs.length !== includes.length) { throw new Error("shallowPopulate hook: Every `\xECnclude` must have a unique `nameAs` property"); } }; var chainedParams = async (paramsArr, context, target, options = {}) => { if (!paramsArr) return void 0; if (!Array.isArray(paramsArr)) paramsArr = [paramsArr]; const { thisKey, skipWhenUndefined } = options; const resultingParams = {}; for (let i = 0, n = paramsArr.length; i < n; i++) { let params = paramsArr[i]; if ((0, import_isFunction.default)(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 void 0; if (params !== resultingParams) (0, import_merge.default)(resultingParams, params); } return resultingParams; }; async function makeCumulatedRequest(app, include, dataMap, context) { const { keyHere, keyThere } = include; let params = { paginate: false }; if ((0, import_has.default)(include, "keyHere") && (0, import_has.default)(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); let query = params.query || {}; query = Object.assign({}, query); if (query.$skip) { delete query.$skip; } if (query.$limit) { delete query.$limit; } 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 }; } async function makeRequestPerItem(item, app, include, context) { 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 }, ...paramsFromInclude], context, target, paramsOptions ); if (!params) { asArray ? (0, import_set.default)(item, nameAs, []) : (0, import_set.default)(item, nameAs, null); return; } const response = await service.find(params); const relatedItems = response.data || response; if (asArray) { (0, import_set.default)(item, nameAs, relatedItems); } else { const relatedItem = relatedItems.length > 0 ? relatedItems[0] : null; (0, import_set.default)(item, nameAs, relatedItem); } } function setItems(data, include, params, response) { const relatedItems = Array.isArray(response) ? response : response.data; const { nameAs, keyThere, asArray } = include; data.forEach((item) => { const keyHere = (0, import_get.default)(item, include.keyHere); if (keyHere !== void 0) { if (Array.isArray(keyHere)) { if (!asArray) { const items = getRelatedItems(keyHere[0], relatedItems, include, params); if (items !== void 0) { (0, import_set.default)(item, nameAs, items); } } else { (0, import_set.default)(item, nameAs, getRelatedItems(keyHere, relatedItems, include, params)); } } else { const items = getRelatedItems(keyHere, relatedItems, include, params); if (items !== void 0) { (0, import_set.default)(item, nameAs, items); } } } }); if (params.query.$select && !params.query.$select.includes(keyThere)) { relatedItems.forEach((item) => { delete item[keyThere]; }); } } function getRelatedItems(ids, relatedItems, include, params) { const { keyThere, asArray } = include; const skip = (0, import_get.default)(params, "query.$skip", 0); const limit = (0, import_get.default)(params, "query.$limit", Math.max); ids = [].concat(ids || []); let skipped = 0; let itemOrItems = asArray ? [] : void 0; 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; if (keyThere.includes(".") && Array.isArray(currentItem[keyThere.slice(0, keyThere.indexOf("."))])) { const arrayName = keyThere.split(".")[0]; const nestedProp = keyThere.slice(keyThere.indexOf(".") + 1); currentId = currentItem[arrayName].map((nestedItem) => { const keyThereVal = (0, import_get.default)(nestedItem, nestedProp); return keyThereVal; }); } else { const keyThereVal = (0, import_get.default)(currentItem, keyThere); currentId = keyThereVal; } if (asArray) { if (Array.isArray(currentId) && currentId.includes(id) || (0, import_isEqual.default)(currentId, id)) { if (skipped < skip) { skipped++; continue; } itemOrItems.push(currentItem); if (itemOrItems.length >= limit) { isDone = true; break; } } } else { if ((0, import_isEqual.default)(currentId, id)) { if (skipped < skip) { skipped++; continue; } itemOrItems = currentItem; isDone = true; break; } } } } return itemOrItems; } function mapDataWithId(byKeyHere, key, keyHere, current) { byKeyHere[key][keyHere] = byKeyHere[key][keyHere] || { key: keyHere, vals: [] }; byKeyHere; byKeyHere[key][keyHere].vals.push(current); return byKeyHere; } // src/hooks/shallow-populate.hook.ts var defaults = { include: void 0, catchOnError: false }; function shallowPopulate(options) { options = Object.assign({}, defaults, options); const includes = [].concat(options.include || []); if (!includes.length) { throw new Error( "shallowPopulate hook: You must provide one or more relationships in the `include` option." ); } assertIncludes(includes); const cumulatedIncludes = includes.filter((include) => !include.requestPerItem); const includesByKeyHere = cumulatedIncludes.reduce((includes2, include) => { if ((0, import_has2.default)(include, "keyHere") && !includes2[include.keyHere]) { includes2[include.keyHere] = include; } return includes2; }, {}); const keysHere = Object.keys(includesByKeyHere); const includesPerItem = includes.filter((include) => include.requestPerItem); return async function shallowPopulate2(context) { const { app, type } = context; let data = type === "before" ? context.data : context.method === "find" ? context.result.data || context.result : context.result; data = [].concat(data || []); if (!data.length) { return context; } const dataMap = data.reduce((byKeyHere, current) => { keysHere.forEach((key) => { byKeyHere[key] = byKeyHere[key] || {}; const keyHere = (0, import_get2.default)(current, key); if (keyHere !== void 0) { if (Array.isArray(keyHere)) { if (!includesByKeyHere[key].asArray) { mapDataWithId(byKeyHere, key, keyHere[0], current); } else { keyHere.forEach((hereKey) => mapDataWithId(byKeyHere, key, hereKey, current)); } } else { mapDataWithId(byKeyHere, key, keyHere, current); } } }); return byKeyHere; }, {}); const promisesCumulatedResults = cumulatedIncludes.map( async (include) => { let result; try { result = await makeCumulatedRequest(app, include, dataMap, context); } catch (err) { if (!shouldCatchOnError(options, include)) throw err; return { include }; } return result; } ); const cumulatedResults = await Promise.all(promisesCumulatedResults); cumulatedResults.forEach((result) => { if (!result) return; const { include } = result; if (!result.response) { data.forEach((item) => { (0, import_set2.default)(item, include.nameAs, include.asArray ? [] : {}); }); return; } const { params, response } = result; setItems(data, include, params, response); }); const promisesPerIncludeAndItem = []; includesPerItem.forEach((include) => { const promisesPerItem = data.map(async (item) => { try { await makeRequestPerItem(item, app, include, context); } catch (err) { if (!shouldCatchOnError(options, include)) throw err; (0, import_set2.default)(item, include.nameAs, include.asArray ? [] : {}); } }); promisesPerIncludeAndItem.push(...promisesPerItem); }); await Promise.all(promisesPerIncludeAndItem); return context; }; } // src/hooks/graph-populate.hook.ts var FILTERS = ["$limit", "$select", "$skip", "$sort"]; function graphPopulate(options) { if (!options.populates) { throw new Error("options.populates must be provided to the feathers-graph-populate hook"); } const { populates } = options; return async function deepPopulateHook(context) { const populateQuery = (0, import_get3.default)(context, "params.$populateParams.query"); if (!populateQuery) return context; const { app } = context; const graphPopulateApp = app.graphPopulate; const keys = Object.keys(populateQuery); const currentPopulates = keys.reduce((currentPopulates2, key) => { if (!populates[key]) return currentPopulates2; const currentQuery = Object.assign({}, populateQuery[key]); const populate2 = populates[key]; const service = app.service(populate2.service); let params = []; if (populate2.params) { if (Array.isArray(populate2.params)) { params.push(...populate2.params); } else { params.push(populate2.params); } } if (!(0, import_isEmpty2.default)(currentQuery)) { const customKeysForQuery = (0, import_get3.default)( service, "options.graphPopulate.whitelist" ); const extractKeys = [...FILTERS]; if (customKeysForQuery) { extractKeys.push(...customKeysForQuery); } const paramsToAdd = Object.keys(currentQuery).reduce( (paramsToAdd2, key2) => { if (!extractKeys.includes(key2)) return paramsToAdd2; const { query } = paramsToAdd2; (0, import_merge2.default)(query, { [key2]: currentQuery[key2] }); delete currentQuery[key2]; return paramsToAdd2; }, { query: {} } ); params.push(paramsToAdd); } if (!(0, import_isEmpty2.default)(currentQuery)) { params.push({ $populateParams: { query: currentQuery } }); } if (graphPopulateApp) { params = graphPopulateApp.withAppParams(params, context.method, service); } currentPopulates2.push(Object.assign({}, populate2, { params })); return currentPopulates2; }, []); if (!currentPopulates || !currentPopulates.length) { return context; } const shallowPopulate2 = shallowPopulate({ include: currentPopulates }); const populatedContext = await shallowPopulate2(context); return populatedContext; }; } // src/hooks/populate.hook.ts var import_set3 = __toESM(require("lodash/set.js")); // src/utils/get-query.ts function getQuery(options) { var _a, _b, _c, _d, _e, _f, _g; const { context, namedQueries } = options; let query = (_b = (_a = context.params) == null ? void 0 : _a.$populateParams) == null ? void 0 : _b.query; const allowByHook = options.allowUnnamedQueryForExternal; const allowByApp = (_d = (_c = context.app.graphPopulate) == null ? void 0 : _c.options) == null ? void 0 : _d.allowUnnamedQueryForExternal; if (query && context.params.provider && !(allowByHook != null ? allowByHook : allowByApp)) { if ((allowByHook != null ? allowByHook : allowByApp) !== true) { delete context.params.$populateParams.query; query = void 0; } } if (!query) { const name = ((_f = (_e = context.params) == null ? void 0 : _e.$populateParams) == null ? void 0 : _f.name) || options.defaultQueryName; if (!name) { return void 0; } query = ((_g = namedQueries == null ? void 0 : namedQueries[name]) == null ? void 0 : _g.query) || namedQueries[name]; } return query; } // src/hooks/populate.hook.ts function populate(options) { const { namedQueries, defaultQueryName, populates, allowUnnamedQueryForExternal } = options; return async function populateFormFeedback(context) { if (!context.params.$populateParams && !defaultQueryName) { return Promise.resolve(context); } (0, import_set3.default)( context, "params.$populateParams.query", getQuery({ context, defaultQueryName, namedQueries, allowUnnamedQueryForExternal }) ); return graphPopulate({ populates })(context); }; } // src/utils/util.populate.ts var import_isObject = __toESM(require("lodash/isObject.js")); async function populateUtil(records, options) { const { app, params, populates } = options; if (!app) { throw new Error("The app object must be provided in the populateUtil options."); } if (!(0, import_isObject.default)(params.$populateParams)) { return records; } const $populateParams = params.$populateParams; const populateQuery = $populateParams.query; if (!populates || !populateQuery || !Object.keys(populateQuery).length) { return records; } const miniContext = { app, method: "find", type: "after", result: records, params }; const deepPopulate = graphPopulate({ populates }); const populated = await deepPopulate(miniContext); return populated.result; } // src/hooks/params-for-server.hook.ts function paramsForServer(...whitelist) { return (context) => { const params = JSON.parse(JSON.stringify(context.params)); params.query = params.query || {}; params.query._$client = params.query._$client || {}; Object.keys(params).forEach((key) => { if (key !== "query") { if (whitelist.includes(key)) { params.query._$client[`_${key}`] = params[key]; delete context.params[key]; } } }); context.params = params; }; } // src/hooks/params-from-client.hook.ts function paramsFromClient(...whitelist) { return (context) => { const params = context.params; if (params && params.query && params.query._$client && typeof params.query._$client === "object") { const client = params.query._$client; whitelist.forEach((key) => { if (`_${key}` in client) { params[key] = client[`_${key}`]; } }); params.query = Object.assign({}, params.query); delete params.query._$client; } return context; }; } // src/utils/define-populates.ts var definePopulates = (populates) => { return populates; }; // src/app/hooks.commons.ts var import_commons = require("@feathersjs/commons"); var import_get4 = __toESM(require("lodash/get.js")); var { each } = import_commons._; function convertHookData(obj) { let hook = {}; if (Array.isArray(obj)) { hook = { all: obj }; } else if (typeof obj !== "object") { hook = { all: [obj] }; } else { each(obj, function(value, key) { hook[key] = !Array.isArray(value) ? [value] : value; }); } return hook; } function getHooks(app, service, type, method, appLast = false) { const appHooks = (0, import_get4.default)(app, ["__hooks", type, method]) || []; const serviceHooks = (0, import_get4.default)(service, ["__hooks", type, method]) || []; return appLast ? [...serviceHooks, ...appHooks] : [...appHooks, ...serviceHooks]; } function enableHooks(obj, methods, types) { if (typeof obj.hooks === "function") { return obj; } const hookData = {}; types.forEach((type) => { hookData[type] = {}; }); Object.defineProperty(obj, "__hooks", { configurable: true, value: hookData, writable: true }); return Object.assign(obj, { hooks(allHooks) { each(allHooks, (current, type) => { if (!this.__hooks[type]) { throw new Error(`'${type}' is not a valid hook type`); } const hooks = convertHookData(current); methods.forEach((method) => { const currentHooks = this.__hooks[type][method] || (this.__hooks[type][method] = []); if (hooks.all) { currentHooks.push(...hooks.all); } if (hooks[method]) { currentHooks.push(...hooks[method]); } }); }); return this; } }); } // src/app/graph-populate.class.ts var GraphPopulateApplication = class { constructor(app, options) { this._app = app; this.options = options; const methods = ["find", "get", "create", "update", "patch", "remove"]; const types = ["before", "after"]; enableHooks(this, methods, types); } // eslint-disable-next-line @typescript-eslint/no-unused-vars withAppParams(params, method, service) { var _a; const serviceHooks = (_a = service == null ? void 0 : service.graphPopulate) == null ? void 0 : _a.__hooks; if (!this.__hooks && !serviceHooks) { if (!params) { return []; } if (Array.isArray(params)) { return params; } else { return [params]; } } const currentParams = []; const before = getHooks(this, service.graphPopulate, "before", method); if (before.length > 0) { currentParams.push(...before); } if (params) { if (Array.isArray(params)) { currentParams.push(...params); } else { currentParams.push(params); } } const after = getHooks(this, service.graphPopulate, "after", method, true); if (after.length > 0) { currentParams.push(...after); } return currentParams; } get allowUnnamedQueryForExternal() { var _a; return (_a = this.options) == null ? void 0 : _a.allowUnnamedQueryForExternal; } }; // src/app/graph-populate.service-mixin.ts var graph_populate_service_mixin_default = (service) => { if (!service.graphPopulate) { service.graphPopulate = {}; } const methods = ["find", "get", "create", "update", "patch", "remove"]; const types = ["before", "after"]; enableHooks(service.graphPopulate, methods, types); }; // src/app/graph-populate.app.ts function initApp(options) { return (app) => { const graphPopulate2 = new GraphPopulateApplication(app, options); app.graphPopulate = graphPopulate2; app.mixins.push(graph_populate_service_mixin_default); }; } // Annotate the CommonJS export names for ESM import in node: 0 && (module.exports = { definePopulates, getQuery, graphPopulate, paramsForServer, paramsFromClient, populate, populateUtil, shallowPopulate });