From 4cc001bb401a5f74ea3a39f763581605cabdb910 Mon Sep 17 00:00:00 2001 From: Nils Schnabel Date: Tue, 27 Aug 2024 07:03:32 +0000 Subject: [PATCH 1/2] derive objectID for properties with navigation property object ID --- lib/change-log.js | 63 +++++++++++++++++++ lib/entity-helper.js | 6 +- tests/bookshop/db/schema.cds | 4 ++ tests/integration/fiori-draft-enabled.test.js | 22 +++++++ 4 files changed, 93 insertions(+), 2 deletions(-) diff --git a/lib/change-log.js b/lib/change-log.js index 4342ab5..0dfe621 100644 --- a/lib/change-log.js +++ b/lib/change-log.js @@ -105,6 +105,68 @@ const _getEntityIDs = function (txParams) { return entityIDs } +/** + * + * @param {*} tx + * @param {*} changes + * + * When consuming app implement '@changelog' on an property element, + * change history can use attribute on associated entity which are specified instead of property value. + * + * eg: + * entity BookStore @(cds.autoexpose): cuid, managed { + * ... + * '@changelog': [bookOfTheMonth.title] + * bookOfTheMonthID: UUID; + * bookOfTheMonth : Association to one Book on bookOfTheMonth.ID = bookOfTheMonthID; + * ... + * } + */ +const _formatPropertyContext = async function (changes, reqData) { + for (const change of changes) { + const p = cds.model.definitions[change.serviceEntity].elements[change.attribute] + if (p?.type === "cds.Association" || typeof change.valueChangedTo === "object" || typeof p["@changelog"] !== "object") continue + + const semkeys = getObjIdElementNamesInArray(p["@changelog"], false) + if (!semkeys.length) continue + + if(semkeys.length > 1) { + throw new Error("") + } + + const a = cds.model.definitions[change.serviceEntity].elements[semkeys[0].split(".")[0]] + + const condition = a.on.reduce((conditions, e, i) => { + if (e === "=") { + const targetProperty = [...a.on[i - 1].ref]; + targetProperty.shift(); + const sourceProperty = a.on[i + 1].ref.join("."); + if(sourceProperty !== change.attribute) { + throw new Error("Unmanaged associations need to only use conditions based on the changed property") + } + conditions.changedFrom[targetProperty.join(".")] = change.valueChangedFrom; + conditions.changedTo[targetProperty.join(".")] = change.valueChangedTo; + } return conditions; + }, {changedFrom: {}, changedTo: {}}) + + const [from, to] = await cds.db.run([ + SELECT.one.from(a.target).where(condition.changedFrom), + SELECT.one.from(a.target).where(condition.changedTo) + ]) + + const semkeysForObjectId = getObjIdElementNamesInArray(p["@changelog"]) + + const fromObjId = await getObjectId(reqData, a.target, semkeysForObjectId, { curObjFromDbQuery: from || undefined }) // Note: ... || undefined is important for subsequent object destructuring with defaults + if (fromObjId) change.valueChangedFrom = fromObjId + + const toObjId = await getObjectId(reqData, a.target, semkeysForObjectId, { curObjFromDbQuery: to || undefined }) // Note: ... || undefined is important for subsequent object destructuring with defaults + if (toObjId) change.valueChangedTo = toObjId + + const isVLvA = a["@Common.ValueList.viaAssociation"] + if (!isVLvA) change.valueDataType = getValueEntityType(a.target, semkeysForObjectId) + } +} + /** * * @param {*} tx @@ -291,6 +353,7 @@ const _isCompositionContextPath = function (aPath, hasComp) { const _formatChangeLog = async function (changes, req) { await _formatObjectID(changes, req.data) + await _formatPropertyContext(changes, req.data) await _formatAssociationContext(changes, req.data) await _formatCompositionContext(changes, req.data) } diff --git a/lib/entity-helper.js b/lib/entity-helper.js index be564ce..9d13dda 100644 --- a/lib/entity-helper.js +++ b/lib/entity-helper.js @@ -20,10 +20,12 @@ const getEntityByContextPath = function (aPath, hasComp = false) { return entity } -const getObjIdElementNamesInArray = function (elements) { +const getObjIdElementNamesInArray = function (elements, shift=true) { if (Array.isArray(elements)) return elements.map(e => { const splitted = (e["="]||e).split('.') - splitted.shift() + if(shift) { + splitted.shift() + } return splitted.join('.') }) else return [] diff --git a/tests/bookshop/db/schema.cds b/tests/bookshop/db/schema.cds index 30daa98..6490fd8 100644 --- a/tests/bookshop/db/schema.cds +++ b/tests/bookshop/db/schema.cds @@ -103,6 +103,10 @@ entity BookStores @(cds.autoexpose) : managed, cuid { books : Composition of many Books on books.bookStore = $self; + @changelog: [bookOfTheMonth.title] + bookOfTheMonthID: UUID; + bookOfTheMonth: Association to one Books on bookOfTheMonth.ID = bookOfTheMonthID; + @title : '{i18n>bookStore.registry}' registry : Composition of one BookStoreRegistry; diff --git a/tests/integration/fiori-draft-enabled.test.js b/tests/integration/fiori-draft-enabled.test.js index 17d5dfe..c56444c 100644 --- a/tests/integration/fiori-draft-enabled.test.js +++ b/tests/integration/fiori-draft-enabled.test.js @@ -85,6 +85,28 @@ describe("change log integration test", () => { expect(afterChanges.length).to.equal(14); }); + it("unmanaged child entity update without objectID annotation - should log object type for object ID", async () => { + + + const changeUnmanagedAssociationId = PATCH.bind( + {}, + `/odata/v4/admin/BookStores(ID=64625905-c234-4d0d-9bc1-283ee8946770,IsActiveEntity=false)`, + { + bookOfTheMonthID: "676059d4-8851-47f1-b558-3bdc461bf7d5" + } + ); + await utils.apiAction("admin", "BookStores", "64625905-c234-4d0d-9bc1-283ee8946770", "AdminService", changeUnmanagedAssociationId); + + const bookChanges = await adminService.run( + SELECT.from(ChangeView).where({ + entity: "sap.capire.bookshop.BookStores", + attribute: "bookOfTheMonthID", + }) + ); + expect(bookChanges.length).to.equal(1); + expect(bookChanges[0].valueChangedTo).to.equal("Jane Eyre"); + }) + it("2.1 Child entity creation - should log basic data type changes (ERP4SMEPREPWORKAPPPLAT-32 ERP4SMEPREPWORKAPPPLAT-613)", async () => { const action = POST.bind( {}, From 58f692d08441eee402007e18971089383b4e6f3d Mon Sep 17 00:00:00 2001 From: Nils Schnabel Date: Tue, 27 Aug 2024 07:36:08 +0000 Subject: [PATCH 2/2] detect unsupported situations --- lib/change-log.js | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/lib/change-log.js b/lib/change-log.js index 0dfe621..5feaf48 100644 --- a/lib/change-log.js +++ b/lib/change-log.js @@ -130,11 +130,16 @@ const _formatPropertyContext = async function (changes, reqData) { const semkeys = getObjIdElementNamesInArray(p["@changelog"], false) if (!semkeys.length) continue - if(semkeys.length > 1) { - throw new Error("") + const associationsUsed = Object.keys(semkeys.reduce((a, semkey) => { + a[semkey.split(".")[0]] = true; + return a; + }, {})); + + if(associationsUsed.length > 1) { + throw new Error(`@changelog ${change.entity}.${change.attribute}: only one navigation property can be used in the annotation, found multiple: ${associationsUsed}`) } - const a = cds.model.definitions[change.serviceEntity].elements[semkeys[0].split(".")[0]] + const a = cds.model.definitions[change.serviceEntity].elements[associationsUsed[0]] const condition = a.on.reduce((conditions, e, i) => { if (e === "=") { @@ -142,7 +147,7 @@ const _formatPropertyContext = async function (changes, reqData) { targetProperty.shift(); const sourceProperty = a.on[i + 1].ref.join("."); if(sourceProperty !== change.attribute) { - throw new Error("Unmanaged associations need to only use conditions based on the changed property") + throw new Error(`@changlog ${change.entity}.${change.attribute}: association ${a.name} is required to only use conditions based on the annotated property, but uses ${sourceProperty}`) } conditions.changedFrom[targetProperty.join(".")] = change.valueChangedFrom; conditions.changedTo[targetProperty.join(".")] = change.valueChangedTo;