@cap-js/change-tracking 1.0.6 → 1.0.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,7 +4,33 @@ All notable changes to this project will be documented in this file.
4
4
  This project adheres to [Semantic Versioning](http://semver.org/).
5
5
  The format is based on [Keep a Changelog](http://keepachangelog.com/).
6
6
 
7
- ## Version 1.0.6 - TBD
7
+ ## Version 1.0.7 - 20.08.24
8
+
9
+ ### Added
10
+
11
+ - A global switch to preserve change logs for deleted data
12
+ - For hierarchical entities, a method to determine their structure and a flag to indicate whether it is a root entity was introduced. For child entities, information about the parent is recorded.
13
+
14
+
15
+ ### Fixed
16
+
17
+ - CDS 8 does not support queries for draft-enabled entities on the application service anymore. This was causing: SqliteError: NOT NULL constraint failed: (...).DraftAdministrativeData_DraftUUID
18
+ - CDS 8 deprecated cds.transaction, causing change logs of nested documents to be wrong, replaced with req.event
19
+ - CDS 8 rejects all direct CRUD requests for auto-exposed Compositions in non-draft cases. This was affecting test cases, since the ChangeView falls into this category
20
+ - req._params and req.context are not official APIs and stopped working with CDS 8, replaced with official APIs
21
+ - When running test cases in CDS 8, some requests failed with a status code of 404
22
+ - ServiceEntity is not captured in the ChangeLog table in some cases
23
+ - When modeling an inline entity, a non-existent association and parent ID was recorded
24
+ - Fixed handling, when reqData was undefined
25
+
26
+ ### Changed
27
+
28
+ - Peer dependency to @sap/cds changed to ">=7"
29
+ - Data marked as personal data using data privacy annotations won't get change-tracked anymore to satisfy product standards
30
+ - Restructured Documentation
31
+
32
+
33
+ ## Version 1.0.6 - 29.04.24
8
34
 
9
35
  ### Fixed
10
36
 
package/README.md CHANGED
@@ -1,43 +1,49 @@
1
1
  # Change Tracking Plugin for SAP Cloud Application Programming Model (CAP)
2
2
 
3
- [![REUSE status](https://api.reuse.software/badge/github.com/cap-js/change-tracking)](https://api.reuse.software/info/github.com/cap-js/change-tracking)
3
+ a [CDS plugin](https://cap.cloud.sap/docs/node.js/cds-plugins#cds-plugin-packages) for automatic capturing, storing, and viewing of the change records of modeled entities
4
4
 
5
- The `@cap-js/change-tracking` package is a [CDS plugin](https://cap.cloud.sap/docs/node.js/cds-plugins#cds-plugin-packages) providing out-of-the box support for automatic capturing, storing, and viewing of the change records of modeled entities as simple as that:
5
+ [![REUSE status](https://api.reuse.software/badge/github.com/cap-js/change-tracking)](https://api.reuse.software/info/github.com/cap-js/change-tracking)
6
6
 
7
- 1. [Install the plugin: `npm add @cap-js/change-tracking`](#setup)
8
- 2. [Add `@changelog` annotations to your CDS models](#annotations)
9
- 3. [Et voilà:](#change-history-view)
7
+ > [!IMPORTANT]
8
+ > This release establishes compatibility with CDS 8.
9
+ >
10
+ > Since the prior release was using **APIs deprecated in CDS8**, the code was modified significantly to enable compatibility. While we tested extensively, there may still be glitches or unexpected situations which we did not cover. So please **test this release extensively before applying it to productive** scenarios. Please also report any bugs or glitches, ideally by contributing a test-case for us to incorporate.
11
+ >
12
+ > See the changelog for a full list of changes
10
13
 
11
- <img width="1300" alt="change-history-loading" src="_assets/change-history.gif">
12
14
 
13
15
  ### Table of Contents
14
16
 
15
- - [Change Tracking Plugin for SAP Cloud Application Programming Model (CAP)](#change-tracking-plugin-for-sap-cloud-application-programming-model-cap)
16
- - [Table of Contents](#table-of-contents)
17
- - [Preliminaries](#preliminaries)
18
- - [Setup](#setup)
19
- - [Annotations](#annotations)
20
- - [Human-readable Types and Fields](#human-readable-types-and-fields)
21
- - [Human-readable IDs](#human-readable-ids)
22
- - [Human-readable Values](#human-readable-values)
23
- - [Test-drive locally](#test-drive-locally)
24
- - [Change History View](#change-history-view)
25
- - [Customizations](#customizations)
26
- - [Altered table view](#altered-table-view)
27
- - [Disable lazy loading](#disable-lazy-loading)
28
- - [Disable UI Facet generation](#disable-ui-facet-generation)
17
+ - [Try it Locally](#try-it-locally)
18
+ - [Detailed Explanation](#detailed-explanation)
19
+ - [Human-readable Types and Fields](#human-readable-types-and-fields)
20
+ - [Human-readable IDs](#human-readable-ids)
21
+ - [Human-readable Values](#human-readable-values)
22
+ - [Advanced Options](#advanced-options)
23
+ - [Altered Table View](#altered-table-view)
24
+ - [Disable Lazy Loading](#disable-lazy-loading)
25
+ - [Disable UI Facet generation](#disable-ui-facet-generation)
29
26
  - [Disable Association to Changes Generation](#disable-association-to-changes-generation)
30
- - [Modelling Samples](#modelling-samples)
31
- - [Specify Object ID](#specify-object-id)
32
- - [Tracing Changes](#tracing-changes)
33
- - [Don'ts](#donts)
34
- - [Contributing](#contributing)
35
- - [Code of Conduct](#code-of-conduct)
36
- - [Licensing](#licensing)
27
+ - [Examples](#examples)
28
+ - [Specify Object ID](#specify-object-id)
29
+ - [Tracing Changes](#tracing-changes)
30
+ - [Don&#39;ts](#donts)
31
+ - [Contributing](#contributing)
32
+ - [Code of Conduct](#code-of-conduct)
33
+ - [Licensing](#licensing)
34
+
35
+ ## Try it Locally
37
36
 
38
- ## Preliminaries
37
+ In this guide, we use the [Incidents Management reference sample app](https://github.com/cap-js/incidents-app) as the base to add change tracking to.
39
38
 
40
- In this guide, we use the [Incidents Management reference sample app](https://github.com/cap-js/incidents-app) as the base to add change tracking to. Clone the repository and apply the step-by-step instructions:
39
+ 1. [Prerequisites](#1-prerequisites)
40
+ 2. [Setup](#2-setup)
41
+ 3. [Annotations](#3-annotations)
42
+ 4. [Testing](#4-testing)
43
+
44
+ ### 1. Prerequisites
45
+
46
+ Clone the repository and apply the step-by-step instructions:
41
47
 
42
48
  ```sh
43
49
  git clone https://github.com/cap-js/incidents-app
@@ -57,7 +63,7 @@ npm i
57
63
  cds w samples/change-tracking
58
64
  ```
59
65
 
60
- ## Setup
66
+ ### 2. Setup
61
67
 
62
68
  To enable change tracking, simply add this self-configuring plugin package to your project:
63
69
 
@@ -65,10 +71,10 @@ To enable change tracking, simply add this self-configuring plugin package to yo
65
71
  npm add @cap-js/change-tracking
66
72
  ```
67
73
 
68
- ## Annotations
74
+ ### 3. Annotations
69
75
 
70
76
  > [!WARNING]
71
- > Please be aware that [**sensitive** or **personal** data](https://cap.cloud.sap/docs/guides/data-privacy/annotations#annotating-personal-data) should not be change tracked, since viewing the log allows users to circumvent [audit-logging](https://cap.cloud.sap/docs/guides/data-privacy/audit-logging#setup).
77
+ > Please be aware that [**sensitive** or **personal** data](https://cap.cloud.sap/docs/guides/data-privacy/annotations#annotating-personal-data) (annotated with `@PersonalData`) is not change tracked, since viewing the log allows users to circumvent [audit-logging](https://cap.cloud.sap/docs/guides/data-privacy/audit-logging#setup).
72
78
 
73
79
  All we need to do is to identify what should be change-tracked by annotating respective entities and elements in our model with the `@changelog` annotation. Following the [best practice of separation of concerns](https://cap.cloud.sap/docs/guides/domain-modeling#separation-of-concerns), we do so in a separate file _srv/change-tracking.cds_:
74
80
 
@@ -90,9 +96,33 @@ The minimal annotation we require for change tracking is `@changelog` on element
90
96
 
91
97
  Additional identifiers or labels can be added to obtain more *human-readable* change records as described below.
92
98
 
99
+ ### 4. Testing
100
+
101
+ With the steps above, we have successfully set up change tracking for our reference application. Let's see that in action.
102
+
103
+ 1. **Start the server**:
104
+
105
+ ```sh
106
+ cds watch
107
+ ```
108
+
109
+ 2. **Make a change** on your change-tracked elements. This change will automatically be persisted in the database table (`sap.changelog.ChangeLog`) and made available in a pre-defined view, namely the [Change History view](#change-history-view) for your convenience.
110
+
111
+ #### Change History View
112
+
113
+ > [!IMPORTANT]
114
+ > To ensure proper lazy loading of the Change History table, please use **SAPUI5 version 1.120.0** or higher.`<br>`
115
+ > If you wish to *disable* this feature, please see the customization section on how to [disable lazy loading](#disable-lazy-loading).
116
+
117
+ <img width="1300" alt="change-history" src="_assets/changes.png">
118
+
119
+ If you have a Fiori Element application, the CDS plugin automatically provides and generates a view `sap.changelog.ChangeView`, the facet of which is automatically added to the Fiori Object Page of your change-tracked entities/elements. In the UI, this corresponds to the *Change History* table which serves to help you to view and search the stored change records of your modeled entities.
120
+
121
+ ## Detailed Explanation
122
+
93
123
  ### Human-readable Types and Fields
94
124
 
95
- By default the implementation looks up *Object Type* names or *Field* namesfrom respective `@title` or `@Common.Label` annotations, and applies i18n lookups. If no such annotations are given, the technical names of the respective CDS definitions are displayed.
125
+ By default the implementation looks up *Object Type* names or *Field* names from respective `@title` or `@Common.Label` annotations, and applies i18n lookups. If no such annotations are given, the technical names of the respective CDS definitions are displayed.
96
126
 
97
127
  For example, without the `@title` annotation, changes to conversation entries would show up with the technical entity name:
98
128
 
@@ -116,10 +146,6 @@ These are already human-readable by default, unless the `@changelog` definition
116
146
 
117
147
  For example, having a `@changelog` annotation without any additional identifiers, changes to conversation entries would show up as simple entity IDs:
118
148
 
119
- ```cds
120
- annotate ProcessorService.Conversations {
121
- ```
122
-
123
149
  <img width="1300" alt="change-history-id" src="_assets/changes-id-wbox.png">
124
150
 
125
151
  However, this is not advisable as we cannot easily distinguish between changes. It is more appropriate to annotate as follows:
@@ -154,29 +180,7 @@ customer @changelog: [customer.name];
154
180
 
155
181
  <img width="1300" alt="change-history-value-hr" src="_assets/changes-value-hr-wbox.png">
156
182
 
157
- ## Test-drive locally
158
-
159
- With the steps above, we have successfully set up change tracking for our reference application. Let's see that in action.
160
-
161
- 1. **Start the server**:
162
-
163
- ```sh
164
- cds watch
165
- ```
166
-
167
- 2. **Make a change** on your change-tracked elements. This change will automatically be persisted in the database table (`sap.changelog.ChangeLog`) and made available in a pre-defined view, namely the [Change History view](#change-history-view) for your convenience.
168
-
169
- ## Change History View
170
-
171
- > [!IMPORTANT]
172
- > To ensure proper lazy loading of the Change History table, please use **SAPUI5 version 1.120.0** or higher.<br>
173
- > If you wish to *disable* this feature, please see the customization section on how to [disable lazy loading](#disable-lazy-loading).
174
-
175
- <img width="1300" alt="change-history" src="_assets/changes.png">
176
-
177
- If you have a Fiori Element application, the CDS plugin automatically provides and generates a view `sap.changelog.ChangeView`, the facet of which is automatically added to the Fiori Object Page of your change-tracked entities/elements. In the UI, this corresponds to the *Change History* table which serves to help you to view and search the stored change records of your modeled entities.
178
-
179
- ## Customizations
183
+ ## Advanced Options
180
184
 
181
185
  ### Altered table view
182
186
 
@@ -231,9 +235,46 @@ For some scenarios, e.g. when doing `UNION` and the `@changelog` annotion is sti
231
235
  > [!IMPORTANT]
232
236
  > This will also supress the addition of the UI facet, since the change-view is not available as target entity anymore.
233
237
 
234
- ## Modelling Samples
238
+ ### Preserve change logs of deleted data
239
+
240
+ By default, deleting a record will also automatically delete all associated change logs. This helps reduce the impact on the size of the database.
241
+ You can turn this behavior off globally by adding the following switch to the `package.json` of your project
235
242
 
236
- This chapter describes more modelling cases for further reference, from simple to complex, including but not limited to the followings.
243
+ ```
244
+ ...
245
+ "cds": {
246
+ "requires": {
247
+ ...
248
+ "change-tracking": {
249
+ "preserveDeletes": true
250
+ }
251
+ ...
252
+ }
253
+ }
254
+ ...
255
+ ```
256
+ > [!IMPORTANT]
257
+ > Preserving the change logs of deleted data can have a significant impact on the size of the change logging table, since now such data also survives automated data retention runs.
258
+ > You must implement an own **data retention strategy** for the change logging table in order to manage the size and performance of your database.
259
+
260
+ ## Examples
261
+
262
+ This section describes modelling cases for further reference, from simple to complex, including the following:
263
+
264
+ - [Specify Object ID](#specify-object-id)
265
+ - [Use Case 1: Annotate single field/multiple fields of associated table(s) as the Object ID](#use-case-1-annotate-single-fieldmultiple-fields-of-associated-tables-as-the-object-id)
266
+ - [Use Case 2: Annotate single field/multiple fields of project customized types as the Object ID](#use-case-2-annotate-single-fieldmultiple-fields-of-project-customized-types-as-the-object-id)
267
+ - [Use Case 3: Annotate chained associated entities from the current entity as the Object ID](#use-case-3-annotate-chained-associated-entities-from-the-current-entity-as-the-object-id)
268
+ - [Tracing Changes](#tracing-changes)
269
+ - [Use Case 1: Trace the changes of child nodes from the current entity and display the meaningful data from child nodes (composition relation)](#use-case-1-trace-the-changes-of-child-nodes-from-the-current-entity-and-display-the-meaningful-data-from-child-nodes-composition-relation)
270
+ - [Use Case 2: Trace the changes of associated entities from the current entity and display the meaningful data from associated entities (association relation)](#use-case-2-trace-the-changes-of-associated-entities-from-the-current-entity-and-display-the-meaningful-data-from-associated-entities-association-relation)
271
+ - [Use Case 3: Trace the changes of fields defined by project customized types and display the meaningful data](#use-case-3-trace-the-changes-of-fields-defined-by-project-customized-types-and-display-the-meaningful-data)
272
+ - [Use Case 4: Trace the changes of chained associated entities from the current entity and display the meaningful data from associated entities (association relation)](#use-case-4-trace-the-changes-of-chained-associated-entities-from-the-current-entity-and-display-the-meaningful-data-from-associated-entities-association-relation)
273
+ - [Use Case 5: Trace the changes of union entity and display the meaningful data](#use-case-5-trace-the-changes-of-union-entity-and-display-the-meaningful-data)
274
+ - [Don&#39;ts](#donts)
275
+ - [Use Case 1: Don&#39;t trace changes for field(s) with `Association to many`](#use-case-1-dont-trace-changes-for-fields-with-association-to-many)
276
+ - [Use Case 2: Don&#39;t trace changes for field(s) with *Unmanaged Association*](#use-case-2-dont-trace-changes-for-fields-with-unmanaged-association)
277
+ - [Use Case 3: Don&#39;t trace changes for CUD on DB entity](#use-case-3-dont-trace-changes-for-cud-on-db-entity)
237
278
 
238
279
  ### Specify Object ID
239
280
 
@@ -544,4 +585,3 @@ We as members, contributors, and leaders pledge to make participation in our com
544
585
  ## Licensing
545
586
 
546
587
  Copyright 2023 SAP SE or an SAP affiliate company and contributors. Please see our [LICENSE](LICENSE) for copyright and license information. Detailed information including third-party components and their licensing/copyright information is available [via the REUSE tool](https://api.reuse.software/info/github.com/cap-js/change-tracking).
547
-
package/cds-plugin.js CHANGED
@@ -1,5 +1,8 @@
1
1
  const cds = require('@sap/cds')
2
2
 
3
+ const isRoot = 'change-tracking-isRootEntity'
4
+ const hasParent = 'change-tracking-parentEntity'
5
+
3
6
  const isChangeTracked = (entity) => (
4
7
  (entity['@changelog']
5
8
  || entity.elements && Object.values(entity.elements).some(e => e['@changelog'])) && entity.query?.SET?.op !== 'union'
@@ -7,6 +10,10 @@ const isChangeTracked = (entity) => (
7
10
 
8
11
  // Add the appropriate Side Effects attribute to the custom action
9
12
  const addSideEffects = (actions, flag, element) => {
13
+ if (!flag && (element === undefined || element === null)) {
14
+ return
15
+ }
16
+
10
17
  for (const se of Object.values(actions)) {
11
18
  const target = flag ? 'TargetProperties' : 'TargetEntities'
12
19
  const sideEffectAttr = se[`@Common.SideEffects.${target}`]
@@ -23,6 +30,114 @@ const addSideEffects = (actions, flag, element) => {
23
30
  }
24
31
  }
25
32
 
33
+ function setChangeTrackingIsRootEntity(entity, csn, val = true) {
34
+ if (csn.definitions?.[entity.name]) {
35
+ csn.definitions[entity.name][isRoot] = val;
36
+ }
37
+ }
38
+
39
+ function checkAndSetRootEntity(parentEntity, entity, csn) {
40
+ if (entity[isRoot] === false) {
41
+ return entity;
42
+ }
43
+ if (parentEntity) {
44
+ return compositionRoot(parentEntity, csn);
45
+ } else {
46
+ setChangeTrackingIsRootEntity(entity, csn);
47
+ return { ...csn.definitions?.[entity.name], name: entity.name };
48
+ }
49
+ }
50
+
51
+ function processEntities(m) {
52
+ for (let name in m.definitions) {
53
+ compositionRoot({...m.definitions[name], name}, m)
54
+ }
55
+ }
56
+
57
+ function compositionRoot(entity, csn) {
58
+ if (!entity || entity.kind !== 'entity') {
59
+ return;
60
+ }
61
+ const parentEntity = compositionParent(entity, csn);
62
+ return checkAndSetRootEntity(parentEntity, entity, csn);
63
+ }
64
+
65
+ function compositionParent(entity, csn) {
66
+ if (!entity || entity.kind !== 'entity') {
67
+ return;
68
+ }
69
+ const parentAssociation = compositionParentAssociation(entity, csn);
70
+ return parentAssociation ?? null;
71
+ }
72
+
73
+ function compositionParentAssociation(entity, csn) {
74
+ if (!entity || entity.kind !== 'entity') {
75
+ return;
76
+ }
77
+ const elements = entity.elements ?? {};
78
+
79
+ // Add the change-tracking-isRootEntity attribute of the child entity
80
+ processCompositionElements(entity, csn, elements);
81
+
82
+ const hasChildFlag = entity[isRoot] !== false;
83
+ const hasParentEntity = entity[hasParent];
84
+
85
+ if (hasChildFlag || !hasParentEntity) {
86
+ // Find parent association of the entity
87
+ const parentAssociation = findParentAssociation(entity, csn, elements);
88
+ if (parentAssociation) {
89
+ const parentAssociationTarget = elements[parentAssociation]?.target;
90
+ if (hasChildFlag) setChangeTrackingIsRootEntity(entity, csn, false);
91
+ return {
92
+ ...csn.definitions?.[parentAssociationTarget],
93
+ name: parentAssociationTarget
94
+ };
95
+ } else return;
96
+ }
97
+ return { ...csn.definitions?.[entity.name], name: entity.name };
98
+ }
99
+
100
+ function processCompositionElements(entity, csn, elements) {
101
+ for (const name in elements) {
102
+ const element = elements[name];
103
+ const target = element?.target;
104
+ const definition = csn.definitions?.[target];
105
+ if (
106
+ element.type !== 'cds.Composition' ||
107
+ target === entity.name ||
108
+ !definition ||
109
+ definition[isRoot] === false
110
+ ) {
111
+ continue;
112
+ }
113
+ setChangeTrackingIsRootEntity({ ...definition, name: target }, csn, false);
114
+ }
115
+ }
116
+
117
+ function findParentAssociation(entity, csn, elements) {
118
+ return Object.keys(elements).find((name) => {
119
+ const element = elements[name];
120
+ const target = element?.target;
121
+ if (element.type === 'cds.Association' && target !== entity.name) {
122
+ const parentDefinition = csn.definitions?.[target] ?? {};
123
+ const parentElements = parentDefinition?.elements ?? {};
124
+ return !!Object.keys(parentElements).find((parentEntityName) => {
125
+ const parentElement = parentElements?.[parentEntityName] ?? {};
126
+ if (parentElement.type === 'cds.Composition') {
127
+ const isCompositionEntity = parentElement.target === entity.name;
128
+ // add parent information in the current entity
129
+ if (isCompositionEntity) {
130
+ csn.definitions[entity.name][hasParent] = {
131
+ associationName: name,
132
+ entityName: target
133
+ };
134
+ }
135
+ return isCompositionEntity;
136
+ }
137
+ });
138
+ }
139
+ });
140
+ }
26
141
 
27
142
  // Unfold @changelog annotations in loaded model
28
143
  cds.on('loaded', m => {
@@ -31,6 +146,9 @@ cds.on('loaded', m => {
31
146
  const { 'sap.changelog.aspect': aspect } = m.definitions; if (!aspect) return // some other model
32
147
  const { '@UI.Facets': [facet], elements: { changes } } = aspect
33
148
  changes.on.pop() // remove ID -> filled in below
149
+
150
+ // Process entities to define the relation
151
+ processEntities(m)
34
152
 
35
153
  for (let name in m.definitions) {
36
154
  const entity = m.definitions[name]
@@ -63,28 +181,20 @@ cds.on('loaded', m => {
63
181
  if(!entity['@changelog.disable_facet'])
64
182
  entity['@UI.Facets']?.push(facet)
65
183
  }
66
- // The changehistory list should be refreshed after the custom action is triggered
184
+
67
185
  if (entity.actions) {
186
+ const hasParentInfo = entity[hasParent];
187
+ const entityName = hasParentInfo?.entityName;
188
+ const parentEntity = entityName ? m.definitions[entityName] : null;
68
189
 
69
- // Update the changehistory list on the current entity when the custom action of the entity is triggered
70
- if (entity['@UI.Facets']) {
71
- addSideEffects(entity.actions, true)
72
- }
190
+ const isParentRootAndHasFacets = parentEntity?.[isRoot] && parentEntity?.['@UI.Facets'];
73
191
 
74
- // When the custom action of the child entity is performed, the change history list of the parent entity is updated
75
- if (entity.elements) {
76
- //ToDo: Revisit Breaklook with node.js Expert
77
- breakLoop: for (const [ele, eleValue] of Object.entries(entity.elements)) {
78
- const parentEntity = m.definitions[eleValue.target]
79
- if (parentEntity && parentEntity['@UI.Facets'] && eleValue.type === 'cds.Association') {
80
- for (const value of Object.values(parentEntity.elements)) {
81
- if (value.target === name) {
82
- addSideEffects(entity.actions, false, ele)
83
- break breakLoop
84
- }
85
- }
86
- }
87
- }
192
+ if (entity[isRoot] && entity['@UI.Facets']) {
193
+ // Add side effects for root entity
194
+ addSideEffects(entity.actions, true);
195
+ } else if (isParentRootAndHasFacets) {
196
+ // Add side effects for child entity
197
+ addSideEffects(entity.actions, false, hasParentInfo?.associationName);
88
198
  }
89
199
  }
90
200
  }
package/lib/change-log.js CHANGED
@@ -15,6 +15,7 @@ const {
15
15
  getValueEntityType,
16
16
  } = require("./entity-helper")
17
17
  const { localizeLogFields } = require("./localization")
18
+ const isRoot = "change-tracking-isRootEntity"
18
19
 
19
20
 
20
21
  const _getRootEntityPathVals = function (txContext, entity, entityKey) {
@@ -26,21 +27,22 @@ const _getRootEntityPathVals = function (txContext, entity, entityKey) {
26
27
  if (txContext.event === "CREATE") {
27
28
  const curEntityPathVal = `${entity.name}(${entityKey})`
28
29
  serviceEntityPathVals.push(curEntityPathVal)
30
+ txContext.hasComp && entityIDs.pop();
29
31
  } else {
30
32
  // When deleting Composition of one node via REST API in draft-disabled mode,
31
33
  // the child node ID would be missing in URI
32
34
  if (txContext.event === "DELETE" && !entityIDs.find((x) => x === entityKey)) {
33
35
  entityIDs.push(entityKey)
34
36
  }
35
- const curEntity = getEntityByContextPath(path)
37
+ const curEntity = getEntityByContextPath(path, txContext.hasComp)
36
38
  const curEntityID = entityIDs.pop()
37
39
  const curEntityPathVal = `${curEntity.name}(${curEntityID})`
38
40
  serviceEntityPathVals.push(curEntityPathVal)
39
41
  }
40
42
 
41
43
 
42
- while (_isCompositionContextPath(path)) {
43
- const hostEntity = getEntityByContextPath(path = path.slice(0, -1))
44
+ while (_isCompositionContextPath(path, txContext.hasComp)) {
45
+ const hostEntity = getEntityByContextPath(path = path.slice(0, -1), txContext.hasComp)
44
46
  const hostEntityID = entityIDs.pop()
45
47
  const hostEntityPathVal = `${hostEntity.name}(${hostEntityID})`
46
48
  serviceEntityPathVals.unshift(hostEntityPathVal)
@@ -55,7 +57,7 @@ const _getAllPathVals = function (txContext) {
55
57
  const entityIDs = _getEntityIDs(txContext.params)
56
58
 
57
59
  for (let idx = 0; idx < paths.length; idx++) {
58
- const entity = getEntityByContextPath(paths.slice(0, idx + 1))
60
+ const entity = getEntityByContextPath(paths.slice(0, idx + 1), txContext.hasComp)
59
61
  const entityID = entityIDs[idx]
60
62
  const entityPathVal = `${entity.name}(${entityID})`
61
63
  pathVals.push(entityPathVal)
@@ -64,6 +66,28 @@ const _getAllPathVals = function (txContext) {
64
66
  return pathVals
65
67
  }
66
68
 
69
+ function convertSubjectToParams(subject) {
70
+ let params = [];
71
+ let subjectRef = [];
72
+ subject?.ref?.forEach((item)=>{
73
+ if (typeof item === 'string') {
74
+ subjectRef.push(item)
75
+ return
76
+ }
77
+
78
+ const keys = {}
79
+ let id = item.id
80
+ if (!id) return
81
+ for (let j = 0; j < item?.where?.length; j = j + 4) {
82
+ const key = item.where[j].ref[0]
83
+ const value = item.where[j + 2].val
84
+ if (key !== 'IsActiveEntity') keys[key] = value
85
+ }
86
+ params.push(keys);
87
+ })
88
+ return params.length > 0 ? params : subjectRef;
89
+ }
90
+
67
91
  const _getEntityIDs = function (txParams) {
68
92
  const entityIDs = []
69
93
  for (const param of txParams) {
@@ -255,12 +279,12 @@ const _formatObjectID = async function (changes, reqData) {
255
279
  }
256
280
  }
257
281
 
258
- const _isCompositionContextPath = function (aPath) {
282
+ const _isCompositionContextPath = function (aPath, hasComp) {
259
283
  if (!aPath) return
260
284
  if (typeof aPath === 'string') aPath = aPath.split('/')
261
285
  if (aPath.length < 2) return false
262
- const target = getEntityByContextPath(aPath)
263
- const parent = getEntityByContextPath(aPath.slice(0, -1))
286
+ const target = getEntityByContextPath(aPath, hasComp)
287
+ const parent = getEntityByContextPath(aPath.slice(0, -1), hasComp)
264
288
  if (!parent.compositions) return false
265
289
  return Object.values(parent.compositions).some(c => c._target === target)
266
290
  }
@@ -289,10 +313,26 @@ function _trackedChanges4 (srv, target, diff) {
289
313
  template, row: diff, processFn: ({ row, key, element }) => {
290
314
  const from = row._old?.[key]
291
315
  const to = row[key]
316
+ const eleParentKeys = element.parent.keys
292
317
  if (from === to) return
293
318
 
294
- const keys = Object.keys(element.parent.keys)
319
+ /**
320
+ *
321
+ * For the Inline entity such as Items,
322
+ * further filtering is required on the keys
323
+ * within the 'association' and 'foreign key' to ultimately retain the keys of the entity itself.
324
+ * entity Order : cuid {
325
+ * title : String;
326
+ * Items : Composition of many {
327
+ * key ID : UUID;
328
+ * quantity : Integer;
329
+ * }
330
+ * }
331
+ */
332
+ const keys = Object.keys(eleParentKeys)
295
333
  .filter(k => k !== "IsActiveEntity")
334
+ .filter(k => eleParentKeys[k]?.type !== "cds.Association") // Skip association
335
+ .filter(k => !eleParentKeys[k]?.["@odata.foreignKey4"]) // Skip foreign key
296
336
  .map(k => `${k}=${row[k]}`)
297
337
  .join(', ')
298
338
 
@@ -339,20 +379,90 @@ const _prepareChangeLogForComposition = async function (entity, entityKey, chang
339
379
  return [ rootEntity, rootEntityID ]
340
380
  }
341
381
 
382
+ async function generatePathAndParams (req, entityKey) {
383
+ const { target, data } = req;
384
+ const { ID, foreignKey, parentEntity } = getAssociationDetails(target);
385
+ const hasParentAndForeignKey = parentEntity && data[foreignKey];
386
+ const targetEntity = hasParentAndForeignKey ? parentEntity : target;
387
+ const targetKey = hasParentAndForeignKey ? data[foreignKey] : entityKey;
388
+
389
+ let compContext = {
390
+ path: hasParentAndForeignKey
391
+ ? `${parentEntity.name}/${target.name}`
392
+ : `${target.name}`,
393
+ params: hasParentAndForeignKey
394
+ ? [{ [ID]: data[foreignKey] }, { [ID]: entityKey }]
395
+ : [{ [ID]: entityKey }],
396
+ hasComp: true
397
+ };
398
+
399
+ if (hasParentAndForeignKey && parentEntity[isRoot]) {
400
+ return compContext;
401
+ }
402
+
403
+ let parentAssoc = await processEntity(targetEntity, targetKey, compContext);
404
+ while (parentAssoc && !parentAssoc.entity[isRoot]) {
405
+ parentAssoc = await processEntity(
406
+ parentAssoc.entity,
407
+ parentAssoc.ID,
408
+ compContext
409
+ );
410
+ }
411
+ return compContext;
412
+ }
413
+
414
+ async function processEntity (entity, entityKey, compContext) {
415
+ const { ID, foreignKey, parentEntity } = getAssociationDetails(entity);
416
+
417
+ if (foreignKey && parentEntity) {
418
+ const parentResult =
419
+ (await SELECT.one
420
+ .from(entity.name)
421
+ .where({ [ID]: entityKey })
422
+ .columns(foreignKey)) || {};
423
+ const hasForeignKey = parentResult[foreignKey];
424
+ if (!hasForeignKey) return;
425
+ compContext.path = `${parentEntity.name}/${compContext.path}`;
426
+ compContext.params.unshift({ [ID]: parentResult[foreignKey] });
427
+ return {
428
+ entity: parentEntity,
429
+ [ID]: hasForeignKey ? parentResult[foreignKey] : undefined
430
+ };
431
+ }
432
+ }
433
+
434
+ function getAssociationDetails (entity) {
435
+ if (!entity) return {};
436
+ const assocName = entity['change-tracking-parentEntity']?.associationName;
437
+ const assoc = entity.elements[assocName];
438
+ const parentEntity = assoc?._target;
439
+ const foreignKey = assoc?.keys?.[0]?.$generatedFieldName;
440
+ const ID = assoc?.keys?.[0]?.ref[0] || 'ID';
441
+ return { ID, foreignKey, parentEntity };
442
+ }
443
+
342
444
 
343
445
  async function track_changes (req) {
344
446
  let diff = await req.diff()
345
447
  if (!diff) return
346
448
 
347
449
  let target = req.target
348
- let isDraftEnabled = !!target.drafts
349
- let isComposition = _isCompositionContextPath(req.context.path)
450
+ let compContext = null;
350
451
  let entityKey = diff.ID
351
-
352
- if (cds.transaction(req).context.event === "DELETE") {
353
- if (isDraftEnabled || !isComposition) {
354
- return await DELETE.from(`sap.changelog.ChangeLog`).where({ entityKey })
355
- }
452
+ const params = convertSubjectToParams(req.subject);
453
+ if (req.subject.ref.length === 1 && params.length === 1 && !target[isRoot]) {
454
+ compContext = await generatePathAndParams(req, entityKey);
455
+ }
456
+ let isComposition = _isCompositionContextPath(
457
+ compContext?.path || req.path,
458
+ compContext?.hasComp
459
+ );
460
+ if (
461
+ req.event === "DELETE" &&
462
+ target[isRoot] &&
463
+ !cds.env.requires["change-tracking"]?.preserveDeletes
464
+ ) {
465
+ return await DELETE.from(`sap.changelog.ChangeLog`).where({ entityKey });
356
466
  }
357
467
 
358
468
  let changes = _trackedChanges4(this, target, diff)
@@ -360,13 +470,22 @@ async function track_changes (req) {
360
470
 
361
471
  await _formatChangeLog(changes, req)
362
472
  if (isComposition) {
363
- [ target, entityKey ] = await _prepareChangeLogForComposition(target, entityKey, changes, this)
473
+ let reqInfo = {
474
+ data: req.data,
475
+ context: {
476
+ path: compContext?.path || req.path,
477
+ params: compContext?.params || params,
478
+ event: req.event,
479
+ hasComp: compContext?.hasComp
480
+ }
481
+ };
482
+ [ target, entityKey ] = await _prepareChangeLogForComposition(target, entityKey, changes, reqInfo)
364
483
  }
365
484
  const dbEntity = getDBEntity(target)
366
485
  await INSERT.into("sap.changelog.ChangeLog").entries({
367
486
  entity: dbEntity.name,
368
487
  entityKey: entityKey,
369
- serviceEntity: target.name,
488
+ serviceEntity: target.name || target,
370
489
  changes: changes.filter(c => c.valueChangedFrom || c.valueChangedTo).map((c) => ({
371
490
  ...c,
372
491
  valueChangedFrom: `${c.valueChangedFrom ?? ''}`,
@@ -11,7 +11,8 @@ const getUUIDFromPathVal = function (pathVal) {
11
11
  return regRes ? regRes[1] : ""
12
12
  }
13
13
 
14
- const getEntityByContextPath = function (aPath) {
14
+ const getEntityByContextPath = function (aPath, hasComp = false) {
15
+ if (hasComp) return cds.model.definitions[aPath[aPath.length - 1]]
15
16
  let entity = cds.model.definitions[aPath[0]]
16
17
  for (let each of aPath.slice(1)) {
17
18
  entity = entity.elements[each]?._target
@@ -85,7 +86,9 @@ async function getObjectId (reqData, entityName, fields, curObj) {
85
86
  req_data[foreignKey] && current.name === entityName
86
87
  ? req_data[foreignKey]
87
88
  : _db_data[foreignKey]
88
- if (IDval) try {
89
+ if (!IDval) {
90
+ _db_data = {};
91
+ } else try {
89
92
  // REVISIT: This always reads all elements -> should read required ones only!
90
93
  let ID = assoc.keys?.[0]?.ref[0] || 'ID'
91
94
  const isComposition = hasComposition(assoc._target, current)
@@ -94,16 +97,12 @@ async function getObjectId (reqData, entityName, fields, curObj) {
94
97
  // This function can recursively retrieve the desired information from reqData without having to read it from db.
95
98
  _db_data = _getCompositionObjFromReq(reqData, IDval)
96
99
  // 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.
97
- if (!_db_data || JSON.stringify(_db_data) === '{}') {
98
- _db_data =
99
- (await SELECT.one
100
- .from(assoc._target)
101
- .where({ [ID]: IDval })) || {}
100
+ const entityKeys = reqData ? Object.keys(reqData).filter(item => !Object.keys(assoc._target.keys).some(ele => item === ele)) : [];
101
+ if (!_db_data || JSON.stringify(_db_data) === '{}' || entityKeys.length === 0) {
102
+ _db_data = await getCurObjFromDbQuery(assoc._target, IDval, ID);
102
103
  }
103
104
  } else {
104
- _db_data =
105
- (await SELECT.one.from(assoc._target).where({ [ID]: IDval })) ||
106
- {}
105
+ _db_data = await getCurObjFromDbQuery(assoc._target, IDval, ID);
107
106
  }
108
107
  } catch (e) {
109
108
  LOG.error("Failed to generate object Id for an association entity.", e)
@@ -168,7 +167,7 @@ const hasComposition = function (parentEntity, subEntity) {
168
167
  }
169
168
 
170
169
  const _getCompositionObjFromReq = function (obj, targetID) {
171
- if (obj.ID === targetID) {
170
+ if (obj?.ID === targetID) {
172
171
  return obj;
173
172
  }
174
173
 
@@ -12,7 +12,9 @@ const _processElement = (processFn, row, key, elements, isRoot, pathSegments, pi
12
12
  const element = elements[key];
13
13
  const { plain } = picked;
14
14
 
15
- if (plain) {
15
+ // do not change-track personal data
16
+ const isPersonalData = element && Object.keys(element).some(key => key.startsWith('@PersonalData'));
17
+ if (plain && !isPersonalData) {
16
18
  /**
17
19
  * @type import('../../types/api').templateProcessorProcessFnArgs
18
20
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cap-js/change-tracking",
3
- "version": "1.0.6",
3
+ "version": "1.0.7",
4
4
  "description": "CDS plugin providing out-of-the box support for automatic capturing, storing, and viewing of the change records of modeled entities.",
5
5
  "repository": "cap-js/change-tracking",
6
6
  "author": "SAP SE (https://www.sap.com)",
@@ -18,7 +18,7 @@
18
18
  "test": "npx jest --silent"
19
19
  },
20
20
  "peerDependencies": {
21
- "@sap/cds": "^7"
21
+ "@sap/cds": ">=7"
22
22
  },
23
23
  "devDependencies": {
24
24
  "@cap-js/change-tracking": "file:.",