Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow complex keys #107

Closed
wants to merge 5 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 27 additions & 12 deletions lib/change-log.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -36,15 +36,15 @@ 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)
}


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)
}

Expand All @@ -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)
}

Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -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 }) => {
Expand Down Expand Up @@ -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.")
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -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 ?? ''}`,
})),
Expand Down
31 changes: 17 additions & 14 deletions lib/entity-helper.js
Original file line number Diff line number Diff line change
@@ -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");

Check failure on line 2 in lib/entity-helper.js

View workflow job for this annotation

GitHub Actions / test (20.x)

'addAbortListener' is assigned a value but never used

Check failure on line 2 in lib/entity-helper.js

View workflow job for this annotation

GitHub Actions / test (18.x)

'addAbortListener' is assigned a value but never used
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) {
Expand All @@ -29,15 +29,15 @@
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 || {}

Expand All @@ -48,12 +48,15 @@

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
Expand Down Expand Up @@ -90,7 +93,7 @@
_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) {
Expand All @@ -99,10 +102,10 @@
// 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)
Expand Down
2 changes: 1 addition & 1 deletion lib/localization.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
9 changes: 7 additions & 2 deletions lib/template-processor.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(",");
Expand Down Expand Up @@ -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}];
}
});

Expand Down
80 changes: 80 additions & 0 deletions tests/integration/complex-keys.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
const cds = require("@sap/cds");
const { assert } = require("console");

Check failure on line 2 in tests/integration/complex-keys.test.js

View workflow job for this annotation

GitHub Actions / test (20.x)

'assert' is assigned a value but never used

Check failure on line 2 in tests/integration/complex-keys.test.js

View workflow job for this annotation

GitHub Actions / test (18.x)

'assert' is assigned a value but never used
const complexkeys = require("path").resolve(__dirname, "./complex-keys/");
const { expect, data, POST, GET } = cds.test(complexkeys);

Check failure on line 4 in tests/integration/complex-keys.test.js

View workflow job for this annotation

GitHub Actions / test (20.x)

'GET' is assigned a value but never used

Check failure on line 4 in tests/integration/complex-keys.test.js

View workflow job for this annotation

GitHub Actions / test (18.x)

'GET' is assigned a value but never used

let service = null;
let ChangeView = null;
let db = null;
let ChangeEntity = null;

Check failure on line 9 in tests/integration/complex-keys.test.js

View workflow job for this annotation

GitHub Actions / test (20.x)

'ChangeEntity' is assigned a value but never used

Check failure on line 9 in tests/integration/complex-keys.test.js

View workflow job for this annotation

GitHub Actions / test (18.x)

'ChangeEntity' is assigned a value but never used

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'
}])
})
});
18 changes: 18 additions & 0 deletions tests/integration/complex-keys/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"dependencies": {
"@cap-js/change-tracking": "*"
},
"devDependencies": {
"@cap-js/sqlite": "*"
},
"cds": {
"requires": {
"db": {
"kind": "sql"
}
},
"features": {
"serve_on_root": true
}
}
}
32 changes: 32 additions & 0 deletions tests/integration/complex-keys/srv/complex-keys.cds
Original file line number Diff line number Diff line change
@@ -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;
}
Loading