@cap-js/change-tracking 2.0.0-beta.2 → 2.0.0-beta.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,7 +4,7 @@ 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 2.0.0-beta.3 - tbd
7
+ ## Version 2.0.0-beta.4 - tbd
8
8
 
9
9
  ### Added
10
10
 
@@ -12,6 +12,17 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/).
12
12
 
13
13
  ### Changed
14
14
 
15
+
16
+ ## Version 2.0.0-beta.3 - 13.03.26
17
+
18
+ ### Fixed
19
+ - CSV data for `i18nKeys` and `CHANGE_TRACKING_DUMMY` is now correctly generated during the HANA build
20
+
21
+ ### Changed
22
+ - Changes from child entities are shown on the parent ChangeView by default
23
+ - Depth of displayed child changes can be configured via `maxDisplayHierarchyDepth`
24
+ - Improved search capabilities for changes
25
+
15
26
  ## Version 2.0.0-beta.2 - 11.03.26
16
27
 
17
28
  ### Fixed
@@ -51,6 +62,9 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/).
51
62
  - Localization is performed on database level and therefore the table `sap.changelog.i18nKeys` that stores localized labels was added
52
63
  - Expose localized label fields on `sap.changelog.ChangeView`
53
64
 
65
+ ### Removed
66
+ - Removed configuration option `considerLocalizedValues`
67
+
54
68
  ## Version 1.1.4 - 03.12.25
55
69
 
56
70
  ### Fixed
