diff --git a/lib/change-log.js b/lib/change-log.js index 61a2429..7b61e43 100644 --- a/lib/change-log.js +++ b/lib/change-log.js @@ -13,6 +13,7 @@ const { getEntityByContextPath, getObjIdElementNamesInArray, getValueEntityType, + splitPath, } = require("./entity-helper") const { localizeLogFields } = require("./localization") const isRoot = "change-tracking-isRootEntity" @@ -193,7 +194,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 = splitPath(childNodeChange._path) const curNodePathVal = path.pop() curChange.modification = childNodeChange._op const objId = await _getChildChangeObjId( @@ -270,7 +271,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 = splitPath(change.serviceEntityPath) const curNodePathVal = path.pop() const parentNodePathVal = path.pop() diff --git a/lib/entity-helper.js b/lib/entity-helper.js index be564ce..3404fe1 100644 --- a/lib/entity-helper.js +++ b/lib/entity-helper.js @@ -37,7 +37,7 @@ const getCurObjFromDbQuery = async function (entityName, queryVal, /**optional*/ } const getCurObjFromReqData = function (reqData, nodePathVal, pathVal) { - const pathVals = pathVal.split('/') + const pathVals = splitPath(pathVal); const rootNodePathVal = pathVals[0] let curReqObj = reqData || {} @@ -183,6 +183,25 @@ const _getCompositionObjFromReq = function (obj, targetID) { return null; }; +function splitPath (path) { + let result = []; + let buf = ""; + let paren = 0; + for (let i = 0; i < path.length; i++) { + const c = path[i]; + if (c === "(") paren++; + if (c === ")") paren--; + if (c === "/" && paren === 0) { + result.push(buf); + buf = ""; + } else { + buf += c; + } + } + if (buf) result.push(buf); + return result; +} + module.exports = { getCurObjFromReqData, getCurObjFromDbQuery, @@ -193,4 +212,5 @@ module.exports = { getEntityByContextPath, getObjIdElementNamesInArray, getValueEntityType, + splitPath, } diff --git a/lib/localization.js b/lib/localization.js index 2eeee21..2094815 100644 --- a/lib/localization.js +++ b/lib/localization.js @@ -1,6 +1,6 @@ const cds = require("@sap/cds/lib"); const LOG = cds.log("change-log"); -const { getNameFromPathVal, getDBEntity } = require("./entity-helper"); +const { getNameFromPathVal, getDBEntity, splitPath } = require("./entity-helper"); const MODIF_I18N_MAP = { create: "{i18n>ChangeLog.modification.create}", @@ -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 = splitPath(change.serviceEntityPath); const parentNodePathVal = path[path.length - 2]; const parentEntityName = getNameFromPathVal(parentNodePathVal); const dbEntity = getDBEntity(parentEntityName); diff --git a/tests/bookshop/db/data/sap.capire.bookshop-Level1Sample.csv b/tests/bookshop/db/data/sap.capire.bookshop-Level1Sample.csv new file mode 100644 index 0000000..4460497 --- /dev/null +++ b/tests/bookshop/db/data/sap.capire.bookshop-Level1Sample.csv @@ -0,0 +1,3 @@ +ID;title;parent_ID +/level1one;Level1Sample title1;/one +/level1two;Level1Sample title2;/two diff --git a/tests/bookshop/db/data/sap.capire.bookshop-Level1SampleDraft.csv b/tests/bookshop/db/data/sap.capire.bookshop-Level1SampleDraft.csv new file mode 100644 index 0000000..cabbed3 --- /dev/null +++ b/tests/bookshop/db/data/sap.capire.bookshop-Level1SampleDraft.csv @@ -0,0 +1,2 @@ +ID;title;parent_ID +/level1draftone;Level1SampleDraft title;/draftone diff --git a/tests/bookshop/db/data/sap.capire.bookshop-Level2Sample.csv b/tests/bookshop/db/data/sap.capire.bookshop-Level2Sample.csv new file mode 100644 index 0000000..87df8e2 --- /dev/null +++ b/tests/bookshop/db/data/sap.capire.bookshop-Level2Sample.csv @@ -0,0 +1,3 @@ +ID;title;parent_ID +/level2one;Level2Sample title1;/level1one +/level2two;Level2Sample title2;/level1two diff --git a/tests/bookshop/db/data/sap.capire.bookshop-RootSample.csv b/tests/bookshop/db/data/sap.capire.bookshop-RootSample.csv new file mode 100644 index 0000000..839e162 --- /dev/null +++ b/tests/bookshop/db/data/sap.capire.bookshop-RootSample.csv @@ -0,0 +1,3 @@ +ID;title +/one;RootSample title1 +/two;RootSample title2 diff --git a/tests/bookshop/db/data/sap.capire.bookshop-RootSampleDraft.csv b/tests/bookshop/db/data/sap.capire.bookshop-RootSampleDraft.csv new file mode 100644 index 0000000..70c4025 --- /dev/null +++ b/tests/bookshop/db/data/sap.capire.bookshop-RootSampleDraft.csv @@ -0,0 +1,2 @@ +ID;title +/draftone;Draft title diff --git a/tests/bookshop/db/schema.cds b/tests/bookshop/db/schema.cds index 1129b2f..138fe47 100644 --- a/tests/bookshop/db/schema.cds +++ b/tests/bookshop/db/schema.cds @@ -295,3 +295,49 @@ entity Schools : managed, cuid { teacher : String; }; } + +// Test for key which include special character: '/' -- draft enabled +@title: 'Root Sample Draft' +entity RootSampleDraft @(cds.autoexpose) : managed { + key ID : String; + child : Composition of many Level1SampleDraft + on child.parent = $self; + title : String; +} + +@title: 'Level1 Sample Draft' +entity Level1SampleDraft : managed { + key ID : String; + parent : Association to one RootSampleDraft; + child : Composition of many Level2SampleDraft + on child.parent = $self; + title : String; +} + +entity Level2SampleDraft : managed { + key ID : String; + title : String; + parent : Association to one Level1SampleDraft; +} + +// Test for key which include special character: '/' -- draft disabled +entity RootSample : managed { + key ID : String; + child : Composition of many Level1Sample + on child.parent = $self; + title : String; +} + +entity Level1Sample : managed { + key ID : String; + parent : Association to one RootSample; + child : Composition of many Level2Sample + on child.parent = $self; + title : String; +} + +entity Level2Sample : managed { + key ID : String; + title : String; + parent : Association to one Level1Sample; +} diff --git a/tests/bookshop/srv/admin-service.cds b/tests/bookshop/srv/admin-service.cds index 49ae812..771fe6f 100644 --- a/tests/bookshop/srv/admin-service.cds +++ b/tests/bookshop/srv/admin-service.cds @@ -8,35 +8,49 @@ service AdminService { entity RootEntity @(cds.autoexpose) as projection on my.RootEntity; @odata.draft.enabled - entity Schools @(cds.autoexpose) as projection on my.Schools; - - entity RootObject as projection on my.RootObject; - entity Level1Object as projection on my.Level1Object; - entity Level2Object as projection on my.Level2Object; - entity Level3Object as projection on my.Level3Object; - entity Level1Entity as projection on my.Level1Entity; - entity Level2Entity as projection on my.Level2Entity; - entity Level3Entity as projection on my.Level3Entity; - entity AssocOne as projection on my.AssocOne; - entity AssocTwo as projection on my.AssocTwo; - entity AssocThree as projection on my.AssocThree; - entity Authors as projection on my.Authors; - entity Report as projection on my.Report; - entity Order as projection on my.Order; - entity Order.Items as projection on my.Order.Items; - entity OrderItem as projection on my.OrderItem; - - entity OrderItemNote as projection on my.OrderItemNote actions { - @cds.odata.bindingparameter.name: 'self' - @Common.SideEffects : {TargetEntities: [self]} - action activate(); - }; - - entity Volumns as projection on my.Volumns actions { - @cds.odata.bindingparameter.name: 'self' - @Common.SideEffects : {TargetEntities: [self]} - action activate(); - }; + entity Schools @(cds.autoexpose) as projection on my.Schools; + + @odata.draft.enabled + entity RootSampleDraft as projection on my.RootSampleDraft; + + entity RootObject as projection on my.RootObject; + entity Level1Object as projection on my.Level1Object; + entity Level2Object as projection on my.Level2Object; + entity Level3Object as projection on my.Level3Object; + entity Level1Entity as projection on my.Level1Entity; + entity Level2Entity as projection on my.Level2Entity; + entity Level3Entity as projection on my.Level3Entity; + entity RootSample as projection on my.RootSample; + entity Level1Sample as projection on my.Level1Sample; + entity AssocOne as projection on my.AssocOne; + entity AssocTwo as projection on my.AssocTwo; + entity AssocThree as projection on my.AssocThree; + entity Authors as projection on my.Authors; + entity Report as projection on my.Report; + entity Order as projection on my.Order; + entity Order.Items as projection on my.Order.Items; + entity OrderItem as projection on my.OrderItem; + + entity OrderItemNote as projection on my.OrderItemNote + actions { + @cds.odata.bindingparameter.name: 'self' + @Common.SideEffects : {TargetEntities: [self]} + action activate(); + }; + + entity Volumns as projection on my.Volumns + actions { + @cds.odata.bindingparameter.name: 'self' + @Common.SideEffects : {TargetEntities: [self]} + action activate(); + }; + + entity Level2Sample as projection on my.Level2Sample + actions { + @cds.odata.bindingparameter.name: 'self' + @Common.SideEffects : {TargetEntities: [self]} + action activate(); + }; entity Customers as projection on my.Customers; } @@ -44,7 +58,7 @@ service AdminService { annotate AdminService.RootEntity with @changelog: [name] { name @changelog; child @changelog : [child.child.child.title]; - lifecycleStatus @changelog : [lifecycleStatus.name]; + lifecycleStatus @changelog : [lifecycleStatus.name]; info @changelog : [info.info.info.name]; }; @@ -63,13 +77,13 @@ annotate AdminService.Level3Entity with @changelog: [parent.parent.parent.lifecy } annotate AdminService.AssocOne with { - name @changelog; - info @changelog: [info.info.name] + name @changelog; + info @changelog: [info.info.name] }; annotate AdminService.AssocTwo with { - name @changelog; - info @changelog: [info.name] + name @changelog; + info @changelog: [info.name] }; annotate AdminService.AssocThree with { @@ -96,47 +110,47 @@ annotate AdminService.Level3Object with { }; annotate AdminService.Authors with { - name @(Common.Label : '{i18n>serviceAuthors.name}'); + name @(Common.Label: '{i18n>serviceAuthors.name}'); }; -annotate AdminService.BookStores with @changelog : [name]{ +annotate AdminService.BookStores with @changelog: [name] { name @changelog; location @changelog; - books @changelog : [books.title]; - lifecycleStatus @changelog : [lifecycleStatus.name]; - city @changelog : [ + books @changelog : [books.title]; + lifecycleStatus @changelog : [lifecycleStatus.name]; + city @changelog : [ city.name, city.country.countryName.code ] }; -annotate AdminService.Books with @changelog : [ +annotate AdminService.Books with @changelog: [ title, author.name.firstName, author.name.lastName -]{ +] { title @changelog; descr @changelog; isUsed @changelog; - author @changelog : [ + author @changelog : [ author.name.firstName, author.name.lastName ]; genre @changelog; - bookType @changelog : [ + bookType @changelog : [ bookType.name, bookType.descr ]; }; -annotate AdminService.Authors with @changelog : [ +annotate AdminService.Authors with @changelog: [ name.firstName, name.lastName -]{ +] { name @changelog; placeOfBirth @changelog; - books @changelog : [ + books @changelog : [ books.name, books.title ]; @@ -152,20 +166,20 @@ annotate AdminService.OrderHeader with { annotate AdminService.OrderItem with { quantity @changelog; - customer @changelog : [ + customer @changelog: [ customer.country, customer.name, customer.city, ]; - order @changelog : [ + order @changelog: [ order.report.comment, order.status ]; } annotate AdminService.OrderItemNote with { - content @changelog; - ActivationStatus @changelog : [ActivationStatus.name]; + content @changelog; + ActivationStatus @changelog: [ActivationStatus.name]; } annotate AdminService.Customers with { @@ -176,5 +190,54 @@ annotate AdminService.Customers with { } annotate AdminService.Schools with { - classes @changelog : [classes.name, classes.teacher] + classes @changelog: [ + classes.name, + classes.teacher + ] +}; + +annotate AdminService.RootSampleDraft with @changelog: [ + ID, + title +] { + title @changelog @title: 'Root Draft Title'; +} + +annotate AdminService.Level1SampleDraft with @changelog: [ + ID, + title, + parent.ID +] { + title @changelog @title: 'Level1 Draft Title'; +} + +annotate AdminService.Level2SampleDraft with @changelog: [ + ID, + title, + parent.parent.ID +] { + title @changelog @title: 'Level2 Draft Title'; +}; + +annotate AdminService.RootSample with @changelog: [ + ID, + title +] { + title @changelog @title: 'Root Sample Title'; +} + +annotate AdminService.Level1Sample with @changelog: [ + ID, + title, + parent.ID +] { + title @changelog @title: 'Level1 Sample Title'; +} + +annotate AdminService.Level2Sample with @changelog: [ + ID, + title, + parent.parent.ID +] { + title @changelog @title: 'Level2 Sample Title'; }; diff --git a/tests/bookshop/srv/admin-service.js b/tests/bookshop/srv/admin-service.js index 2532f11..d6075cc 100644 --- a/tests/bookshop/srv/admin-service.js +++ b/tests/bookshop/srv/admin-service.js @@ -36,6 +36,20 @@ module.exports = cds.service.impl(async (srv) => { .set({ title: "Game Science" }); }; + const onActivateLevel2Sample = async (req) => { + const entity = req.entity; + const entityID = "/level2one"; + await UPDATE.entity(entity) + .where({ ID: entityID }) + .set({ title: "special title" }); + + const rootSampleEntity = "AdminService.RootSample"; + const rootSampleID = "/two"; + await UPDATE.entity(rootSampleEntity, { ID: rootSampleID }) + .set({ title: "Black Myth Zhong Kui" }); + }; + srv.on("activate", "AdminService.Volumns", onActivateVolumns); srv.on("activate", "AdminService.OrderItemNote", onActivateOrderItemNote); + srv.on("activate", "AdminService.Level2Sample", onActivateLevel2Sample); }); diff --git a/tests/integration/fiori-draft-disabled.test.js b/tests/integration/fiori-draft-disabled.test.js index 155bb9d..b53963f 100644 --- a/tests/integration/fiori-draft-disabled.test.js +++ b/tests/integration/fiori-draft-disabled.test.js @@ -796,4 +796,57 @@ describe("change log draft disabled test", () => { expect(changeLogs[0].entityKey).to.equal("6ac4afbf-deda-45ae-88e6-2883157cd576"); expect(changeLogs[0].serviceEntity).to.equal("AdminService.RootObject"); }); + + it("Special Character Handling in draft-disabled - issue#187", async () => { + await POST( + `/odata/v4/admin/RootSample(ID='${encodeURIComponent('/one')}')/child(ID='${encodeURIComponent('/level1one')}')/child(ID='${encodeURIComponent('/level2one')}')/AdminService.activate` + ); + + let changes = await SELECT.from(ChangeView).where({ + entity: "sap.capire.bookshop.Level2Sample", + attribute: "title", + }); + + expect(changes.length).to.equal(1); + expect(changes[0].valueChangedFrom).to.equal("Level2Sample title1"); + expect(changes[0].valueChangedTo).to.equal("special title"); + expect(changes[0].entityKey).to.equal("/one"); + expect(changes[0].parentKey).to.equal("/level1one"); + expect(changes[0].objectID).to.equal("/level2one, special title, /one"); + + // Check the changeLog to make sure the entity information is root + let changeLogs = await SELECT.from(ChangeLog).where({ + entity: "sap.capire.bookshop.RootSample", + entityKey: "/one", + serviceEntity: "AdminService.RootSample", + }); + + expect(changeLogs.length).to.equal(1); + expect(changeLogs[0].entity).to.equal("sap.capire.bookshop.RootSample"); + expect(changeLogs[0].entityKey).to.equal("/one"); + expect(changeLogs[0].serviceEntity).to.equal("AdminService.RootSample"); + + changes = await SELECT.from(ChangeView).where({ + entity: "sap.capire.bookshop.RootSample", + attribute: "title", + }); + + expect(changes[0].valueChangedFrom).to.equal("RootSample title2"); + expect(changes[0].valueChangedTo).to.equal("Black Myth Zhong Kui"); + expect(changes[0].entityKey).to.equal("/two"); + expect(changes[0].parentKey).to.equal(""); + expect(changes[0].objectID).to.equal("/two, Black Myth Zhong Kui"); + + // Check the changeLog to make sure the entity information is root + changeLogs = await SELECT.from(ChangeLog).where({ + entity: "sap.capire.bookshop.RootSample", + entityKey: "/two", + serviceEntity: "AdminService.RootSample", + }); + + expect(changeLogs.length).to.equal(1); + expect(changeLogs[0].entity).to.equal("sap.capire.bookshop.RootSample"); + expect(changeLogs[0].entityKey).to.equal("/two"); + expect(changeLogs[0].serviceEntity).to.equal("AdminService.RootSample"); + }); }); diff --git a/tests/integration/fiori-draft-enabled.test.js b/tests/integration/fiori-draft-enabled.test.js index 9585883..1b23336 100644 --- a/tests/integration/fiori-draft-enabled.test.js +++ b/tests/integration/fiori-draft-enabled.test.js @@ -1644,4 +1644,110 @@ describe("change log integration test", () => { expect(changeLogs[0].entityKey).to.equal("5ab2a87b-3a56-4d97-a697-7af72334a384"); expect(changeLogs[0].serviceEntity).to.equal("AdminService.BookStores"); }); + + it("Special Character Handling in draft-enabled - issue#187", async () => { + delete cds.services.AdminService.entities.RootSampleDraft["@changelog"]; + delete cds.services.AdminService.entities.Level1SampleDraft["@changelog"]; + delete cds.db.entities.Level1SampleDraft["@changelog"]; + delete cds.db.entities.RootSampleDraft["@changelog"]; + + const action = PATCH.bind({}, `/odata/v4/admin/Level1SampleDraft(ID='${encodeURIComponent('/level1draftone')}',IsActiveEntity=false)`, { + title: "new special title", + }); + await utils.apiAction("admin", "RootSampleDraft", `'${encodeURIComponent('/draftone')}'`, "AdminService", action); + + let changes = await adminService.run( + SELECT.from(ChangeView).where({ + entity: "sap.capire.bookshop.Level1SampleDraft", + attribute: "title", + }) + ); + expect(changes.length).to.equal(1); + + const change = changes[0]; + expect(changes[0].valueChangedFrom).to.equal("Level1SampleDraft title"); + expect(changes[0].valueChangedTo).to.equal("new special title"); + expect(changes[0].entityKey).to.equal("/draftone"); + expect(changes[0].parentKey).to.equal("/draftone"); + // if object type is localized, use the localized object type as object ID + expect(change.objectID).to.equal("Level1 Sample Draft"); + expect(change.parentObjectID).to.equal("Root Sample Draft"); + + cds.services.AdminService.entities.RootSampleDraft["@changelog"] = [ + { "=": "ID" }, + { "=": "title" } + ]; + cds.services.AdminService.entities.Level1SampleDraft["@changelog"] = [ + { "=": "ID" }, + { "=": "title" }, + { "=": "parent.ID" } + ]; + + // Root and child nodes are created at the same time + const createAction = POST.bind({}, `/odata/v4/admin/RootSampleDraft`, { + ID: "/drafttwo", + title: "New title for RootSampleDraft", + child: [ + { + ID: "/level1drafttwo", + title: "New title for Level1SampleDraft", + child: [ + { + ID: "/level2drafttwo", + title: "New title for Level2SampleDraft", + }, + ], + }, + ], + }); + await utils.apiAction( + "admin", + "RootSampleDraft", + `'${encodeURIComponent('/drafttwo')}'`, + "AdminService", + createAction, + true, + ); + changes = await adminService.run( + SELECT.from(ChangeView).where({ + entity: "sap.capire.bookshop.RootSampleDraft", + attribute: "title", + modification: "create", + }) + ); + expect(changes.length).to.equal(1); + expect(changes[0].valueChangedFrom).to.equal(""); + expect(changes[0].valueChangedTo).to.equal("New title for RootSampleDraft"); + expect(changes[0].entityKey).to.equal("/drafttwo"); + expect(changes[0].parentKey).to.equal(""); + expect(changes[0].objectID).to.equal("/drafttwo, New title for RootSampleDraft"); + + changes = await adminService.run( + SELECT.from(ChangeView).where({ + entity: "sap.capire.bookshop.Level1SampleDraft", + attribute: "title", + modification: "create", + }) + ); + expect(changes.length).to.equal(1); + expect(changes[0].valueChangedFrom).to.equal(""); + expect(changes[0].valueChangedTo).to.equal("New title for Level1SampleDraft"); + expect(changes[0].entityKey).to.equal("/drafttwo"); + expect(changes[0].parentKey).to.equal("/drafttwo"); + expect(changes[0].objectID).to.equal("/level1drafttwo, New title for Level1SampleDraft, /drafttwo"); + + changes = await adminService.run( + SELECT.from(ChangeView).where({ + entity: "sap.capire.bookshop.Level2SampleDraft", + attribute: "title", + modification: "create", + }) + ); + expect(changes.length).to.equal(1); + expect(changes[0].valueChangedFrom).to.equal(""); + expect(changes[0].valueChangedTo).to.equal("New title for Level2SampleDraft"); + expect(changes[0].entityKey).to.equal("/drafttwo"); + expect(changes[0].parentKey).to.equal("/level1drafttwo"); + expect(changes[0].objectID).to.equal("/level2drafttwo, New title for Level2SampleDraft, /drafttwo"); + }); }); diff --git a/tests/integration/service-api.test.js b/tests/integration/service-api.test.js index 6fac962..5365d76 100644 --- a/tests/integration/service-api.test.js +++ b/tests/integration/service-api.test.js @@ -793,4 +793,58 @@ describe("change log integration test", () => { delete cds.services.AdminService.entities.Order.elements.netAmount["@changelog"]; delete cds.services.AdminService.entities.Order.elements.isUsed["@changelog"]; }); + + it("Special Character Handling in service-api - issue#187", async () => { + const sampleData = { + ID: "/three", + title: "RootSample title3", + child: [ + { + ID: "/level1three", + title: "Level1Sample title3", + child: [ + { + ID: "/level2three", + title: "Level2Sample title3", + }, + ] + }, + ], + }; + + await adminService.run(INSERT.into(adminService.entities.RootSample).entries(sampleData)); + + let changes = await SELECT.from(ChangeView).where({ + entity: "sap.capire.bookshop.RootSample", + attribute: "title", + }); + expect(changes.length).to.equal(1); + expect(changes[0].valueChangedFrom).to.equal(""); + expect(changes[0].valueChangedTo).to.equal("RootSample title3"); + expect(changes[0].entityKey).to.equal("/three"); + expect(changes[0].parentKey).to.equal(""); + expect(changes[0].objectID).to.equal("/three, RootSample title3"); + + changes = await SELECT.from(ChangeView).where({ + entity: "sap.capire.bookshop.Level1Sample", + attribute: "title", + }); + expect(changes.length).to.equal(1); + expect(changes[0].valueChangedFrom).to.equal(""); + expect(changes[0].valueChangedTo).to.equal("Level1Sample title3"); + expect(changes[0].entityKey).to.equal("/three"); + expect(changes[0].parentKey).to.equal("/three"); + expect(changes[0].objectID).to.equal("/level1three, Level1Sample title3, /three"); + + changes = await SELECT.from(ChangeView).where({ + entity: "sap.capire.bookshop.Level2Sample", + attribute: "title", + }); + expect(changes.length).to.equal(1); + expect(changes[0].valueChangedFrom).to.equal(""); + expect(changes[0].valueChangedTo).to.equal("Level2Sample title3"); + expect(changes[0].entityKey).to.equal("/three"); + expect(changes[0].parentKey).to.equal("/level1three"); + expect(changes[0].objectID).to.equal("/level2three, Level2Sample title3, /three"); + }); });