@cap-js/change-tracking 1.0.6 → 1.0.8

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,60 @@ 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.8 - 28.03.25
8
+
9
+ ### Added
10
+
11
+ - Added @UI.MultiLineText to value fields
12
+ - Added support for Multi-Tenancy
13
+ - Added configuration options to disable tracking of CREATE/UPDATE/DELETE operations on a project level
14
+
15
+ ### Fixed
16
+
17
+ - Handling of numeric and boolean fields was faulty, when an initial value of `0` for numeric or `false` for boolean was supplied
18
+ - Decimal values were handled differently for HANA and SQlite
19
+ - Missing UI Label for one attribute (`ChangeLog.ID`) of the Changes UI facet
20
+ - Support for @UI.HeaderInfo.TypeName as fallback for the UI Label of the key
21
+ - Compilation error when an association is used as a key
22
+ - Fixed handling of unmanaged composition of many
23
+ - Proper casing of the operation enum type
24
+
25
+
26
+ ### Changed
27
+
28
+ - Added warning and mitigation for multi-tenant deployments with MTX
29
+ - Added a disclaimer of upcoming new version having a minimum requirement of CDS 8.6 for multitenancy fix
30
+ - Changed the default limit on non-HANA databases from 255 to 5000 characters for all String values
31
+ - Updated peer dependency from CDS7 to CDS8
32
+
33
+
34
+ ## Version 1.0.7 - 20.08.24
35
+
36
+ ### Added
37
+
38
+ - A global switch to preserve change logs for deleted data
39
+ - 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.
40
+
41
+
42
+ ### Fixed
43
+
44
+ - 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
45
+ - CDS 8 deprecated cds.transaction, causing change logs of nested documents to be wrong, replaced with req.event
46
+ - 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
47
+ - req._params and req.context are not official APIs and stopped working with CDS 8, replaced with official APIs
48
+ - When running test cases in CDS 8, some requests failed with a status code of 404
49
+ - ServiceEntity is not captured in the ChangeLog table in some cases
50
+ - When modeling an inline entity, a non-existent association and parent ID was recorded
51
+ - Fixed handling, when reqData was undefined
52
+
53
+ ### Changed
54
+
55
+ - Peer dependency to @sap/cds changed to ">=7"
56
+ - Data marked as personal data using data privacy annotations won't get change-tracked anymore to satisfy product standards
57
+ - Restructured Documentation
58
+
59
+
60
+ ## Version 1.0.6 - 29.04.24
8
61
 
9
62
  ### Fixed
10
63
 
package/README.md CHANGED
@@ -1,43 +1,61 @@
1
1
  # Change Tracking Plugin for SAP Cloud Application Programming Model (CAP)
