@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 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
  [![REUSE status](https://api.reuse.software/badge/github.com/cap-js/change-tracking)](https://api.reuse.software/info/github.com/cap-js/change-tracking)
6
6
 
7
7
  > [!IMPORTANT]
8
- > This release establishes compatibility with CDS 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
- > Since the prior release was using **APIs deprecated in CDS8**, the code was modified significantly to enable compatibility. While we tested extensively, there may still be glitches or unexpected situations which we did not cover. So please **test this release extensively before applying it to productive** scenarios. Please also report any bugs or glitches, ideally by contributing a test-case for us to incorporate.
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).
@@ -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
  ########################################
@@ -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=Änderungshistorie
55
+ ChangeHistory=\u00C4nderungshistorie
@@ -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['@changelog']
8
- || entity.elements && Object.values(entity.elements).some(e => e['@changelog'])) && entity.query?.SET?.op !== 'union'
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
- cds.on('loaded', m => {
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
- // Process entities to define the relation
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
- // Determine entity keys
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
- // If no key attribute is defined for the entity, the logic to add association to ChangeView should be skipped.
162
- if(keys.length === 0) {
163
- continue;
164
- }
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
- // Add association to ChangeView...
167
- const on = [...changes.on]; keys.forEach((k, i) => { i && on.push('||'); on.push({
168
- ref: k === 'up_' ? [k,'ID'] : [k] // REVISIT: up_ handling is a dirty hack for now
169
- })})
170
- const assoc = { ...changes, on }
171
- const query = entity.projection || entity.query?.SELECT
172
- if(!entity['@changelog.disable_assoc'])
173
- {
174
- if (query) {
175
- (query.columns ??= ['*']).push({ as: 'changes', cast: assoc })
176
- } else {
177
- entity.elements.changes = assoc
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
- // Add UI.Facet for Change History List
181
- if(!entity['@changelog.disable_facet'])
182
- entity['@UI.Facets']?.push(facet)
211
+ // Add UI.Facet for Change History List
212
+ if (!entity['@changelog.disable_facet'])
213
+ entity['@UI.Facets']?.push(facet)
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
- cds.on('served', () => {
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
- aspect aspect @(UI.Facets: [{
7
+ @cds.persistence.skip entity aspect @(UI.Facets: [{
8
8
  $Type : 'UI.ReferenceFacet',
9
9
  ID : 'ChangeHistoryFacet',
10
10
  Label : '{i18n>ChangeHistory}',
@@ -37,15 +37,14 @@ view ChangeView as
37
37
  * Top-level changes entity, e.g. UPDATE Incident by, at, ...
38
38
  */
39
39
  entity ChangeLog : managed, cuid {
40
- serviceEntity : String @title: '{i18n>ChangeLog.serviceEntity}'; // definition name of target entity (on service level) - e.g. ProcessorsService.Incidents
41
- entity : String @title: '{i18n>ChangeLog.entity}'; // definition name of target entity (on db level) - e.g. sap.capire.incidents.Incidents
40
+ serviceEntity : String(5000) @title: '{i18n>ChangeLog.serviceEntity}'; // definition name of target entity (on service level) - e.g. ProcessorsService.Incidents
41
+ entity : String(5000) @title: '{i18n>ChangeLog.entity}'; // definition name of target entity (on db level) - e.g. sap.capire.incidents.Incidents
42
42
  entityKey : UUID @title: '{i18n>ChangeLog.entityKey}'; // primary key of target entity, e.g. Incidents.ID
43
- 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 @title: '{i18n>Changes.keys}';
57
- attribute : String @title: '{i18n>Changes.attribute}';
58
- valueChangedFrom : String @title: '{i18n>Changes.valueChangedFrom}';
59
- valueChangedTo : String @title: '{i18n>Changes.valueChangedTo}';
55
+ keys : String(5000) @title: '{i18n>Changes.keys}';
56
+ attribute : String(5000) @title: '{i18n>Changes.attribute}';
57
+ valueChangedFrom : String(5000) @title: '{i18n>Changes.valueChangedFrom}' @UI.MultiLineText;
58
+ valueChangedTo : String(5000) @title: '{i18n>Changes.valueChangedTo}' @UI.MultiLineText;
60
59
 
61
60
  // Business meaningful object id
62
- entityID : String @title: '{i18n>Changes.entityID}';
63
- entity : String @title: '{i18n>Changes.entity}'; // similar to ChangeLog.entity, but could be nested entity in a composition tree
64
- serviceEntity : String @title: '{i18n>Changes.serviceEntity}'; // similar to ChangeLog.serviceEntity, but could be nested entity in a composition tree
61
+ entityID : String(5000) @title: '{i18n>Changes.entityID}';
62
+ entity : String(5000) @title: '{i18n>Changes.entity}'; // similar to ChangeLog.entity, but could be nested entity in a composition tree
63
+ serviceEntity : String(5000) @title: '{i18n>Changes.serviceEntity}'; // similar to ChangeLog.serviceEntity, but could be nested entity in a composition tree
65
64
 
66
65
  // Business meaningful parent object id
67
- parentEntityID : String @title: '{i18n>Changes.parentEntityID}';
66
+ parentEntityID : String(5000) @title: '{i18n>Changes.parentEntityID}';
68
67
  parentKey : UUID @title: '{i18n>Changes.parentKey}';
69
- serviceEntityPath : String @title: '{i18n>Changes.serviceEntityPath}';
68
+ serviceEntityPath : String(5000) @title: '{i18n>Changes.serviceEntityPath}';
70
69
 
71
70
  @title: '{i18n>Changes.modification}'
72
71
  modification : String enum {
73
- create = 'Create';
74
- update = 'Edit';
75
- delete = 'Delete';
72
+ Create = 'create';
73
+ Update = 'update';
74
+ Delete = 'delete';
76
75
  };
77
76
 
78
- valueDataType : String @title: '{i18n>Changes.valueDataType}';
79
- changeLog : Association to ChangeLog @title: '{i18n>ChangeLog.ID}';
77
+ valueDataType : String(5000) @title: '{i18n>Changes.valueDataType}';
78
+ changeLog : Association to ChangeLog @title: '{i18n>ChangeLog.ID}' @UI.Hidden;
80
79
  }
81
80
 
82
81
  annotate ChangeView with @(UI: {
package/lib/change-log.js CHANGED
@@ -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.split('/')
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 === "delete") {
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.split('/')
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
- return await DELETE.from(`sap.changelog.ChangeLog`).where({ entityKey });
534
+ await DELETE.from(`sap.changelog.ChangeLog`).where({ entityKey });
535
+ return;
466
536
  }
467
537
 
468
- let changes = _trackedChanges4(this, target, diff)
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
- await INSERT.into("sap.changelog.ChangeLog").entries({
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 }
@@ -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.split('/')
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
  }
@@ -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.split('/');
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.7",
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": "SEE LICENSE IN 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": ">=7"
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/sqlite": "^1",
26
- "axios": "^1",
27
- "chai": "^4.3.10",
28
- "chai-as-promised": "^7.1.1",
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": {