UNPKG

@axway/api-builder-runtime

Version:

API Builder Runtime

562 lines (524 loc) 17 kB
const _ = require('lodash'); const async = require('async'); const apiBuilderConfig = require('@axway/api-builder-config'); module.exports = (APIBuilder, options = {}) => { const log = options.logger; return { execComposite, checkParse }; function execComposite(params) { log && log.trace('Composite connector executed. Method:', params.method); const { Model, isWrite, isCollection, method } = params; let arg = params.arg; let next = params.next; if (_.isFunction(arg)) { next = arg; arg = undefined; } const instances = {}; const joinedModels = getJoinedModelsInfo({ method, isWrite, Model, arg }); if (joinedModels instanceof Error) { return next(joinedModels); } else { retrieveJoinedModels({ Model, isWrite, isCollection, method, arg, instances, joinedModels }, (err, resp) => { if (err) { return next(err); } else { return next(null, resp); } }); } } function execModelMethod(MasterModel, model, method, arg, next) { const SourceModel = APIBuilder.getModel(model.name); if (arg) { translateKeysForModel(MasterModel, SourceModel, arg, model.isNotJoined); SourceModel[method](arg, next); } else { SourceModel[method](next); } } function runJoin({ Model, isCollection, instances, joinedModels, isInnerJoin, next }) { let retVal = null; // instance0 is an instance of the parent model. const instance0 = instances[Object.keys(instances)[0]]; const joinOneModel = (Object.keys(instances).length === 1); if (joinOneModel) { if (!instance0) { retVal = false; return next(); } if (isCollection) { async.map( instance0, (instance, cb) => { runJoin({ Model: Model, isCollection: false, instances: { key0: instance }, joinedModels: joinedModels, isInnerJoin: isInnerJoin, next: cb }); }, (err, results) => { if (err) { return next(err); } else { return next( null, Array.isArray(results) ? _.compact(results) : results ); } }); } else { const id = instance0.getPrimaryKey ? instance0.getPrimaryKey() : instance0.id; retVal = { [Model.getPrimaryKeyName()]: id }; populateValuesFromResult(Model, joinedModels[0], retVal, instance0.toJSON()); async.each(joinedModels.slice(1), queryModel, returnInstance); } } else { // Join multiple models Object.keys(Model.fields).forEach((key) => { const field = Model.fields[key]; const collectionName = field.model; if (instances[collectionName]) { if (!retVal) { retVal = {}; } retVal[key] = instances[collectionName]; } }); return next(null, retVal); } /** * Queries one particular model for the data needed for the join. * * @param model * @param next */ function queryModel(model, next) { const SourceModel = APIBuilder.getModel(model.name); const query = {}; const joinBy = model.left_join; let hasJoin = false; _.forIn(joinBy, (value, key) => { query[key] = value === instance0.getModel().getPrimaryKeyName() && instance0.getPrimaryKey ? instance0.getPrimaryKey() : instance0[value]; const hasField = (key === SourceModel.getPrimaryKeyName() || SourceModel.fields[key]); if (!hasField && log) { log.warn(`Skipping join on "${key}" because the model "${model.name}" has no matching field.`); } else if (query[key] !== undefined) { hasJoin = true; } }); if (!hasJoin) { if (isInnerJoin) { retVal = null; } return next(); } const q = { where: query, limit: calculateLimit(model) }; log && log.trace('Querying ' + model.name + ' where ' + JSON.stringify(q)); execModelMethod(Model, model, 'query', q, (err, result) => { const isMultipleJoin = model.multiple; if (err) { return next(err); } log && log.trace('Performing', isInnerJoin ? 'inner join' : 'left join'); log && log.trace(`Multiple join set to ${isMultipleJoin}`); if (isInnerJoin && (!result || (result instanceof Array && result.length === 0))) { retVal = null; } if (isMultipleJoin) { processMultipleJoin(model, result); } else { processSingleJoin(model, result); } return next(); }); } function processMultipleJoin(model, result) { if (!result.length) { return; } // since the current field in the main model may have been aliased, // we should get the name of the field being referred to in the joined model. const key = Model.fields[model.fieldName].name; // initialise the array to be returned. // in the future if we actually want to return an empty array when nothing matched we // can move this outside the result.length check (RDPP-1265) retVal[model.fieldName] = []; // loop through each result and build the array to be returned for (let i = 0; i < result.length; i++) { let item = result[i].toJSON(); // if the merge type is field at this point, it means we want an array containing // values of a specific key rather than the whole result. if (model.mergeType === 'field') { item = item[key]; } retVal[model.fieldName].push(item); } } function processSingleJoin(model, result) { if (!result) { return; } // If the field only consists of a query for a single result then take // all the fields that we want from the model that was queried and add to the retVal populateValuesFromResult(Model, model, retVal, result); } /** * Returns the limit calculated based on particular rules. * @param model contains information for limit calculation * @return limit the number of items in the query result */ function calculateLimit(model) { var limit = 1; if (model.multiple) { // since multiple is set then we want to query for more than one instance of the // model. limit = (model.limit && (model.limit > 0 && model.limit < 1001)) ? model.limit : 10; } return limit; } /** * Returns a composite instance based on the resultant queries. */ function returnInstance(err) { if (err) { return next(err); } if (!retVal) { return next(null, null); } const instance = Model.instance(retVal, true); instance.setPrimaryKey(retVal[Model.getPrimaryKeyName()]); return next(null, instance); } } function retrieveJoinedModels({ Model, isWrite, isCollection, method, arg, instances, joinedModels }, next) { async.each( joinedModels, (model, cb) => { if (isWrite && model.readonly) { return cb(); } if (model.left_join || !model.name) { return cb(); } const localArg = arg ? calculateLocalArg(Model, model, arg, isWrite) : arg; log && log.trace('Querying model:', model.name); execModelMethod(Model, model, method, localArg, (err, instance) => { if (err) { cb(err); } else { if (instance) { // sometimes we don't get back an array when we expect one if (isCollection && !Array.isArray(instance)) { instance = [ instance ]; } instances[model.name] = instance; } cb(); } }); }, (err) => { if (err) { return next(err); } else if (Object.keys(instances).length === 0) { // no instances to join with return next(null, null); } else { const isInnerJoin = Model.getMeta('inner_join'); runJoin({ Model, isCollection, instances, joinedModels, isInnerJoin, next }); } } ); } function getJoinedModelsInfo({ method, isWrite, Model, arg }) { const modelFields = Model.fields; const modelJoinsInfo = getJoinsInfo(Model); const joinedModels = []; const modelMap = {}; for (let fieldName in modelFields) { if (modelFields.hasOwnProperty(fieldName)) { const field = modelFields[fieldName]; const modelName = field.model; if (!modelName || modelMap[modelName]) { continue; } modelMap[modelName] = true; if (!APIBuilder.getModel(modelName)) { return new Error(`Unable to find model ${modelName}.`); } const modelJoinInfo = modelJoinsInfo[modelName]; if (modelJoinInfo) { if (isWrite && (arg.getChangedFields ? arg.getChangedFields() : arg)[fieldName]) { return new Error('API-354: Joined fields cannot be written to yet.'); } if (method === 'query' && containsKey(arg, fieldName, [ 'sel', 'unsel' ])) { return new Error('API-354: Joined fields cannot be queried on yet.'); } const mergeInfo = getMergeInfo(field, modelJoinInfo); joinedModels.push({ name: modelName, readonly: true, left_join: modelJoinInfo.join_properties, fieldName: fieldName, mergeType: mergeInfo.mergeType, multiple: mergeInfo.multiple, limit: field.limit }); } else { joinedModels.unshift({ name: modelName, isNotJoined: true }); } } } log && log.trace('Joined models:', joinedModels); return joinedModels; } /** * @param {Object} Model the instance of the model * @returns {Object} object that contains joins meta information for the model */ function getJoinsInfo(Model) { const joinMeta = Model.getMeta('left_join') || Model.getMeta('inner_join'); if (!joinMeta) { return {}; } const modelMetas = {}; const multipleJoins = Array.isArray(joinMeta); if (multipleJoins) { joinMeta.forEach((meta) => { modelMetas[meta.model] = meta; }); } else { modelMetas[joinMeta.model] = joinMeta; } return modelMetas; } /** * The logic is left as it was before but could be improved to fix these if approved as issues: * * https://techweb.axway.com/jira/browse/RDPP-4454 * https://techweb.axway.com/jira/browse/RDPP-4453 * * @param {Object} modelJoinInfo */ function getMergeInfo(field, modelJoinInfo) { const hasAlias = !!field.name; const fieldType = field.type; const isMultipleJoin = modelJoinInfo.multiple; // if a field has a name property then the value of that field // will be assumed to come from a property of the joined model. (aka merge as fields) // Otherwise it will be a join as object or array depending on the field type. const mergeInfo = { multiple: false }; if (hasAlias) { mergeInfo.mergeType = 'field'; } else if (fieldType === Object || fieldType === 'object') { mergeInfo.mergeType = 'object'; } else { // Note that the effect of this code is that if field has no alias and the field.type // is String the logic behaves as it is Array. We keep it like before but there is // ticket about this to discuss: mergeInfo.mergeType = 'array'; mergeInfo.multiple = true; } // multiple is used when querying the joined model. // If it is false, a limit of one will be queried. // Normally if the merge type is not array, then only a single value will be returned. // modelJoinInfo.multiple allows for many results to be returned from the joined model query // but only of the field type is array. This will result in an array containing multiple // values of the matching key of the joined model specified on field.name. if (isMultipleJoin && (fieldType === Array || fieldType === 'array')) { mergeInfo.multiple = true; } return mergeInfo; } function calculateLocalArg(Model, model, arg, isWrite) { const fieldKey = fetchModelObjectFieldKey(Model, model); let localArg = arg; if (fieldKey) { localArg = localArg[fieldKey] || (localArg.where && localArg.where[fieldKey]) || localArg; localArg = checkParse(localArg); } if (isWrite) { const SourceModel = APIBuilder.getModel(model.name); if (!localArg.getPrimaryKey) { // Object const mappedArgs = _.pick(localArg, Object.keys(SourceModel.fields)); // Pass along the primary key if set if (localArg.hasOwnProperty(Model.getPrimaryKeyName()) && SourceModel.getPrimaryKeyName()) { mappedArgs[SourceModel.getPrimaryKeyName()] = localArg[Model.getPrimaryKeyName()]; } localArg = mappedArgs; } else { // Instance - convert instance to their source model instance and map aliases. const instanceFields = { cleanFields: {}, dirtyFields: {} }; const allValues = localArg.values(); const allDirtyFields = localArg.values(true); // Filter out instance fields not associated with this source model and unalias` for (let valueKey in allValues) { if (!allValues.hasOwnProperty(valueKey)) { continue; } if (Model.fields[valueKey] && Model.fields[valueKey].model === model.name) { const name = Model.fields[valueKey].name || valueKey; if (allDirtyFields.hasOwnProperty(valueKey)) { instanceFields.dirtyFields[name] = allValues[valueKey]; } else { instanceFields.cleanFields[name] = allValues[valueKey]; } } } // Create a new instance with the fields. Along these lines should be the fix for // https://techweb.axway.com/jira/browse/RDPP-4452 const inst = SourceModel.instance(instanceFields.cleanFields); inst.setPrimaryKey(localArg.getPrimaryKey()); inst.set(instanceFields.dirtyFields); localArg = inst; } } return localArg; } function fetchModelObjectFieldKey(Model, model) { for (let key in Model.fields) { /* istanbul ignore else */ if (Model.fields.hasOwnProperty(key)) { const field = Model.fields[key]; const isObject = field.type === Object || field.type === 'object'; const isArray = field.type === Array || field.type === 'array'; if (field.model === model.name && (!field.name && (isObject || isArray))) { return key; } } } return null; } function populateValuesFromResult(Model, model, retVal, result) { for (var key in Model.fields) { /* istanbul ignore else */ if (!Model.fields.hasOwnProperty(key)) { continue; } var field = Model.fields[key], isObject = field.type === Object || field.type === 'object', isArray = field.type === Array || field.type === 'array'; // check of the field should come from the queried model if (field.model === model.name) { // if the field has no name property and is object or array this means we want to // perform a join as object or array, setting the result of the query on the // returned value rather than just a single field from the result if (!field.name && (isObject || isArray)) { retVal[key] = result; } else { // key is the key from the main model. this may be different on the queried // model if it was aliased. field.name refers to the original name of the field // on the joined model. If it exists in the field definition we will use that. // Otherwise just use the name of the main model field. retVal[key] = result[field.name || key]; } } } } function translateKeysForModel(MasterModel, SourceModel, arg, isSourceNotJoined) { if (!_.isObject(arg)) { return; } let mFields = MasterModel.fields; let sFields = SourceModel.fields; const masterPkName = MasterModel.getPrimaryKeyName(); const sourcePkName = SourceModel.getPrimaryKeyName(); // Operators supported by composite model const operators = apiBuilderConfig.flags.enableAliasesInCompositeOperators ? [ '$like', '$eq', '$ne', '$in', '$nin', '$lt', '$gt', '$lte', '$gte' ] : [ '$like' ]; for (var key in arg) { /* istanbul ignore else */ if (arg.hasOwnProperty(key)) { if (_.isObject(arg[key]) && !(Object.keys(arg[key]).filter(v => operators.indexOf(v) !== -1)).length) { translateKeysForModel(MasterModel, SourceModel, arg[key], isSourceNotJoined); continue; } // if the source field is aliased and exists in the souce model, store it by the // original name and delete the aliased. if (mFields[key] && mFields[key].name && (mFields[key].name !== key) && sFields[mFields[key].name]) { arg[mFields[key].name] = arg[key]; delete arg[key]; } else if (isSourceNotJoined && key === masterPkName && sourcePkName !== masterPkName) { // This is a special case where the source model is not joined and the primary // key has been aliased (see RDPP-4732-30). arg[sourcePkName] = arg[key]; delete arg[key]; } } } } function checkParse(val) { if (typeof val === 'string' && val[0] === '{') { try { return JSON.parse(val); } catch (err) { // Eat the parse error. log && log.warn('Failed to parse JSON:', val, 'with error:', err, 'continuing on.'); } } return val; } function containsKey(obj, key, ignore) { for (var sKey in obj) { /* istanbul ignore else */ if (obj.hasOwnProperty(sKey)) { if (ignore && ignore.indexOf(sKey) >= 0) { continue; } if (sKey === key || (_.isObject(obj[sKey]) && containsKey(obj[sKey], key, ignore))) { return true; } } } return false; } };