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 => {