@cap-js/change-tracking 1.0.5 → 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 +42 -0
- package/README.md +419 -70
- package/cds-plugin.js +138 -20
- package/index.cds +2 -2
- package/lib/change-log.js +141 -22
- package/lib/entity-helper.js +57 -8
- package/lib/template-processor.js +3 -1
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,48 @@ 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.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
|
|
34
|
+
|
|
35
|
+
### Fixed
|
|
36
|
+
|
|
37
|
+
- Storage of wrong ObjectID in some special scenarios
|
|
38
|
+
- Missing localization of managed fields
|
|
39
|
+
- Views without keys won't get the association and UI facet pushed anymore
|
|
40
|
+
|
|
41
|
+
### Added
|
|
42
|
+
|
|
43
|
+
- A method to disable automatic generation of the UI Facet
|
|
44
|
+
|
|
45
|
+
### Changed
|
|
46
|
+
|
|
47
|
+
- Improved documentation of the @changelog Annotation
|
|
48
|
+
|
|
7
49
|
## Version 1.0.5 - 15.01.24
|
|
8
50
|
|
|
9
51
|
### Fixed
|
package/README.md
CHANGED
|
@@ -1,41 +1,49 @@
|
|
|
1
1
|
# Change Tracking Plugin for SAP Cloud Application Programming Model (CAP)
|
|
2
2
|
|
|
3
|
-
[
|
|
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:
|
|
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
|
|
6
4
|
|
|
7
|
-
|
|
8
|
-
2. [Add `@changelog` annotations to your CDS models](#annotations)
|
|
9
|
-
3. [Et voilà:](#change-history-view)
|
|
10
|
-
|
|
11
|
-
<img width="1300" alt="change-history-loading" src="_assets/change-history.gif">
|
|
5
|
+
[](https://api.reuse.software/info/github.com/cap-js/change-tracking)
|
|
12
6
|
|
|
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
|
|
13
13
|
|
|
14
14
|
|
|
15
15
|
### Table of Contents
|
|
16
16
|
|
|
17
|
-
- [
|
|
18
|
-
|
|
19
|
-
- [
|
|
20
|
-
- [
|
|
21
|
-
- [
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
- [
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
- [
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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)
|
|
26
|
+
- [Disable Association to Changes Generation](#disable-association-to-changes-generation)
|
|
27
|
+
- [Examples](#examples)
|
|
28
|
+
- [Specify Object ID](#specify-object-id)
|
|
29
|
+
- [Tracing Changes](#tracing-changes)
|
|
30
|
+
- [Don'ts](#donts)
|
|
31
|
+
- [Contributing](#contributing)
|
|
32
|
+
- [Code of Conduct](#code-of-conduct)
|
|
33
|
+
- [Licensing](#licensing)
|
|
34
|
+
|
|
35
|
+
## Try it Locally
|
|
36
|
+
|
|
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.
|
|
38
|
+
|
|
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:
|
|
39
47
|
|
|
40
48
|
```sh
|
|
41
49
|
git clone https://github.com/cap-js/incidents-app
|
|
@@ -55,9 +63,7 @@ npm i
|
|
|
55
63
|
cds w samples/change-tracking
|
|
56
64
|
```
|
|
57
65
|
|
|
58
|
-
|
|
59
|
-
|
|
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,12 +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
|
-
|
|
69
|
-
|
|
70
|
-
## Annotations
|
|
74
|
+
### 3. Annotations
|
|
71
75
|
|
|
72
76
|
> [!WARNING]
|
|
73
|
-
> Please be aware that [**sensitive** or **personal** data](https://cap.cloud.sap/docs/guides/data-privacy/annotations#annotating-personal-data)
|
|
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).
|
|
74
78
|
|
|
75
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_:
|
|
76
80
|
|
|
@@ -92,10 +96,33 @@ The minimal annotation we require for change tracking is `@changelog` on element
|
|
|
92
96
|
|
|
93
97
|
Additional identifiers or labels can be added to obtain more *human-readable* change records as described below.
|
|
94
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
|
|
95
122
|
|
|
96
123
|
### Human-readable Types and Fields
|
|
97
124
|
|
|
98
|
-
By default the implementation looks up *Object Type* names or *Field*
|
|
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.
|
|
99
126
|
|
|
100
127
|
For example, without the `@title` annotation, changes to conversation entries would show up with the technical entity name:
|
|
101
128
|
|
|
@@ -111,7 +138,6 @@ We get a human-readable display for *Object Type*:
|
|
|
111
138
|
|
|
112
139
|
<img width="1300" alt="change-history-type-hr" src="_assets/changes-type-hr-wbox.png">
|
|
113
140
|
|
|
114
|
-
|
|
115
141
|
### Human-readable IDs
|
|
116
142
|
|
|
117
143
|
The changelog annotations for *Object ID* are defined at entity level.
|
|
@@ -120,9 +146,6 @@ These are already human-readable by default, unless the `@changelog` definition
|
|
|
120
146
|
|
|
121
147
|
For example, having a `@changelog` annotation without any additional identifiers, changes to conversation entries would show up as simple entity IDs:
|
|
122
148
|
|
|
123
|
-
```cds
|
|
124
|
-
annotate ProcessorService.Conversations {
|
|
125
|
-
```
|
|
126
149
|
<img width="1300" alt="change-history-id" src="_assets/changes-id-wbox.png">
|
|
127
150
|
|
|
128
151
|
However, this is not advisable as we cannot easily distinguish between changes. It is more appropriate to annotate as follows:
|
|
@@ -130,11 +153,11 @@ However, this is not advisable as we cannot easily distinguish between changes.
|
|
|
130
153
|
```cds
|
|
131
154
|
annotate ProcessorService.Conversations with @changelog: [author, timestamp] {
|
|
132
155
|
```
|
|
156
|
+
|
|
133
157
|
<img width="1300" alt="change-history-id-hr" src="_assets/changes-id-hr-wbox.png">
|
|
134
158
|
|
|
135
159
|
Expanding the changelog annotation by additional identifiers `[author, timestamp]`, we can now better identify the `message` change events by their respective author and timestamp.
|
|
136
160
|
|
|
137
|
-
|
|
138
161
|
### Human-readable Values
|
|
139
162
|
|
|
140
163
|
The changelog annotations for *New Value* and *Old Value* are defined at element level.
|
|
@@ -144,7 +167,7 @@ They are already human-readable by default, unless the `@changelog` definition c
|
|
|
144
167
|
For example, having a `@changelog` annotation without any additional identifiers, changes to incident customer would show up as UUIDs:
|
|
145
168
|
|
|
146
169
|
```cds
|
|
147
|
-
|
|
170
|
+
customer @changelog;
|
|
148
171
|
```
|
|
149
172
|
|
|
150
173
|
<img width="1300" alt="change-history-value" src="_assets/changes-value-wbox.png">
|
|
@@ -152,33 +175,12 @@ For example, having a `@changelog` annotation without any additional identifiers
|
|
|
152
175
|
Hence, here it is essential to add a unique identifier to obtain human-readable value columns:
|
|
153
176
|
|
|
154
177
|
```cds
|
|
155
|
-
|
|
178
|
+
customer @changelog: [customer.name];
|
|
156
179
|
```
|
|
157
180
|
|
|
158
181
|
<img width="1300" alt="change-history-value-hr" src="_assets/changes-value-hr-wbox.png">
|
|
159
182
|
|
|
160
|
-
|
|
161
|
-
## Test-drive locally
|
|
162
|
-
|
|
163
|
-
With the steps above, we have successfully set up change tracking for our reference application. Let's see that in action.
|
|
164
|
-
|
|
165
|
-
1. **Start the server**:
|
|
166
|
-
```sh
|
|
167
|
-
cds watch
|
|
168
|
-
```
|
|
169
|
-
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.
|
|
170
|
-
|
|
171
|
-
## Change History View
|
|
172
|
-
|
|
173
|
-
> [!IMPORTANT]
|
|
174
|
-
> To ensure proper lazy loading of the Change History table, please use **SAPUI5 version 1.120.0** or higher.<br>
|
|
175
|
-
> If you wish to *disable* this feature, please see the customization section on how to [disable lazy loading](#disable-lazy-loading).
|
|
176
|
-
|
|
177
|
-
<img width="1300" alt="change-history" src="_assets/changes.png">
|
|
178
|
-
|
|
179
|
-
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.
|
|
180
|
-
|
|
181
|
-
## Customizations
|
|
183
|
+
## Advanced Options
|
|
182
184
|
|
|
183
185
|
### Altered table view
|
|
184
186
|
|
|
@@ -218,21 +220,368 @@ annotate sap.changelog.aspect @(UI.Facets: [{
|
|
|
218
220
|
Target: 'changes/@UI.PresentationVariant',
|
|
219
221
|
![@UI.PartOfPreview]
|
|
220
222
|
}]);
|
|
221
|
-
|
|
222
223
|
```
|
|
223
224
|
|
|
224
225
|
The system now uses the SAPUI5 default setting `![@UI.PartOfPreview]: true`, such that the table will always shown when navigating to that respective Object page.
|
|
225
226
|
|
|
227
|
+
### Disable UI Facet generation
|
|
228
|
+
|
|
229
|
+
If you do not want the UI facet added to a specific UI, you can annotate the service entity with `@changelog.disable_facet`. This will disable the automatic addition of the UI faced to this specific entity, but also all views or further projections up the chain.
|
|
230
|
+
|
|
231
|
+
### Disable Association to Changes Generation
|
|
232
|
+
|
|
233
|
+
For some scenarios, e.g. when doing `UNION` and the `@changelog` annotion is still propageted, the automatic addition of the association to `changes` does not make sense. You can use `@changelog.disable_assoc`for this to be disabled on entity level.
|
|
234
|
+
|
|
235
|
+
> [!IMPORTANT]
|
|
236
|
+
> This will also supress the addition of the UI facet, since the change-view is not available as target entity anymore.
|
|
237
|
+
|
|
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
|
|
242
|
+
|
|
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'ts](#donts)
|
|
275
|
+
- [Use Case 1: Don'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'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't trace changes for CUD on DB entity](#use-case-3-dont-trace-changes-for-cud-on-db-entity)
|
|
278
|
+
|
|
279
|
+
### Specify Object ID
|
|
280
|
+
|
|
281
|
+
Use cases for Object ID annotation
|
|
282
|
+
|
|
283
|
+
#### Use Case 1: Annotate single field/multiple fields of associated table(s) as the Object ID
|
|
284
|
+
|
|
285
|
+
Modelling in `db/schema.cds`
|
|
286
|
+
|
|
287
|
+
```cds
|
|
288
|
+
entity Incidents : cuid, managed {
|
|
289
|
+
...
|
|
290
|
+
customer : Association to Customers;
|
|
291
|
+
title : String @title: 'Title';
|
|
292
|
+
urgency : Association to Urgency default 'M';
|
|
293
|
+
status : Association to Status default 'N';
|
|
294
|
+
...
|
|
295
|
+
}
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
Add the following `@changelog` annotations in `srv/change-tracking.cds`
|
|
299
|
+
|
|
300
|
+
```cds
|
|
301
|
+
annotate ProcessorService.Incidents with @changelog: [customer.name, urgency.code, status.criticality] {
|
|
302
|
+
title @changelog;
|
|
303
|
+
}
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+

|
|
307
|
+
|
|
308
|
+
#### Use Case 2: Annotate single field/multiple fields of project customized types as the Object ID
|
|
309
|
+
|
|
310
|
+
Modelling in `db/schema.cds`
|
|
311
|
+
|
|
312
|
+
```cds
|
|
313
|
+
entity Incidents : cuid, managed {
|
|
314
|
+
...
|
|
315
|
+
customer : Association to Customers;
|
|
316
|
+
title : String @title: 'Title';
|
|
317
|
+
...
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
entity Customers : cuid, managed {
|
|
321
|
+
...
|
|
322
|
+
email : EMailAddress; // customized type
|
|
323
|
+
phone : PhoneNumber; // customized type
|
|
324
|
+
...
|
|
325
|
+
}
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
Add the following `@changelog` annotations in `srv/change-tracking.cds`
|
|
329
|
+
|
|
330
|
+
```cds
|
|
331
|
+
annotate ProcessorService.Incidents with @changelog: [customer.email, customer.phone] {
|
|
332
|
+
title @changelog;
|
|
333
|
+
}
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+

|
|
337
|
+
|
|
338
|
+
#### Use Case 3: Annotate chained associated entities from the current entity as the Object ID
|
|
339
|
+
|
|
340
|
+
Modelling in `db/schema.cds`
|
|
341
|
+
|
|
342
|
+
```cds
|
|
343
|
+
entity Incidents : cuid, managed {
|
|
344
|
+
...
|
|
345
|
+
customer : Association to Customers;
|
|
346
|
+
...
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
entity Customers : cuid, managed {
|
|
350
|
+
...
|
|
351
|
+
addresses : Association to Addresses;
|
|
352
|
+
...
|
|
353
|
+
}
|
|
354
|
+
```
|
|
355
|
+
|
|
356
|
+
Add the following `@changelog` annotations in `srv/change-tracking.cds`
|
|
357
|
+
|
|
358
|
+
```cds
|
|
359
|
+
annotate ProcessorService.Incidents with @changelog: [customer.addresses.city, customer.addresses.postCode] {
|
|
360
|
+
title @changelog;
|
|
361
|
+
}
|
|
362
|
+
```
|
|
363
|
+
|
|
364
|
+

|
|
365
|
+
|
|
366
|
+
> Change-tracking supports annotating chained associated entities from the current entity as object ID of current entity in case the entity in consumer applications is a pure relation table. However, the usage of chained associated entities is not recommended due to performance cost.
|
|
367
|
+
|
|
368
|
+
### Tracing Changes
|
|
369
|
+
|
|
370
|
+
Use cases for tracing changes
|
|
371
|
+
|
|
372
|
+
#### Use Case 1: Trace the changes of child nodes from the current entity and display the meaningful data from child nodes (composition relation)
|
|
373
|
+
|
|
374
|
+
Modelling in `db/schema.cds`
|
|
375
|
+
|
|
376
|
+
```cds
|
|
377
|
+
entity Incidents : managed, cuid {
|
|
378
|
+
...
|
|
379
|
+
title : String @title: 'Title';
|
|
380
|
+
conversation : Composition of many Conversation;
|
|
381
|
+
...
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
aspect Conversation: managed, cuid {
|
|
385
|
+
...
|
|
386
|
+
message : String;
|
|
387
|
+
}
|
|
388
|
+
```
|
|
389
|
+
|
|
390
|
+
Add the following `@changelog` annotations in `srv/change-tracking.cds`
|
|
391
|
+
|
|
392
|
+
```cds
|
|
393
|
+
annotate ProcessorService.Incidents with @changelog: [title] {
|
|
394
|
+
conversation @changelog: [conversation.message];
|
|
395
|
+
}
|
|
396
|
+
```
|
|
397
|
+
|
|
398
|
+

|
|
399
|
+
|
|
400
|
+
#### Use Case 2: Trace the changes of associated entities from the current entity and display the meaningful data from associated entities (association relation)
|
|
401
|
+
|
|
402
|
+
Modelling in `db/schema.cds`
|
|
403
|
+
|
|
404
|
+
```cds
|
|
405
|
+
entity Incidents : cuid, managed {
|
|
406
|
+
...
|
|
407
|
+
customer : Association to Customers;
|
|
408
|
+
title : String @title: 'Title';
|
|
409
|
+
...
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
entity Customers : cuid, managed {
|
|
413
|
+
...
|
|
414
|
+
email : EMailAddress;
|
|
415
|
+
...
|
|
416
|
+
}
|
|
417
|
+
```
|
|
418
|
+
|
|
419
|
+
Add the following `@changelog` annotations in `srv/change-tracking.cds`
|
|
420
|
+
|
|
421
|
+
```cds
|
|
422
|
+
annotate ProcessorService.Incidents with @changelog: [title] {
|
|
423
|
+
customer @changelog: [customer.email];
|
|
424
|
+
}
|
|
425
|
+
```
|
|
426
|
+
|
|
427
|
+

|
|
428
|
+
|
|
429
|
+
#### Use Case 3: Trace the changes of fields defined by project customized types and display the meaningful data
|
|
430
|
+
|
|
431
|
+
Modelling in `db/schema.cds`
|
|
432
|
+
|
|
433
|
+
```cds
|
|
434
|
+
type StatusType : Association to Status;
|
|
435
|
+
|
|
436
|
+
entity Incidents : cuid, managed {
|
|
437
|
+
...
|
|
438
|
+
title : String @title: 'Title';
|
|
439
|
+
status : StatusType default 'N';
|
|
440
|
+
...
|
|
441
|
+
}
|
|
442
|
+
```
|
|
443
|
+
|
|
444
|
+
Add the following `@changelog` annotations in `srv/change-tracking.cds`
|
|
445
|
+
|
|
446
|
+
```cds
|
|
447
|
+
annotate ProcessorService.Incidents with @changelog: [title] {
|
|
448
|
+
status @changelog: [status.code];
|
|
449
|
+
}
|
|
450
|
+
```
|
|
451
|
+
|
|
452
|
+

|
|
453
|
+
|
|
454
|
+
#### Use Case 4: Trace the changes of chained associated entities from the current entity and display the meaningful data from associated entities (association relation)
|
|
455
|
+
|
|
456
|
+
Modelling in `db/schema.cds`
|
|
457
|
+
|
|
458
|
+
```cds
|
|
459
|
+
entity Incidents : cuid, managed {
|
|
460
|
+
...
|
|
461
|
+
title : String @title: 'Title';
|
|
462
|
+
customer : Association to Customers;
|
|
463
|
+
...
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
entity Customers : cuid, managed {
|
|
467
|
+
...
|
|
468
|
+
addresses : Association to Addresses;
|
|
469
|
+
...
|
|
470
|
+
}
|
|
471
|
+
```
|
|
472
|
+
|
|
473
|
+
Add the following `@changelog` annotations in `srv/change-tracking.cds`
|
|
474
|
+
|
|
475
|
+
```cds
|
|
476
|
+
annotate ProcessorService.Incidents with @changelog: [title] {
|
|
477
|
+
customer @changelog: [customer.addresses.city, customer.addresses.streetAddress];
|
|
478
|
+
}
|
|
479
|
+
```
|
|
480
|
+
|
|
481
|
+

|
|
482
|
+
|
|
483
|
+
> Change-tracking supports analyzing chained associated entities from the current entity in case the entity in consumer applications is a pure relation table. However, the usage of chained associated entities is not recommended due to performance cost.
|
|
484
|
+
|
|
485
|
+
#### Use Case 5: Trace the changes of union entity and display the meaningful data
|
|
486
|
+
|
|
487
|
+
`Payable.cds`:
|
|
488
|
+
|
|
489
|
+
```cds
|
|
490
|
+
entity Payables : cuid {
|
|
491
|
+
displayId : String;
|
|
492
|
+
@changelog
|
|
493
|
+
name : String;
|
|
494
|
+
cryptoAmount : Decimal;
|
|
495
|
+
fiatAmount : Decimal;
|
|
496
|
+
};
|
|
497
|
+
```
|
|
498
|
+
|
|
499
|
+
`Payment.cds`:
|
|
500
|
+
|
|
501
|
+
```cds
|
|
502
|
+
entity Payments : cuid {
|
|
503
|
+
displayId : String; //readable ID
|
|
504
|
+
@changelog
|
|
505
|
+
name : String;
|
|
506
|
+
};
|
|
507
|
+
```
|
|
508
|
+
|
|
509
|
+
Union entity in `BusinessTransaction.cds`:
|
|
510
|
+
|
|
511
|
+
```cds
|
|
512
|
+
entity BusinessTransactions as(
|
|
513
|
+
select from payments.Payments{
|
|
514
|
+
key ID,
|
|
515
|
+
displayId,
|
|
516
|
+
name,
|
|
517
|
+
changes : Association to many ChangeView
|
|
518
|
+
on changes.objectID = ID AND changes.entity = 'payments.Payments'
|
|
519
|
+
}
|
|
520
|
+
)
|
|
521
|
+
union all
|
|
522
|
+
(
|
|
523
|
+
select from payables.Payables {
|
|
524
|
+
key ID,
|
|
525
|
+
displayId,
|
|
526
|
+
name,
|
|
527
|
+
changes : Association to many ChangeView
|
|
528
|
+
on changes.objectID = ID AND changes.entity = 'payables.Payables'
|
|
529
|
+
}
|
|
530
|
+
);
|
|
531
|
+
```
|
|
532
|
+
|
|
533
|
+

|
|
534
|
+
|
|
535
|
+
### Don'ts
|
|
536
|
+
|
|
537
|
+
Don'ts
|
|
538
|
+
|
|
539
|
+
#### Use Case 1: Don't trace changes for field(s) with `Association to many`
|
|
540
|
+
|
|
541
|
+
```cds
|
|
542
|
+
entity Customers : cuid, managed {
|
|
543
|
+
...
|
|
544
|
+
incidents : Association to many Incidents on incidents.customer = $self;
|
|
545
|
+
}
|
|
546
|
+
```
|
|
547
|
+
|
|
548
|
+
The reason is that: the relationship: `Association to many` is only for modelling purpose and there is no concrete field in database table. In the above sample, there is no column for incidents in the table Customers, but there is a navigation property of incidents in Customers OData entity metadata.
|
|
549
|
+
|
|
550
|
+
#### Use Case 2: Don't trace changes for field(s) with *Unmanaged Association*
|
|
551
|
+
|
|
552
|
+
```cds
|
|
553
|
+
entity AggregatedBusinessTransactionData @(cds.autoexpose) : cuid {
|
|
554
|
+
FootprintInventory: Association to one FootprintInventories
|
|
555
|
+
on FootprintInventory.month = month
|
|
556
|
+
and FootprintInventory.year = year
|
|
557
|
+
and FootprintInventory.FootprintInventoryScope.ID = FootprintInventoryScope.ID;
|
|
558
|
+
...
|
|
559
|
+
}
|
|
560
|
+
```
|
|
561
|
+
|
|
562
|
+
The reason is that: When deploying to relational databases, Associations are mapped to foreign keys. Yet, when mapped to non-relational databases they're just references. More details could be found in [Prefer Managed Associations](https://cap.cloud.sap/docs/guides/domain-models#managed-associations). In the above sample, there is no column for FootprintInventory in the table AggregatedBusinessTransactionData, but there is a navigation property FootprintInventoryof in OData entity metadata.
|
|
563
|
+
|
|
564
|
+
#### Use Case 3: Don't trace changes for CUD on DB entity
|
|
565
|
+
|
|
566
|
+
```cds
|
|
567
|
+
this.on("UpdateActivationStatus", async (req) =>
|
|
568
|
+
// PaymentAgreementsOutgoingDb is the DB entity
|
|
569
|
+
await UPDATE.entity(PaymentAgreementsOutgoingDb)
|
|
570
|
+
.where({ ID: paymentAgreement.ID })
|
|
571
|
+
.set({ ActivationStatus_code: ActivationCodes.ACTIVE });
|
|
572
|
+
);
|
|
573
|
+
```
|
|
574
|
+
|
|
575
|
+
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.
|
|
576
|
+
|
|
226
577
|
## Contributing
|
|
227
578
|
|
|
228
579
|
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).
|
|
229
580
|
|
|
230
|
-
|
|
231
581
|
## Code of Conduct
|
|
232
582
|
|
|
233
583
|
We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone. By participating in this project, you agree to abide by its [Code of Conduct](CODE_OF_CONDUCT.md) at all times.
|
|
234
584
|
|
|
235
|
-
|
|
236
585
|
## Licensing
|
|
237
586
|
|
|
238
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).
|
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]
|
|
@@ -40,12 +158,19 @@ cds.on('loaded', m => {
|
|
|
40
158
|
const keys = [], { elements: elms } = entity
|
|
41
159
|
for (let e in elms) if (elms[e].key) keys.push(e)
|
|
42
160
|
|
|
161
|
+
// If no key attribute is defined for the entity, the logic to add association to ChangeView should be skipped.
|
|
162
|
+
if(keys.length === 0) {
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
|
|
43
166
|
// Add association to ChangeView...
|
|
44
167
|
const on = [...changes.on]; keys.forEach((k, i) => { i && on.push('||'); on.push({
|
|
45
168
|
ref: k === 'up_' ? [k,'ID'] : [k] // REVISIT: up_ handling is a dirty hack for now
|
|
46
169
|
})})
|
|
47
170
|
const assoc = { ...changes, on }
|
|
48
171
|
const query = entity.projection || entity.query?.SELECT
|
|
172
|
+
if(!entity['@changelog.disable_assoc'])
|
|
173
|
+
{
|
|
49
174
|
if (query) {
|
|
50
175
|
(query.columns ??= ['*']).push({ as: 'changes', cast: assoc })
|
|
51
176
|
} else {
|
|
@@ -53,30 +178,23 @@ cds.on('loaded', m => {
|
|
|
53
178
|
}
|
|
54
179
|
|
|
55
180
|
// Add UI.Facet for Change History List
|
|
56
|
-
entity['@
|
|
181
|
+
if(!entity['@changelog.disable_facet'])
|
|
182
|
+
entity['@UI.Facets']?.push(facet)
|
|
183
|
+
}
|
|
57
184
|
|
|
58
|
-
// The changehistory list should be refreshed after the custom action is triggered
|
|
59
185
|
if (entity.actions) {
|
|
186
|
+
const hasParentInfo = entity[hasParent];
|
|
187
|
+
const entityName = hasParentInfo?.entityName;
|
|
188
|
+
const parentEntity = entityName ? m.definitions[entityName] : null;
|
|
60
189
|
|
|
61
|
-
|
|
62
|
-
if (entity['@UI.Facets']) {
|
|
63
|
-
addSideEffects(entity.actions, true)
|
|
64
|
-
}
|
|
190
|
+
const isParentRootAndHasFacets = parentEntity?.[isRoot] && parentEntity?.['@UI.Facets'];
|
|
65
191
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
for (const value of Object.values(parentEntity.elements)) {
|
|
73
|
-
if (value.target === name) {
|
|
74
|
-
addSideEffects(entity.actions, false, ele)
|
|
75
|
-
break breakLoop
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
}
|
|
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);
|
|
80
198
|
}
|
|
81
199
|
}
|
|
82
200
|
}
|
package/index.cds
CHANGED
|
@@ -40,8 +40,8 @@ entity ChangeLog : managed, cuid {
|
|
|
40
40
|
serviceEntity : String @title: '{i18n>ChangeLog.serviceEntity}'; // definition name of target entity (on service level) - e.g. ProcessorsService.Incidents
|
|
41
41
|
entity : String @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
|
-
createdAt : managed:createdAt
|
|
44
|
-
createdBy : managed:createdBy
|
|
43
|
+
createdAt : managed:createdAt;
|
|
44
|
+
createdBy : managed:createdBy;
|
|
45
45
|
changes : Composition of many Changes on changes.changeLog = $self;
|
|
46
46
|
}
|
|
47
47
|
|
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) {
|
|
@@ -97,7 +121,7 @@ const _getEntityIDs = function (txParams) {
|
|
|
97
121
|
* ...
|
|
98
122
|
* }
|
|
99
123
|
*/
|
|
100
|
-
const _formatAssociationContext = async function (changes) {
|
|
124
|
+
const _formatAssociationContext = async function (changes, reqData) {
|
|
101
125
|
for (const change of changes) {
|
|
102
126
|
const a = cds.model.definitions[change.serviceEntity].elements[change.attribute]
|
|
103
127
|
if (a?.type !== "cds.Association") continue
|
|
@@ -111,10 +135,10 @@ const _formatAssociationContext = async function (changes) {
|
|
|
111
135
|
SELECT.one.from(a.target).where({ [ID]: change.valueChangedTo })
|
|
112
136
|
])
|
|
113
137
|
|
|
114
|
-
const fromObjId = await getObjectId(a.target, semkeys, { curObjFromDbQuery: from || undefined }) // Note: ... || undefined is important for subsequent object destructuring with defaults
|
|
138
|
+
const fromObjId = await getObjectId(reqData, a.target, semkeys, { curObjFromDbQuery: from || undefined }) // Note: ... || undefined is important for subsequent object destructuring with defaults
|
|
115
139
|
if (fromObjId) change.valueChangedFrom = fromObjId
|
|
116
140
|
|
|
117
|
-
const toObjId = await getObjectId(a.target, semkeys, { curObjFromDbQuery: to || undefined }) // Note: ... || undefined is important for subsequent object destructuring with defaults
|
|
141
|
+
const toObjId = await getObjectId(reqData, a.target, semkeys, { curObjFromDbQuery: to || undefined }) // Note: ... || undefined is important for subsequent object destructuring with defaults
|
|
118
142
|
if (toObjId) change.valueChangedTo = toObjId
|
|
119
143
|
|
|
120
144
|
const isVLvA = a["@Common.ValueList.viaAssociation"]
|
|
@@ -219,7 +243,7 @@ const _getObjectIdByPath = async function (
|
|
|
219
243
|
const entityUUID = getUUIDFromPathVal(nodePathVal)
|
|
220
244
|
const obj = await getCurObjFromDbQuery(entityName, entityUUID)
|
|
221
245
|
const curObj = { curObjFromReqData, curObjFromDbQuery: obj }
|
|
222
|
-
return getObjectId(entityName, objIdElementNames, curObj)
|
|
246
|
+
return getObjectId(reqData, entityName, objIdElementNames, curObj)
|
|
223
247
|
}
|
|
224
248
|
|
|
225
249
|
const _formatObjectID = async function (changes, reqData) {
|
|
@@ -255,19 +279,19 @@ 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
|
}
|
|
267
291
|
|
|
268
292
|
const _formatChangeLog = async function (changes, req) {
|
|
269
293
|
await _formatObjectID(changes, req.data)
|
|
270
|
-
await _formatAssociationContext(changes)
|
|
294
|
+
await _formatAssociationContext(changes, req.data)
|
|
271
295
|
await _formatCompositionContext(changes, req.data)
|
|
272
296
|
}
|
|
273
297
|
|
|
@@ -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
|
-
|
|
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
|
|
349
|
-
let isComposition = _isCompositionContextPath(req.context.path)
|
|
450
|
+
let compContext = null;
|
|
350
451
|
let entityKey = diff.ID
|
|
351
|
-
|
|
352
|
-
if (req.
|
|
353
|
-
|
|
354
|
-
|
|
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
|
-
|
|
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 ?? ''}`,
|
package/lib/entity-helper.js
CHANGED
|
@@ -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
|
|
@@ -70,7 +71,7 @@ const getCurObjFromReqData = function (reqData, nodePathVal, pathVal) {
|
|
|
70
71
|
}
|
|
71
72
|
|
|
72
73
|
|
|
73
|
-
async function getObjectId (entityName, fields, curObj) {
|
|
74
|
+
async function getObjectId (reqData, entityName, fields, curObj) {
|
|
74
75
|
let all = [], { curObjFromReqData: req_data={}, curObjFromDbQuery: db_data={} } = curObj
|
|
75
76
|
let entity = cds.model.definitions[entityName]
|
|
76
77
|
if (!fields?.length) fields = entity["@changelog"]?.map?.(k => k['='] || k) || []
|
|
@@ -81,13 +82,28 @@ async function getObjectId (entityName, fields, curObj) {
|
|
|
81
82
|
while (path.length > 1) {
|
|
82
83
|
let assoc = current.elements[path[0]]; if (!assoc?.isAssociation) break
|
|
83
84
|
let foreignKey = assoc.keys?.[0]?.$generatedFieldName
|
|
84
|
-
let IDval =
|
|
85
|
-
|
|
85
|
+
let IDval =
|
|
86
|
+
req_data[foreignKey] && current.name === entityName
|
|
87
|
+
? req_data[foreignKey]
|
|
88
|
+
: _db_data[foreignKey]
|
|
89
|
+
if (!IDval) {
|
|
90
|
+
_db_data = {};
|
|
91
|
+
} else try {
|
|
86
92
|
// REVISIT: This always reads all elements -> should read required ones only!
|
|
87
93
|
let ID = assoc.keys?.[0]?.ref[0] || 'ID'
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
94
|
+
const isComposition = hasComposition(assoc._target, current)
|
|
95
|
+
// Peer association and composition are distinguished by the value of isComposition.
|
|
96
|
+
if (isComposition) {
|
|
97
|
+
// This function can recursively retrieve the desired information from reqData without having to read it from db.
|
|
98
|
+
_db_data = _getCompositionObjFromReq(reqData, IDval)
|
|
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.
|
|
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);
|
|
103
|
+
}
|
|
104
|
+
} else {
|
|
105
|
+
_db_data = await getCurObjFromDbQuery(assoc._target, IDval, ID);
|
|
106
|
+
}
|
|
91
107
|
} catch (e) {
|
|
92
108
|
LOG.error("Failed to generate object Id for an association entity.", e)
|
|
93
109
|
throw new Error("Failed to generate object Id for an association entity.", e)
|
|
@@ -96,7 +112,7 @@ async function getObjectId (entityName, fields, curObj) {
|
|
|
96
112
|
path.shift()
|
|
97
113
|
}
|
|
98
114
|
field = path.join('_')
|
|
99
|
-
let obj =
|
|
115
|
+
let obj = current.name === entityName && req_data[field] ? req_data[field] : _db_data[field]
|
|
100
116
|
if (obj) all.push(obj)
|
|
101
117
|
} else {
|
|
102
118
|
let e = entity.elements[field]
|
|
@@ -134,6 +150,39 @@ const getValueEntityType = function (entityName, fields) {
|
|
|
134
150
|
return types.join(', ')
|
|
135
151
|
}
|
|
136
152
|
|
|
153
|
+
const hasComposition = function (parentEntity, subEntity) {
|
|
154
|
+
if (!parentEntity.compositions) {
|
|
155
|
+
return false
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const compositions = Object.values(parentEntity.compositions);
|
|
159
|
+
|
|
160
|
+
for (const composition of compositions) {
|
|
161
|
+
if (composition.target === subEntity.name) {
|
|
162
|
+
return true;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return false
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const _getCompositionObjFromReq = function (obj, targetID) {
|
|
170
|
+
if (obj?.ID === targetID) {
|
|
171
|
+
return obj;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
for (const key in obj) {
|
|
175
|
+
if (typeof obj[key] === "object" && obj[key] !== null) {
|
|
176
|
+
const result = _getCompositionObjFromReq(obj[key], targetID);
|
|
177
|
+
if (result) {
|
|
178
|
+
return result;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return null;
|
|
184
|
+
};
|
|
185
|
+
|
|
137
186
|
module.exports = {
|
|
138
187
|
getCurObjFromReqData,
|
|
139
188
|
getCurObjFromDbQuery,
|
|
@@ -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
|
-
|
|
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.
|
|
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": "
|
|
21
|
+
"@sap/cds": ">=7"
|
|
22
22
|
},
|
|
23
23
|
"devDependencies": {
|
|
24
24
|
"@cap-js/change-tracking": "file:.",
|