@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 +54 -1
- package/README.md +134 -62
- package/cds-plugin.js +200 -53
- package/index.cds +17 -18
- package/lib/change-log.js +195 -20
- package/lib/entity-helper.js +10 -11
- package/lib/localization.js +1 -1
- package/lib/template-processor.js +3 -1
- package/package.json +2 -2
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.
|
|
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
|
[](https://api.reuse.software/info/github.com/cap-js/change-tracking)
|
|
4
6
|
|
|
5
|
-
|
|
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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
- [
|
|
16
|
-
|
|
17
|
-
- [
|
|
18
|
-
- [
|
|
19
|
-
- [
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
- [
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
39
|
+
- [Examples](#examples)
|
|
40
|
+
- [Specify Object ID](#specify-object-id)
|
|
41
|
+
- [Tracing Changes](#tracing-changes)
|
|
42
|
+
- [Don'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
|
-
|
|
56
|
+
### 1. Prerequisites
|
|
39
57
|
|
|
40
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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*
|
|
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
|
-
##
|
|
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
|
-
|
|
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
|
-
|
|
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'ts](#donts)
|
|
306
|
+
- [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)
|
|
307
|
+
- [Use Case 2: Don'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'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
|
|
4
|
-
|
|
5
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
(
|
|
58
|
-
|
|
59
|
-
entity.
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
70
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
57
|
-
attribute : String
|
|
58
|
-
valueChangedFrom : String
|
|
59
|
-
valueChangedTo : String
|
|
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
|
|
63
|
-
entity : String
|
|
64
|
-
serviceEntity : String
|
|
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
|
|
66
|
+
parentEntityID : String(5000) @title: '{i18n>Changes.parentEntityID}';
|
|
68
67
|
parentKey : UUID @title: '{i18n>Changes.parentKey}';
|
|
69
|
-
serviceEntityPath : String
|
|
68
|
+
serviceEntityPath : String(5000) @title: '{i18n>Changes.serviceEntityPath}';
|
|
70
69
|
|
|
71
70
|
@title: '{i18n>Changes.modification}'
|
|
72
71
|
modification : String enum {
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
72
|
+
Create = 'create';
|
|
73
|
+
Update = 'update';
|
|
74
|
+
Delete = 'delete';
|
|
76
75
|
};
|
|
77
76
|
|
|
78
|
-
valueDataType : String
|
|
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 ===
|
|
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
|
-
|
|
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
|
|
349
|
-
let isComposition = _isCompositionContextPath(req.context.path)
|
|
506
|
+
let compContext = null;
|
|
350
507
|
let entityKey = diff.ID
|
|
351
|
-
|
|
352
|
-
if (
|
|
353
|
-
|
|
354
|
-
|
|
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
|
-
|
|
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 ?? ''}`,
|
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
|
|
@@ -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)
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
|
170
|
+
if (obj?.ID === targetID) {
|
|
172
171
|
return obj;
|
|
173
172
|
}
|
|
174
173
|
|
package/lib/localization.js
CHANGED
|
@@ -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
|
-
|
|
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.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": "
|
|
21
|
+
"@sap/cds": ">=8"
|
|
22
22
|
},
|
|
23
23
|
"devDependencies": {
|
|
24
24
|
"@cap-js/change-tracking": "file:.",
|