diff --git a/lib/change-log.js b/lib/change-log.js index 4342ab5..6fa9a0b 100644 --- a/lib/change-log.js +++ b/lib/change-log.js @@ -25,7 +25,7 @@ const _getRootEntityPathVals = function (txContext, entity, entityKey) { let path = txContext.path.split('/') if (txContext.event === "CREATE") { - const curEntityPathVal = `${entity.name}(${entityKey})` + const curEntityPathVal = {target: entity.name, key: entityKey}; serviceEntityPathVals.push(curEntityPathVal) txContext.hasComp && entityIDs.pop(); } else { @@ -36,7 +36,7 @@ const _getRootEntityPathVals = function (txContext, entity, entityKey) { } const curEntity = getEntityByContextPath(path, txContext.hasComp) const curEntityID = entityIDs.pop() - const curEntityPathVal = `${curEntity.name}(${curEntityID})` + const curEntityPathVal = {target: curEntity.name, key: {ID: curEntityID}} serviceEntityPathVals.push(curEntityPathVal) } @@ -44,7 +44,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: {ID: hostEntityID}}; serviceEntityPathVals.unshift(hostEntityPathVal) } @@ -59,7 +59,7 @@ const _getAllPathVals = function (txContext) { 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: {ID: entityID}} pathVals.push(entityPathVal) } @@ -174,7 +174,7 @@ 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( @@ -249,7 +249,7 @@ const _getObjectIdByPath = async function ( const _formatObjectID = async function (changes, reqData) { 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() @@ -307,7 +307,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: {ID: diff.ID}}]; templateProcessor({ template, row: diff, processFn: ({ row, key, element }) => { @@ -354,7 +354,7 @@ function _trackedChanges4 (srv, target, diff) { } const _prepareChangeLogForComposition = async function (entity, entityKey, changes, req) { - const rootEntityPathVals = _getRootEntityPathVals(req.context, entity, entityKey) + const rootEntityPathVals = _getRootEntityPathVals(req.context, entity, flattenKey(entityKey)) if (rootEntityPathVals.length < 2) { LOG.info("Parent entity doesn't exist.") @@ -363,10 +363,9 @@ 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) @@ -442,6 +441,17 @@ function getAssociationDetails (entity) { } +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; +} + + async function track_changes (req) { let diff = await req.diff() if (!diff) return @@ -462,7 +472,8 @@ 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) @@ -482,12 +493,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..1647288 100644 --- a/lib/entity-helper.js +++ b/lib/entity-helper.js @@ -1,14 +1,14 @@ -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 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) { @@ -29,15 +29,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 +48,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 @@ -90,7 +93,7 @@ async function getObjectId (reqData, entityName, fields, curObj) { _db_data = {}; } else try { // REVISIT: This always reads all elements -> should read required ones only! - let ID = assoc.keys?.[0]?.ref[0] || 'ID' + let ID = assoc.keys?.[0]?.ref || ['ID'] const isComposition = hasComposition(assoc._target, current) // Peer association and composition are distinguished by the value of isComposition. if (isComposition) { @@ -99,10 +102,10 @@ async function getObjectId (reqData, entityName, fields, curObj) { // 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, {[ID[0]]: IDval}); } } else { - _db_data = await getCurObjFromDbQuery(assoc._target, IDval, ID); + _db_data = await getCurObjFromDbQuery(assoc._target, {[ID[0]]: IDval}); } } catch (e) { LOG.error("Failed to generate object Id for an association entity.", e) 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..ea7083d 100644 --- a/lib/template-processor.js +++ b/lib/template-processor.js @@ -2,6 +2,8 @@ const DELIMITER = require("@sap/cds/libx/_runtime/common/utils/templateDelimiter"); +const cds = require("@sap/cds"); + const _formatRowContext = (tKey, keyNames, row) => { const keyValuePairs = keyNames.map((key) => `${key}=${row[key]}`); const keyValuePairsSerialized = keyValuePairs.join(","); @@ -46,8 +48,11 @@ 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 targetEntityName = template.target.elements[key].target; + const targetEntity = cds.model.definitions[targetEntityName]; + const keyElements = targetEntity.keys.filter(k => k.type !== "cds.Association").filter(k => !k.virtual).map(k => k.name); + const keys = Object.fromEntries(keyElements.map((k) => [k, subRow[k]])) + subRow._path = [...row._path, {target: targetEntityName, key: keys}]; } }); diff --git a/tests/integration/complex-keys.test.js b/tests/integration/complex-keys.test.js new file mode 100644 index 0000000..b19a067 --- /dev/null +++ b/tests/integration/complex-keys.test.js @@ -0,0 +1,80 @@ +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"); + ChangeView = service.entities.ChangeView; + db = await cds.connect.to("sql:my.db"); + 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`, { + 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(ID=${root.data.ID},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(ID=${root.data.ID},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(ID=${root.data.ID},IsActiveEntity=false)/complexkeys.ComplexKeys.draftActivate`, { preserveChanges: false }) + expect(save.status).to.equal(201) + + + const changes = await service.run(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..d96c466 --- /dev/null +++ b/tests/integration/complex-keys/srv/complex-keys.cds @@ -0,0 +1,32 @@ +namespace complexkeys; + +using {cuid} from '@sap/cds/common'; + + +context db { + + @changelog: [name] + entity Root: cuid { + @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; + } +} + + +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