feathers-graph-populate
Version:
Add lightning fast, GraphQL-like populates to your FeathersJS API.
705 lines (687 loc) • 24.5 kB
JavaScript
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
});