@cap-js/change-tracking 1.0.7 → 1.1.0
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 +44 -0
- package/README.md +34 -2
- package/_i18n/i18n.properties +4 -0
- package/_i18n/i18n_de.properties +5 -1
- package/_i18n/i18n_en.properties +4 -0
- package/cds-plugin.js +127 -90
- package/index.cds +19 -20
- package/lib/change-log.js +79 -9
- package/lib/entity-helper.js +21 -1
- package/lib/localization.js +3 -3
- package/package.json +10 -11
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,50 @@ 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.1.0 - TBD
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- License entry
|
|
12
|
+
- Added translations for the UI labels for more languages
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
|
|
16
|
+
- Handling of multiple records in one request
|
|
17
|
+
- Handle cases where the key contains '/'
|
|
18
|
+
- Instantiate the changes association correctly so it does not impact other `@cap-js` plugins
|
|
19
|
+
|
|
20
|
+
### Changed
|
|
21
|
+
|
|
22
|
+
- Prepare for CDS9 in tests
|
|
23
|
+
|
|
24
|
+
## Version 1.0.8 - 28.03.25
|
|
25
|
+
|
|
26
|
+
### Added
|
|
27
|
+
|
|
28
|
+
- Added @UI.MultiLineText to value fields
|
|
29
|
+
- Added support for Multi-Tenancy
|
|
30
|
+
- Added configuration options to disable tracking of CREATE/UPDATE/DELETE operations on a project level
|
|
31
|
+
|
|
32
|
+
### Fixed
|
|
33
|
+
|
|
34
|
+
- Handling of numeric and boolean fields was faulty, when an initial value of `0` for numeric or `false` for boolean was supplied
|
|
35
|
+
- Decimal values were handled differently for HANA and SQlite
|
|
36
|
+
- Missing UI Label for one attribute (`ChangeLog.ID`) of the Changes UI facet
|
|
37
|
+
- Support for @UI.HeaderInfo.TypeName as fallback for the UI Label of the key
|
|
38
|
+
- Compilation error when an association is used as a key
|
|
39
|
+
- Fixed handling of unmanaged composition of many
|
|
40
|
+
- Proper casing of the operation enum type
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
### Changed
|
|
44
|
+
|
|
45
|
+
- Added warning and mitigation for multi-tenant deployments with MTX
|
|
46
|
+
- Added a disclaimer of upcoming new version having a minimum requirement of CDS 8.6 for multitenancy fix
|
|
47
|
+
- Changed the default limit on non-HANA databases from 255 to 5000 characters for all String values
|
|
48
|
+
- Updated peer dependency from CDS7 to CDS8
|
|
49
|
+
|
|
50
|
+
|
|
7
51
|
## Version 1.0.7 - 20.08.24
|
|
8
52
|
|
|
9
53
|
### Added
|
package/README.md
CHANGED
|
@@ -5,12 +5,24 @@ a [CDS plugin](https://cap.cloud.sap/docs/node.js/cds-plugins#cds-plugin-package
|
|
|
5
5
|
[](https://api.reuse.software/info/github.com/cap-js/change-tracking)
|
|
6
6
|
|
|
7
7
|
> [!IMPORTANT]
|
|
8
|
-
>
|
|
8
|
+
> Following the CAP best practices, the new release now requires CDS8 or CDS9 as minimum versions. 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.
|
|
9
12
|
>
|
|
10
|
-
>
|
|
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.
|
|
11
14
|
>
|
|
12
15
|
> See the changelog for a full list of changes
|
|
13
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.
|
|
20
|
+
|
|
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.
|
|
25
|
+
|
|
14
26
|
|
|
15
27
|
### Table of Contents
|
|
16
28
|
|
|
@@ -70,6 +82,7 @@ To enable change tracking, simply add this self-configuring plugin package to yo
|
|
|
70
82
|
```sh
|
|
71
83
|
npm add @cap-js/change-tracking
|
|
72
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).
|
|
73
86
|
|
|
74
87
|
### 3. Annotations
|
|
75
88
|
|
|
@@ -235,6 +248,24 @@ For some scenarios, e.g. when doing `UNION` and the `@changelog` annotion is sti
|
|
|
235
248
|
> [!IMPORTANT]
|
|
236
249
|
> This will also supress the addition of the UI facet, since the change-view is not available as target entity anymore.
|
|
237
250
|
|
|
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
|
+
|
|
238
269
|
### Preserve change logs of deleted data
|
|
239
270
|
|
|
240
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.
|
|
@@ -574,6 +605,7 @@ this.on("UpdateActivationStatus", async (req) =>
|
|
|
574
605
|
|
|
575
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.
|
|
576
607
|
|
|
608
|
+
|
|
577
609
|
## Contributing
|
|
578
610
|
|
|
579
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).
|
package/_i18n/i18n.properties
CHANGED
|
@@ -45,6 +45,10 @@ ChangeLog.modification.create=Create
|
|
|
45
45
|
ChangeLog.modification.update=Update
|
|
46
46
|
#XFLD: Field label
|
|
47
47
|
ChangeLog.modification.delete=Delete
|
|
48
|
+
#XFLD: Field label
|
|
49
|
+
ChangeLog.createdAt=Changed at
|
|
50
|
+
#XFLD: Field label
|
|
51
|
+
ChangeLog.createdBy=Changed by
|
|
48
52
|
|
|
49
53
|
## Change History Table##
|
|
50
54
|
########################################
|
package/_i18n/i18n_de.properties
CHANGED
|
@@ -45,7 +45,11 @@ ChangeLog.modification.create=Anlegen
|
|
|
45
45
|
ChangeLog.modification.update=Aktualisieren
|
|
46
46
|
#XFLD: Field label
|
|
47
47
|
ChangeLog.modification.delete=L\u00F6schen
|
|
48
|
+
#XFLD: Field label
|
|
49
|
+
ChangeLog.createdAt=\u00C4nderung am
|
|
50
|
+
#XFLD: Field label
|
|
51
|
+
ChangeLog.createdBy=\u00C4nderung von
|
|
48
52
|
|
|
49
53
|
## Change History Table##
|
|
50
54
|
########################################
|
|
51
|
-
ChangeHistory
|
|
55
|
+
ChangeHistory=\u00C4nderungshistorie
|
package/_i18n/i18n_en.properties
CHANGED
|
@@ -45,6 +45,10 @@ ChangeLog.modification.create=Create
|
|
|
45
45
|
ChangeLog.modification.update=Update
|
|
46
46
|
#XFLD: Field label
|
|
47
47
|
ChangeLog.modification.delete=Delete
|
|
48
|
+
#XFLD: Field label
|
|
49
|
+
ChangeLog.createdAt=Changed at
|
|
50
|
+
#XFLD: Field label
|
|
51
|
+
ChangeLog.createdBy=Changed by
|
|
48
52
|
|
|
49
53
|
## Change History Table##
|
|
50
54
|
########################################
|
package/cds-plugin.js
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
const cds = require('@sap/cds')
|
|
2
|
+
const DEBUG = cds.debug('changelog')
|
|
2
3
|
|
|
3
4
|
const isRoot = 'change-tracking-isRootEntity'
|
|
4
5
|
const hasParent = 'change-tracking-parentEntity'
|
|
5
6
|
|
|
6
|
-
const isChangeTracked = (entity) =>
|
|
7
|
-
(entity
|
|
8
|
-
|
|
9
|
-
)
|
|
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
|
+
}
|
|
10
12
|
|
|
11
13
|
// Add the appropriate Side Effects attribute to the custom action
|
|
12
14
|
const addSideEffects = (actions, flag, element) => {
|
|
@@ -30,179 +32,207 @@ const addSideEffects = (actions, flag, element) => {
|
|
|
30
32
|
}
|
|
31
33
|
}
|
|
32
34
|
|
|
33
|
-
function setChangeTrackingIsRootEntity(entity, csn, val = true) {
|
|
35
|
+
function setChangeTrackingIsRootEntity (entity, csn, val = true) {
|
|
34
36
|
if (csn.definitions?.[entity.name]) {
|
|
35
|
-
csn.definitions[entity.name][isRoot] = val
|
|
37
|
+
csn.definitions[entity.name][isRoot] = val
|
|
36
38
|
}
|
|
37
39
|
}
|
|
38
40
|
|
|
39
|
-
function checkAndSetRootEntity(parentEntity, entity, csn) {
|
|
41
|
+
function checkAndSetRootEntity (parentEntity, entity, csn) {
|
|
40
42
|
if (entity[isRoot] === false) {
|
|
41
|
-
return entity
|
|
43
|
+
return entity
|
|
42
44
|
}
|
|
43
45
|
if (parentEntity) {
|
|
44
|
-
return compositionRoot(parentEntity, csn)
|
|
46
|
+
return compositionRoot(parentEntity, csn)
|
|
45
47
|
} else {
|
|
46
|
-
setChangeTrackingIsRootEntity(entity, csn)
|
|
47
|
-
return { ...csn.definitions?.[entity.name], name: entity.name }
|
|
48
|
+
setChangeTrackingIsRootEntity(entity, csn)
|
|
49
|
+
return { ...csn.definitions?.[entity.name], name: entity.name }
|
|
48
50
|
}
|
|
49
51
|
}
|
|
50
52
|
|
|
51
|
-
function processEntities(m) {
|
|
53
|
+
function processEntities (m) {
|
|
52
54
|
for (let name in m.definitions) {
|
|
53
|
-
compositionRoot({...m.definitions[name], name}, m)
|
|
55
|
+
compositionRoot({ ...m.definitions[name], name }, m)
|
|
54
56
|
}
|
|
55
57
|
}
|
|
56
58
|
|
|
57
|
-
function compositionRoot(entity, csn) {
|
|
59
|
+
function compositionRoot (entity, csn) {
|
|
58
60
|
if (!entity || entity.kind !== 'entity') {
|
|
59
|
-
return
|
|
61
|
+
return
|
|
60
62
|
}
|
|
61
|
-
const parentEntity = compositionParent(entity, csn)
|
|
62
|
-
return checkAndSetRootEntity(parentEntity, entity, csn)
|
|
63
|
+
const parentEntity = compositionParent(entity, csn)
|
|
64
|
+
return checkAndSetRootEntity(parentEntity, entity, csn)
|
|
63
65
|
}
|
|
64
66
|
|
|
65
|
-
function compositionParent(entity, csn) {
|
|
67
|
+
function compositionParent (entity, csn) {
|
|
66
68
|
if (!entity || entity.kind !== 'entity') {
|
|
67
|
-
return
|
|
69
|
+
return
|
|
68
70
|
}
|
|
69
|
-
const parentAssociation = compositionParentAssociation(entity, csn)
|
|
70
|
-
return parentAssociation ?? null
|
|
71
|
+
const parentAssociation = compositionParentAssociation(entity, csn)
|
|
72
|
+
return parentAssociation ?? null
|
|
71
73
|
}
|
|
72
74
|
|
|
73
|
-
function compositionParentAssociation(entity, csn) {
|
|
75
|
+
function compositionParentAssociation (entity, csn) {
|
|
74
76
|
if (!entity || entity.kind !== 'entity') {
|
|
75
|
-
return
|
|
77
|
+
return
|
|
76
78
|
}
|
|
77
|
-
const elements = entity.elements ?? {}
|
|
79
|
+
const elements = entity.elements ?? {}
|
|
78
80
|
|
|
79
81
|
// Add the change-tracking-isRootEntity attribute of the child entity
|
|
80
|
-
processCompositionElements(entity, csn, elements)
|
|
82
|
+
processCompositionElements(entity, csn, elements)
|
|
81
83
|
|
|
82
|
-
const hasChildFlag = entity[isRoot] !== false
|
|
83
|
-
const hasParentEntity = entity[hasParent]
|
|
84
|
+
const hasChildFlag = entity[isRoot] !== false
|
|
85
|
+
const hasParentEntity = entity[hasParent]
|
|
84
86
|
|
|
85
87
|
if (hasChildFlag || !hasParentEntity) {
|
|
86
88
|
// Find parent association of the entity
|
|
87
|
-
const parentAssociation = findParentAssociation(entity, csn, elements)
|
|
89
|
+
const parentAssociation = findParentAssociation(entity, csn, elements)
|
|
88
90
|
if (parentAssociation) {
|
|
89
|
-
const parentAssociationTarget = elements[parentAssociation]?.target
|
|
90
|
-
if (hasChildFlag) setChangeTrackingIsRootEntity(entity, csn, false)
|
|
91
|
+
const parentAssociationTarget = elements[parentAssociation]?.target
|
|
92
|
+
if (hasChildFlag) setChangeTrackingIsRootEntity(entity, csn, false)
|
|
91
93
|
return {
|
|
92
94
|
...csn.definitions?.[parentAssociationTarget],
|
|
93
95
|
name: parentAssociationTarget
|
|
94
|
-
}
|
|
95
|
-
} else return
|
|
96
|
+
}
|
|
97
|
+
} else return
|
|
96
98
|
}
|
|
97
|
-
return { ...csn.definitions?.[entity.name], name: entity.name }
|
|
99
|
+
return { ...csn.definitions?.[entity.name], name: entity.name }
|
|
98
100
|
}
|
|
99
101
|
|
|
100
|
-
function processCompositionElements(entity, csn, elements) {
|
|
102
|
+
function processCompositionElements (entity, csn, elements) {
|
|
101
103
|
for (const name in elements) {
|
|
102
|
-
const element = elements[name]
|
|
103
|
-
const target = element?.target
|
|
104
|
-
const definition = csn.definitions?.[target]
|
|
104
|
+
const element = elements[name]
|
|
105
|
+
const target = element?.target
|
|
106
|
+
const definition = csn.definitions?.[target]
|
|
105
107
|
if (
|
|
106
108
|
element.type !== 'cds.Composition' ||
|
|
107
109
|
target === entity.name ||
|
|
108
110
|
!definition ||
|
|
109
111
|
definition[isRoot] === false
|
|
110
112
|
) {
|
|
111
|
-
continue
|
|
113
|
+
continue
|
|
112
114
|
}
|
|
113
|
-
setChangeTrackingIsRootEntity({ ...definition, name: target }, csn, false)
|
|
115
|
+
setChangeTrackingIsRootEntity({ ...definition, name: target }, csn, false)
|
|
114
116
|
}
|
|
115
117
|
}
|
|
116
118
|
|
|
117
|
-
function findParentAssociation(entity, csn, elements) {
|
|
119
|
+
function findParentAssociation (entity, csn, elements) {
|
|
118
120
|
return Object.keys(elements).find((name) => {
|
|
119
|
-
const element = elements[name]
|
|
120
|
-
const target = element?.target
|
|
121
|
+
const element = elements[name]
|
|
122
|
+
const target = element?.target
|
|
121
123
|
if (element.type === 'cds.Association' && target !== entity.name) {
|
|
122
|
-
const parentDefinition = csn.definitions?.[target] ?? {}
|
|
123
|
-
const parentElements = parentDefinition?.elements ?? {}
|
|
124
|
+
const parentDefinition = csn.definitions?.[target] ?? {}
|
|
125
|
+
const parentElements = parentDefinition?.elements ?? {}
|
|
124
126
|
return !!Object.keys(parentElements).find((parentEntityName) => {
|
|
125
|
-
const parentElement = parentElements?.[parentEntityName] ?? {}
|
|
127
|
+
const parentElement = parentElements?.[parentEntityName] ?? {}
|
|
126
128
|
if (parentElement.type === 'cds.Composition') {
|
|
127
|
-
const isCompositionEntity = parentElement.target === entity.name
|
|
129
|
+
const isCompositionEntity = parentElement.target === entity.name
|
|
128
130
|
// add parent information in the current entity
|
|
129
131
|
if (isCompositionEntity) {
|
|
130
132
|
csn.definitions[entity.name][hasParent] = {
|
|
131
133
|
associationName: name,
|
|
132
134
|
entityName: target
|
|
133
|
-
}
|
|
135
|
+
}
|
|
134
136
|
}
|
|
135
|
-
return isCompositionEntity
|
|
137
|
+
return isCompositionEntity
|
|
136
138
|
}
|
|
137
|
-
})
|
|
139
|
+
})
|
|
138
140
|
}
|
|
139
|
-
})
|
|
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
|
|
140
158
|
}
|
|
141
159
|
|
|
160
|
+
|
|
142
161
|
// Unfold @changelog annotations in loaded model
|
|
143
|
-
|
|
162
|
+
function enhanceModel (m) {
|
|
163
|
+
|
|
164
|
+
const _enhanced = 'sap.changelog.enhanced'
|
|
165
|
+
if (m.meta?.[_enhanced]) return // already enhanced
|
|
144
166
|
|
|
145
167
|
// Get definitions from Dummy entity in our models
|
|
146
168
|
const { 'sap.changelog.aspect': aspect } = m.definitions; if (!aspect) return // some other model
|
|
147
169
|
const { '@UI.Facets': [facet], elements: { changes } } = aspect
|
|
148
|
-
changes.on.pop() // remove ID -> filled in below
|
|
149
|
-
|
|
150
|
-
//
|
|
151
|
-
processEntities(m)
|
|
170
|
+
if (changes.on.length > 2) changes.on.pop() // remove ID -> filled in below
|
|
171
|
+
|
|
172
|
+
processEntities(m) // REVISIT: why is that required ?!?
|
|
152
173
|
|
|
153
174
|
for (let name in m.definitions) {
|
|
175
|
+
|
|
154
176
|
const entity = m.definitions[name]
|
|
155
|
-
if (isChangeTracked(entity)) {
|
|
177
|
+
if (entity.kind === 'entity' && !entity['@cds.autoexposed'] && isChangeTracked(entity)) {
|
|
156
178
|
|
|
157
|
-
|
|
158
|
-
const keys = [], { elements: elms } = entity
|
|
159
|
-
for (let e in elms) if (elms[e].key) keys.push(e)
|
|
179
|
+
if (!entity['@changelog.disable_assoc']) {
|
|
160
180
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
}
|
|
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 = new cds.builtin.classes.Association({ ...changes, on: [ ...changes.on, ...keys ] })
|
|
165
184
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
(
|
|
176
|
-
|
|
177
|
-
entity.
|
|
178
|
-
|
|
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
|
|
179
210
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
211
|
+
// Add UI.Facet for Change History List
|
|
212
|
+
if (!entity['@changelog.disable_facet'])
|
|
213
|
+
entity['@UI.Facets']?.push(facet)
|
|
183
214
|
}
|
|
184
215
|
|
|
185
216
|
if (entity.actions) {
|
|
186
|
-
const hasParentInfo = entity[hasParent]
|
|
187
|
-
const entityName = hasParentInfo?.entityName
|
|
188
|
-
const parentEntity = entityName ? m.definitions[entityName] : null
|
|
189
|
-
|
|
190
|
-
const isParentRootAndHasFacets = parentEntity?.[isRoot] && parentEntity?.['@UI.Facets'];
|
|
191
|
-
|
|
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']
|
|
192
221
|
if (entity[isRoot] && entity['@UI.Facets']) {
|
|
193
222
|
// Add side effects for root entity
|
|
194
|
-
addSideEffects(entity.actions, true)
|
|
223
|
+
addSideEffects(entity.actions, true)
|
|
195
224
|
} else if (isParentRootAndHasFacets) {
|
|
196
225
|
// Add side effects for child entity
|
|
197
|
-
addSideEffects(entity.actions, false, hasParentInfo?.associationName)
|
|
226
|
+
addSideEffects(entity.actions, false, hasParentInfo?.associationName)
|
|
198
227
|
}
|
|
199
228
|
}
|
|
200
229
|
}
|
|
201
230
|
}
|
|
202
|
-
})
|
|
231
|
+
(m.meta ??= {})[_enhanced] = true
|
|
232
|
+
}
|
|
203
233
|
|
|
204
234
|
// Add generic change tracking handlers
|
|
205
|
-
|
|
235
|
+
function addGenericHandlers() {
|
|
206
236
|
const { track_changes, _afterReadChangeView } = require("./lib/change-log")
|
|
207
237
|
for (const srv of cds.services) {
|
|
208
238
|
if (srv instanceof cds.ApplicationService) {
|
|
@@ -220,4 +250,11 @@ cds.on('served', () => {
|
|
|
220
250
|
}
|
|
221
251
|
}
|
|
222
252
|
}
|
|
223
|
-
}
|
|
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
|
-
createdAt : managed:createdAt;
|
|
44
|
-
createdBy : managed:createdBy;
|
|
43
|
+
createdAt : managed:createdAt @title : '{i18n>ChangeLog.createdAt}';
|
|
44
|
+
createdBy : managed:createdBy @title : '{i18n>ChangeLog.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
|
@@ -13,11 +13,31 @@ const {
|
|
|
13
13
|
getEntityByContextPath,
|
|
14
14
|
getObjIdElementNamesInArray,
|
|
15
15
|
getValueEntityType,
|
|
16
|
+
splitPath,
|
|
16
17
|
} = require("./entity-helper")
|
|
17
18
|
const { localizeLogFields } = require("./localization")
|
|
18
19
|
const isRoot = "change-tracking-isRootEntity"
|
|
19
20
|
|
|
20
21
|
|
|
22
|
+
function formatDecimal(str, scale) {
|
|
23
|
+
if (typeof str === "number" && !isNaN(str)) {
|
|
24
|
+
str = String(str);
|
|
25
|
+
} else return str;
|
|
26
|
+
|
|
27
|
+
if (scale > 0) {
|
|
28
|
+
let parts = str.split(".");
|
|
29
|
+
let decimalPart = parts[1] || "";
|
|
30
|
+
|
|
31
|
+
while (decimalPart.length < scale) {
|
|
32
|
+
decimalPart += "0";
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return `${parts[0]}.${decimalPart}`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return str;
|
|
39
|
+
}
|
|
40
|
+
|
|
21
41
|
const _getRootEntityPathVals = function (txContext, entity, entityKey) {
|
|
22
42
|
const serviceEntityPathVals = []
|
|
23
43
|
const entityIDs = _getEntityIDs(txContext.params)
|
|
@@ -168,13 +188,13 @@ const _formatCompositionContext = async function (changes, reqData) {
|
|
|
168
188
|
const childNodeChanges = []
|
|
169
189
|
|
|
170
190
|
for (const change of changes) {
|
|
171
|
-
if (typeof change.valueChangedTo === "object") {
|
|
191
|
+
if (typeof change.valueChangedTo === "object" && change.valueChangedTo instanceof Date !== true) {
|
|
172
192
|
if (!Array.isArray(change.valueChangedTo)) {
|
|
173
193
|
change.valueChangedTo = [change.valueChangedTo]
|
|
174
194
|
}
|
|
175
195
|
for (const childNodeChange of change.valueChangedTo) {
|
|
176
196
|
const curChange = Object.assign({}, change)
|
|
177
|
-
const path = childNodeChange._path
|
|
197
|
+
const path = splitPath(childNodeChange._path)
|
|
178
198
|
const curNodePathVal = path.pop()
|
|
179
199
|
curChange.modification = childNodeChange._op
|
|
180
200
|
const objId = await _getChildChangeObjId(
|
|
@@ -197,7 +217,9 @@ const _formatCompositionValue = function (
|
|
|
197
217
|
childNodeChange,
|
|
198
218
|
childNodeChanges
|
|
199
219
|
) {
|
|
200
|
-
if (curChange.modification ===
|
|
220
|
+
if (curChange.modification === undefined) {
|
|
221
|
+
return
|
|
222
|
+
} else if (curChange.modification === "delete") {
|
|
201
223
|
curChange.valueChangedFrom = objId
|
|
202
224
|
curChange.valueChangedTo = ""
|
|
203
225
|
} else if (curChange.modification === "update") {
|
|
@@ -249,7 +271,7 @@ const _getObjectIdByPath = async function (
|
|
|
249
271
|
const _formatObjectID = async function (changes, reqData) {
|
|
250
272
|
const objectIdCache = new Map()
|
|
251
273
|
for (const change of changes) {
|
|
252
|
-
const path = change.serviceEntityPath
|
|
274
|
+
const path = splitPath(change.serviceEntityPath)
|
|
253
275
|
const curNodePathVal = path.pop()
|
|
254
276
|
const parentNodePathVal = path.pop()
|
|
255
277
|
|
|
@@ -316,6 +338,28 @@ function _trackedChanges4 (srv, target, diff) {
|
|
|
316
338
|
const eleParentKeys = element.parent.keys
|
|
317
339
|
if (from === to) return
|
|
318
340
|
|
|
341
|
+
/**
|
|
342
|
+
*
|
|
343
|
+
* HANA driver always filling up the defined decimal places with zeros,
|
|
344
|
+
* need to skip the change log if the value is not changed.
|
|
345
|
+
* Example:
|
|
346
|
+
* entity Books : cuid {
|
|
347
|
+
* price : Decimal(11, 4);
|
|
348
|
+
* }
|
|
349
|
+
* When price is updated from 3000.0000 to 3000,
|
|
350
|
+
* the change log should not be created.
|
|
351
|
+
*/
|
|
352
|
+
if (
|
|
353
|
+
row._op === "update" &&
|
|
354
|
+
element.type === "cds.Decimal" &&
|
|
355
|
+
cds.db.kind === "hana" &&
|
|
356
|
+
typeof to === "number"
|
|
357
|
+
) {
|
|
358
|
+
const scaleNum = element.scale || 0;
|
|
359
|
+
if (from === formatDecimal(to, scaleNum))
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
|
|
319
363
|
/**
|
|
320
364
|
*
|
|
321
365
|
* For the Inline entity such as Items,
|
|
@@ -441,11 +485,36 @@ function getAssociationDetails (entity) {
|
|
|
441
485
|
return { ID, foreignKey, parentEntity };
|
|
442
486
|
}
|
|
443
487
|
|
|
488
|
+
function isEmpty(value) {
|
|
489
|
+
return value === null || value === undefined || value === "";
|
|
490
|
+
}
|
|
444
491
|
|
|
445
492
|
async function track_changes (req) {
|
|
493
|
+
const config = cds.env.requires["change-tracking"];
|
|
494
|
+
|
|
495
|
+
if (
|
|
496
|
+
(req.event === 'UPDATE' && config?.disableUpdateTracking) ||
|
|
497
|
+
(req.event === 'CREATE' && config?.disableCreateTracking) ||
|
|
498
|
+
(req.event === 'DELETE' && config?.disableDeleteTracking)
|
|
499
|
+
) {
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
|
|
446
503
|
let diff = await req.diff()
|
|
447
504
|
if (!diff) return
|
|
448
505
|
|
|
506
|
+
const diffs = Array.isArray(diff) ? diff : [diff];
|
|
507
|
+
const changes = (
|
|
508
|
+
await Promise.all(diffs.map(item => trackChangesForDiff(item, req, this)))
|
|
509
|
+
).filter(Boolean);
|
|
510
|
+
|
|
511
|
+
if (changes.length > 0) {
|
|
512
|
+
await INSERT.into("sap.changelog.ChangeLog").entries(changes);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
async function trackChangesForDiff(diff, req, that){
|
|
449
518
|
let target = req.target
|
|
450
519
|
let compContext = null;
|
|
451
520
|
let entityKey = diff.ID
|
|
@@ -462,10 +531,11 @@ async function track_changes (req) {
|
|
|
462
531
|
target[isRoot] &&
|
|
463
532
|
!cds.env.requires["change-tracking"]?.preserveDeletes
|
|
464
533
|
) {
|
|
465
|
-
|
|
534
|
+
await DELETE.from(`sap.changelog.ChangeLog`).where({ entityKey });
|
|
535
|
+
return;
|
|
466
536
|
}
|
|
467
537
|
|
|
468
|
-
let changes = _trackedChanges4(
|
|
538
|
+
let changes = _trackedChanges4(that, target, diff)
|
|
469
539
|
if (!changes) return
|
|
470
540
|
|
|
471
541
|
await _formatChangeLog(changes, req)
|
|
@@ -482,16 +552,16 @@ async function track_changes (req) {
|
|
|
482
552
|
[ target, entityKey ] = await _prepareChangeLogForComposition(target, entityKey, changes, reqInfo)
|
|
483
553
|
}
|
|
484
554
|
const dbEntity = getDBEntity(target)
|
|
485
|
-
|
|
555
|
+
return {
|
|
486
556
|
entity: dbEntity.name,
|
|
487
557
|
entityKey: entityKey,
|
|
488
558
|
serviceEntity: target.name || target,
|
|
489
|
-
changes: changes.filter(c => c.valueChangedFrom || c.valueChangedTo).map((c) => ({
|
|
559
|
+
changes: changes.filter(c => !isEmpty(c.valueChangedFrom) || !isEmpty(c.valueChangedTo)).map((c) => ({
|
|
490
560
|
...c,
|
|
491
561
|
valueChangedFrom: `${c.valueChangedFrom ?? ''}`,
|
|
492
562
|
valueChangedTo: `${c.valueChangedTo ?? ''}`,
|
|
493
563
|
})),
|
|
494
|
-
}
|
|
564
|
+
};
|
|
495
565
|
}
|
|
496
566
|
|
|
497
567
|
module.exports = { track_changes, _afterReadChangeView }
|
package/lib/entity-helper.js
CHANGED
|
@@ -37,7 +37,7 @@ const getCurObjFromDbQuery = async function (entityName, queryVal, /**optional*/
|
|
|
37
37
|
}
|
|
38
38
|
|
|
39
39
|
const getCurObjFromReqData = function (reqData, nodePathVal, pathVal) {
|
|
40
|
-
const pathVals = pathVal
|
|
40
|
+
const pathVals = splitPath(pathVal);
|
|
41
41
|
const rootNodePathVal = pathVals[0]
|
|
42
42
|
let curReqObj = reqData || {}
|
|
43
43
|
|
|
@@ -183,6 +183,25 @@ const _getCompositionObjFromReq = function (obj, targetID) {
|
|
|
183
183
|
return null;
|
|
184
184
|
};
|
|
185
185
|
|
|
186
|
+
function splitPath (path) {
|
|
187
|
+
let result = [];
|
|
188
|
+
let buf = "";
|
|
189
|
+
let paren = 0;
|
|
190
|
+
for (let i = 0; i < path.length; i++) {
|
|
191
|
+
const c = path[i];
|
|
192
|
+
if (c === "(") paren++;
|
|
193
|
+
if (c === ")") paren--;
|
|
194
|
+
if (c === "/" && paren === 0) {
|
|
195
|
+
result.push(buf);
|
|
196
|
+
buf = "";
|
|
197
|
+
} else {
|
|
198
|
+
buf += c;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
if (buf) result.push(buf);
|
|
202
|
+
return result;
|
|
203
|
+
}
|
|
204
|
+
|
|
186
205
|
module.exports = {
|
|
187
206
|
getCurObjFromReqData,
|
|
188
207
|
getCurObjFromDbQuery,
|
|
@@ -193,4 +212,5 @@ module.exports = {
|
|
|
193
212
|
getEntityByContextPath,
|
|
194
213
|
getObjIdElementNamesInArray,
|
|
195
214
|
getValueEntityType,
|
|
215
|
+
splitPath,
|
|
196
216
|
}
|
package/lib/localization.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
const cds = require("@sap/cds/lib");
|
|
2
2
|
const LOG = cds.log("change-log");
|
|
3
|
-
const { getNameFromPathVal, getDBEntity } = require("./entity-helper");
|
|
3
|
+
const { getNameFromPathVal, getDBEntity, splitPath } = require("./entity-helper");
|
|
4
4
|
|
|
5
5
|
const MODIF_I18N_MAP = {
|
|
6
6
|
create: "{i18n>ChangeLog.modification.create}",
|
|
@@ -36,7 +36,7 @@ const _localizeDefaultObjectID = function (change, locale) {
|
|
|
36
36
|
change.objectID = change.entity ? change.entity : "";
|
|
37
37
|
}
|
|
38
38
|
if (change.objectID && change.serviceEntityPath && !change.parentObjectID && change.parentKey) {
|
|
39
|
-
const path = change.serviceEntityPath
|
|
39
|
+
const path = splitPath(change.serviceEntityPath);
|
|
40
40
|
const parentNodePathVal = path[path.length - 2];
|
|
41
41
|
const parentEntityName = getNameFromPathVal(parentNodePathVal);
|
|
42
42
|
const dbEntity = getDBEntity(parentEntityName);
|
|
@@ -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) {
|
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cap-js/change-tracking",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.1.0",
|
|
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)",
|
|
7
|
-
"license": "
|
|
7
|
+
"license": "Apache-2.0",
|
|
8
8
|
"main": "cds-plugin.js",
|
|
9
9
|
"files": [
|
|
10
10
|
"lib",
|
|
@@ -18,18 +18,17 @@
|
|
|
18
18
|
"test": "npx jest --silent"
|
|
19
19
|
},
|
|
20
20
|
"peerDependencies": {
|
|
21
|
-
"@sap/cds": ">=
|
|
21
|
+
"@sap/cds": ">=8"
|
|
22
|
+
},
|
|
23
|
+
"engines": {
|
|
24
|
+
"node": ">=20.0.0"
|
|
22
25
|
},
|
|
23
26
|
"devDependencies": {
|
|
24
27
|
"@cap-js/change-tracking": "file:.",
|
|
25
|
-
"@cap-js/
|
|
26
|
-
"
|
|
27
|
-
"
|
|
28
|
-
"
|
|
29
|
-
"chai-subset": "^1.6.0",
|
|
30
|
-
"eslint": "^8",
|
|
31
|
-
"express": "^4",
|
|
32
|
-
"jest": "^29"
|
|
28
|
+
"@cap-js/attachments": "^2",
|
|
29
|
+
"@cap-js/sqlite": "^1 || ^2",
|
|
30
|
+
"@cap-js/cds-test": "*",
|
|
31
|
+
"express": "^4"
|
|
33
32
|
},
|
|
34
33
|
"cds": {
|
|
35
34
|
"requires": {
|