diff --git a/lib/change-log.js b/lib/change-log.js index df95a31..76618df 100644 --- a/lib/change-log.js +++ b/lib/change-log.js @@ -14,6 +14,13 @@ const { getObjIdElementNamesInArray, getValueEntityType, } = require("./entity-helper") + +const { + getKey, + stringifyKey, + stringifyPath, + getAssociationKey, +} = require("./keys") const { localizeLogFields } = require("./localization") const isRoot = "change-tracking-isRootEntity" @@ -39,23 +46,23 @@ function formatDecimal(str, scale) { 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) } @@ -63,7 +70,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) } @@ -72,13 +79,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) } @@ -107,23 +114,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 @@ -140,7 +130,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 @@ -154,10 +144,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"] @@ -169,7 +159,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 @@ -177,13 +168,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) { @@ -193,14 +185,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) } @@ -253,6 +246,7 @@ const _formatCompositionEntityType = function (change) { const _getObjectIdByPath = async function ( reqData, + reqTarget, nodePathVal, serviceEntityPath, /**optional*/ objIdElementNames @@ -262,13 +256,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() @@ -276,6 +270,7 @@ const _formatObjectID = async function (changes, reqData) { if (!curNodeObjId) { curNodeObjId = await _getObjectIdByPath( reqData, + reqTarget, curNodePathVal, change.serviceEntityPath ) @@ -286,6 +281,7 @@ const _formatObjectID = async function (changes, reqData) { if (!parentNodeObjId && parentNodePathVal) { parentNodeObjId = await _getObjectIdByPath( reqData, + reqTarget, parentNodePathVal, change.serviceEntityPath ) @@ -300,7 +296,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) @@ -309,9 +305,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) { @@ -326,7 +322,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 }) => { @@ -404,13 +400,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 } @@ -422,18 +417,18 @@ 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 ? 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 }], + ? [ getAssociationKey(assoc, data), entityKey] + : [ entityKey], hasComp: true }; @@ -445,7 +440,7 @@ async function generatePathAndParams (req, entityKey) { while (parentAssoc && !parentAssoc.entity[isRoot]) { parentAssoc = await processEntity( parentAssoc.entity, - parentAssoc.ID, + parentAssoc.key, compContext ); } @@ -453,21 +448,21 @@ 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 = (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 = getAssociationKey(assoc, parentResult) + if (!key) return; + compContext.path = [{target: parentEntity.name, key}, ...compContext.path]; + compContext.params.unshift(key); return { entity: parentEntity, - [ID]: hasForeignKey ? parentResult[foreignKey] : undefined + key }; } } @@ -478,8 +473,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 }; } function isEmpty(value) { @@ -492,13 +486,13 @@ async function track_changes (req) { 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 ( @@ -506,7 +500,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: stringifyKey(entityKey)}); } let changes = _trackedChanges4(this, target, diff) @@ -515,9 +509,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 @@ -526,12 +521,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: stringifyKey(entityKey), serviceEntity: target.name || target, changes: changes.filter(c => !isEmpty(c.valueChangedFrom) || !isEmpty(c.valueChangedTo)).map((c) => ({ ...c, + serviceEntityPath: stringifyPath(c.serviceEntityPath), + parentKey: stringifyKey(c.parentKey), valueChangedFrom: `${c.valueChangedFrom ?? ''}`, valueChangedTo: `${c.valueChangedTo ?? ''}`, })), diff --git a/lib/entity-helper.js b/lib/entity-helper.js index be564ce..b277d3d 100644 --- a/lib/entity-helper.js +++ b/lib/entity-helper.js @@ -1,21 +1,21 @@ -const cds = require("@sap/cds") +const cds = require("@sap/cds"); 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 +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 @@ -71,7 +74,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 +84,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 +173,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..3b268c2 --- /dev/null +++ b/lib/keys.js @@ -0,0 +1,111 @@ +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 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 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 + 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, + getAssociationKey, + stringifyKey, + stringifyPath, + parsePath, +} \ No newline at end of file diff --git a/lib/localization.js b/lib/localization.js index 2eeee21..005b1c8 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 = change.serviceEntityPath.split('/'); + const path = parsePath(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/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/complex-keys.test.js b/tests/integration/complex-keys.test.js new file mode 100644 index 0000000..94ef79a --- /dev/null +++ b/tests/integration/complex-keys.test.js @@ -0,0 +1,137 @@ +const cds = require("@sap/cds"); +const complexkeys = require("path").resolve(__dirname, "./complex-keys/"); +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"]; + }); + + beforeEach(async () => { + await data.reset(); + }); + + it("logs many-to-many composition create 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 + }); + 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 + }); + 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' + }]) + }) + + + 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: + '' + }]) + }) +}); 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-disabled.test.js b/tests/integration/fiori-draft-disabled.test.js index 39fdca4..bfe36c9 100644 --- a/tests/integration/fiori-draft-disabled.test.js +++ b/tests/integration/fiori-draft-disabled.test.js @@ -314,6 +314,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 c114449..ee378cf 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(8); + expect(changelogDeleted.length).to.equal(8); + const compareAttributes = ['keys', 'attribute', 'entity', 'serviceEntity', 'parentKey', 'serviceEntityPath', 'valueDataType', 'objectID', 'parentObjectID', 'entityKey']; let commonItems = changelogCreated.filter(beforeItem => { @@ -82,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 () => { @@ -192,6 +195,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"); @@ -209,6 +213,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ë"); @@ -226,6 +231,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ë"); @@ -242,6 +248,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ë");