feathers-graph-populate
Version:
Add lightning fast, GraphQL-like populates to your FeathersJS API.
662 lines (646 loc) • 21.5 kB
JavaScript
// src/hooks/graph-populate.hook.ts
import _get3 from "lodash/get.js";
import _isEmpty2 from "lodash/isEmpty.js";
import _merge2 from "lodash/merge.js";
// src/hooks/shallow-populate.hook.ts
import _get2 from "lodash/get.js";
import _set2 from "lodash/set.js";
import _has2 from "lodash/has.js";
// src/utils/shallow-populate.utils.ts
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";
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 !_isEmpty(params) || _isFunction(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 (!_has(include, "asArray")) {
include.asArray = true;
}
if (!_has(include, "params")) {
include.params = {};
}
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 (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 `\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 (_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 void 0;
if (params !== resultingParams)
_merge(resultingParams, params);
}
return resultingParams;
};
async function makeCumulatedRequest(app, include, dataMap, context) {
const { keyHere, keyThere } = include;
let params = { paginate: false };
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);
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 ? _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);
}
}
function setItems(data, include, params, response) {
const relatedItems = Array.isArray(response) ? response : response.data;
const { nameAs, keyThere, asArray } = include;
data.forEach((item) => {
const keyHere = _get(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) {
_set(item, nameAs, items);
}
} else {
_set(item, nameAs, getRelatedItems(keyHere, relatedItems, include, params));
}
} else {
const items = getRelatedItems(keyHere, relatedItems, include, params);
if (items !== void 0) {
_set(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 = _get(params, "query.$skip", 0);
const limit = _get(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 = _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.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;
}
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 (_has2(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 = _get2(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) => {
_set2(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;
_set2(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 = _get3(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 (!_isEmpty2(currentQuery)) {
const customKeysForQuery = _get3(
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;
_merge2(query, { [key2]: currentQuery[key2] });
delete currentQuery[key2];
return paramsToAdd2;
},
{ query: {} }
);
params.push(paramsToAdd);
}
if (!_isEmpty2(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
import _set3 from "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);
}
_set3(
context,
"params.$populateParams.query",
getQuery({ context, defaultQueryName, namedQueries, allowUnnamedQueryForExternal })
);
return graphPopulate({ populates })(context);
};
}
// src/utils/util.populate.ts
import _isObject from "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 (!_isObject(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
import { _ } from "@feathersjs/commons";
import _get4 from "lodash/get.js";
var { each } = _;
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 = _get4(app, ["__hooks", type, method]) || [];
const serviceHooks = _get4(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);
};
}
export {
initApp as default,
definePopulates,
getQuery,
graphPopulate,
paramsForServer,
paramsFromClient,
populate,
populateUtil,
shallowPopulate
};