diff --git a/notes/srv/RemoteHandler.js b/notes/srv/RemoteHandler.js new file mode 100644 index 00000000..f231f077 --- /dev/null +++ b/notes/srv/RemoteHandler.js @@ -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; diff --git a/notes/srv/notes-service.js b/notes/srv/notes-service.js index edb29787..3930af7c 100644 --- a/notes/srv/notes-service.js +++ b/notes/srv/notes-service.js @@ -1,5 +1,7 @@ const cds = require("@sap/cds"); +const RemoteHandler = require('./RemoteHandler'); + const s4apiKey = process.env.S4_APIKEY; if (!s4apiKey && cds.env.profiles.indexOf("sandbox") >= 0) { console.error( @@ -13,11 +15,21 @@ module.exports = cds.service.impl(async function () { 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 const bpServiceDelegate = { 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 this.on("READ", Suppliers, async (req, next) => { const expandIndex = req.query.SELECT.columns.findIndex( @@ -58,4 +70,6 @@ module.exports = cds.service.impl(async function () { return bpServiceDelegate.run(req.query); }); + */ + }); diff --git a/test/notes.test.js b/test/notes.test.js index 799edcc6..2b7b4c66 100644 --- a/test/notes.test.js +++ b/test/notes.test.js @@ -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()); });