Implement generic expand and navigation feature
This commit is contained in:
252
notes/srv/RemoteHandler.js
Normal file
252
notes/srv/RemoteHandler.js
Normal 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;
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
const cds = require("@sap/cds");
|
const cds = require("@sap/cds");
|
||||||
|
|
||||||
|
const RemoteHandler = require('./RemoteHandler');
|
||||||
|
|
||||||
const s4apiKey = process.env.S4_APIKEY;
|
const s4apiKey = process.env.S4_APIKEY;
|
||||||
if (!s4apiKey && cds.env.profiles.indexOf("sandbox") >= 0) {
|
if (!s4apiKey && cds.env.profiles.indexOf("sandbox") >= 0) {
|
||||||
console.error(
|
console.error(
|
||||||
@@ -13,11 +15,21 @@ module.exports = cds.service.impl(async function () {
|
|||||||
|
|
||||||
const bpService = await cds.connect.to("API_BUSINESS_PARTNER");
|
const bpService = await cds.connect.to("API_BUSINESS_PARTNER");
|
||||||
|
|
||||||
|
// TODO: This seems to be a bug in the compiler
|
||||||
|
RemoteHandler.columnNameFixes = { ["NotesService.Suppliers.BusinessPartner"]: "ID" };
|
||||||
|
|
||||||
// REVISIT: This is a workaround for the missing capability to add headers to the service
|
// REVISIT: This is a workaround for the missing capability to add headers to the service
|
||||||
const bpServiceDelegate = {
|
const bpServiceDelegate = {
|
||||||
run: query => bpService.send({ query, headers: { APIKey: s4apiKey } })
|
run: query => bpService.send({ query, headers: { APIKey: s4apiKey } })
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const remoteHandler = new RemoteHandler(this, { [Suppliers.name]: bpServiceDelegate });
|
||||||
|
|
||||||
|
this.on("READ", Suppliers, (req, next) => remoteHandler.handle(req, next) );
|
||||||
|
this.on("READ", Notes, (req, next) => remoteHandler.handle(req, next) );
|
||||||
|
|
||||||
|
|
||||||
|
/*
|
||||||
// Suppliers?$expand=notes
|
// Suppliers?$expand=notes
|
||||||
this.on("READ", Suppliers, async (req, next) => {
|
this.on("READ", Suppliers, async (req, next) => {
|
||||||
const expandIndex = req.query.SELECT.columns.findIndex(
|
const expandIndex = req.query.SELECT.columns.findIndex(
|
||||||
@@ -58,4 +70,6 @@ module.exports = cds.service.impl(async function () {
|
|||||||
return bpServiceDelegate.run(req.query);
|
return bpServiceDelegate.run(req.query);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
*/
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -172,5 +172,15 @@ describe("Notes", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("get notes via navigation", async () => {
|
||||||
|
const { status, data } = await GET("/notes/Suppliers('11')/notes");
|
||||||
|
|
||||||
|
expect({ status, data }).to.containSubset({
|
||||||
|
status: 200,
|
||||||
|
data: {value: SuppliersExpandNotes.value[0].notes },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
after(() => mockServer.close());
|
after(() => mockServer.close());
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user