2
2
 
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
+
3
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)
4
6
 
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:
7
+ > [!IMPORTANT]
8
+ > Following the CAP best practices, the new release now requires CDS8 as minimum version in the peer dependencies. If you want to use the plugin with an older version of CAP (CDS7), you will need to manually update the peer dependency in `package.json`. Please be aware that there will be no support for this version of the plugin with a CDS version below 8!
9
+
10
+ > [!IMPORTANT]
11
+ > This release establishes support for multi-tenant deployments using MTX and extensibility.
12
+ >
13
+ > To achieve this, the code was modified significantly. While we tested extensively, there still may 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.
14
+ >
15
+ > See the changelog for a full list of changes
16
+
17
+ > [!Warning]
18
+ >
19
+ > Please note that if your project is multi-tenant, then the CDS version must be higher than 8.6 and the mtx version higher than 2.5 for change-tracking to work.
6
20
 
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)
21
+ > [!Warning]
22
+ >
23
+ > When using multi-tenancy with MTX, the generated facets and associations have to be created by the model provider of the MTX component. Therefore, the plugin also must be added to the `package.json` of the MTX sidecar.
24
+ >Although we tested this scenario extensively, there still might be cases where the automatic generation will not work as expected. If this happends in your scenario, we suggest using the `@changelog.disable_assoc` ([see here](#disable-association-to-changes-generation)) for all tracked entities and to add the association and facet manually to the service entity.
10
25
 
11
- <img width="1300" alt="change-history-loading" src="_assets/change-history.gif">
12
26
 
13
27
  ### Table of Contents
14
28
 
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)
29
+ - [Try it Locally](#try-it-locally)
30
+ - [Detailed Explanation](#detailed-explanation)
31
+ - [Human-readable Types and Fields](#human-readable-types-and-fields)
32
+ - [Human-readable IDs](#human-readable-ids)
33
+ - [Human-readable Values](#human-readable-values)
34
+ - [Advanced Options](#advanced-options)
35
+ - [Altered Table View](#altered-table-view)
36
+ - [Disable Lazy Loading](#disable-lazy-loading)
37
+ - [Disable UI Facet generation](#disable-ui-facet-generation)
29
38
  - [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)
39
+ - [Examples](#examples)
40
+ - [Specify Object ID](#specify-object-id)
41
+ - [Tracing Changes](#tracing-changes)
42
+ - [Don&#39;ts](#donts)
43
+ - [Contributing](#contributing)
44
+ - [Code of Conduct](#code-of-conduct)
45
+ - [Licensing](#licensing)
46
+
47
+ ## Try it Locally
48
+
49
+ 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.
50
+
51
+ 1. [Prerequisites](#1-prerequisites)
52
+ 2. [Setup](#2-setup)
53
+ 3. [Annotations](#3-annotations)
54
+ 4. [Testing](#4-testing)
37
55
 
38
- ## Preliminaries
56
+ ### 1. Prerequisites
39
57
 
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:
58
+ Clone the repository and apply the step-by-step instructions:
41
59
 
42
60
  ```sh
43
61
  git clone https://github.com/cap-js/incidents-app
@@ -57,18 +75,19 @@ npm i
57
75
  cds w samples/change-tracking
58
76
  ```
59
77
 
60
- ## Setup
78
+ ### 2. Setup
61
79
 
62
80
  To enable change tracking, simply add this self-configuring plugin package to your project:
63
81
 
64
82
  ```sh
65
83
  npm add @cap-js/change-tracking
66
84
  ```
85
+ If you use multi-tenancy, please add the plugin also to the MTX poroject(The mtx version must be higher than 2.5).
67
86
 
68
- ## Annotations
87
+ ### 3. Annotations
69
88
 
70
89
  > [!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).
90
+ > 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
91
 
73
92
  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
93
 
@@ -90,9 +109,33 @@ The minimal annotation we require for change tracking is `@changelog` on element
90
109
 
91
110
  Additional identifiers or labels can be added to obtain more *human-readable* change records as described below.
92
111
 
112
+ ### 4. Testing
113
+
114
+ With the steps above, we have successfully set up change tracking for our reference application. Let's see that in action.
115
+
116
+ 1. **Start the server**:
117
+
118
+ ```sh
119
+ cds watch
120
+ ```
121
+
122
+ 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.
123
+
124
+ #### Change History View
125
+
126
+ > [!IMPORTANT]
127
+ > To ensure proper lazy loading of the Change History table, please use **SAPUI5 version 1.120.0** or higher.`<br>`
128
+ > If you wish to *disable* this feature, please see the customization section on how to [disable lazy loading](#disable-lazy-loading).
129
+
130
+ <img width="1300" alt="change-history" src="_assets/changes.png">
131
+
132
+ 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.
133
+
134
+ ## Detailed Explanation
135
+
93
136
  ### Human-readable Types and Fields
94
137
 
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.
138
+ 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
139
 
97
140
  For example, without the `@title` annotation, changes to conversation entries would show up with the technical entity name:
98
141
 
@@ -116,10 +159,6 @@ These are already human-readable by default, unless the `@changelog` definition
116
159
 
117
160
  For example, having a `@changelog` annotation without any additional identifiers, changes to conversation entries would show up as simple entity IDs:
118
161
 
119
- ```cds
120
- annotate ProcessorService.Conversations {
121
- ```
122
-
123
162
  <img width="1300" alt="change-history-id" src="_assets/changes-id-wbox.png">
124
163
 
125
164
  However, this is not advisable as we cannot easily distinguish between changes. It is more appropriate to annotate as follows:
@@ -154,29 +193,7 @@ customer @changelog: [customer.name];
154
193
 
155
194
  <img width="1300" alt="change-history-value-hr" src="_assets/changes-value-hr-wbox.png">
156
195
 
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
196
+ ## Advanced Options
180
197
 
181
198
  ### Altered table view
182
199
 
@@ -231,9 +248,64 @@ For some scenarios, e.g. when doing `UNION` and the `@changelog` annotion is sti
231
248
  > [!IMPORTANT]
232
249
  > This will also supress the addition of the UI facet, since the change-view is not available as target entity anymore.
233
250
 
234
- ## Modelling Samples
251
+ ### Select types of changes to track
252
+
253
+ If you do not want to track some types of changes, you can disable them using `disableCreateTracking`, `disableUpdateTracking`
254
+ and `disableDeleteTracking` configs in your project settings:
255
+ ```json
256
+ {
257
+ "cds": {
258
+ "requires": {
259
+ "change-tracking": {
260
+ "disableCreateTracking": true,
261
+ "disableUpdateTracking": false,
262
+ "disableDeleteTracking": true
263
+ }
264
+ }
265
+ }
266
+ }
267
+ ```
268
+
269
+ ### Preserve change logs of deleted data
235
270
 
236
- This chapter describes more modelling cases for further reference, from simple to complex, including but not limited to the followings.
271
+ By default, deleting a record will also automatically delete all associated change logs. This helps reduce the impact on the size of the database.
272
+ You can turn this behavior off globally by adding the following switch to the `package.json` of your project
273
+
274
+ ```
275
+ ...
276
+ "cds": {
277
+ "requires": {
278
+ ...
279
+ "change-tracking": {
280
+ "preserveDeletes": true
281
+ }
282
+ ...
283
+ }
284
+ }
285
+ ...
286
+ ```
287
+ > [!IMPORTANT]
288
+ > 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.
289
+ > You must implement an own **data retention strategy** for the change logging table in order to manage the size and performance of your database.
290
+
291
+ ## Examples
292
+
293
+ This section describes modelling cases for further reference, from simple to complex, including the following:
294
+
295
+ - [Specify Object ID](#specify-object-id)
296
+ - [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)
297
+ - [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)
298
+ - [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)
299
+ - [Tracing Changes](#tracing-changes)
300
+ - [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)
301
+ - [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)
302
+ - [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)
303
+ - [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)
304
+ - [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)
305
+ - [Don&#39;ts](#donts)
306
+ - [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)
307
+ - [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)
308
+ - [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
309
 
238
310
  ### Specify Object ID
239
311
 
@@ -533,6 +605,7 @@ this.on("UpdateActivationStatus", async (req) =>
533
605
 
534
606
  The reason is that: Application level services are by design the only place where business logic is enforced. This by extension means, that it also is the only point where e.g. change-tracking would be enabled. The underlying method used to do change tracking is `req.diff` which is responsible to read the necessary before-image from the database, and this method is not available on DB level.
535
607
 
608
+
536
609
  ## Contributing
537
610
 
538
611
  This project is open to feature requests/suggestions, bug reports etc. via [GitHub issues](https://github.com/cap-js/change-tracking/issues). Contribution and feedback are encouraged and always welcome. For more information about how to contribute, the project structure, as well as additional contribution information, see our [Contribution Guidelines](CONTRIBUTING.md).
@@ -544,4 +617,3 @@ We as members, contributors, and leaders pledge to make participation in our com
544
617
  ## Licensing
545
618
 
546
619
  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,12 +1,21 @@
1
1
  const cds = require('@sap/cds')
2
+ const DEBUG = cds.debug('changelog')
2
3
 
3
- const isChangeTracked = (entity) => (
4
- (entity['@changelog']
5
- || entity.elements && Object.values(entity.elements).some(e => e['@changelog'])) && entity.query?.SET?.op !== 'union'
6
- )
4
+ const isRoot = 'change-tracking-isRootEntity'
5
+ const hasParent = 'change-tracking-parentEntity'
6
+
7
+ const isChangeTracked = (entity) => {
8
+ if (entity.query?.SET?.op === 'union') return false // REVISIT: should that be an error or warning?
9
+ if (entity['@changelog']) return true
10
+ if (Object.values(entity.elements).some(e => e['@changelog'])) return true
11
+ }
7
12
 
8
13
  // Add the appropriate Side Effects attribute to the custom action
9
14
  const addSideEffects = (actions, flag, element) => {
15
+ if (!flag && (element === undefined || element === null)) {
16
+ return
17
+ }
18
+
10
19
  for (const se of Object.values(actions)) {
11
20
  const target = flag ? 'TargetProperties' : 'TargetEntities'
12
21
  const sideEffectAttr = se[`@Common.SideEffects.${target}`]
@@ -23,76 +32,207 @@ const addSideEffects = (actions, flag, element) => {
23
32
  }
24
33
  }
25
34
 
35
+ function setChangeTrackingIsRootEntity (entity, csn, val = true) {
36
+ if (csn.definitions?.[entity.name]) {
37
+ csn.definitions[entity.name][isRoot] = val
38
+ }
39
+ }
40
+
41
+ function checkAndSetRootEntity (parentEntity, entity, csn) {
42
+ if (entity[isRoot] === false) {
43
+ return entity
44
+ }
45
+ if (parentEntity) {
46
+ return compositionRoot(parentEntity, csn)
47
+ } else {
48
+ setChangeTrackingIsRootEntity(entity, csn)
49
+ return { ...csn.definitions?.[entity.name], name: entity.name }
50
+ }
51
+ }
52
+
53
+ function processEntities (m) {
54
+ for (let name in m.definitions) {
55
+ compositionRoot({ ...m.definitions[name], name }, m)
56
+ }
57
+ }
58
+
59
+ function compositionRoot (entity, csn) {
60
+ if (!entity || entity.kind !== 'entity') {
61
+ return
62
+ }
63
+ const parentEntity = compositionParent(entity, csn)
64
+ return checkAndSetRootEntity(parentEntity, entity, csn)
65
+ }
66
+
67
+ function compositionParent (entity, csn) {
68
+ if (!entity || entity.kind !== 'entity') {
69
+ return
70
+ }
71
+ const parentAssociation = compositionParentAssociation(entity, csn)
72
+ return parentAssociation ?? null
73
+ }
74
+
75
+ function compositionParentAssociation (entity, csn) {
76
+ if (!entity || entity.kind !== 'entity') {
77
+ return
78
+ }
79
+ const elements = entity.elements ?? {}
80
+
81
+ // Add the change-tracking-isRootEntity attribute of the child entity
82
+ processCompositionElements(entity, csn, elements)
83
+
84
+ const hasChildFlag = entity[isRoot] !== false
85
+ const hasParentEntity = entity[hasParent]
86
+
87
+ if (hasChildFlag || !hasParentEntity) {
88
+ // Find parent association of the entity
89
+ const parentAssociation = findParentAssociation(entity, csn, elements)
90
+ if (parentAssociation) {
91
+ const parentAssociationTarget = elements[parentAssociation]?.target
92
+ if (hasChildFlag) setChangeTrackingIsRootEntity(entity, csn, false)
93
+ return {
94
+ ...csn.definitions?.[parentAssociationTarget],
95
+ name: parentAssociationTarget
96
+ }
97
+ } else return
98
+ }
99
+ return { ...csn.definitions?.[entity.name], name: entity.name }
100
+ }
101
+
102
+ function processCompositionElements (entity, csn, elements) {
103
+ for (const name in elements) {
104
+ const element = elements[name]
105
+ const target = element?.target
106
+ const definition = csn.definitions?.[target]
107
+ if (
108
+ element.type !== 'cds.Composition' ||
109
+ target === entity.name ||
110
+ !definition ||
111
+ definition[isRoot] === false
112
+ ) {
113
+ continue
114
+ }
115
+ setChangeTrackingIsRootEntity({ ...definition, name: target }, csn, false)
116
+ }
117
+ }
118
+
119
+ function findParentAssociation (entity, csn, elements) {
120
+ return Object.keys(elements).find((name) => {
121
+ const element = elements[name]
122
+ const target = element?.target
123
+ if (element.type === 'cds.Association' && target !== entity.name) {
124
+ const parentDefinition = csn.definitions?.[target] ?? {}
125
+ const parentElements = parentDefinition?.elements ?? {}
126
+ return !!Object.keys(parentElements).find((parentEntityName) => {
127
+ const parentElement = parentElements?.[parentEntityName] ?? {}
128
+ if (parentElement.type === 'cds.Composition') {
129
+ const isCompositionEntity = parentElement.target === entity.name
130
+ // add parent information in the current entity
131
+ if (isCompositionEntity) {
132
+ csn.definitions[entity.name][hasParent] = {
133
+ associationName: name,
134
+ entityName: target
135
+ }
136
+ }
137
+ return isCompositionEntity
138
+ }
139
+ })
140
+ }
141
+ })
142
+ }
143
+
144
+
145
+
146
+ /**
147
+ * Returns an expression for the key of the given entity, which we can use as the right-hand-side of an ON condition.
148
+ */
149
+ function entityKey4 (entity) {
150
+ const xpr = []
151
+ for (let k in entity.elements) {
152
+ const e = entity.elements[k]; if (!e.key) continue
153
+ if (xpr.length) xpr.push('||')
154
+ if (e.type === 'cds.Association') xpr.push({ ref: [k, e.keys?.[0]?.ref?.[0]] })
155
+ else xpr.push({ ref:[k] })
156
+ }
157
+ return xpr
158
+ }
159
+
26
160
 
27
161
  // Unfold @changelog annotations in loaded model
28
- cds.on('loaded', m => {
162
+ function enhanceModel (m) {
163
+
164
+ const _enhanced = 'sap.changelog.enhanced'
165
+ if (m.meta?.[_enhanced]) return // already enhanced
29
166
 
30
167
  // Get definitions from Dummy entity in our models
31
168
  const { 'sap.changelog.aspect': aspect } = m.definitions; if (!aspect) return // some other model
32
169
  const { '@UI.Facets': [facet], elements: { changes } } = aspect
33
- changes.on.pop() // remove ID -> filled in below
170
+ if (changes.on.length > 2) changes.on.pop() // remove ID -> filled in below
171
+
172
+ processEntities(m) // REVISIT: why is that required ?!?
34
173
 
35
174
  for (let name in m.definitions) {
175
+
36
176
  const entity = m.definitions[name]
37
- if (isChangeTracked(entity)) {
177
+ if (entity.kind === 'entity' && !entity['@cds.autoexposed'] && isChangeTracked(entity)) {
38
178
 
39
- // Determine entity keys
40
- const keys = [], { elements: elms } = entity
41
- for (let e in elms) if (elms[e].key) keys.push(e)
179
+ if (!entity['@changelog.disable_assoc']) {
42
180
 
43
- // If no key attribute is defined for the entity, the logic to add association to ChangeView should be skipped.
44
- if(keys.length === 0) {
45
- continue;
46
- }
181
+ // Add association to ChangeView...
182
+ const keys = entityKey4(entity); if (!keys.length) continue // If no key attribute is defined for the entity, the logic to add association to ChangeView should be skipped.
183
+ const assoc = { ...changes, on: [ ...changes.on, ...keys ] }
47
184
 
48
- // Add association to ChangeView...
49
- const on = [...changes.on]; keys.forEach((k, i) => { i && on.push('||'); on.push({
50
- ref: k === 'up_' ? [k,'ID'] : [k] // REVISIT: up_ handling is a dirty hack for now
51
- })})
52
- const assoc = { ...changes, on }
53
- const query = entity.projection || entity.query?.SELECT
54
- if(!entity['@changelog.disable_assoc'])
55
- {
56
- if (query) {
57
- (query.columns ??= ['*']).push({ as: 'changes', cast: assoc })
58
- } else {
59
- entity.elements.changes = assoc
60
- }
185
+ // --------------------------------------------------------------------
186
+ // PARKED: Add auto-exposed projection on ChangeView to service if applicable
187
+ // const namespace = name.match(/^(.*)\.[^.]+$/)[1]
188
+ // const service = m.definitions[namespace]
189
+ // if (service) {
190
+ // const projection = {from:{ref:[assoc.target]}}
191
+ // m.definitions[assoc.target = namespace + '.' + Changes] = {
192
+ // '@cds.autoexposed':true, kind:'entity', projection
193
+ // }
194
+ // DEBUG?.(`\n
195
+ // extend service ${namespace} with {
196
+ // entity ${Changes} as projection on ${projection.from.ref[0]};
197
+ // }
198
+ // `.replace(/ {10}/g,''))
199
+ // }
200
+ // --------------------------------------------------------------------
201
+
202
+ DEBUG?.(`\n
203
+ extend ${name} with {
204
+ changes : Association to many ${assoc.target} on ${ assoc.on.map(x => x.ref?.join('.') || x).join(' ') };
205
+ }
206
+ `.replace(/ {8}/g,''))
207
+ const query = entity.projection || entity.query?.SELECT
208
+ if (query) (query.columns ??= ['*']).push({ as: 'changes', cast: assoc })
209
+ else if (entity.elements) entity.elements.changes = assoc
61
210
 
62
- // Add UI.Facet for Change History List
63
- if(!entity['@changelog.disable_facet'])
64
- entity['@UI.Facets']?.push(facet)
211
+ // Add UI.Facet for Change History List
212
+ if (!entity['@changelog.disable_facet'])
213
+ entity['@UI.Facets']?.push(facet)
65
214
  }
66
- // The changehistory list should be refreshed after the custom action is triggered
67
- if (entity.actions) {
68
215
 
69
- // Update the changehistory list on the current entity when the custom action of the entity is triggered
70
- if (entity['@UI.Facets']) {
216
+ if (entity.actions) {
217
+ const hasParentInfo = entity[hasParent]
218
+ const entityName = hasParentInfo?.entityName
219
+ const parentEntity = entityName ? m.definitions[entityName] : null
220
+ const isParentRootAndHasFacets = parentEntity?.[isRoot] && parentEntity?.['@UI.Facets']
221
+ if (entity[isRoot] && entity['@UI.Facets']) {
222
+ // Add side effects for root entity
71
223
  addSideEffects(entity.actions, true)
72
- }
73
-
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
- }
224
+ } else if (isParentRootAndHasFacets) {
225
+ // Add side effects for child entity
226
+ addSideEffects(entity.actions, false, hasParentInfo?.associationName)
88
227
  }
89
228
  }
90
229
  }
91
230
  }
92
- })
231
+ (m.meta ??= {})[_enhanced] = true
232
+ }
93
233
 
94
234
  // Add generic change tracking handlers
95
- cds.on('served', () => {
235
+ function addGenericHandlers() {
96
236
  const { track_changes, _afterReadChangeView } = require("./lib/change-log")
97
237
  for (const srv of cds.services) {
98
238
  if (srv instanceof cds.ApplicationService) {
@@ -110,4 +250,11 @@ cds.on('served', () => {
110
250
  }
111
251
  }
112
252
  }
113
- })
253
+ }
254
+
255
+
256
+ // Register plugin hooks
257
+ cds.on('compile.for.runtime', csn => { DEBUG?.('on','compile.for.runtime'); enhanceModel(csn) })
258
+ cds.on('compile.to.edmx', csn => { DEBUG?.('on','compile.to.edmx'); enhanceModel(csn) })
259
+ cds.on('compile.to.dbx', csn => { DEBUG?.('on','compile.to.dbx'); enhanceModel(csn) })
260
+ cds.on('served', addGenericHandlers)
package/index.cds CHANGED
@@ -4,7 +4,7 @@ namespace sap.changelog;
4
4
  /**
5
5
  * Used in cds-plugin.js as template for tracked entities
6
6
  */
7
- aspect aspect @(UI.Facets: [{
7
+ @cds.persistence.skip entity aspect @(UI.Facets: [{
8
8
  $Type : 'UI.ReferenceFacet',
9
9
  ID : 'ChangeHistoryFacet',
10
10
  Label : '{i18n>ChangeHistory}',
@@ -37,15 +37,14 @@ view ChangeView as
37
37
  * Top-level changes entity, e.g. UPDATE Incident by, at, ...
38
38
  */
39
39
  entity ChangeLog : managed, cuid {
40
- serviceEntity : String @title: '{i18n>ChangeLog.serviceEntity}'; // definition name of target entity (on service level) - e.g. ProcessorsService.Incidents
41
- entity : String @title: '{i18n>ChangeLog.entity}'; // definition name of target entity (on db level) - e.g. sap.capire.incidents.Incidents
40
+ serviceEntity : String(5000) @title: '{i18n>ChangeLog.serviceEntity}'; // definition name of target entity (on service level) - e.g. ProcessorsService.Incidents
41
+ entity : String(5000) @title: '{i18n>ChangeLog.entity}'; // definition name of target entity (on db level) - e.g. sap.capire.incidents.Incidents
42
42
  entityKey : UUID @title: '{i18n>ChangeLog.entityKey}'; // primary key of target entity, e.g. Incidents.ID
43
43
  createdAt : managed:createdAt;
44
44
  createdBy : managed:createdBy;
45
45
  changes : Composition of many Changes on changes.changeLog = $self;
46
46
  }
47
47
 
48
-
49
48
  /**
50
49
  * Attribute-level Changes with simple capturing of one-level
51
50
  * composition trees in parent... elements.
@@ -53,30 +52,30 @@ entity ChangeLog : managed, cuid {
53
52
  entity Changes {
54
53
 
55
54
  key ID : UUID @UI.Hidden;
56
- keys : String @title: '{i18n>Changes.keys}';
57
- attribute : String @title: '{i18n>Changes.attribute}';
58
- valueChangedFrom : String @title: '{i18n>Changes.valueChangedFrom}';
59
- valueChangedTo : String @title: '{i18n>Changes.valueChangedTo}';
55
+ keys : String(5000) @title: '{i18n>Changes.keys}';
56
+ attribute : String(5000) @title: '{i18n>Changes.attribute}';
57
+ valueChangedFrom : String(5000) @title: '{i18n>Changes.valueChangedFrom}' @UI.MultiLineText;
58
+ valueChangedTo : String(5000) @title: '{i18n>Changes.valueChangedTo}' @UI.MultiLineText;
60
59
 
61
60
  // Business meaningful object id
62
- entityID : String @title: '{i18n>Changes.entityID}';
63
- entity : String @title: '{i18n>Changes.entity}'; // similar to ChangeLog.entity, but could be nested entity in a composition tree
64
- serviceEntity : String @title: '{i18n>Changes.serviceEntity}'; // similar to ChangeLog.serviceEntity, but could be nested entity in a composition tree
61
+ entityID : String(5000) @title: '{i18n>Changes.entityID}';
62
+ entity : String(5000) @title: '{i18n>Changes.entity}'; // similar to ChangeLog.entity, but could be nested entity in a composition tree
63
+ serviceEntity : String(5000) @title: '{i18n>Changes.serviceEntity}'; // similar to ChangeLog.serviceEntity, but could be nested entity in a composition tree
65
64
 
66
65
  // Business meaningful parent object id
67
- parentEntityID : String @title: '{i18n>Changes.parentEntityID}';
66
+ parentEntityID : String(5000) @title: '{i18n>Changes.parentEntityID}';
68
67
  parentKey : UUID @title: '{i18n>Changes.parentKey}';
69
- serviceEntityPath : String @title: '{i18n>Changes.serviceEntityPath}';
68
+ serviceEntityPath : String(5000) @title: '{i18n>Changes.serviceEntityPath}';
70
69
 
71
70
  @title: '{i18n>Changes.modification}'
72
71
  modification : String enum {
73
- create = 'Create';
74
- update = 'Edit';
75
- delete = 'Delete';
72
+ Create = 'create';
73
+ Update = 'update';
74
+ Delete = 'delete';
76
75
  };
77
76
 
78
- valueDataType : String @title: '{i18n>Changes.valueDataType}';
79
- changeLog : Association to ChangeLog @title: '{i18n>ChangeLog.ID}';
77
+ valueDataType : String(5000) @title: '{i18n>Changes.valueDataType}';
78
+ changeLog : Association to ChangeLog @title: '{i18n>ChangeLog.ID}' @UI.Hidden;
80
79
  }
81
80
 
82
81
  annotate ChangeView with @(UI: {
package/lib/change-log.js CHANGED
@@ -15,8 +15,28 @@ 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
 
21
+ function formatDecimal(str, scale) {
22
+ if (typeof str === "number" && !isNaN(str)) {
23
+ str = String(str);
24
+ } else return str;
25
+
26
+ if (scale > 0) {
27
+ let parts = str.split(".");
28
+ let decimalPart = parts[1] || "";
29
+
30
+ while (decimalPart.length < scale) {
31
+ decimalPart += "0";
32
+ }
33
+
34
+ return `${parts[0]}.${decimalPart}`;
35
+ }
36
+
37
+ return str;
38
+ }
39
+
20
40
  const _getRootEntityPathVals = function (txContext, entity, entityKey) {
21
41
  const serviceEntityPathVals = []
22
42
  const entityIDs = _getEntityIDs(txContext.params)
@@ -26,21 +46,22 @@ const _getRootEntityPathVals = function (txContext, entity, entityKey) {
26
46
  if (txContext.event === "CREATE") {
27
47
  const curEntityPathVal = `${entity.name}(${entityKey})`
28
48
  serviceEntityPathVals.push(curEntityPathVal)
49
+ txContext.hasComp && entityIDs.pop();
29
50
  } else {
30
51
  // When deleting Composition of one node via REST API in draft-disabled mode,
31
52
  // the child node ID would be missing in URI
32
53
  if (txContext.event === "DELETE" && !entityIDs.find((x) => x === entityKey)) {
33
54
  entityIDs.push(entityKey)
34
55
  }
35
- const curEntity = getEntityByContextPath(path)
56
+ const curEntity = getEntityByContextPath(path, txContext.hasComp)
36
57
  const curEntityID = entityIDs.pop()
37
58
  const curEntityPathVal = `${curEntity.name}(${curEntityID})`
38
59
  serviceEntityPathVals.push(curEntityPathVal)
39
60
  }
40
61
 
41
62
 
42
- while (_isCompositionContextPath(path)) {
43
- const hostEntity = getEntityByContextPath(path = path.slice(0, -1))
63
+ while (_isCompositionContextPath(path, txContext.hasComp)) {
64
+ const hostEntity = getEntityByContextPath(path = path.slice(0, -1), txContext.hasComp)
44
65
  const hostEntityID = entityIDs.pop()
45
66
  const hostEntityPathVal = `${hostEntity.name}(${hostEntityID})`
46
67
  serviceEntityPathVals.unshift(hostEntityPathVal)
@@ -55,7 +76,7 @@ const _getAllPathVals = function (txContext) {
55
76
  const entityIDs = _getEntityIDs(txContext.params)
56
77
 
57
78
  for (let idx = 0; idx < paths.length; idx++) {
58
- const entity = getEntityByContextPath(paths.slice(0, idx + 1))
79
+ const entity = getEntityByContextPath(paths.slice(0, idx + 1), txContext.hasComp)
59
80
  const entityID = entityIDs[idx]
60
81
  const entityPathVal = `${entity.name}(${entityID})`
61
82
  pathVals.push(entityPathVal)
@@ -64,6 +85,28 @@ const _getAllPathVals = function (txContext) {
64
85
  return pathVals
65
86
  }
66
87
 
88
+ function convertSubjectToParams(subject) {
89
+ let params = [];
90
+ let subjectRef = [];
91
+ subject?.ref?.forEach((item)=>{
92
+ if (typeof item === 'string') {
93
+ subjectRef.push(item)
94
+ return
95
+ }
96
+
97
+ const keys = {}
98
+ let id = item.id
99
+ if (!id) return
100
+ for (let j = 0; j < item?.where?.length; j = j + 4) {
101
+ const key = item.where[j].ref[0]
102
+ const value = item.where[j + 2].val
103
+ if (key !== 'IsActiveEntity') keys[key] = value
104
+ }
105
+ params.push(keys);
106
+ })
107
+ return params.length > 0 ? params : subjectRef;
108
+ }
109
+
67
110
  const _getEntityIDs = function (txParams) {
68
111
  const entityIDs = []
69
112
  for (const param of txParams) {
@@ -144,7 +187,7 @@ const _formatCompositionContext = async function (changes, reqData) {
144
187
  const childNodeChanges = []
145
188
 
146
189
  for (const change of changes) {
147
- if (typeof change.valueChangedTo === "object") {
190
+ if (typeof change.valueChangedTo === "object" && change.valueChangedTo instanceof Date !== true) {
148
191
  if (!Array.isArray(change.valueChangedTo)) {
149
192
  change.valueChangedTo = [change.valueChangedTo]
150
193
  }
@@ -173,7 +216,9 @@ const _formatCompositionValue = function (
173
216
  childNodeChange,
174
217
  childNodeChanges
175
218
  ) {
176
- if (curChange.modification === "delete") {
219
+ if (curChange.modification === undefined) {
220
+ return
221
+ } else if (curChange.modification === "delete") {
177
222
  curChange.valueChangedFrom = objId
178
223
  curChange.valueChangedTo = ""
179
224
  } else if (curChange.modification === "update") {
@@ -255,12 +300,12 @@ const _formatObjectID = async function (changes, reqData) {
255
300
  }
256
301
  }
257
302
 
258
- const _isCompositionContextPath = function (aPath) {
303
+ const _isCompositionContextPath = function (aPath, hasComp) {
259
304
  if (!aPath) return
260
305
  if (typeof aPath === 'string') aPath = aPath.split('/')
261
306
  if (aPath.length < 2) return false
262
- const target = getEntityByContextPath(aPath)
263
- const parent = getEntityByContextPath(aPath.slice(0, -1))
307
+ const target = getEntityByContextPath(aPath, hasComp)
308
+ const parent = getEntityByContextPath(aPath.slice(0, -1), hasComp)
264
309
  if (!parent.compositions) return false
265
310
  return Object.values(parent.compositions).some(c => c._target === target)
266
311
  }
@@ -289,10 +334,48 @@ function _trackedChanges4 (srv, target, diff) {
289
334
  template, row: diff, processFn: ({ row, key, element }) => {
290
335
  const from = row._old?.[key]
291
336
  const to = row[key]
337
+ const eleParentKeys = element.parent.keys
292
338
  if (from === to) return
293
339
 
294
- const keys = Object.keys(element.parent.keys)
340
+ /**
341
+ *
342
+ * HANA driver always filling up the defined decimal places with zeros,
343
+ * need to skip the change log if the value is not changed.
344
+ * Example:
345
+ * entity Books : cuid {
346
+ * price : Decimal(11, 4);
347
+ * }
348
+ * When price is updated from 3000.0000 to 3000,
349
+ * the change log should not be created.
350
+ */
351
+ if (
352
+ row._op === "update" &&
353
+ element.type === "cds.Decimal" &&
354
+ cds.db.kind === "hana" &&
355
+ typeof to === "number"
356
+ ) {
357
+ const scaleNum = element.scale || 0;
358
+ if (from === formatDecimal(to, scaleNum))
359
+ return;
360
+ }
361
+
362
+ /**
363
+ *
364
+ * For the Inline entity such as Items,
365
+ * further filtering is required on the keys
366
+ * within the 'association' and 'foreign key' to ultimately retain the keys of the entity itself.
367
+ * entity Order : cuid {
368
+ * title : String;
369
+ * Items : Composition of many {
370
+ * key ID : UUID;
371
+ * quantity : Integer;
372
+ * }
373
+ * }
374
+ */
375
+ const keys = Object.keys(eleParentKeys)
295
376
  .filter(k => k !== "IsActiveEntity")
377
+ .filter(k => eleParentKeys[k]?.type !== "cds.Association") // Skip association
378
+ .filter(k => !eleParentKeys[k]?.["@odata.foreignKey4"]) // Skip foreign key
296
379
  .map(k => `${k}=${row[k]}`)
297
380
  .join(', ')
298
381
 
@@ -339,20 +422,103 @@ const _prepareChangeLogForComposition = async function (entity, entityKey, chang
339
422
  return [ rootEntity, rootEntityID ]
340
423
  }
341
424
 
425
+ async function generatePathAndParams (req, entityKey) {
426
+ const { target, data } = req;
427
+ const { ID, foreignKey, parentEntity } = getAssociationDetails(target);
428
+ const hasParentAndForeignKey = parentEntity && data[foreignKey];
429
+ const targetEntity = hasParentAndForeignKey ? parentEntity : target;
430
+ const targetKey = hasParentAndForeignKey ? data[foreignKey] : entityKey;
431
+
432
+ let compContext = {
433
+ path: hasParentAndForeignKey
434
+ ? `${parentEntity.name}/${target.name}`
435
+ : `${target.name}`,
436
+ params: hasParentAndForeignKey
437
+ ? [{ [ID]: data[foreignKey] }, { [ID]: entityKey }]
438
+ : [{ [ID]: entityKey }],
439
+ hasComp: true
440
+ };
441
+
442
+ if (hasParentAndForeignKey && parentEntity[isRoot]) {
443
+ return compContext;
444
+ }
445
+
446
+ let parentAssoc = await processEntity(targetEntity, targetKey, compContext);
447
+ while (parentAssoc && !parentAssoc.entity[isRoot]) {
448
+ parentAssoc = await processEntity(
449
+ parentAssoc.entity,
450
+ parentAssoc.ID,
451
+ compContext
452
+ );
453
+ }
454
+ return compContext;
455
+ }
456
+
457
+ async function processEntity (entity, entityKey, compContext) {
458
+ const { ID, foreignKey, parentEntity } = getAssociationDetails(entity);
459
+
460
+ if (foreignKey && parentEntity) {
461
+ const parentResult =
462
+ (await SELECT.one
463
+ .from(entity.name)
464
+ .where({ [ID]: entityKey })
465
+ .columns(foreignKey)) || {};
466
+ const hasForeignKey = parentResult[foreignKey];
467
+ if (!hasForeignKey) return;
468
+ compContext.path = `${parentEntity.name}/${compContext.path}`;
469
+ compContext.params.unshift({ [ID]: parentResult[foreignKey] });
470
+ return {
471
+ entity: parentEntity,
472
+ [ID]: hasForeignKey ? parentResult[foreignKey] : undefined
473
+ };
474
+ }
475
+ }
476
+
477
+ function getAssociationDetails (entity) {
478
+ if (!entity) return {};
479
+ const assocName = entity['change-tracking-parentEntity']?.associationName;
480
+ const assoc = entity.elements[assocName];
481
+ const parentEntity = assoc?._target;
482
+ const foreignKey = assoc?.keys?.[0]?.$generatedFieldName;
483
+ const ID = assoc?.keys?.[0]?.ref[0] || 'ID';
484
+ return { ID, foreignKey, parentEntity };
485
+ }
486
+
487
+ function isEmpty(value) {
488
+ return value === null || value === undefined || value === "";
489
+ }
342
490
 
343
491
  async function track_changes (req) {
492
+ const config = cds.env.requires["change-tracking"];
493
+
494
+ if (
495
+ (req.event === 'UPDATE' && config?.disableUpdateTracking) ||
496
+ (req.event === 'CREATE' && config?.disableCreateTracking) ||
497
+ (req.event === 'DELETE' && config?.disableDeleteTracking)
498
+ ) {
499
+ return;
500
+ }
501
+
344
502
  let diff = await req.diff()
345
503
  if (!diff) return
346
504
 
347
505
  let target = req.target
348
- let isDraftEnabled = !!target.drafts
349
- let isComposition = _isCompositionContextPath(req.context.path)
506
+ let compContext = null;
350
507
  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
- }
508
+ const params = convertSubjectToParams(req.subject);
509
+ if (req.subject.ref.length === 1 && params.length === 1 && !target[isRoot]) {
510
+ compContext = await generatePathAndParams(req, entityKey);
511
+ }
512
+ let isComposition = _isCompositionContextPath(
513
+ compContext?.path || req.path,
514
+ compContext?.hasComp
515
+ );
516
+ if (
517
+ req.event === "DELETE" &&
518
+ target[isRoot] &&
519
+ !cds.env.requires["change-tracking"]?.preserveDeletes
520
+ ) {
521
+ return await DELETE.from(`sap.changelog.ChangeLog`).where({ entityKey });
356
522
  }
357
523
 
358
524
  let changes = _trackedChanges4(this, target, diff)
@@ -360,14 +526,23 @@ async function track_changes (req) {
360
526
 
361
527
  await _formatChangeLog(changes, req)
362
528
  if (isComposition) {
363
- [ target, entityKey ] = await _prepareChangeLogForComposition(target, entityKey, changes, this)
529
+ let reqInfo = {
530
+ data: req.data,
531
+ context: {
532
+ path: compContext?.path || req.path,
533
+ params: compContext?.params || params,
534
+ event: req.event,
535
+ hasComp: compContext?.hasComp
536
+ }
537
+ };
538
+ [ target, entityKey ] = await _prepareChangeLogForComposition(target, entityKey, changes, reqInfo)
364
539
  }
365
540
  const dbEntity = getDBEntity(target)
366
541
  await INSERT.into("sap.changelog.ChangeLog").entries({
367
542
  entity: dbEntity.name,
368
543
  entityKey: entityKey,
369
- serviceEntity: target.name,
370
- changes: changes.filter(c => c.valueChangedFrom || c.valueChangedTo).map((c) => ({
544
+ serviceEntity: target.name || target,
545
+ changes: changes.filter(c => !isEmpty(c.valueChangedFrom) || !isEmpty(c.valueChangedTo)).map((c) => ({
371
546
  ...c,
372
547
  valueChangedFrom: `${c.valueChangedFrom ?? ''}`,
373
548
  valueChangedTo: `${c.valueChangedTo ?? ''}`,
@@ -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
 
@@ -98,7 +98,7 @@ const _getLabelI18nKeyOnEntity = function (entityName, /** optinal */ attribute)
98
98
  let def = cds.model.definitions[entityName];
99
99
  if (attribute) def = def?.elements[attribute]
100
100
  if (!def) return "";
101
- return def['@Common.Label'] || def['@title'];
101
+ return def['@Common.Label'] || def['@title'] || def['@UI.HeaderInfo.TypeName'];
102
102
  };
103
103
 
104
104
  const localizeLogFields = function (data, locale) {
@@ -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.8",
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": ">=8"
22
22
  },
23
23
  "devDependencies": {
24
24
  "@cap-js/change-tracking": "file:.",