package/README.md CHANGED
@@ -1,6 +1,6 @@
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
3
+ a [CDS plugin](https://cap.cloud.sap/docs/node.js/cds-plugins#cds-plugin-packages) for automatic capturing, storing, and viewing of change records for modelled entities.
4
4
 
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
 
@@ -19,9 +19,8 @@ a [CDS plugin](https://cap.cloud.sap/docs/node.js/cds-plugins#cds-plugin-package
19
19
  - [Altered Table View](#altered-table-view)
20
20
  - [Disable Lazy Loading](#disable-lazy-loading)
21
21
  - [Disable UI Facet generation](#disable-ui-facet-generation)
22
- - [Disable Association to Changes Generation](#disable-association-to-changes-generation)
22
+ - [Disable Association to Changes Generation](#disable-association-to-changes-generation)
23
23
  - [Examples](#examples)
24
- - [Specify Object ID](#specify-object-id)
25
24
  - [Tracing Changes](#tracing-changes)
26
25
  - [Don'ts](#donts)
27
26
  - [Contributing](#contributing)
@@ -30,12 +29,23 @@ a [CDS plugin](https://cap.cloud.sap/docs/node.js/cds-plugins#cds-plugin-package
30
29
 
31
30
  ## Try it Locally
32
31
 
33
- To enable change tracking, simply add this self-configuring plugin package to your project:
32
+ To enable change tracking, simply add this self-configuring plugin package to your project and add the `@changelog` annotation to your data model, as explained in the [Detailed Explanation](#detailed-explanation).
34
33
 
35
34
  ```sh
36
35
  npm add @cap-js/change-tracking
37
36
  ```
38
37
 
38
+ Alternatively, a full sample application is provided in the `tests/bookshop` folder, against which you can test your changes:
39
+
40
+ ```sh
41
+ git clone https://github.com/cap-js/change-tracking
42
+ cd change-tracking
43
+ npm i
44
+ cd tests/bookshop
45
+ cds watch
46
+ ```
47
+
48
+
39
49
  > [!Warning]
40
50
  >
41
51
  > 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.
@@ -83,10 +93,6 @@ cds watch
83
93
 
84
94
  #### Change History View
85
95
 
86
- > [!IMPORTANT]
87
- > To ensure proper lazy loading of the Change History table, please use **SAPUI5 version 1.120.0** or higher.`<br>`
88
- > If you wish to *disable* this feature, please see the customization section on how to [disable lazy loading](#disable-lazy-loading).
89
-
90
96
  <img width="1300" alt="change-history" src="_assets/changes.png">
91
97
 
92
98
  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.
@@ -95,7 +101,7 @@ If you have a Fiori Element application, the CDS plugin automatically provides a
95
101
 
96
102
  ### Human-readable Types and Fields
97
103
 
98
- 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.
104
+ By default the implementation looks up *Object Type* names or *Field* names from respective `@title` or `@Common.Label` annotations and uses the technical name as a fall back.
99
105
 
100
106
  For example, without the `@title` annotation, changes to conversation entries would show up with the technical entity name:
101
107
 
@@ -104,7 +110,7 @@ For example, without the `@title` annotation, changes to conversation entries wo
104
110
  With an annotation, and possible i18n translations like so:
105
111
 
106
112
  ```cds
107
- annotate Conversations with @title: 'Conversations';
113
+ annotate Incidents.conversations with @title: '{i18n>CONVERSATION}';
108
114
  ```
109
115
 
110
116
  We get a human-readable display for *Object Type*:
@@ -115,21 +121,39 @@ We get a human-readable display for *Object Type*:
115
121
 
116
122
  The changelog annotations for *Object ID* are defined at entity level.
117
123
 
118
- These are already human-readable by default, unless the `@changelog` definition cannot be uniquely mapped such as types `enum` or `Association`.
119
-
120
- For example, having a `@changelog` annotation without any additional identifiers, changes to conversation entries would show up as simple entity IDs:
124
+ Having a `@changelog` annotation without any additional identifiers, changes to conversation entries show up as simple entity IDs:
121
125
 
122
126
  <img width="1300" alt="change-history-id" src="_assets/changes-id-wbox.png">
123
127
 
124
- However, this is not advisable as we cannot easily distinguish between changes. It is more appropriate to annotate as follows:
128
+ However, this is not advisable, with an explicit object ID increasing readability as follows:
125
129
 
126
130
  ```cds
127
- annotate ProcessorService.Conversations with @changelog: [author, timestamp] {
131
+ annotate Incidents.conversation with @changelog: [author, timestamp];
128
132
  ```
129
133
 
130
134
  <img width="1300" alt="change-history-id-hr" src="_assets/changes-id-hr-wbox.png">
131
135
 
132
- Expanding the changelog annotation by additional identifiers `[author, timestamp]`, we can now better identify the `message` change events by their respective author and timestamp.
136
+ The annotation accepts a list of paths, meaning the following examples are all possible as well:
137
+
138
+ ```cds
139
+ type CustomType : String;
140
+
141
+ extend Customers with elements {
142
+ note: CustomType
143
+ }
144
+
145
+ annotate Incidents with @changelog: [
146
+ title, customer.note, urgency.name
147
+ ];
148
+ ```
149
+
150
+ ```cds
151
+ annotate Incidents with @changelog: [
152
+ customer.address.city, customer.address.streetAddress, status.criticality
153
+ ] {
154
+ title @changelog;
155
+ }
156
+ ```
133
157
 
134
158
  ### Human-readable Values
135
159
 
@@ -153,27 +177,33 @@ customer @changelog: [customer.name];
153
177
 
154
178
  <img width="1300" alt="change-history-value-hr" src="_assets/changes-value-hr-wbox.png">
155
179
 
180
+ ### Tracing any kind of change
181
+
182
+ Change tracking is implemented with Database triggers and supports HANA Cloud, SQLite, Postgres and H2.
183
+
184
+ Leveraging database triggers means any change will be tracked no matter how it is represented in the service. Thus tracking changes made via unions, or via views with joins will still work.
185
+
156
186
  ## Advanced Options
157
187
 
158
188
  ### Altered table view
159
189
 
160
- The *Change History* view can be easily adapted and configured to your own needs by simply changing or extending it. For example, let's assume we only want to show the first 5 columns in equal spacing, we would extend `srv/change-tracking.cds` as follows:
190
+ The *Change History* view can be easily adapted and configured to your own needs by simply changing or extending it. For example, let's assume we only want to show the first 5 columns in equal spacing, we would extend `db/change-tracking.cds` as follows:
161
191
 
162
192
  ```cds
163
193
  using from '@cap-js/change-tracking';
164
194
 
165
195
  annotate sap.changelog.ChangeView with @(
166
196
  UI.LineItem : [
167
- { Value: modification, @HTML5.CssDefaults: { width:'20%' }},
168
- { Value: createdAt, @HTML5.CssDefaults: { width:'20%' }},
169
- { Value: createdBy, @HTML5.CssDefaults: { width:'20%' }},
170
- { Value: entity, @HTML5.CssDefaults: { width:'20%' }},
171
- { Value: objectID, @HTML5.CssDefaults: { width:'20%' }}
197
+ { Value: modificationLabel },
198
+ { Value: createdAt },
199
+ { Value: createdBy },
200
+ { Value: entityLabel },
201
+ { Value: objectID }
172
202
  ]
173
203
  );
174
204
  ```
175
205
 
176
- In the UI, the *Change History* table now contains 5 equally-spaced columns with the desired properties:
206
+ In the UI, the *Change History* table now contains only the five columns with the desired properties:
177
207
 
178
208
  <img width="1300" alt="change-history-custom" src="_assets/changes-custom.png">
179
209
 
@@ -181,7 +211,7 @@ For more information and examples on adding Fiori Annotations, see [Adding SAP F
181
211
 
182
212
  ### Disable lazy loading
183
213
 
184
- To disable the lazy loading feature of the *Change History* table, you can add the following annotation to your `srv/change-tracking.cds`:
214
+ To disable the lazy loading feature of the *Change History* table, you can add the following annotation to your `db/change-tracking.cds`:
185
215
 
186
216
  ```cds
187
217
  using from '@cap-js/change-tracking';
@@ -191,11 +221,11 @@ annotate sap.changelog.aspect @(UI.Facets: [{
191
221
  ID : 'ChangeHistoryFacet',
192
222
  Label : '{i18n>ChangeHistory}',
193
223
  Target: 'changes/@UI.PresentationVariant',
194
- ![@UI.PartOfPreview]
224
+ @UI.PartOfPreview
195
225
  }]);
196
226
  ```
197
227
 
198
- The system now uses the SAPUI5 default setting `![@UI.PartOfPreview]: true`, such that the table will always shown when navigating to that respective Object page.
228
+ The system now uses the SAP Fiori elements default setting `@UI.PartOfPreview: true`, such that the table will always be shown when navigating to that respective Object page.
199
229
 
200
230
  ### Disable UI Facet generation
201
231
 
@@ -220,10 +250,10 @@ entity SalesOrders {
220
250
 
221
251
  ### Disable Association to Changes Generation
222
252
 
223
- For some scenarios, e.g. when doing `UNION` and the `@changelog` annotion is still propageted, the automatic addition of the association to `changes` does not make sense. You can use `@changelog.disable_assoc`for this to be disabled on entity level.
253
+ For some scenarios, e.g. when doing `UNION` and the `@changelog` annotation is still propagated, the automatic addition of the association to `changes` does not make sense. You can use `@changelog.disable_assoc`for this to be disabled on entity level.
224
254
 
225
255
  > [!IMPORTANT]
226
- > This will also supress the addition of the UI facet, since the change-view is not available as target entity anymore.
256
+ > This will also suppress the addition of the UI facet, since the change-view is no longer available as the target entity.
227
257
 
228
258
  ### Select types of changes to track
229
259
 
@@ -249,25 +279,39 @@ By default, deleting a record will also automatically delete all associated chan
249
279
  You can turn this behavior off globally by adding the following switch to the `package.json` of your project
250
280
 
251
281
  ```json
252
- ...
253
282
  "cds": {
254
283
  "requires": {
255
- ...
256
284
  "change-tracking": {
257
285
  "preserveDeletes": true
258
286
  }
259
- ...
260
287
  }
261
288
  }
262
- ...
263
289
  ```
290
+
264
291
  > [!IMPORTANT]
265
292
  > 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.
266
293
  > You must implement an own **data retention strategy** for the change logging table in order to manage the size and performance of your database.
267
294
 
295
+ ### Adjust the depth of the entity hierarchy tracking
296
+
297
+ By default, the depth of the changes hierarchy for any entity is 3. This means, its changes as well as the changes of its compositions and the compositions of its compositions are shown on the UI.
298
+
299
+ ```json
300
+ "cds": {
301
+ "requires": {
302
+ "change-tracking": {
303
+ "maxDisplayHierarchyDepth": 3
304
+ }
305
+ }
306
+ }
307
+ ```
308
+
309
+ > [!IMPORTANT]
310
+ > The depth of the hierarchy has a performance impact, so be careful with increasing it!
311
+
268
312
  ### Tracking localized values
269
313
 
270
- If you are using a model like
314
+ Localized properties, like `descr` in the example, are respected and the localized value during change log creation is used for the label.
271
315
 
272
316
  ```cds
273
317
  entity Incidents : cuid, managed {
@@ -281,129 +325,20 @@ entity Status {
281
325
  }
282
326
  ```
283
327
 
284
- you can save the localized values into the change log instead of the default values, by setting `considerLocalizedValues`:
285
-
286
- ```json
287
- ...
288
- "cds": {
289
- "requires": {
290
- "change-tracking": {
291
- "considerLocalizedValues": true
292
- }
293
- }
294
- }
295
- ...
296
- ```
297
-
298
328
  Please be aware this means the localized value is then stored and shown in the change log, e.g. if a user speaking another language accesses the change log later, they will still see the value in the language used by the user who caused the change log.
299
329
 
300
330
  ## Examples
301
331
 
302
332
  This section describes modelling cases for further reference, from simple to complex, including the following:
303
333
 
304
- - [Specify Object ID](#specify-object-id)
305
- - [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)
306
- - [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)
307
- - [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)
308
334
  - [Tracing Changes](#tracing-changes)
309
335
  - [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)
310
336
  - [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)
311
- - [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)
312
- - [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)
313
- - [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)
337
+ - [Use Case 3: Trace the changes of chained associated entities from the current entity and display the meaningful data from associated entities (association relation)](#use-case-3-trace-the-changes-of-chained-associated-entities-from-the-current-entity-and-display-the-meaningful-data-from-associated-entities-association-relation)
314
338
  - [Don&#39;ts](#donts)
315
339
  - [Use Case 1: Don&#39;t trace changes for field(s) with `Association to many`](#use-case-1-dont-trace-changes-for-fields-with-association-to-many)
316
340
  - [Use Case 2: Don&#39;t trace changes for field(s) with *Unmanaged Association*](#use-case-2-dont-trace-changes-for-fields-with-unmanaged-association)
317
341
 
318
- ### Specify Object ID
319
-
320
- Use cases for Object ID annotation
321
-
322
- #### Use Case 1: Annotate single field/multiple fields of associated table(s) as the Object ID
323
-
324
- Modelling in `db/schema.cds`
325
-
326
- ```cds
327
- entity Incidents : cuid, managed {
328
- ...
329
- customer : Association to Customers;
330
- title : String @title: 'Title';
331
- urgency : Association to Urgency default 'M';
332
- status : Association to Status default 'N';
333
- ...
334
- }
335
- ```
336
-
337
- Add the following `@changelog` annotations in `srv/change-tracking.cds`
338
-
339
- ```cds
340
- annotate ProcessorService.Incidents with @changelog: [customer.name, urgency.code, status.criticality] {
341
- title @changelog;
342
- }
343
- ```
344
-
345
- ![AssociationID](_assets/AssociationID.png)
346
-
347
- #### Use Case 2: Annotate single field/multiple fields of project customized types as the Object ID
348
-
349
- Modelling in `db/schema.cds`
350
-
351
- ```cds
352
- entity Incidents : cuid, managed {
353
- ...
354
- customer : Association to Customers;
355
- title : String @title: 'Title';
356
- ...
357
- }
358
-
359
- entity Customers : cuid, managed {
360
- ...
361
- email : EMailAddress; // customized type
362
- phone : PhoneNumber; // customized type
363
- ...
364
- }
365
- ```
366
-
367
- Add the following `@changelog` annotations in `srv/change-tracking.cds`
368
-
369
- ```cds
370
- annotate ProcessorService.Incidents with @changelog: [customer.email, customer.phone] {
371
- title @changelog;
372
- }
373
- ```
374
-
375
- ![CustomTypeID](_assets/CustomTypeID.png)
376
-
377
- #### Use Case 3: Annotate chained associated entities from the current entity as the Object ID
378
-
379
- Modelling in `db/schema.cds`
380
-
381
- ```cds
382
- entity Incidents : cuid, managed {
383
- ...
384
- customer : Association to Customers;
385
- ...
386
- }
387
-
388
- entity Customers : cuid, managed {
389
- ...
390
- addresses : Association to Addresses;
391
- ...
392
- }
393
- ```
394
-
395
- Add the following `@changelog` annotations in `srv/change-tracking.cds`
396
-
397
- ```cds
398
- annotate ProcessorService.Incidents with @changelog: [customer.addresses.city, customer.addresses.postCode] {
399
- title @changelog;
400
- }
401
- ```
402
-
403
- ![ChainedAssociationID](_assets/ChainedAssociationID.png)
404
-
405
- > Change-tracking supports annotating chained associated entities from the current entity as object ID of current entity in case the entity in consumer applications is a pure relation table. However, the usage of chained associated entities is not recommended due to performance cost.
406
-
407
342
  ### Tracing Changes
408
343
 
409
344
  Use cases for tracing changes
@@ -426,16 +361,14 @@ aspect Conversation: managed, cuid {
426
361
  }
427
362
  ```
428
363
 
429
- Add the following `@changelog` annotations in `srv/change-tracking.cds`
364
+ Add the following `@changelog` annotations in `db/change-tracking.cds`
430
365
 
431
366
  ```cds
432
- annotate ProcessorService.Incidents with @changelog: [title] {
367
+ annotate Incidents with @changelog: [title] {
433
368
  conversation @changelog: [conversation.message];
434
369
  }
435
370
  ```
436
371
 
437
- ![CompositionChange](_assets/CompositionChange.png)
438
-
439
372
  #### Use Case 2: Trace the changes of associated entities from the current entity and display the meaningful data from associated entities (association relation)
440
373
 
441
374
  Modelling in `db/schema.cds`
@@ -455,42 +388,15 @@ entity Customers : cuid, managed {
455
388
  }
456
389
  ```
457
390
 
458
- Add the following `@changelog` annotations in `srv/change-tracking.cds`
391
+ Add the following `@changelog` annotations in `db/change-tracking.cds`
459
392
 
460
393
  ```cds
461
- annotate ProcessorService.Incidents with @changelog: [title] {
394
+ annotate Incidents with @changelog: [title] {
462
395
  customer @changelog: [customer.email];
463
396
  }
464
397
  ```
465
398
 
466
- ![AssociationChange](_assets/AssociationChange.png)
467
-
468
- #### Use Case 3: Trace the changes of fields defined by project customized types and display the meaningful data
469
-
470
- Modelling in `db/schema.cds`
471
-
472
- ```cds
473
- type StatusType : Association to Status;
474
-
475
- entity Incidents : cuid, managed {
476
- ...
477
- title : String @title: 'Title';
478
- status : StatusType default 'N';
479
- ...
480
- }
481
- ```
482
-
483
- Add the following `@changelog` annotations in `srv/change-tracking.cds`
484
-
485
- ```cds
486
- annotate ProcessorService.Incidents with @changelog: [title] {
487
- status @changelog: [status.code];
488
- }
489
- ```
490
-
491
- ![CustomTypeChange](_assets/CustomTypeChange.png)
492
-
493
- #### Use Case 4: Trace the changes of chained associated entities from the current entity and display the meaningful data from associated entities (association relation)
399
+ #### Use Case 3: Trace the changes of chained associated entities from the current entity and display the meaningful data from associated entities (association relation)
494
400
 
495
401
  Modelling in `db/schema.cds`
496
402
 
@@ -504,73 +410,21 @@ entity Incidents : cuid, managed {
504
410
 
505
411
  entity Customers : cuid, managed {
506
412
  ...
507
- addresses : Association to Addresses;
413
+ address : Composition of one Addresses;
508
414
  ...
509
415
  }
510
416
  ```
511
417
 
512
- Add the following `@changelog` annotations in `srv/change-tracking.cds`
418
+ Add the following `@changelog` annotations in `db/change-tracking.cds`
513
419
 
514
420
  ```cds
515
- annotate ProcessorService.Incidents with @changelog: [title] {
516
- customer @changelog: [customer.addresses.city, customer.addresses.streetAddress];
421
+ annotate Incidents with @changelog: [title] {
422
+ customer @changelog: [customer.address.city, customer.address.streetAddress];
517
423
  }
518
424
  ```
519
425
 
520
- ![ChainedAssociationChange](_assets/ChainedAssociationChange.png)
521
-
522
426
  > Change-tracking supports analyzing chained associated entities from the current entity in case the entity in consumer applications is a pure relation table. However, the usage of chained associated entities is not recommended due to performance cost.
523
427
 
524
- #### Use Case 5: Trace the changes of union entity and display the meaningful data
525
-
526
- `Payable.cds`:
527
-
528
- ```cds
529
- entity Payables : cuid {
530
- displayId : String;
531
- @changelog
532
- name : String;
533
- cryptoAmount : Decimal;
534
- fiatAmount : Decimal;
535
- };
536
- ```
537
-
538
- `Payment.cds`:
539
-
540
- ```cds
541
- entity Payments : cuid {
542
- displayId : String; //readable ID
543
- @changelog
544
- name : String;
545
- };
546
- ```
547
-
548
- Union entity in `BusinessTransaction.cds`:
549
-
550
- ```cds
551
- entity BusinessTransactions as(
552
- select from payments.Payments{
553
- key ID,
554
- displayId,
555
- name,
556
- changes : Association to many ChangeView
557
- on changes.objectID = ID AND changes.entity = 'payments.Payments'
558
- }
559
- )
560
- union all
561
- (
562
- select from payables.Payables {
563
- key ID,
564
- displayId,
565
- name,
566
- changes : Association to many ChangeView
567
- on changes.objectID = ID AND changes.entity = 'payables.Payables'
568
- }
569
- );
570
- ```
571
-
572
- ![UnionChange.png](_assets/UnionChange.png)
573
-
574
428
  ### Don'ts
575
429
 
576
430
  Don'ts
@@ -605,17 +459,6 @@ The reason is that: When deploying to relational databases, Associations are map
605
459
 
606
460
  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).
607
461
 
608
- ### Testing changes locally
609
-
610
- In the `tests/bookshop` folder a full sample application is provided, against which you can test your changes:
611
-
612
- ```sh
613
- git clone https://github.com/cap-js/change-tracking
614
- npm i
615
- cd tests/bookshop
616
- cds watch
617
- ```
618
-
619
462
  ## Code of Conduct
620
463
 
621
464
  We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone. By participating in this project, you agree to abide by its [Code of Conduct](CODE_OF_CONDUCT.md) at all times.
package/index.cds CHANGED
@@ -33,27 +33,18 @@ entity aspect @(UI.Facets: [{
33
33
  view ChangeView as
34
34
  select from Changes as change
35
35
  left outer join i18nKeys as attributeI18n
36
- on attributeI18n.ID = change.attribute
37
- and (
38
- attributeI18n.locale = $user.locale
39
- or attributeI18n.locale = 'en'
40
- )
36
+ on attributeI18n.ID = change.attribute
37
+ and attributeI18n.locale = $user.locale
41
38
  left outer join i18nKeys as entityI18n
42
- on entityI18n.ID = change.entity
43
- and (
44
- entityI18n.locale = $user.locale
45
- or entityI18n.locale = 'en'
46
- )
39
+ on entityI18n.ID = change.entity
40
+ and entityI18n.locale = $user.locale
47
41
  left outer join i18nKeys as modificationI18n
48
- on modificationI18n.ID = change.modification
49
- and (
50
- modificationI18n.locale = $user.locale
51
- or modificationI18n.locale = 'en'
52
- )
42
+ on modificationI18n.ID = change.modification
43
+ and modificationI18n.locale = $user.locale
53
44
  {
54
45
  key change.ID @UI.Hidden,
55
- change.parent: redirected to ChangeView,
56
- change.children: redirected to ChangeView,
46
+ change.parent : redirected to ChangeView,
47
+ change.children : redirected to ChangeView,
57
48
  change.attribute,
58
49
  change.valueChangedFrom,
59
50
  change.valueChangedTo,
@@ -66,20 +57,41 @@ view ChangeView as
66
57
  change.createdBy,
67
58
  change.transactionID,
68
59
  COALESCE(
69
- attributeI18n.text, change.attribute
60
+ attributeI18n.text, (
61
+ select text from i18nKeys
62
+ where
63
+ ID = change.attribute
64
+ and locale = 'en'
65
+ ), change.attribute
70
66
  ) as attributeLabel : String(15) @title: '{i18n>Changes.attribute}',
71
67
  COALESCE(
72
- entityI18n.text, change.entity
68
+ entityI18n.text, (
69
+ select text from i18nKeys
70
+ where
71
+ ID = change.entity
72
+ and locale = 'en'
73
+ ), change.entity
73
74
  ) as entityLabel : String(24) @title: '{i18n>Changes.entity}',
74
75
  COALESCE(
75
- modificationI18n.text, change.modification
76
+ modificationI18n.text, (
77
+ select text from i18nKeys
78
+ where
79
+ ID = change.modification
80
+ and locale = 'en'
81
+ ), change.modification
76
82
  ) as modificationLabel : String(16) @title: '{i18n>Changes.modification}',
77
83
  COALESCE(
78
84
  change.valueChangedFromLabel, change.valueChangedFrom
79
- ) as valueChangedFromLabel : String(5000) @title: '{i18n>Changes.valueChangedFrom}',
85
+ ) as valueChangedFromLabel : String(5000) @(
86
+ title: '{i18n>Changes.valueChangedFrom}',
87
+ UI.MultiLineText
88
+ ),
80
89
  COALESCE(
81
90
  change.valueChangedToLabel, change.valueChangedTo
82
- ) as valueChangedToLabel : String(5000) @title: '{i18n>Changes.valueChangedTo}',
91
+ ) as valueChangedToLabel : String(5000) @(
92
+ title: '{i18n>Changes.valueChangedTo}',
93
+ UI.MultiLineText
94
+ ),
83
95
  // For the hierarchy
84
96
  null as LimitedDescendantCount : Int16 @UI.Hidden,
85
97
  null as DistanceFromRoot : Int16 @UI.Hidden,
@@ -216,3 +228,28 @@ annotate ChangeView with @(
216
228
  'LimitedRank'
217
229
  ],
218
230
  );
231
+
232
+ // Annotations for searching
233
+ annotate ChangeView with @(cds.search: {
234
+ valueChangedFrom: false,
235
+ valueChangedTo : false,
236
+ entity : false,
237
+ attribute : false,
238
+ modification : false,
239
+ valueDataType : false,
240
+ modificationLabel,
241
+ entityLabel,
242
+ entityKey,
243
+ objectID,
244
+ attributeLabel,
245
+ valueChangedFromLabel,
246
+ valueChangedToLabel,
247
+ createdBy,
248
+ }) {
249
+ entityLabel @Search.ranking: HIGH;
250
+ attributeLabel @Search.ranking: HIGH;
251
+ objectID @Search.ranking: HIGH;
252
+
253
+ entityKey @Search.ranking: LOW;
254
+ modificationLabel @Search.ranking: LOW;
255
+ };
@@ -709,7 +709,7 @@ function _generateObjectIDCalculation(objectIDs, entity, refRow, model) {
709
709
 
710
710
  for (const oid of objectIDs) {
711
711
  if (oid.included) {
712
- parts.push(`SELECT CAST(? AS VARCHAR) AS val`);
712
+ parts.push(`SELECT COALESCE(CAST(? AS VARCHAR), '<empty>') AS val`);
713
713
  bindings.push(`${refRow}.getString("${oid.name}")`);
714
714
  } else {
715
715
  // Sub-select needed (Lookup)
@@ -1,7 +1,7 @@
1
1
  const cds = require('@sap/cds');
2
- const { fs } = cds.utils;
3
2
 
4
- const { prepareCSNForTriggers, generateTriggersForEntities, writeLabelsCSV, ensureUndeployJsonHasTriggerPattern } = require('../utils/trigger-utils.js');
3
+ const { prepareCSNForTriggers, generateTriggersForEntities, ensureUndeployJsonHasTriggerPattern } = require('../utils/trigger-utils.js');
4
+ const { getLabelTranslations } = require('../localization.js');
5
5
 
6
6
  function registerHDICompilerHook() {
7
7
  const _hdi_migration = cds.compiler.to.hdi.migration;
@@ -10,17 +10,36 @@ function registerHDICompilerHook() {
10
10
  const { runtimeCSN, hierarchy, entities } = prepareCSNForTriggers(csn, true);
11
11
 
12
12
  const triggers = generateTriggersForEntities(runtimeCSN, hierarchy, entities, generateHANATriggers);
13
-
13
+ const data = [];
14
14
  if (triggers.length > 0) {
15
15
  delete csn.definitions['sap.changelog.CHANGE_TRACKING_DUMMY']['@cds.persistence.skip'];
16
- writeLabelsCSV(entities, runtimeCSN);
17
- const dir = 'db/src/gen/data/';
18
- fs.writeFileSync(`${dir}/sap.changelog-CHANGE_TRACKING_DUMMY.csv`, `X\n1`);
19
16
  ensureUndeployJsonHasTriggerPattern();
17
+
18
+ const labels = getLabelTranslations(entities, runtimeCSN);
19
+ const header = 'ID;locale;text';
20
+ const escape = (v) => {
21
+ const s = String(v ?? '');
22
+ return s.includes(';') || s.includes('\n') || s.includes('"') ? `"${s.replace(/"/g, '""')}"` : s;
23
+ };
24
+ const rows = labels.map((row) => `${escape(row.ID)};${escape(row.locale)};${escape(row.text)}`);
25
+ const i18nContent = [header, ...rows].join('\n') + '\n';
26
+
27
+ data.push(
28
+ {
29
+ name: 'sap.changelog-CHANGE_TRACKING_DUMMY',
30
+ sql: 'X\n1',
31
+ suffix: '.csv'
32
+ },
33
+ {
34
+ name: 'sap.changelog-i18nKeys',
35
+ sql: i18nContent,
36
+ suffix: '.csv'
37
+ }
38
+ );
20
39
  }
21
40
 
22
41
  const ret = _hdi_migration(csn, options, beforeImage);
23
- ret.definitions = [...ret.definitions, ...triggers];
42
+ ret.definitions = ret.definitions.concat(triggers).concat(data);
24
43
  return ret;
25
44
  };
26
45
  }
@@ -152,7 +152,7 @@ function buildObjectIDExpr(objectIDs, entity, rowRef, model) {
152
152
  const parts = [];
153
153
  for (const oid of objectIDs) {
154
154
  if (oid.included) {
155
- parts.push(`TO_NVARCHAR(:${rowRef}.${oid.name})`);
155
+ parts.push(`COALESCE(TO_NVARCHAR(:${rowRef}.${oid.name}), '<empty>')`);
156
156
  } else {
157
157
  const where = keys.reduce((acc, k) => {
158
158
  acc[k] = { val: `:${rowRef}.${k}` };
@@ -137,7 +137,7 @@ function buildObjectIDAssignment(objectIDs, entity, keys, recVar, targetVar, mod
137
137
  const parts = [];
138
138
  for (const oid of objectIDs) {
139
139
  if (oid.included) {
140
- parts.push(`${recVar}.${oid.name}::TEXT`);
140
+ parts.push(`COALESCE(${recVar}.${oid.name}::TEXT, '<empty>')`);
141
141
  } else {
142
142
  const where = keys.reduce((acc, k) => {
143
143
  acc[k] = { val: `${recVar}.${k}` };
@@ -148,7 +148,7 @@ function buildObjectIDSelect(objectIDs, entityName, entityKeys, refRow, model) {
148
148
  objectID.selectSQL = toSQL(query, model);
149
149
  }
150
150
 
151
- const unionParts = objectIDs.map((id) => (id.included ? `SELECT ${refRow}.${id.name} AS value WHERE ${refRow}.${id.name} IS NOT NULL` : `SELECT (${id.selectSQL}) AS value`));
151
+ const unionParts = objectIDs.map((id) => (id.included ? `SELECT COALESCE(${refRow}.${id.name}, '<empty>') AS value` : `SELECT (${id.selectSQL}) AS value`));
152
152
 
153
153
  return `(SELECT GROUP_CONCAT(value, ', ') FROM (${unionParts.join('\nUNION ALL\n')}))`;
154
154
  }
@@ -105,8 +105,12 @@ function extractTrackedColumns(entity, model = cds.context?.model ?? cds.model,
105
105
  // Use override annotation if provided, otherwise use the element's own annotation
106
106
  const changelogAnnotation = overrideAnnotations?.elementAnnotations?.[name] ?? col['@changelog'];
107
107
 
108
- // Skip non-changelog columns + association columns (we want the generated FKs instead)
109
- if (!changelogAnnotation || col._foreignKey4 || col['@odata.foreignKey4']) continue;
108
+ // Skip foreign key columns (we want the generated FKs instead)
109
+ if (col._foreignKey4 || col['@odata.foreignKey4']) continue;
110
+
111
+ // Auto-include composition elements, unless explicitly defined with @changelog: false
112
+ const isComposition = col.type === 'cds.Composition';
113
+ if ((!changelogAnnotation && !isComposition) || changelogAnnotation === false) continue;
110
114
 
111
115
  // skip any PersonalData* annotation
112
116
  const hasPersonalData = Object.keys(col).some((k) => k.startsWith('@PersonalData'));
@@ -2,8 +2,6 @@ const utils = require('./change-tracking.js');
2
2
 
3
3
  /**
4
4
  * Finds composition parent info for an entity.
5
- * Checks if root entity has a @changelog annotation on a composition field pointing to this entity.
6
- *
7
5
  * Returns null if not found, or an object with:
8
6
  * { parentEntityName, compositionFieldName, parentKeyBinding, isCompositionOfOne }
9
7
  */
@@ -13,9 +11,9 @@ function getCompositionParentInfo(entity, rootEntity, rootMergedAnnotations) {
13
11
  for (const [elemName, elem] of Object.entries(rootEntity.elements)) {
14
12
  if (elem.type !== 'cds.Composition' || elem.target !== entity.name) continue;
15
13
 
16
- // Check if this composition has @changelog annotation
14
+ // Check if this composition has @changelog: false annotation
17
15
  const changelogAnnotation = rootMergedAnnotations?.elementAnnotations?.[elemName] ?? elem['@changelog'];
18
- if (!changelogAnnotation) continue;
16
+ if (changelogAnnotation === false) continue;
19
17
 
20
18
  // Found a tracked composition - get the FK binding from child to parent
21
19
  const parentKeyBinding = utils.getCompositionParentBinding(entity, rootEntity);
@@ -46,12 +44,12 @@ function getCompositionParentInfo(entity, rootEntity, rootMergedAnnotations) {
46
44
  function getGrandParentCompositionInfo(rootEntity, grandParentEntity, grandParentMergedAnnotations, grandParentCompositionField) {
47
45
  if (!grandParentEntity || !grandParentCompositionField) return null;
48
46
 
49
- // Check if the grandparent's composition field has @changelog annotation
47
+ // Check if the grandparent's composition field has @changelog: false annotation
50
48
  const elem = grandParentEntity.elements?.[grandParentCompositionField];
51
49
  if (!elem || elem.type !== 'cds.Composition' || elem.target !== rootEntity.name) return null;
52
50
 
53
51
  const changelogAnnotation = grandParentMergedAnnotations?.elementAnnotations?.[grandParentCompositionField] ?? elem['@changelog'];
54
- if (!changelogAnnotation) return null;
52
+ if (changelogAnnotation === false) return null;
55
53
 
56
54
  // Get FK binding from rootEntity to grandParentEntity
57
55
  const grandParentKeyBinding = utils.getCompositionParentBinding(rootEntity, grandParentEntity);
@@ -8,6 +8,11 @@ function isChangeTracked(entity) {
8
8
  return Object.values(entity.elements).some((e) => e['@changelog']);
9
9
  }
10
10
 
11
+ function _hasTrackedElements(entity) {
12
+ if (!entity?.elements) return false;
13
+ return Object.values(entity.elements).some((e) => e['@changelog'] && e['@changelog'] !== false);
14
+ }
15
+
11
16
  // Compares two @changelog annotation values for equality
12
17
  function _annotationsEqual(a, b) {
13
18
  // Handle null/undefined/false cases
@@ -97,20 +102,27 @@ function _mergeChangelogAnnotations(dbEntity, serviceEntities) {
97
102
  };
98
103
  }
99
104
 
100
- function getEntitiesForTriggerGeneration(model, collected) {
101
- const result = [];
102
- const processedDbEntities = new Set();
105
+ function _extractServiceAnnotations(serviceEntity) {
106
+ const entityAnnotation = serviceEntity['@changelog'];
107
+ const elementAnnotations = {};
108
+ for (const element of serviceEntity.elements) {
109
+ if (element['@changelog'] !== undefined) {
110
+ elementAnnotations[element.name] = element['@changelog'];
111
+ }
112
+ }
113
+ return { entity: serviceEntity, entityAnnotation, elementAnnotations };
114
+ }
103
115
 
104
- // Process collected service entities - resolve entities and annotations from names
116
+ // Resolve collected service entities into DB entities with merged annotations
117
+ function _collectServiceEntities(model, collected, result, processed) {
105
118
  for (const [dbEntityName, serviceEntityNames] of collected) {
106
- processedDbEntities.add(dbEntityName);
119
+ processed.add(dbEntityName);
107
120
  const dbEntity = model[dbEntityName];
108
121
  if (!dbEntity) {
109
122
  DEBUG?.(`DB entity ${dbEntityName} not found in model, skipping`);
110
123
  continue;
111
124
  }
112
125
 
113
- // Resolve service entities and extract their annotations
114
126
  const serviceEntities = [];
115
127
  for (const name of serviceEntityNames) {
116
128
  const serviceEntity = model[name];
@@ -118,21 +130,7 @@ function getEntitiesForTriggerGeneration(model, collected) {
118
130
  DEBUG?.(`Service entity ${name} not found in model, skipping`);
119
131
  continue;
120
132
  }
121
-
122
- // Extract @changelog annotations from the service entity
123
- const entityAnnotation = serviceEntity['@changelog'];
124
- const elementAnnotations = {};
125
- for (const element of serviceEntity.elements) {
126
- if (element['@changelog'] !== undefined) {
127
- elementAnnotations[element.name] = element['@changelog'];
128
- }
129
- }
130
-
131
- serviceEntities.push({
132
- entity: serviceEntity,
133
- entityAnnotation,
134
- elementAnnotations
135
- });
133
+ serviceEntities.push(_extractServiceAnnotations(serviceEntity));
136
134
  }
137
135
 
138
136
  try {
@@ -144,42 +142,68 @@ function getEntitiesForTriggerGeneration(model, collected) {
144
142
  throw error;
145
143
  }
146
144
  }
145
+ }
147
146
 
148
- // Add table entities that have @changelog but weren't collected
147
+ // Include standalone DB entities that have @changelog but no service projection
148
+ function _collectStandaloneEntities(model, result, processed) {
149
149
  for (const def of model) {
150
150
  const isTableEntity = def.kind === 'entity' && !def.query && !def.projection;
151
- if (!isTableEntity || processedDbEntities.has(def.name)) continue;
151
+ if (!isTableEntity || processed.has(def.name)) continue;
152
152
 
153
153
  if (isChangeTracked(def)) {
154
- // No service entities collected, use null for mergedAnnotations (use entity's own annotations)
155
154
  result.push({ dbEntityName: def.name, mergedAnnotations: null });
156
- processedDbEntities.add(def.name);
155
+ processed.add(def.name);
157
156
  DEBUG?.(`Including DB entity ${def.name} directly (no service entities collected)`);
158
157
  }
159
158
  }
159
+ }
160
160
 
161
- // Add composition-of-many target entities that have @changelog on the composition field
162
- for (const { dbEntityName, mergedAnnotations } of [...result]) {
163
- const dbEntity = model[dbEntityName];
161
+ /**
162
+ * Auto-discover composition target entities up to the configured hierarchy depth
163
+ * Compositions are auto-tracked when the parent is tracked and field is not set to @changelog: false, and target has at least one @changelog element (or the field has an explicit @changelog)
164
+ */
165
+ function _discoverCompositionTargets(model, result, processed) {
166
+ const maxDepth = cds.env.requires?.['change-tracking']?.maxDisplayHierarchyDepth ?? 3;
167
+ let currentEntities = [...result];
168
+
169
+ for (let depth = 1; depth < maxDepth; depth++) {
170
+ const newEntities = [];
164
171
 
165
- for (const element of Object.values(dbEntity.elements)) {
166
- if (element.type !== 'cds.Composition' || !element.is2many || !element.target) continue;
172
+ for (const { dbEntityName, mergedAnnotations } of currentEntities) {
173
+ const dbEntity = model[dbEntityName];
174
+ if (!dbEntity) continue;
167
175
 
168
- const changelogAnnotation = mergedAnnotations?.elementAnnotations?.[element.name] ?? element['@changelog'];
169
- if (!changelogAnnotation) continue;
176
+ for (const element of Object.values(dbEntity.elements)) {
177
+ if (element.type !== 'cds.Composition' || !element.target) continue;
178
+ if (processed.has(element.target)) continue;
170
179
 
171
- // Skip if target entity is already processed
172
- if (processedDbEntities.has(element.target)) continue;
180
+ const changelogAnnotation = mergedAnnotations?.elementAnnotations?.[element.name] ?? element['@changelog'];
181
+ if (changelogAnnotation === false) continue;
173
182
 
174
- const targetEntity = model[element.target];
175
- if (!targetEntity) continue;
183
+ const targetEntity = model[element.target];
184
+ if (!targetEntity) continue;
185
+ if (!changelogAnnotation && !_hasTrackedElements(targetEntity)) continue;
176
186
 
177
- // Add target entity with null mergedAnnotations (it uses its own annotations, if any)
178
- result.push({ dbEntityName: element.target, mergedAnnotations: null });
179
- processedDbEntities.add(element.target);
180
- DEBUG?.(`Including composition target ${element.target} for tracked composition ${element.name} on ${dbEntityName}`);
187
+ const entry = { dbEntityName: element.target, mergedAnnotations: null };
188
+ result.push(entry);
189
+ processed.add(element.target);
190
+ newEntities.push(entry);
191
+ DEBUG?.(`Including composition target ${element.target} for ${changelogAnnotation ? 'tracked' : 'auto-tracked'} composition ${element.name} on ${dbEntityName} (depth ${depth})`);
192
+ }
181
193
  }
194
+
195
+ if (newEntities.length === 0) break;
196
+ currentEntities = newEntities;
182
197
  }
198
+ }
199
+
200
+ function getEntitiesForTriggerGeneration(model, collected) {
201
+ const result = [];
202
+ const processed = new Set();
203
+
204
+ _collectServiceEntities(model, collected, result, processed);
205
+ _collectStandaloneEntities(model, result, processed);
206
+ _discoverCompositionTargets(model, result, processed);
183
207
 
184
208
  return result;
185
209
  }
@@ -225,11 +249,12 @@ function analyzeCompositions(csn) {
225
249
 
226
250
  // Second pass: build hierarchy with grandparent info
227
251
  const hierarchy = new Map();
252
+ const maxDepth = cds.env.requires?.['change-tracking']?.maxDisplayHierarchyDepth ?? 3;
228
253
  for (const [childName, parentInfo] of childParentMap) {
229
254
  const { parent: parentName, compositionField } = parentInfo;
230
255
 
231
- // Check if the parent itself has a parent (grandparent)
232
- const grandParentInfo = childParentMap.get(parentName);
256
+ // Only include grandparent info if maxDisplayHierarchyDepth allows it (depth > 2 needed for grandparent)
257
+ const grandParentInfo = maxDepth > 2 ? childParentMap.get(parentName) : null;
233
258
 
234
259
  hierarchy.set(childName, {
235
260
  parent: parentName,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cap-js/change-tracking",
3
- "version": "2.0.0-beta.2",
3
+ "version": "2.0.0-beta.3",
4
4
  "publishConfig": {
5
5
  "access": "public",
6
6
  "tag": "beta"
@@ -44,7 +44,6 @@
44
44
  "requires": {
45
45
  "change-tracking": {
46
46
  "model": "@cap-js/change-tracking",
47
- "considerLocalizedValues": false,
48
47
  "maxDisplayHierarchyDepth": 3,
49
48
  "preserveDeletes": false
50
49
  }