Implement generic expand and navigation feature

This commit is contained in:
Uwe Klinger
2021-05-11 12:10:25 +02:00
parent 016587094f
commit 7ae992c5bb
3 changed files with 276 additions and 0 deletions

252
notes/srv/RemoteHandler.js Normal file
View File

@@ -0,0 +1,252 @@
function getEntity(absoluteName) {
const [serviceName, entityName] = absoluteName.split(".");
return cds.services[serviceName].entities[entityName];
}
function fixColumnName(entity, name) {
const fullName = `${entity.name}.${name}`;
return RemoteHandler.columnNameFixes[fullName] || name;
}
function associationLink(entity, associationName) {
const association = entity.associations[associationName];
const cardinalityMax = association.cardinality && association.cardinality.max;
if (!association)
throw new Error(
`Association "${associationName}" does not exists for entity "${entity.name}".`
);
if (association.keys) {
return associationKey(entity, association);
}
if (association.on && association.on.length === 3 && association.on[0].ref[0] === associationName && association.on[1] === "=" ) {
const keyFieldName = fixColumnName(entity, association.on[2].ref[0]);
const targetKeyFieldName = association.on[0].ref.slice(1).join("_");
return [keyFieldName, targetKeyFieldName, association.target, cardinalityMax];
}
if (association.on) {
const { reverseAssociationName } = associationOn(entity, association);
const targetEntity = getEntity(association.target);
const reverseAssociation =
targetEntity.associations[reverseAssociationName];
const [targetKeyFieldName, keyFieldName] = associationKey(
targetEntity,
reverseAssociation
);
return [keyFieldName, targetKeyFieldName, association.target, cardinalityMax];
}
throw new Error(
`Association "${associationName}" of entity "${entity.name}" has no "on" and no "keys".`
);
}
function associationOn(entity, association) {
if (
!association.on.length === 3 ||
association.on[1] !== "=" ||
association.on[2].ref[0] !== "$self"
)
throw new Error(
`Association "${association.name}" for "${entity.name}" has not the expected form.`
);
const reverseAssociationName = association.on[0].ref[1];
const [targetServiceName, targetEntityName] = association.target.split(".");
return { targetServiceName, targetEntityName, reverseAssociationName };
}
function associationKey(entity, association) {
const key = association.keys && association.keys[0];
if (!key)
throw new Error(
`Association "${association.name}" for entity "${entity.name}" has no keys.`
);
return [key["$generatedFieldName"], key.ref[0], association.target, association.cardinality.max];
}
class RemoteHandler {
constructor(service, remoteEntities) {
this.service = service;
this.remoteEntities = remoteEntities;
}
serviceFor(entityName) {
return this.remoteEntities[entityName] || this.service;
}
/**
* Expand "to one" associations with a single key field
*
* @param {*} req
* @param {*} next
* @param {*} associationName
* @param {*} targetService
* @param {*} headers
* @returns
*/
async mixinExpand(req, result, expand) {
const associationName = expand.ref[0];
// Get association target
const [keyFieldName, targetKeyFieldName, targetEntityName, cardinalityMax] =
associationLink(req.target, associationName);
// Request all associated entities
// REVISIT: Still needed?
//const mock = !cds.env.requires.API_BUSINESS_PARTNER.credentials;
//const tx = mock ? BupaService.tx(req) : BupaService;
let ids = [];
if (Array.isArray(result)) {
ids = result.map((entry) => entry[keyFieldName]);
} else {
ids = [result[keyFieldName]];
}
// Take over columns from original query
const expandColumns = expand.expand.map((entry) => entry.ref[0]);
if (expandColumns.indexOf(targetKeyFieldName) < 0)
expandColumns.push(targetKeyFieldName);
const targetService = this.serviceFor(targetEntityName);
// Select target
const targetQuery = SELECT.from(targetEntityName)
.where({ [targetKeyFieldName]: ids })
.columns(expandColumns);
const targetResult = await targetService.run(targetQuery);
let targetResultMap;
switch (cardinalityMax) {
case '1':
targetResultMap = this.mixinExpand_to_1(targetResult, targetKeyFieldName);
break;
case '*':
targetResultMap = this.mixinExpand_to_many(targetResult, targetKeyFieldName);
break;
default:
throw new Error(`Association with cardinality may ${cardinalityMax} is not supported.`);
}
const resultArray = Array.isArray(result) ? result : [ result ];
for (const entry of resultArray) {
const id = entry[keyFieldName];
const targetEntry = targetResultMap[id];
if (targetEntry) entry[associationName] = targetEntry;
}
}
mixinExpand_to_1(targetResult, targetKeyFieldName) {
const targetResultMap = {};
for (const targetEntry of targetResult) {
const id = targetEntry[targetKeyFieldName];
targetResultMap[id] = targetEntry;
}
return targetResultMap;
}
mixinExpand_to_many(targetResult, targetKeyFieldName) {
const targetResultMap = {};
for (const targetEntry of targetResult) {
const id = targetEntry[targetKeyFieldName];
if (!targetResultMap[id]) targetResultMap[id] = [];
targetResultMap[id].push(targetEntry);
}
return targetResultMap;
}
async resolveNavigation(req, next) {
const select = req.query.SELECT;
if (select.from.ref.length !== 2) {
throw new Error(
`Unsupported navigation query with different than 2 entities in FROM clause.`
);
}
// Get target
const entityName = select.from.ref[0].id;
const entity = getEntity(entityName);
const [keyFieldName, targetKeyFieldName, targetEntityName] =
associationLink(entity, select.from.ref[1]);
const sourceService = this.serviceFor(entityName);
const targetService = this.serviceFor(targetEntityName);
const selectOne = SELECT.one([keyFieldName])
.from(entityName)
.where(select.from.ref[0].where);
const entry = await sourceService.run(selectOne);
const selectTarget = SELECT(req.query.SELECT.columns)
.from(targetEntityName)
.where({ [targetKeyFieldName]: entry[keyFieldName] });
return await targetService.run(selectTarget);
}
async handle(req, next) {
let doRequest;
if (
req.query.SELECT.from.ref.length > 1 &&
req.target.name !== req.query.SELECT.from.ref[0]
) {
doRequest = () => this.resolveNavigation(req, next)
} else {
const targetService = this.serviceFor(req.target.name);
doRequest = targetService === this.service ?
next : () => targetService.run(req.query)
}
return this.resolveExpands(req, doRequest);
}
async resolveExpands(req, next) {
const select = req.query.SELECT;
const expandFilter = (column) => {
if (!column.expand) return false;
const associationName = column.ref[0];
const associationTargetName =
req.target.associations[associationName].target;
return (
this.remoteEntities[associationTargetName] !==
this.remoteEntities[req.target.name]
);
};
const expands = select.columns.filter(expandFilter);
select.columns = select.columns.filter((column) => !expandFilter(column));
if (expands.length === 0) return next();
for (const expand of expands) {
const associationName = expand.ref[0];
const [keyFieldName] = associationLink(req.target, associationName);
// Make sure id property is contained in select
if (
!select.columns.find((column) =>
column.ref.find((ref) => ref == keyFieldName)
)
)
select.columns.push({ ref: keyFieldName });
}
// Call service implementation
const result = await next();
await Promise.all(
expands.map((expand) => this.mixinExpand(req, result, expand))
);
return result;
}
}
RemoteHandler.columnNameFixes = {};
module.exports = RemoteHandler;