From a20aeb4318ce908f25174ddd70a733ccfe4143ff Mon Sep 17 00:00:00 2001 From: Nils Schnabel Date: Tue, 17 Sep 2024 11:53:22 +0000 Subject: [PATCH 01/11] support complex keys --- lib/change-log.js | 118 +++++++++--------- lib/entity-helper.js | 79 +++++++----- lib/keys.js | 88 +++++++++++++ lib/localization.js | 2 +- lib/template-processor.js | 7 +- tests/integration/complex-keys.test.js | 81 ++++++++++++ tests/integration/complex-keys/package.json | 18 +++ .../complex-keys/srv/complex-keys.cds | 34 +++++ tests/integration/fiori-draft-enabled.test.js | 3 + 9 files changed, 337 insertions(+), 93 deletions(-) create mode 100644 lib/keys.js create mode 100644 tests/integration/complex-keys.test.js create mode 100644 tests/integration/complex-keys/package.json create mode 100644 tests/integration/complex-keys/srv/complex-keys.cds diff --git a/lib/change-log.js b/lib/change-log.js index 4342ab5..66ad45d 100644 --- a/lib/change-log.js +++ b/lib/change-log.js @@ -14,29 +14,34 @@ const { getObjIdElementNamesInArray, getValueEntityType, } = require("./entity-helper") + +const { + getKey, + flattenKey, +} = require("./keys") const { localizeLogFields } = require("./localization") const isRoot = "change-tracking-isRootEntity" const _getRootEntityPathVals = function (txContext, entity, entityKey) { const serviceEntityPathVals = [] - const entityIDs = _getEntityIDs(txContext.params) + const entityIDs = [...txContext.params] - let path = txContext.path.split('/') + let path = [...txContext.path] if (txContext.event === "CREATE") { - const curEntityPathVal = `${entity.name}(${entityKey})` + const curEntityPathVal = {target: entity.name, key: entityKey}; serviceEntityPathVals.push(curEntityPathVal) txContext.hasComp && entityIDs.pop(); } else { // When deleting Composition of one node via REST API in draft-disabled mode, // the child node ID would be missing in URI - if (txContext.event === "DELETE" && !entityIDs.find((x) => x === entityKey)) { + if (txContext.event === "DELETE" && !entityIDs.find(p => JSON.stringify(p) === JSON.stringify(entityKey))) { entityIDs.push(entityKey) } const curEntity = getEntityByContextPath(path, txContext.hasComp) const curEntityID = entityIDs.pop() - const curEntityPathVal = `${curEntity.name}(${curEntityID})` + const curEntityPathVal = {target: curEntity.name, key: curEntityID} serviceEntityPathVals.push(curEntityPathVal) } @@ -44,7 +49,7 @@ const _getRootEntityPathVals = function (txContext, entity, entityKey) { while (_isCompositionContextPath(path, txContext.hasComp)) { const hostEntity = getEntityByContextPath(path = path.slice(0, -1), txContext.hasComp) const hostEntityID = entityIDs.pop() - const hostEntityPathVal = `${hostEntity.name}(${hostEntityID})` + const hostEntityPathVal = {target: hostEntity.name, key: hostEntityID} serviceEntityPathVals.unshift(hostEntityPathVal) } @@ -53,13 +58,13 @@ const _getRootEntityPathVals = function (txContext, entity, entityKey) { const _getAllPathVals = function (txContext) { const pathVals = [] - const paths = txContext.path.split('/') - const entityIDs = _getEntityIDs(txContext.params) + const paths = [...txContext.path] + const entityIDs = [...txContext.params] for (let idx = 0; idx < paths.length; idx++) { const entity = getEntityByContextPath(paths.slice(0, idx + 1), txContext.hasComp) const entityID = entityIDs[idx] - const entityPathVal = `${entity.name}(${entityID})` + const entityPathVal = {target: entity.name, key: entityID}; pathVals.push(entityPathVal) } @@ -88,23 +93,6 @@ function convertSubjectToParams(subject) { return params.length > 0 ? params : subjectRef; } -const _getEntityIDs = function (txParams) { - const entityIDs = [] - for (const param of txParams) { - let id = "" - if (typeof param === "object" && !Array.isArray(param)) { - id = param.ID - } - if (typeof param === "string") { - id = param - } - if (id) { - entityIDs.push(id) - } - } - return entityIDs -} - /** * * @param {*} tx @@ -121,7 +109,7 @@ const _getEntityIDs = function (txParams) { * ... * } */ -const _formatAssociationContext = async function (changes, reqData) { +const _formatAssociationContext = async function (changes, reqData, reqTarget) { for (const change of changes) { const a = cds.model.definitions[change.serviceEntity].elements[change.attribute] if (a?.type !== "cds.Association") continue @@ -135,10 +123,10 @@ const _formatAssociationContext = async function (changes, reqData) { SELECT.one.from(a.target).where({ [ID]: change.valueChangedTo }) ]) - const fromObjId = await getObjectId(reqData, a.target, semkeys, { curObjFromDbQuery: from || undefined }) // Note: ... || undefined is important for subsequent object destructuring with defaults + const fromObjId = await getObjectId(reqData, reqTarget, a.target, semkeys, { 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, semkeys, { curObjFromDbQuery: to || undefined }) // Note: ... || undefined is important for subsequent object destructuring with defaults + const toObjId = await getObjectId(reqData, reqTarget, a.target, semkeys, { curObjFromDbQuery: to || undefined }) // Note: ... || undefined is important for subsequent object destructuring with defaults if (toObjId) change.valueChangedTo = toObjId const isVLvA = a["@Common.ValueList.viaAssociation"] @@ -150,7 +138,8 @@ const _getChildChangeObjId = async function ( change, childNodeChange, curNodePathVal, - reqData + reqData, + reqTarget ) { const composition = cds.model.definitions[change.serviceEntity].elements[change.attribute] const objIdElements = composition ? composition["@changelog"] : null @@ -158,13 +147,14 @@ const _getChildChangeObjId = async function ( return _getObjectIdByPath( reqData, + reqTarget, curNodePathVal, childNodeChange._path, objIdElementNames ) } -const _formatCompositionContext = async function (changes, reqData) { +const _formatCompositionContext = async function (changes, reqData, reqTarget) { const childNodeChanges = [] for (const change of changes) { @@ -174,14 +164,15 @@ const _formatCompositionContext = async function (changes, reqData) { } for (const childNodeChange of change.valueChangedTo) { const curChange = Object.assign({}, change) - const path = childNodeChange._path.split('/') + const path = [...childNodeChange._path] const curNodePathVal = path.pop() curChange.modification = childNodeChange._op const objId = await _getChildChangeObjId( change, childNodeChange, curNodePathVal, - reqData + reqData, + reqTarget ) _formatCompositionValue(curChange, objId, childNodeChange, childNodeChanges) } @@ -234,6 +225,7 @@ const _formatCompositionEntityType = function (change) { const _getObjectIdByPath = async function ( reqData, + reqTarget, nodePathVal, serviceEntityPath, /**optional*/ objIdElementNames @@ -243,13 +235,13 @@ const _getObjectIdByPath = async function ( const entityUUID = getUUIDFromPathVal(nodePathVal) const obj = await getCurObjFromDbQuery(entityName, entityUUID) const curObj = { curObjFromReqData, curObjFromDbQuery: obj } - return getObjectId(reqData, entityName, objIdElementNames, curObj) + return getObjectId(reqData, reqTarget, entityName, objIdElementNames, curObj) } -const _formatObjectID = async function (changes, reqData) { +const _formatObjectID = async function (changes, reqData, reqTarget) { const objectIdCache = new Map() for (const change of changes) { - const path = change.serviceEntityPath.split('/') + const path = [...change.serviceEntityPath]; const curNodePathVal = path.pop() const parentNodePathVal = path.pop() @@ -257,6 +249,7 @@ const _formatObjectID = async function (changes, reqData) { if (!curNodeObjId) { curNodeObjId = await _getObjectIdByPath( reqData, + reqTarget, curNodePathVal, change.serviceEntityPath ) @@ -267,6 +260,7 @@ const _formatObjectID = async function (changes, reqData) { if (!parentNodeObjId && parentNodePathVal) { parentNodeObjId = await _getObjectIdByPath( reqData, + reqTarget, parentNodePathVal, change.serviceEntityPath ) @@ -281,7 +275,7 @@ const _formatObjectID = async function (changes, reqData) { const _isCompositionContextPath = function (aPath, hasComp) { if (!aPath) return - if (typeof aPath === 'string') aPath = aPath.split('/') + if (typeof aPath === 'string') aPath = JSON.parse(aPath) if (aPath.length < 2) return false const target = getEntityByContextPath(aPath, hasComp) const parent = getEntityByContextPath(aPath.slice(0, -1), hasComp) @@ -290,9 +284,9 @@ const _isCompositionContextPath = function (aPath, hasComp) { } const _formatChangeLog = async function (changes, req) { - await _formatObjectID(changes, req.data) - await _formatAssociationContext(changes, req.data) - await _formatCompositionContext(changes, req.data) + await _formatObjectID(changes, req.data, req.target) + await _formatAssociationContext(changes, req.data, req.target) + await _formatCompositionContext(changes, req.data, req.target) } const _afterReadChangeView = function (data, req) { @@ -307,7 +301,7 @@ function _trackedChanges4 (srv, target, diff) { if (!template.elements.size) return const changes = [] - diff._path = `${target.name}(${diff.ID})` + diff._path = [{target: target.name, key: getKey(target, diff)}]; templateProcessor({ template, row: diff, processFn: ({ row, key, element }) => { @@ -363,13 +357,12 @@ const _prepareChangeLogForComposition = async function (entity, entityKey, chang const parentEntityPathVal = rootEntityPathVals[rootEntityPathVals.length - 2] const parentKey = getUUIDFromPathVal(parentEntityPathVal) - const serviceEntityPath = rootEntityPathVals.join('/') + const serviceEntityPath = [...rootEntityPathVals] const parentServiceEntityPath = _getAllPathVals(req.context) .slice(0, rootEntityPathVals.length - 2) - .join('/') for (const change of changes) { - change.parentEntityID = await _getObjectIdByPath(req.data, parentEntityPathVal, parentServiceEntityPath) + change.parentEntityID = await _getObjectIdByPath(req.data, req.target, parentEntityPathVal, parentServiceEntityPath) change.parentKey = parentKey change.serviceEntityPath = serviceEntityPath } @@ -384,15 +377,15 @@ async function generatePathAndParams (req, entityKey) { const { ID, foreignKey, parentEntity } = getAssociationDetails(target); const hasParentAndForeignKey = parentEntity && data[foreignKey]; const targetEntity = hasParentAndForeignKey ? parentEntity : target; - const targetKey = hasParentAndForeignKey ? data[foreignKey] : entityKey; + const targetKey = hasParentAndForeignKey ? {ID: data[foreignKey]} : entityKey; let compContext = { path: hasParentAndForeignKey - ? `${parentEntity.name}/${target.name}` - : `${target.name}`, + ? [{target: parentEntity.name}, {target: target.name}] + : [{target: target.name}], params: hasParentAndForeignKey - ? [{ [ID]: data[foreignKey] }, { [ID]: entityKey }] - : [{ [ID]: entityKey }], + ? [{ [ID]: data[foreignKey] }, entityKey] + : [ entityKey], hasComp: true }; @@ -404,7 +397,7 @@ async function generatePathAndParams (req, entityKey) { while (parentAssoc && !parentAssoc.entity[isRoot]) { parentAssoc = await processEntity( parentAssoc.entity, - parentAssoc.ID, + parentAssoc.key, compContext ); } @@ -418,15 +411,16 @@ async function processEntity (entity, entityKey, compContext) { const parentResult = (await SELECT.one .from(entity.name) - .where({ [ID]: entityKey }) + .where(entityKey) .columns(foreignKey)) || {}; const hasForeignKey = parentResult[foreignKey]; if (!hasForeignKey) return; - compContext.path = `${parentEntity.name}/${compContext.path}`; - compContext.params.unshift({ [ID]: parentResult[foreignKey] }); + const key = { [ID]: parentResult[foreignKey] }; + compContext.path = [{target: parentEntity.name, key}, ...compContext.path]; + compContext.params.unshift(key); return { entity: parentEntity, - [ID]: hasForeignKey ? parentResult[foreignKey] : undefined + key }; } } @@ -441,20 +435,19 @@ function getAssociationDetails (entity) { return { ID, foreignKey, parentEntity }; } - async function track_changes (req) { let diff = await req.diff() if (!diff) return let target = req.target let compContext = null; - let entityKey = diff.ID + let entityKey = getKey(req.target, diff) const params = convertSubjectToParams(req.subject); if (req.subject.ref.length === 1 && params.length === 1 && !target[isRoot]) { compContext = await generatePathAndParams(req, entityKey); } let isComposition = _isCompositionContextPath( - compContext?.path || req.path, + compContext?.path || req.path.split("/").map(p => ({target: p})), compContext?.hasComp ); if ( @@ -462,7 +455,7 @@ async function track_changes (req) { target[isRoot] && !cds.env.requires["change-tracking"]?.preserveDeletes ) { - return await DELETE.from(`sap.changelog.ChangeLog`).where({ entityKey }); + return await DELETE.from(`sap.changelog.ChangeLog`).where({entityKey: flattenKey(entityKey)}); } let changes = _trackedChanges4(this, target, diff) @@ -471,9 +464,10 @@ async function track_changes (req) { await _formatChangeLog(changes, req) if (isComposition) { let reqInfo = { + target: req.target, data: req.data, context: { - path: compContext?.path || req.path, + path: compContext?.path || req.path.split("/").map(p => ({target: p})), params: compContext?.params || params, event: req.event, hasComp: compContext?.hasComp @@ -482,12 +476,16 @@ async function track_changes (req) { [ target, entityKey ] = await _prepareChangeLogForComposition(target, entityKey, changes, reqInfo) } const dbEntity = getDBEntity(target) + + await INSERT.into("sap.changelog.ChangeLog").entries({ entity: dbEntity.name, - entityKey: entityKey, + entityKey: flattenKey(entityKey), serviceEntity: target.name || target, changes: changes.filter(c => c.valueChangedFrom || c.valueChangedTo).map((c) => ({ ...c, + parentKey: flattenKey(c.parentKey), + entityKey: flattenKey(c.entityKey), valueChangedFrom: `${c.valueChangedFrom ?? ''}`, valueChangedTo: `${c.valueChangedTo ?? ''}`, })), diff --git a/lib/entity-helper.js b/lib/entity-helper.js index be564ce..34a44f3 100644 --- a/lib/entity-helper.js +++ b/lib/entity-helper.js @@ -1,21 +1,22 @@ -const cds = require("@sap/cds") +const cds = require("@sap/cds"); +const { addAbortListener } = require("@sap/cds/libx/_runtime/cds-services/adapter/odata-v4/okra/odata-server/core/OdataResponse"); const LOG = cds.log("change-log") +const { getAssociationKey, getKey } = require('./keys') const getNameFromPathVal = function (pathVal) { - return /^(.+?)\(/.exec(pathVal)?.[1] || "" + return pathVal?.target; } const getUUIDFromPathVal = function (pathVal) { - const regRes = /\((.+?)\)/.exec(pathVal) - return regRes ? regRes[1] : "" + return pathVal?.key ?? ""; } const getEntityByContextPath = function (aPath, hasComp = false) { - if (hasComp) return cds.model.definitions[aPath[aPath.length - 1]] - let entity = cds.model.definitions[aPath[0]] + if (hasComp) return cds.model.definitions[aPath[aPath.length - 1].target] + let entity = cds.model.definitions[aPath[0].target] for (let each of aPath.slice(1)) { - entity = entity.elements[each]?._target + entity = entity.elements[each.target]?._target } return entity } @@ -29,15 +30,15 @@ const getObjIdElementNamesInArray = function (elements) { else return [] } -const getCurObjFromDbQuery = async function (entityName, queryVal, /**optional*/ queryKey='ID') { - if (!queryVal) return {} +const getCurObjFromDbQuery = async function (entityName, key) { + if (!key) return {} // REVISIT: This always reads all elements -> should read required ones only! - const obj = await SELECT.one.from(entityName).where({[queryKey]: queryVal}) + const obj = await SELECT.one.from(entityName).where(key) return obj || {} } -const getCurObjFromReqData = function (reqData, nodePathVal, pathVal) { - const pathVals = pathVal.split('/') +const getCurObjFromReqData = function (reqData, nodePathVal, pathVals) { + pathVals = [...pathVals] const rootNodePathVal = pathVals[0] let curReqObj = reqData || {} @@ -48,12 +49,15 @@ const getCurObjFromReqData = function (reqData, nodePathVal, pathVal) { for (const subNodePathVal of pathVals) { const srvObjName = getNameFromPathVal(subNodePathVal) - const curSrvObjUUID = getUUIDFromPathVal(subNodePathVal) const associationName = _getAssociationName(parentSrvObjName, srvObjName) if (curReqObj) { let associationData = curReqObj[associationName] if (!Array.isArray(associationData)) associationData = [associationData] - curReqObj = associationData?.find(x => x?.ID === curSrvObjUUID) || {} + curReqObj = associationData?.find(x => + Object.entries(subNodePathVal.key) + .every(([k, v]) => + x?.[k] === v + )) || {} } if (subNodePathVal === nodePathVal) return curReqObj || {} parentSrvObjName = srvObjName @@ -71,7 +75,7 @@ const getCurObjFromReqData = function (reqData, nodePathVal, pathVal) { } -async function getObjectId (reqData, entityName, fields, curObj) { +async function getObjectId (reqData, reqTarget, entityName, fields, curObj) { let all = [], { curObjFromReqData: req_data={}, curObjFromDbQuery: db_data={} } = curObj let entity = cds.model.definitions[entityName] if (!fields?.length) fields = entity["@changelog"]?.map?.(k => k['='] || k) || [] @@ -81,28 +85,32 @@ async function getObjectId (reqData, entityName, fields, curObj) { let current = entity, _db_data = db_data while (path.length > 1) { let assoc = current.elements[path[0]]; if (!assoc?.isAssociation) break - let foreignKey = assoc.keys?.[0]?.$generatedFieldName - let IDval = - req_data[foreignKey] && current.name === entityName - ? req_data[foreignKey] - : _db_data[foreignKey] + let IDval = null; + if (current.name === entityName) { + // try req_data first + IDval = getAssociationKey(assoc, req_data) + } + if(!IDval) { + // try db_data otherwise + IDval = getAssociationKey(assoc, _db_data) + } + if (!IDval) { _db_data = {}; } else try { // REVISIT: This always reads all elements -> should read required ones only! - let ID = assoc.keys?.[0]?.ref[0] || 'ID' const isComposition = hasComposition(assoc._target, current) // Peer association and composition are distinguished by the value of isComposition. if (isComposition) { // This function can recursively retrieve the desired information from reqData without having to read it from db. - _db_data = _getCompositionObjFromReq(reqData, IDval) + _db_data = _getCompositionObjFromReq(reqTarget, reqData, IDval) // When multiple layers of child nodes are deleted at the same time, the deep layer of child nodes will lose the information of the upper nodes, so data needs to be extracted from the db. const entityKeys = reqData ? Object.keys(reqData).filter(item => !Object.keys(assoc._target.keys).some(ele => item === ele)) : []; if (!_db_data || JSON.stringify(_db_data) === '{}' || entityKeys.length === 0) { - _db_data = await getCurObjFromDbQuery(assoc._target, IDval, ID); + _db_data = await getCurObjFromDbQuery(assoc._target, IDval); } } else { - _db_data = await getCurObjFromDbQuery(assoc._target, IDval, ID); + _db_data = await getCurObjFromDbQuery(assoc._target, IDval); } } catch (e) { LOG.error("Failed to generate object Id for an association entity.", e) @@ -166,16 +174,27 @@ const hasComposition = function (parentEntity, subEntity) { return false } -const _getCompositionObjFromReq = function (obj, targetID) { - if (obj?.ID === targetID) { +const _getCompositionObjFromReq = function (entity, obj, objkey) { + if (JSON.stringify(getKey(entity, obj)) === JSON.stringify(objkey)) { return obj; } + for (const key in obj) { - if (typeof obj[key] === "object" && obj[key] !== null) { - const result = _getCompositionObjFromReq(obj[key], targetID); - if (result) { - return result; + const subobj = obj[key]; + if (typeof subobj === "object" && subobj !== null) { + if(Array.isArray(subobj)) { + for(let subobjobj of subobj) { + const result = _getCompositionObjFromReq(entity.elements[key]._target, subobjobj, objkey); + if (result) { + return result; + } + } + } else { + const result = _getCompositionObjFromReq(entity.elements[key]._target, obj[key], objkey); + if (result) { + return result; + } } } } diff --git a/lib/keys.js b/lib/keys.js new file mode 100644 index 0000000..81fef62 --- /dev/null +++ b/lib/keys.js @@ -0,0 +1,88 @@ +const { resolve } = require("@sap/cds"); + +function getKey(entity, data) { + const result = {}; + for (let [key, def] of Object.entries(entity.keys)) { + if (!def.virtual && !def.isAssociation) { + result[key] = data[key]; + } + } + return result; +} + + +const flattenKey = (k) => { + if (!k) return k; + if (Object.entries(k).length == 1) { + // for backwards compatibility, a single key is persisted as only the value instead of a JSON object + return Object.values(k)[0]; + } + + return k; +} + +const resolveToSourceFields = (ref, assoc) => { + if (ref[0] == assoc.name) { + return null; + } + if (ref[0] == "$self") { + return Object.values(assoc.parent.keys).filter(k => !k.virtual).map(k => k.name) + } + return ref; +} + +const resolveToTargetFields = (ref, assoc) => { + ref = [...ref]; + if (ref[0] !== assoc.name) { + return null; + } + ref.shift() + const elem = assoc._target.elements[ref[0]]; + if (elem.isAssociation) { + return elem.keys.map(k => k.$generatedFieldName); + } + return ref; +} + +const getAssociationKey = (assoc, data) => { + try { + if (assoc.keys) { + return assoc.keys.reduce((a, key) => { + let targetField = key.ref[0]; + let sourceField = key.$generatedFieldName; + if (!data[sourceField]) { + throw Error('incomplete data') + } + a[targetField] = data[sourceField]; + return a; + }, {}) + } + else if (assoc.on) { + return assoc.on.reduce((a, on, i) => { + if (on == '=') { + const left = assoc.on[i - 1] + const right = assoc.on[i + 1] + const sourceFields = resolveToSourceFields(left.ref, assoc) ?? resolveToSourceFields(right.ref, assoc); + const targetFields = resolveToTargetFields(left.ref, assoc) ?? resolveToTargetFields(right.ref, assoc); + + sourceFields.forEach((sourceField, i) => { + const targetField = targetFields[i]; + if (!data[sourceField]) { + throw Error('incomplete data') + } + a[targetField] = data[sourceField]; + }) + } + return a; + }, {}) + } + } catch (e) { + return undefined; + } +} + +module.exports = { + getKey, + flattenKey, + getAssociationKey +} \ No newline at end of file diff --git a/lib/localization.js b/lib/localization.js index 2fd5b39..6cb3aba 100644 --- a/lib/localization.js +++ b/lib/localization.js @@ -36,7 +36,7 @@ const _localizeDefaultObjectID = function (change, locale) { change.objectID = change.entity ? change.entity : ""; } if (change.objectID && change.serviceEntityPath && !change.parentObjectID && change.parentKey) { - const path = change.serviceEntityPath.split('/'); + const path = JSON.parse(change.serviceEntityPath); const parentNodePathVal = path[path.length - 2]; const parentEntityName = getNameFromPathVal(parentNodePathVal); const dbEntity = getDBEntity(parentEntityName); diff --git a/lib/template-processor.js b/lib/template-processor.js index b47a87f..497e314 100644 --- a/lib/template-processor.js +++ b/lib/template-processor.js @@ -1,6 +1,7 @@ // Enhanced class based on cds v5.5.5 @sap/cds/libx/_runtime/common/utils/templateProcessor const DELIMITER = require("@sap/cds/libx/_runtime/common/utils/templateDelimiter"); +const {getKey} = require('./keys'); const _formatRowContext = (tKey, keyNames, row) => { const keyValuePairs = keyNames.map((key) => `${key}=${row[key]}`); @@ -46,8 +47,10 @@ const _processRow = (processFn, row, template, tKey, tValue, isRoot, pathOptions /** Enhancement by SME: Support CAP Change Histroy * Construct path from root entity to current entity. */ - const serviceNodeName = template.target.elements[key].target; - subRow._path = `${row._path}/${serviceNodeName}(${subRow.ID})`; + const target = template.target.elements[key].target; + const targetEntity = cds.model.definitions[target]; + const targetKey = getKey(targetEntity, subRow) + subRow._path = [...row._path, {target, key: targetKey}]; } }); diff --git a/tests/integration/complex-keys.test.js b/tests/integration/complex-keys.test.js new file mode 100644 index 0000000..a5d058f --- /dev/null +++ b/tests/integration/complex-keys.test.js @@ -0,0 +1,81 @@ +const cds = require("@sap/cds"); +const { assert } = require("console"); +const complexkeys = require("path").resolve(__dirname, "./complex-keys/"); +const { expect, data, POST, GET } = cds.test(complexkeys); + +let service = null; +let ChangeView = null; +let db = null; +let ChangeEntity = null; + +describe("change log with complex keys", () => { + beforeAll(async () => { + service = await cds.connect.to("complexkeys.ComplexKeys"); + db = await cds.connect.to("sql:my.db"); + ChangeView = db.model.definitions["sap.changelog.ChangeView"]; + ChangeEntity = db.model.definitions["sap.changelog.Changes"]; + }); + + beforeEach(async () => { + await data.reset(); + }); + + it("logs many-to-many composition with complex keys correctly", async () => { + + const root = await POST(`/complex-keys/Root`, { + MySecondId: "asdasd", + name: "Root" + }); + expect(root.status).to.equal(201) + + const linked1 = await POST(`/complex-keys/Linked`, { + name: "Linked 1" + }); + expect(linked1.status).to.equal(201) + + const linked2 = await POST(`/complex-keys/Linked`, { + name: "Linked 2" + }); + expect(linked2.status).to.equal(201) + + const link1 = await POST(`/complex-keys/Root(MyId=${root.data.MyId},MySecondId='asdasd',IsActiveEntity=false)/links`, { + linked_ID: linked1.data.ID, + root_ID: root.ID + }); + expect(link1.status).to.equal(201) + + const link2 = await POST(`/complex-keys/Root(MyId=${root.data.MyId},MySecondId='asdasd',IsActiveEntity=false)/links`, { + linked_ID: linked2.data.ID, + root_ID: root.ID + }); + expect(link2.status).to.equal(201) + + const save = await POST(`/complex-keys/Root(MyId=${root.data.MyId},MySecondId='asdasd',IsActiveEntity=false)/complexkeys.ComplexKeys.draftActivate`, { preserveChanges: false }) + expect(save.status).to.equal(201) + + + const changes = await SELECT.from(ChangeView); + expect(changes).to.have.length(3); + expect(changes.map(change => ({ + modification: change.modification, + attribute: change.attribute, + valueChangedTo: change.valueChangedTo, + }))).to.have.deep.members([ + { + attribute: 'name', + modification: 'create', + valueChangedTo: + 'Root' + }, { + attribute: 'links', + modification: 'create', + valueChangedTo: + 'Linked 1' + }, { + attribute: 'links', + modification: 'create', + valueChangedTo: + 'Linked 2' + }]) + }) +}); \ No newline at end of file diff --git a/tests/integration/complex-keys/package.json b/tests/integration/complex-keys/package.json new file mode 100644 index 0000000..0aef0d6 --- /dev/null +++ b/tests/integration/complex-keys/package.json @@ -0,0 +1,18 @@ +{ + "dependencies": { + "@cap-js/change-tracking": "*" + }, + "devDependencies": { + "@cap-js/sqlite": "*" + }, + "cds": { + "requires": { + "db": { + "kind": "sql" + } + }, + "features": { + "serve_on_root": true + } + } +} \ No newline at end of file diff --git a/tests/integration/complex-keys/srv/complex-keys.cds b/tests/integration/complex-keys/srv/complex-keys.cds new file mode 100644 index 0000000..281d13b --- /dev/null +++ b/tests/integration/complex-keys/srv/complex-keys.cds @@ -0,0 +1,34 @@ +namespace complexkeys; + +using {cuid} from '@sap/cds/common'; + + +context db { + + @changelog: [name] + entity Root { + key MyId: UUID; + key MySecondId: String; + @changelog + name: cds.String; + @changelog: [links.linked.name] + links: Composition of many Link on links.root = $self + } + + entity Link { + key root: Association to one Root; + key linked: Association to one Linked; + } + + entity Linked: cuid { + name: cds.String; + } +} + +@path: '/complex-keys' +service ComplexKeys { + @odata.draft.enabled + entity Root as projection on db.Root; + entity Link as projection on db.Link; + entity Linked as projection on db.Linked; +} \ No newline at end of file diff --git a/tests/integration/fiori-draft-enabled.test.js b/tests/integration/fiori-draft-enabled.test.js index 17d5dfe..ed31fe1 100644 --- a/tests/integration/fiori-draft-enabled.test.js +++ b/tests/integration/fiori-draft-enabled.test.js @@ -72,6 +72,9 @@ describe("change log integration test", () => { const changelogCreated = afterChanges.filter(ele=> ele.modification === "Create"); const changelogDeleted = afterChanges.filter(ele=> ele.modification === "Delete"); + expect(changelogCreated.length).to.equal(7); + expect(changelogDeleted.length).to.equal(7); + const compareAttributes = ['keys', 'attribute', 'entity', 'serviceEntity', 'parentKey', 'serviceEntityPath', 'valueDataType', 'objectID', 'parentObjectID', 'entityKey']; let commonItems = changelogCreated.filter(beforeItem => { From b217bc24ea4ba95aebe69c08e7950eca809bf8ef Mon Sep 17 00:00:00 2001 From: Nils Schnabel Date: Tue, 17 Sep 2024 13:45:44 +0000 Subject: [PATCH 02/11] use getAssociationKey in more places --- lib/change-log.js | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/lib/change-log.js b/lib/change-log.js index 66ad45d..bf4e978 100644 --- a/lib/change-log.js +++ b/lib/change-log.js @@ -18,6 +18,7 @@ const { const { getKey, flattenKey, + getAssociationKey, } = require("./keys") const { localizeLogFields } = require("./localization") const isRoot = "change-tracking-isRootEntity" @@ -374,7 +375,7 @@ const _prepareChangeLogForComposition = async function (entity, entityKey, chang async function generatePathAndParams (req, entityKey) { const { target, data } = req; - const { ID, foreignKey, parentEntity } = getAssociationDetails(target); + const { foreignKey, parentEntity, assoc } = getAssociationDetails(target); const hasParentAndForeignKey = parentEntity && data[foreignKey]; const targetEntity = hasParentAndForeignKey ? parentEntity : target; const targetKey = hasParentAndForeignKey ? {ID: data[foreignKey]} : entityKey; @@ -384,7 +385,7 @@ async function generatePathAndParams (req, entityKey) { ? [{target: parentEntity.name}, {target: target.name}] : [{target: target.name}], params: hasParentAndForeignKey - ? [{ [ID]: data[foreignKey] }, entityKey] + ? [ getAssociationKey(assoc, data), entityKey] : [ entityKey], hasComp: true }; @@ -405,7 +406,7 @@ async function generatePathAndParams (req, entityKey) { } async function processEntity (entity, entityKey, compContext) { - const { ID, foreignKey, parentEntity } = getAssociationDetails(entity); + const { foreignKey, parentEntity, assoc } = getAssociationDetails(entity); if (foreignKey && parentEntity) { const parentResult = @@ -413,9 +414,8 @@ async function processEntity (entity, entityKey, compContext) { .from(entity.name) .where(entityKey) .columns(foreignKey)) || {}; - const hasForeignKey = parentResult[foreignKey]; - if (!hasForeignKey) return; - const key = { [ID]: parentResult[foreignKey] }; + const key = getAssociationKey(assoc, parentResult) + if (!key) return; compContext.path = [{target: parentEntity.name, key}, ...compContext.path]; compContext.params.unshift(key); return { @@ -431,8 +431,7 @@ function getAssociationDetails (entity) { const assoc = entity.elements[assocName]; const parentEntity = assoc?._target; const foreignKey = assoc?.keys?.[0]?.$generatedFieldName; - const ID = assoc?.keys?.[0]?.ref[0] || 'ID'; - return { ID, foreignKey, parentEntity }; + return { foreignKey, parentEntity, assoc }; } async function track_changes (req) { From 2d42031f1da5a105bf4df89cdee5dd4e44435a29 Mon Sep 17 00:00:00 2001 From: Nick Josipovic Date: Wed, 18 Sep 2024 11:06:53 +0200 Subject: [PATCH 03/11] Update entity-helper.js --- lib/entity-helper.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/entity-helper.js b/lib/entity-helper.js index 34a44f3..34ef4fc 100644 --- a/lib/entity-helper.js +++ b/lib/entity-helper.js @@ -1,5 +1,5 @@ const cds = require("@sap/cds"); -const { addAbortListener } = require("@sap/cds/libx/_runtime/cds-services/adapter/odata-v4/okra/odata-server/core/OdataResponse"); +//const { addAbortListener } = require("@sap/cds/libx/_runtime/cds-services/adapter/odata-v4/okra/odata-server/core/OdataResponse"); const LOG = cds.log("change-log") const { getAssociationKey, getKey } = require('./keys') From 31d60e5cfa3f0d2b3c60db4dd05a260cbcbc530f Mon Sep 17 00:00:00 2001 From: Nick Josipovic Date: Wed, 18 Sep 2024 11:07:27 +0200 Subject: [PATCH 04/11] Update complex-keys.test.js --- tests/integration/complex-keys.test.js | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/integration/complex-keys.test.js b/tests/integration/complex-keys.test.js index a5d058f..18a5829 100644 --- a/tests/integration/complex-keys.test.js +++ b/tests/integration/complex-keys.test.js @@ -1,7 +1,8 @@ const cds = require("@sap/cds"); -const { assert } = require("console"); +//const { assert } = require("console"); const complexkeys = require("path").resolve(__dirname, "./complex-keys/"); -const { expect, data, POST, GET } = cds.test(complexkeys); +//const { expect, data, POST, GET } = cds.test(complexkeys); +const { expect, data, POST } = cds.test(complexkeys); let service = null; let ChangeView = null; @@ -78,4 +79,4 @@ describe("change log with complex keys", () => { 'Linked 2' }]) }) -}); \ No newline at end of file +}); From f032969c6f577ab113652d963cdae3108857393f Mon Sep 17 00:00:00 2001 From: Nick Josipovic Date: Wed, 18 Sep 2024 11:09:48 +0200 Subject: [PATCH 05/11] Update complex-keys.test.js --- tests/integration/complex-keys.test.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/integration/complex-keys.test.js b/tests/integration/complex-keys.test.js index 18a5829..644bfb4 100644 --- a/tests/integration/complex-keys.test.js +++ b/tests/integration/complex-keys.test.js @@ -4,17 +4,17 @@ const complexkeys = require("path").resolve(__dirname, "./complex-keys/"); //const { expect, data, POST, GET } = cds.test(complexkeys); const { expect, data, POST } = cds.test(complexkeys); -let service = null; +//let service = null; let ChangeView = null; let db = null; -let ChangeEntity = null; +//let ChangeEntity = null; describe("change log with complex keys", () => { beforeAll(async () => { - service = await cds.connect.to("complexkeys.ComplexKeys"); + //service = await cds.connect.to("complexkeys.ComplexKeys"); db = await cds.connect.to("sql:my.db"); ChangeView = db.model.definitions["sap.changelog.ChangeView"]; - ChangeEntity = db.model.definitions["sap.changelog.Changes"]; + //ChangeEntity = db.model.definitions["sap.changelog.Changes"]; }); beforeEach(async () => { From 1d84b9889ce8f1fcc724dd05e68d1181b63efcab Mon Sep 17 00:00:00 2001 From: Nick Josipovic Date: Wed, 18 Sep 2024 11:11:11 +0200 Subject: [PATCH 06/11] Update keys.js --- lib/keys.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/keys.js b/lib/keys.js index 81fef62..d93080d 100644 --- a/lib/keys.js +++ b/lib/keys.js @@ -1,4 +1,4 @@ -const { resolve } = require("@sap/cds"); +//const { resolve } = require("@sap/cds"); function getKey(entity, data) { const result = {}; @@ -85,4 +85,4 @@ module.exports = { getKey, flattenKey, getAssociationKey -} \ No newline at end of file +} From 8f1556246318f036061b8ce7a894e04fe8be6a8a Mon Sep 17 00:00:00 2001 From: Nils Schnabel Date: Wed, 18 Sep 2024 13:03:18 +0000 Subject: [PATCH 07/11] parsePath/serializePath --- lib/change-log.js | 11 ++++++----- lib/keys.js | 33 ++++++++++++++++++++++++++++----- lib/localization.js | 3 ++- 3 files changed, 36 insertions(+), 11 deletions(-) diff --git a/lib/change-log.js b/lib/change-log.js index bf4e978..482960e 100644 --- a/lib/change-log.js +++ b/lib/change-log.js @@ -17,7 +17,8 @@ const { const { getKey, - flattenKey, + stringifyKey, + stringifyPath, getAssociationKey, } = require("./keys") const { localizeLogFields } = require("./localization") @@ -454,7 +455,7 @@ async function track_changes (req) { target[isRoot] && !cds.env.requires["change-tracking"]?.preserveDeletes ) { - return await DELETE.from(`sap.changelog.ChangeLog`).where({entityKey: flattenKey(entityKey)}); + return await DELETE.from(`sap.changelog.ChangeLog`).where({entityKey: stringifyKey(entityKey)}); } let changes = _trackedChanges4(this, target, diff) @@ -479,12 +480,12 @@ async function track_changes (req) { await INSERT.into("sap.changelog.ChangeLog").entries({ entity: dbEntity.name, - entityKey: flattenKey(entityKey), + entityKey: stringifyKey(entityKey), serviceEntity: target.name || target, changes: changes.filter(c => c.valueChangedFrom || c.valueChangedTo).map((c) => ({ ...c, - parentKey: flattenKey(c.parentKey), - entityKey: flattenKey(c.entityKey), + serviceEntityPath: stringifyPath(c.serviceEntityPath), + parentKey: stringifyKey(c.parentKey), valueChangedFrom: `${c.valueChangedFrom ?? ''}`, valueChangedTo: `${c.valueChangedTo ?? ''}`, })), diff --git a/lib/keys.js b/lib/keys.js index 81fef62..3b268c2 100644 --- a/lib/keys.js +++ b/lib/keys.js @@ -1,5 +1,3 @@ -const { resolve } = require("@sap/cds"); - function getKey(entity, data) { const result = {}; for (let [key, def] of Object.entries(entity.keys)) { @@ -10,8 +8,31 @@ function getKey(entity, data) { return result; } +const stringifyPath = (path) => { + const isSingleKeyPath = path.every((pathElement) => Object.keys(pathElement.key).length === 1); + if(isSingleKeyPath) { + // for backwards compatibility, if all keys are simple, store in the former format + return path.map((pathElement) => { + return `${pathElement.target}(${stringifyKey(pathElement.key)})` + }).join("/") + } + return JSON.stringify(path); +} + +const parsePath = (path) => { + try { + return JSON.parse(path); + } catch(e) { + const getTarget = (pathElement) => { + return pathElement.match(/(.*)\((.*)\)/)[1] + } + return path.split("/").map((pathElement) => ({ + target: getTarget(pathElement) + })); + } +} -const flattenKey = (k) => { +const stringifyKey = (k) => { if (!k) return k; if (Object.entries(k).length == 1) { // for backwards compatibility, a single key is persisted as only the value instead of a JSON object @@ -83,6 +104,8 @@ const getAssociationKey = (assoc, data) => { module.exports = { getKey, - flattenKey, - getAssociationKey + getAssociationKey, + stringifyKey, + stringifyPath, + parsePath, } \ No newline at end of file diff --git a/lib/localization.js b/lib/localization.js index 6cb3aba..854fb57 100644 --- a/lib/localization.js +++ b/lib/localization.js @@ -1,6 +1,7 @@ const cds = require("@sap/cds/lib"); const LOG = cds.log("change-log"); const { getNameFromPathVal, getDBEntity } = require("./entity-helper"); +const { parsePath } = require("./keys"); const MODIF_I18N_MAP = { create: "{i18n>ChangeLog.modification.create}", @@ -36,7 +37,7 @@ const _localizeDefaultObjectID = function (change, locale) { change.objectID = change.entity ? change.entity : ""; } if (change.objectID && change.serviceEntityPath && !change.parentObjectID && change.parentKey) { - const path = JSON.parse(change.serviceEntityPath); + const path = parsePath(change.serviceEntityPath); const parentNodePathVal = path[path.length - 2]; const parentEntityName = getNameFromPathVal(parentNodePathVal); const dbEntity = getDBEntity(parentEntityName); From c33719fd96fcee69dd8a58a02aded075083cccb6 Mon Sep 17 00:00:00 2001 From: Nils Schnabel Date: Thu, 19 Sep 2024 06:57:36 +0000 Subject: [PATCH 08/11] remove unused/commented variables --- lib/entity-helper.js | 1 - tests/integration/complex-keys.test.js | 6 ------ 2 files changed, 7 deletions(-) diff --git a/lib/entity-helper.js b/lib/entity-helper.js index 34ef4fc..b277d3d 100644 --- a/lib/entity-helper.js +++ b/lib/entity-helper.js @@ -1,5 +1,4 @@ const cds = require("@sap/cds"); -//const { addAbortListener } = require("@sap/cds/libx/_runtime/cds-services/adapter/odata-v4/okra/odata-server/core/OdataResponse"); const LOG = cds.log("change-log") const { getAssociationKey, getKey } = require('./keys') diff --git a/tests/integration/complex-keys.test.js b/tests/integration/complex-keys.test.js index 644bfb4..493e045 100644 --- a/tests/integration/complex-keys.test.js +++ b/tests/integration/complex-keys.test.js @@ -1,20 +1,14 @@ const cds = require("@sap/cds"); -//const { assert } = require("console"); const complexkeys = require("path").resolve(__dirname, "./complex-keys/"); -//const { expect, data, POST, GET } = cds.test(complexkeys); const { expect, data, POST } = cds.test(complexkeys); -//let service = null; let ChangeView = null; let db = null; -//let ChangeEntity = null; describe("change log with complex keys", () => { beforeAll(async () => { - //service = await cds.connect.to("complexkeys.ComplexKeys"); db = await cds.connect.to("sql:my.db"); ChangeView = db.model.definitions["sap.changelog.ChangeView"]; - //ChangeEntity = db.model.definitions["sap.changelog.Changes"]; }); beforeEach(async () => { From feb4c1850027ef9df733fba0475216302a8a3b1f Mon Sep 17 00:00:00 2001 From: Nils Schnabel Date: Thu, 19 Sep 2024 07:05:31 +0000 Subject: [PATCH 09/11] serviceEntityPath tests --- tests/integration/fiori-draft-disabled.test.js | 1 + tests/integration/fiori-draft-enabled.test.js | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/tests/integration/fiori-draft-disabled.test.js b/tests/integration/fiori-draft-disabled.test.js index af89c0d..d24ed0c 100644 --- a/tests/integration/fiori-draft-disabled.test.js +++ b/tests/integration/fiori-draft-disabled.test.js @@ -231,6 +231,7 @@ describe("change log draft disabled test", () => { expect(orderChange.valueChangedFrom).to.equal("note 1"); expect(orderChange.valueChangedTo).to.equal(""); expect(orderChange.parentKey).to.equal("9a61178f-bfb3-4c17-8d17-c6b4a63e0097"); + expect(orderChange.serviceEntityPath).to.equal('AdminService.Order(0a41a187-a2ff-4df6-bd12-fae8996e6e31)/AdminService.OrderItem(9a61178f-bfb3-4c17-8d17-c6b4a63e0097)/AdminService.OrderItemNote(a40a9fd8-573d-4f41-1111-fa8ea0d8b1bc)'); expect(orderChange.parentObjectID).to.equal("sap.capire.bookshop.OrderItem"); }); diff --git a/tests/integration/fiori-draft-enabled.test.js b/tests/integration/fiori-draft-enabled.test.js index ed31fe1..4d32f7d 100644 --- a/tests/integration/fiori-draft-enabled.test.js +++ b/tests/integration/fiori-draft-enabled.test.js @@ -113,6 +113,7 @@ describe("change log integration test", () => { const bookChange = bookChanges[0]; expect(bookChange.entityKey).to.equal("64625905-c234-4d0d-9bc1-283ee8946770"); + expect(bookChange.serviceEntityPath).to.equal("AdminService.BookStores(64625905-c234-4d0d-9bc1-283ee8946770)"); expect(bookChange.attribute).to.equal("Books"); expect(bookChange.modification).to.equal("Create"); expect(bookChange.objectID).to.equal("Shakespeare and Company"); @@ -130,6 +131,7 @@ describe("change log integration test", () => { const titleChange = titleChanges[0]; expect(titleChange.entityKey).to.equal("64625905-c234-4d0d-9bc1-283ee8946770"); + expect(titleChange.serviceEntityPath).to.equal("AdminService.BookStores(64625905-c234-4d0d-9bc1-283ee8946770)/AdminService.Books(9d703c23-54a8-4eff-81c1-cdce6b8376b2)"); expect(titleChange.attribute).to.equal("Title"); expect(titleChange.modification).to.equal("Create"); expect(titleChange.objectID).to.equal("test title, Emily, Brontë"); @@ -147,6 +149,7 @@ describe("change log integration test", () => { const authorChange = authorChanges[0]; expect(authorChange.entityKey).to.equal("64625905-c234-4d0d-9bc1-283ee8946770"); + expect(authorChange.serviceEntityPath).to.equal("AdminService.BookStores(64625905-c234-4d0d-9bc1-283ee8946770)/AdminService.Books(9d703c23-54a8-4eff-81c1-cdce6b8376b2)"); expect(authorChange.attribute).to.equal("Author"); expect(authorChange.modification).to.equal("Create"); expect(authorChange.objectID).to.equal("test title, Emily, Brontë"); @@ -163,6 +166,7 @@ describe("change log integration test", () => { expect(isUsedChanges.length).to.equal(1); const isUsedChange = isUsedChanges[0]; expect(isUsedChange.entityKey).to.equal("64625905-c234-4d0d-9bc1-283ee8946770"); + expect(isUsedChange.serviceEntityPath).to.equal("AdminService.BookStores(64625905-c234-4d0d-9bc1-283ee8946770)/AdminService.Books(9d703c23-54a8-4eff-81c1-cdce6b8376b2)"); expect(isUsedChange.attribute).to.equal("isUsed"); expect(isUsedChange.modification).to.equal("Create"); expect(isUsedChange.objectID).to.equal("test title, Emily, Brontë"); From 4ccdf9e0eb558a77a791114b599aa3402d46694c Mon Sep 17 00:00:00 2001 From: Nils Schnabel Date: Thu, 19 Sep 2024 07:48:29 +0000 Subject: [PATCH 10/11] complex keys composition deletion testcase --- tests/integration/complex-keys.test.js | 75 +++++++++++++++++++++++--- 1 file changed, 68 insertions(+), 7 deletions(-) diff --git a/tests/integration/complex-keys.test.js b/tests/integration/complex-keys.test.js index 493e045..94ef79a 100644 --- a/tests/integration/complex-keys.test.js +++ b/tests/integration/complex-keys.test.js @@ -1,12 +1,13 @@ const cds = require("@sap/cds"); const complexkeys = require("path").resolve(__dirname, "./complex-keys/"); -const { expect, data, POST } = cds.test(complexkeys); +const { expect, data, POST, DELETE } = cds.test(complexkeys); let ChangeView = null; let db = null; describe("change log with complex keys", () => { beforeAll(async () => { + data.reset(); db = await cds.connect.to("sql:my.db"); ChangeView = db.model.definitions["sap.changelog.ChangeView"]; }); @@ -15,7 +16,7 @@ describe("change log with complex keys", () => { await data.reset(); }); - it("logs many-to-many composition with complex keys correctly", async () => { + it("logs many-to-many composition create with complex keys correctly", async () => { const root = await POST(`/complex-keys/Root`, { MySecondId: "asdasd", @@ -34,21 +35,18 @@ describe("change log with complex keys", () => { expect(linked2.status).to.equal(201) const link1 = await POST(`/complex-keys/Root(MyId=${root.data.MyId},MySecondId='asdasd',IsActiveEntity=false)/links`, { - linked_ID: linked1.data.ID, - root_ID: root.ID + linked_ID: linked1.data.ID }); expect(link1.status).to.equal(201) const link2 = await POST(`/complex-keys/Root(MyId=${root.data.MyId},MySecondId='asdasd',IsActiveEntity=false)/links`, { - linked_ID: linked2.data.ID, - root_ID: root.ID + linked_ID: linked2.data.ID }); expect(link2.status).to.equal(201) const save = await POST(`/complex-keys/Root(MyId=${root.data.MyId},MySecondId='asdasd',IsActiveEntity=false)/complexkeys.ComplexKeys.draftActivate`, { preserveChanges: false }) expect(save.status).to.equal(201) - const changes = await SELECT.from(ChangeView); expect(changes).to.have.length(3); expect(changes.map(change => ({ @@ -73,4 +71,67 @@ describe("change log with complex keys", () => { 'Linked 2' }]) }) + + + it("logs many-to-many composition create+delete with complex keys correctly", async () => { + const root = await POST(`/complex-keys/Root`, { + MySecondId: "asdasd", + name: "Root" + }); + expect(root.status).to.equal(201) + + const linked1 = await POST(`/complex-keys/Linked`, { + name: "Linked 1" + }); + expect(linked1.status).to.equal(201) + + const link = await POST(`/complex-keys/Root(MyId=${root.data.MyId},MySecondId='asdasd',IsActiveEntity=false)/links`, { + linked_ID: linked1.data.ID, + root_ID: root.ID + }); + expect(link.status).to.equal(201) + + const save = await POST(`/complex-keys/Root(MyId=${root.data.MyId},MySecondId='asdasd',IsActiveEntity=false)/complexkeys.ComplexKeys.draftActivate`, { preserveChanges: false }) + expect(save.status).to.equal(201) + + const edit = await POST(`/complex-keys/Root(MyId=${root.data.MyId},MySecondId='asdasd',IsActiveEntity=true)/complexkeys.ComplexKeys.draftEdit`, { preserveChanges: false }) + expect(edit.status).to.equal(201) + + const link1delete = await DELETE(`/complex-keys/Link(root_MyId=${root.data.MyId},root_MySecondId='asdasd',linked_ID=${linked1.data.ID},IsActiveEntity=false)`); + expect(link1delete.status).to.equal(204) + + const save2 = await POST(`/complex-keys/Root(MyId=${root.data.MyId},MySecondId='asdasd',IsActiveEntity=false)/complexkeys.ComplexKeys.draftActivate`, { preserveChanges: false }) + expect(save2.status).to.equal(200) + + const changes = await SELECT.from(ChangeView); + expect(changes).to.have.length(3); + expect(changes.map(change => ({ + modification: change.modification, + attribute: change.attribute, + valueChangedFrom: change.valueChangedFrom, + valueChangedTo: change.valueChangedTo, + }))).to.have.deep.members([ + { + attribute: 'name', + modification: 'create', + valueChangedFrom: + '', + valueChangedTo: + 'Root' + }, { + attribute: 'links', + modification: 'create', + valueChangedFrom: + '', + valueChangedTo: + 'Linked 1' + }, { + attribute: 'links', + modification: 'delete', + valueChangedFrom: + 'Linked 1', + valueChangedTo: + '' + }]) + }) }); From c1badf2b8f7b08e3a53015d61514014d35d4a336 Mon Sep 17 00:00:00 2001 From: I560824 Date: Fri, 6 Dec 2024 16:51:00 +0800 Subject: [PATCH 11/11] fix ut error. --- tests/bookshop/srv/admin-service.cds | 2 +- tests/integration/fiori-draft-enabled.test.js | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/bookshop/srv/admin-service.cds b/tests/bookshop/srv/admin-service.cds index 785296a..a351ba8 100644 --- a/tests/bookshop/srv/admin-service.cds +++ b/tests/bookshop/srv/admin-service.cds @@ -40,7 +40,7 @@ service AdminService { annotate AdminService.RootEntity with @changelog: [name] { name @changelog; - child @changelog : [child.child.child.title]; + child @changelog : [child.title]; lifecycleStatus @changelog : [lifecycleStatus.name]; info @changelog : [info.info.info.name]; }; diff --git a/tests/integration/fiori-draft-enabled.test.js b/tests/integration/fiori-draft-enabled.test.js index 89e8836..ee378cf 100644 --- a/tests/integration/fiori-draft-enabled.test.js +++ b/tests/integration/fiori-draft-enabled.test.js @@ -72,8 +72,8 @@ describe("change log integration test", () => { const changelogCreated = afterChanges.filter(ele=> ele.modification === "Create"); const changelogDeleted = afterChanges.filter(ele=> ele.modification === "Delete"); - expect(changelogCreated.length).to.equal(7); - expect(changelogDeleted.length).to.equal(7); + expect(changelogCreated.length).to.equal(8); + expect(changelogDeleted.length).to.equal(8); const compareAttributes = ['keys', 'attribute', 'entity', 'serviceEntity', 'parentKey', 'serviceEntityPath', 'valueDataType', 'objectID', 'parentObjectID', 'entityKey']; @@ -85,7 +85,7 @@ describe("change log integration test", () => { }); }); expect(commonItems.length > 0).to.be.true; - expect(afterChanges.length).to.equal(14); + expect(afterChanges.length).to.equal(16); }); it("1.7 When creating or deleting a record with a numeric type of 0 and a boolean type of false, a changelog should also be generated", async () => {