diff --git a/notes/.eslintrc b/notes/.eslintrc index 639d13a0..e80e8f52 100644 --- a/notes/.eslintrc +++ b/notes/.eslintrc @@ -6,7 +6,7 @@ "jest": true }, "parserOptions": { - "ecmaVersion": 2017 + "ecmaVersion": 2020 }, "globals": { "SELECT": true, diff --git a/notes/srv/RemoteHandler.js b/notes/srv/RemoteHandler.js index f231f077..8b1cceee 100644 --- a/notes/srv/RemoteHandler.js +++ b/notes/srv/RemoteHandler.js @@ -1,71 +1,33 @@ -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".` - ); +/** + * + * @param {string} msg + * @returns {never} + */ +function throwError(msg) { + throw new Error(msg); } -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 }; +/** + * @param {{name: string}|string} entity + * @param {{name: string}|string} association + * @param {string} msg + * @returns {never} + */ +function throwAssocError(entity, association, msg) { + throw new Error(`Error with association "${association.name || association}" of entity "${entity.name || entity}": ${msg}`); } -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]; +function getEntity(absoluteName) { + const [serviceName, entityName] = absoluteName.split("."); + return cds.services[serviceName]?.entities[entityName] || throwError(`Unknown entity "${absoluteName}"`); } + class RemoteHandler { constructor(service, remoteEntities) { @@ -91,8 +53,8 @@ class RemoteHandler { const associationName = expand.ref[0]; // Get association target - const [keyFieldName, targetKeyFieldName, targetEntityName, cardinalityMax] = - associationLink(req.target, associationName); + const {keyFieldName, targetKeyFieldName, target, is2many, is2one} = + this.association(req.target, associationName); // Request all associated entities // REVISIT: Still needed? @@ -110,25 +72,22 @@ class RemoteHandler { if (expandColumns.indexOf(targetKeyFieldName) < 0) expandColumns.push(targetKeyFieldName); - const targetService = this.serviceFor(targetEntityName); + const targetService = this.serviceFor(target.name); // Select target - const targetQuery = SELECT.from(targetEntityName) + const targetQuery = SELECT.from(target.name) .where({ [targetKeyFieldName]: ids }) .columns(expandColumns); const targetResult = await targetService.run(targetQuery); let targetResultMap; - switch (cardinalityMax) { - case '1': + if (is2one) { targetResultMap = this.mixinExpand_to_1(targetResult, targetKeyFieldName); - break; - case '*': + } else if (is2many) { targetResultMap = this.mixinExpand_to_many(targetResult, targetKeyFieldName); - break; - default: - throw new Error(`Association with cardinality may ${cardinalityMax} is not supported.`); + } else { + throwAssocError(req.target, associationName, `Unsupported cardinality.`); } const resultArray = Array.isArray(result) ? result : [ result ]; @@ -160,6 +119,27 @@ class RemoteHandler { return targetResultMap; } + /** + * @example + * Notes(24B58115-E394-423B-BEAB-53419A32B927)/supplier + * + * --> + * { SELECT: { from: { ref: {[ + * [ id: 'NotesService.Notes', + * where: [ + * ref: [ 'ID' ], + * '=', + * val: ''545A3CF9-84CF-46C8-93DC-E29F0F2BC6BE' + * ], + * ], + * [ 'supplier' ] + * ]}}} + * + * + * @param {*} req + * @param {*} next + * @returns + */ async resolveNavigation(req, next) { const select = req.query.SELECT; if (select.from.ref.length !== 2) { @@ -168,26 +148,37 @@ class RemoteHandler { ); } - // Get target - const entityName = select.from.ref[0].id; + const entityName = select.from.ref[0].id || throwError(`Missing source entity name for navigation`); const entity = getEntity(entityName); + const associationName = select.from.ref[1] || throwError(`Missing association name for navigation`); - const [keyFieldName, targetKeyFieldName, targetEntityName] = - associationLink(entity, select.from.ref[1]); + const {keyFieldName, targetKeyFieldName, target, is2many, is2one} = this.association(entity, associationName); const sourceService = this.serviceFor(entityName); - const targetService = this.serviceFor(targetEntityName); + const targetService = this.serviceFor(target.name); - const selectOne = SELECT.one([keyFieldName]) + if (sourceService === targetService) return await next(); + + // REVISIT: How to call service datasource w/o handlers + const selectEntry = SELECT.one([keyFieldName]) .from(entityName) .where(select.from.ref[0].where); - const entry = await sourceService.run(selectOne); + const entry = await sourceService.run(selectEntry); + // REVISIT: How to call service datasource w/o handlers + // TODO: Seems not to respect filter for targetkeyFieldName const selectTarget = SELECT(req.query.SELECT.columns) - .from(targetEntityName) + .from(target) .where({ [targetKeyFieldName]: entry[keyFieldName] }); - return await targetService.run(selectTarget); - } + const result = await targetService.run(selectTarget); + if (is2many) { + return result; + } else if (is2one) { + return result?.[0]; + } else { + throw new Error('Unsupported association cardinality'); + } +} async handle(req, next) { let doRequest; @@ -198,25 +189,28 @@ class RemoteHandler { ) { doRequest = () => this.resolveNavigation(req, next) } else { - const targetService = this.serviceFor(req.target.name); - doRequest = targetService === this.service ? - next : () => targetService.run(req.query) + doRequest = this.isRemote(req.target.name) ? + () => this.serviceFor(req.target.name).run(req.query) : next; } return this.resolveExpands(req, doRequest); } + isRemote(entityName) { + return this.serviceFor(entityName) !== this.service; + } + + isSeparated(entityNameA, entityNameB) { + return this.serviceFor(entityNameA) !== this.serviceFor(entityNameB); + } + 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] - ); + + return this.isSeparated(req.target.name, req.target.associations[associationName].target); }; const expands = select.columns.filter(expandFilter); @@ -224,17 +218,20 @@ class RemoteHandler { if (expands.length === 0) return next(); + const temporaryKeyFieldNames = []; for (const expand of expands) { const associationName = expand.ref[0]; - const [keyFieldName] = associationLink(req.target, associationName); + const {keyFieldName} = this.association(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 }); + temporaryKeyFieldNames.push(keyFieldName); + } } // Call service implementation @@ -244,8 +241,134 @@ class RemoteHandler { expands.map((expand) => this.mixinExpand(req, result, expand)) ); + if (temporaryKeyFieldNames.length > 0) { + for (const entry of result) { + for (const name of temporaryKeyFieldNames) + delete entry[name]; + } + } + return result; } + + association(entity, associationName, recursion = 0) { + let associationMetaData; + + if (++recursion > 2) throwAssocError(entity, association, "Association has recursive definition."); + + const association = entity.associations[associationName] || throwAssocError(entity, associationName, `Association does not exists`); + + associationMetaData = this.associationKey(entity, association); + if (!associationMetaData) { + associationMetaData = this.associationOn(entity, association); + } + if (!associationMetaData) { + associationMetaData = this.associationOnSelf(entity, association, recursion); + } + + if (!association) throwAssocError(entity, association, "Only associations with one key field or on conidition with one field are supported."); + + associationMetaData.is2many = association.is2many; + associationMetaData.is2one = association.is2one; + associationMetaData.entity = entity; + associationMetaData.target = getEntity(association.target); + return associationMetaData; + } + + /** + * Association with "on" condition + * + * @example + * entity Notes { + * supplier_ID : Suppliers:ID; + * supplier: Association to Suppliers on supplier.ID = supplier_ID; + * } + * + * --> + * + * { association: { on: { [ + * ref: [ 'supplier', 'ID' ], // . + * '=', + * ref: [ 'supplier_ID' ] // + * ] }}} + * + * @param {*} entity + * @param {*} association + * @returns + */ + associationOn(entity, association) { + const onLength = association.on?.length ?? 0; + if (onLength === 0) return; + + const on = association.on; + if (!(onLength === 3 && on[0]?.ref?.[0] === association.name && on[1] === "=" && on[2]?.ref[0] !== "$self")) return; //throwAssocError(entity, association, "Association on condition must compare to $self"); + + return { + keyFieldName: fixColumnName(entity, association.on[2].ref[0]), + targetKeyFieldName: association.on[0].ref.slice(1).join("_") + } + } + + /** + * + * @example + * extend entity BusinessPartner { + * notes: Composition of many Notes on notes = $self; + * } + * + * @param {*} entity + * @param {*} association + * @returns + */ + + associationOnSelf(entity, association, recursion) { + const onLength = association.on?.length ?? 0; + if (onLength === 0) return; + + const on = association.on; + if (!(onLength === 3 && on[0]?.ref && on[1] === "=" && on[2]?.ref[0] === "$self")) return; //throwAssocError(entity, association, "Association on condition must compare to $self"); + + const reverseAssociationName = association.on[0].ref[1]; + const reverseAssociationMetaData = this.association(targetEntity, reverseAssociationName, false); + + return { + keyFieldName: reverseAssociationMetaData.targetKeyFieldName, + targetKeyFieldName: reverseAssociationMetaData.keyFieldName + } + } + + /** + * Association with keys + * + * @example + * entity Notes { + * supplier: Association to Suppliers; + * } + * + * --> + * + * { association: keys: [ { + * $generatedFieldName: 'supplier_ID', + * ref: [ 'ID' ] + * } ] + * } + * + * @param {*} entity + * @param {*} association + * @returns + */ + associationKey(entity, association) { + const keyLength = association.keys?.length ?? 0; + if (keyLength === 0) return; + if (keyLength > 1) throwAssocError(entity, association, `Association has ${keyLength} key fields, but only 1 is supported.`); + const key = association.keys[0]; + + return { + keyFieldName: key["$generatedFieldName"] || throwError(entity, association, "Missing $generatedFieldName"), + targetKeyFieldName: key.ref[0] || throwError(entity, association, "Missing key ref") + } + } + } RemoteHandler.columnNameFixes = {}; diff --git a/test/notes.test.js b/test/notes.test.js index 2b7b4c66..14b2c30f 100644 --- a/test/notes.test.js +++ b/test/notes.test.js @@ -4,12 +4,21 @@ if (cds.User.default) cds.User.default = cds.User.Privileged; // hard core monkey patch else cds.User = cds.User.Privileged; // hard core monkey patch for older cds releases +// TODO: remove hack process.env.S4_APIKEY = "-"; +const envelope = (entity, value) => { + const result = {"@odata.context": entity.match(/\$metadata/) ? entity : `$metadata#${entity}`}; -const BPs = { - "@odata.context": "$metadata#Suppliers", - value: [ + if (Array.isArray(value)) { + result.value = value; + } else { + Object.assign(result, value); + } + return result; +} + +const BPs = [ { BusinessPartner: "11", BusinessPartnerFullName: "Alice Wonder", @@ -20,12 +29,9 @@ const BPs = { BusinessPartnerFullName: "Hugo Hollandaise", BusinessPartnerType: "CUSTOMER", }, - ], -}; +]; -const Suppliers = { - "@odata.context": "$metadata#Suppliers", - value: [ +const Suppliers = [ { ID: "11", fullName: "Alice Wonder", @@ -36,12 +42,9 @@ const Suppliers = { fullName: "Hugo Hollandaise", customerType: "CUSTOMER", }, - ], -}; + ]; -const SuppliersExpandNotes = { - "@odata.context": "$metadata#Suppliers", - value: [ +const SuppliersExpandNotes = [ { ID: "11", fullName: "Alice Wonder", @@ -71,8 +74,40 @@ const SuppliersExpandNotes = { }, ], }, - ], -}; + ]; + +const NotesExpandSuppliers = [ + { + "ID": "24B58115-E394-423B-BEAB-53419A32B927", + "note": "note3", + "supplier_ID": "9980000082", + "supplier": { + "ID": "9980000082", + "fullName": "Hugo Hollandaise", + "customerType": "CUSTOMER" + } + }, + { + "ID": "545A3CF9-84CF-46C8-93DC-E29F0F2BC6BE", + "note": "note2 for 11", + "supplier_ID": "11", + "supplier": { + "ID": "11", + "fullName": "Alice Wonder", + "customerType": "CUSTOMER" + } + }, + { + "ID": "D632D4EE-E772-454A-913E-26A7B8DAA7FB", + "note": "note1 for 11", + "supplier_ID": "11", + "supplier": { + "ID": "11", + "fullName": "Alice Wonder", + "customerType": "CUSTOMER" + } + } + ]; class MockServer { async start() { @@ -97,12 +132,15 @@ class MockServer { } } +if (!global.beforeAll) global.beforeAll = global.before; + describe("Notes", () => { const mockServer = new MockServer(); - before( async () => { + beforeAll( async () => { mockServer.start(); + // TODO: Need better solution. Does it conflict with other tests? cds.env.add({ requires: { API_BUSINESS_PARTNER: { @@ -159,7 +197,7 @@ describe("Notes", () => { expect({ status, data }).to.containSubset({ status: 200, - data: Suppliers, + data: envelope("Suppliers", Suppliers) }); }); @@ -168,7 +206,7 @@ describe("Notes", () => { expect({ status, data }).to.containSubset({ status: 200, - data: SuppliersExpandNotes, + data: envelope('Suppliers(notes())', SuppliersExpandNotes), }); }); @@ -177,10 +215,29 @@ describe("Notes", () => { expect({ status, data }).to.containSubset({ status: 200, - data: {value: SuppliersExpandNotes.value[0].notes }, + data: envelope('../$metadata#Notes', SuppliersExpandNotes[0].notes) }); }); + it("get notes with suppliers", async () => { + const { status, data } = await GET("/notes/Notes?$expand=supplier"); + + expect({ status, data }).to.containSubset({ + status: 200, + data: envelope('Notes(supplier())', NotesExpandSuppliers) + }); + }); + + // TODO: Seems not to respect filter for targetkeyFieldName +/* + it.only("get supplier via navigation", async () => { + const { status, data } = await GET("/notes/Notes(545A3CF9-84CF-46C8-93DC-E29F0F2BC6BE)/supplier"); + expect({ status, data }).to.containSubset({ + status: 200, + data: envelope("Suppliers", NotesExpandSuppliers[0].supplier ) + }); + }); +*/ after(() => mockServer.close()); });