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

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.4 - tbd
7
+ ## Version 2.0.0-beta.6 - tbd
8
8
 
9
9
  ### Added
10
10
 
@@ -12,6 +12,21 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/).
12
12
 
13
13
  ### Changed
14
14
 
15
+ ## Version 2.0.0-beta.5 - 17.03.26
16
+
17
+ ### Added
18
+ - Support dynamic localized label lookup, meaning if for example a property is change tracked and its change tracking label (@changelog : [<association>.<localized_prop>]) points to one localized property from its code list entity, the label is dynamically fetched when the change is read based on the users locale.
19
+
20
+ ### Fixed
21
+ - Postgres considers `disable*Tracking` for children changes
22
+ - Human-readable `@changelog` annotation supports combination of direct entity elements and association elements
23
+
24
+ ## Version 2.0.0-beta.4 - 16.03.26
25
+
26
+ ### Added
27
+
28
+ - Tracked Date, DateTime, Time and Timestamp properties are now correctly formatted again.
29
+ - If a tracked property is annotated with `@Common.Timezone` the changelog now considers the Timezone as well.
15
30
 
16
31
  ## Version 2.0.0-beta.3 - 13.03.26
17
32
 
package/README.md CHANGED
@@ -177,12 +177,44 @@ customer @changelog: [customer.name];
177
177
 
178
178
  <img width="1300" alt="change-history-value-hr" src="_assets/changes-value-hr-wbox.png">
179
179
 
180
+ #### Localized values
181
+ If a human-readable value is annotated for the changelog, it will be localized.
182
+
183
+ ```cds
184
+ extend Incidents with elements {
185
+ status: Association to one Status @changelog: [status.descr];
186
+ }
187
+
188
+ entity Status {
189
+ key code: String(1);
190
+ descr: localized String(20);
191
+ }
192
+ ```
193
+
194
+ By default the value label stored for the change is localized in the language of the user who caused the change. Meaning if a German speaking user changes the status, the human-readable value would be by default in German.
195
+
196
+ In cases, like above, where the human-readable value only consists of one field, targets a localized property and goes along the (un-)managed association, a dynamic human-readable value is used, meaning if an English-speaking user looks at the changes, the value label will be shown in English, for a French-speaking user in French and so on.
197
+
180
198
  ### Tracing any kind of change
181
199
 
182
200
  Change tracking is implemented with Database triggers and supports HANA Cloud, SQLite, Postgres and H2.
183
201
 
184
202
  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
203
 
204
+ #### Tracking datetime fields with a fixed time zone
205
+
206
+ The plugin supports tracking datetime field changes when the field has a time zone annotated.
207
+
208
+ ```cds
209
+ extend Incidents with elements {
210
+ closedAt : DateTime @changelog @Common.Timezone : 'Europe/Berlin';
211
+ openedAt : DateTime @changelog @Common.Timezone : openedTimeZone;
212
+ openedTimeZone : String @Common.IsTimezone;
213
+ }
214
+ ```
215
+
216
+ In both cases the plugin will show the annotated time zone for change values in changes for the two fields. In the second case the time zone is dynamically fetched and modifications to the time zone field will also reflect in the change records for that field.
217
+
186
218
  ## Advanced Options
187
219
 
188
220
  ### Altered table view
package/cds-plugin.js CHANGED
@@ -1,6 +1,6 @@
1
1
  const cds = require('@sap/cds');
2
2
 
3
- const { enhanceModel } = require('./lib/model-enhancer.js');
3
+ const { enhanceModel } = require('./lib/csn-enhancements');
4
4
  const { registerSessionVariableHandlers } = require('./lib/skipHandlers.js');
5
5
  const { deploySQLiteTriggers } = require('./lib/sqlite/register.js');
6
6
  const { registerPostgresCompilerHook, deployPostgresLabels } = require('./lib/postgres/register.js');
package/index.cds CHANGED
@@ -42,9 +42,9 @@ view ChangeView as
42
42
  on modificationI18n.ID = change.modification
43
43
  and modificationI18n.locale = $user.locale
44
44
  {
45
- key change.ID @UI.Hidden,
46
- change.parent : redirected to ChangeView,
47
- change.children : redirected to ChangeView,
45
+ key change.ID @UI.Hidden,
46
+ change.parent : redirected to ChangeView,
47
+ change.children : redirected to ChangeView,
48
48
  change.attribute,
49
49
  change.valueChangedFrom,
50
50
  change.valueChangedTo,
@@ -63,7 +63,7 @@ view ChangeView as
63
63
  ID = change.attribute
64
64
  and locale = 'en'
65
65
  ), change.attribute
66
- ) as attributeLabel : String(15) @title: '{i18n>Changes.attribute}',
66
+ ) as attributeLabel : String(15) @title: '{i18n>Changes.attribute}',
67
67
  COALESCE(
68
68
  entityI18n.text, (
69
69
  select text from i18nKeys
@@ -71,7 +71,7 @@ view ChangeView as
71
71
  ID = change.entity
72
72
  and locale = 'en'
73
73
  ), change.entity
74
- ) as entityLabel : String(24) @title: '{i18n>Changes.entity}',
74
+ ) as entityLabel : String(24) @title: '{i18n>Changes.entity}',
75
75
  COALESCE(
76
76
  modificationI18n.text, (
77
77
  select text from i18nKeys
@@ -79,24 +79,152 @@ view ChangeView as
79
79
  ID = change.modification
80
80
  and locale = 'en'
81
81
  ), change.modification
82
- ) as modificationLabel : String(16) @title: '{i18n>Changes.modification}',
82
+ ) as modificationLabel : String(16) @title: '{i18n>Changes.modification}',
83
83
  COALESCE(
84
84
  change.valueChangedFromLabel, change.valueChangedFrom
85
- ) as valueChangedFromLabel : String(5000) @(
86
- title: '{i18n>Changes.valueChangedFrom}',
87
- UI.MultiLineText
88
- ),
85
+ ) as valueChangedFromLabel : String(5000) @(
86
+ title: '{i18n>Changes.valueChangedFrom}',
87
+ UI.MultiLineText
88
+ ),
89
+ (
90
+ case
91
+ when valueDataType = 'cds.DateTime'
92
+ then COALESCE(
93
+ change.valueChangedFromLabel, change.valueChangedFrom
94
+ )
95
+ else null
96
+ end
97
+ ) as valueChangedFromLabelDateTime : DateTime @(title: '{i18n>Changes.valueChangedFrom}',
98
+ ),
99
+ (
100
+ case
101
+ when valueDataType = 'cds.DateTime' or valueDataType = 'cds.Timestamp'
102
+ then COALESCE(
103
+ change.valueChangedFromLabel, change.valueChangedFrom
104
+ )
105
+ else null
106
+ end
107
+ ) as valueChangedFromLabelDateTimeWTZ : DateTime @(
108
+ title : '{i18n>Changes.valueChangedFrom}',
109
+ Common.Timezone: valueTimeZone
110
+ ),
111
+ (
112
+ case
113
+ when valueDataType = 'cds.Time'
114
+ then COALESCE(
115
+ change.valueChangedFromLabel, change.valueChangedFrom
116
+ )
117
+ else null
118
+ end
119
+ ) as valueChangedFromLabelTime : Time @(title: '{i18n>Changes.valueChangedFrom}',
120
+ ),
121
+ (
122
+ case
123
+ when valueDataType = 'cds.Date'
124
+ then COALESCE(
125
+ change.valueChangedFromLabel, change.valueChangedFrom
126
+ )
127
+ else null
128
+ end
129
+ ) as valueChangedFromLabelDate : Date @(title: '{i18n>Changes.valueChangedFrom}',
130
+ ),
131
+ (
132
+ case
133
+ when valueDataType = 'cds.Timestamp'
134
+ then COALESCE(
135
+ change.valueChangedFromLabel, change.valueChangedFrom
136
+ )
137
+ else null
138
+ end
139
+ ) as valueChangedFromLabelTimestamp : Timestamp @(title: '{i18n>Changes.valueChangedFrom}',
140
+ ),
141
+ (
142
+ case
143
+ when valueDataType = 'cds.Decimal'
144
+ then COALESCE(
145
+ change.valueChangedFromLabel, change.valueChangedFrom
146
+ )
147
+ else null
148
+ end
149
+ ) as valueChangedFromLabelDecimal : Decimal @(title: '{i18n>Changes.valueChangedFrom}',
150
+ ),
89
151
  COALESCE(
90
152
  change.valueChangedToLabel, change.valueChangedTo
91
- ) as valueChangedToLabel : String(5000) @(
92
- title: '{i18n>Changes.valueChangedTo}',
93
- UI.MultiLineText
94
- ),
153
+ ) as valueChangedToLabel : String(5000) @(
154
+ title: '{i18n>Changes.valueChangedTo}',
155
+ UI.MultiLineText
156
+ ),
157
+ (
158
+ case
159
+ when valueDataType = 'cds.DateTime'
160
+ then COALESCE(
161
+ change.valueChangedToLabel, change.valueChangedTo
162
+ )
163
+ else null
164
+ end
165
+ ) as valueChangedToLabelDateTime : DateTime @(title: '{i18n>Changes.valueChangedTo}',
166
+ ),
167
+ (
168
+ case
169
+ when valueDataType = 'cds.DateTime' or valueDataType = 'cds.Timestamp'
170
+ then COALESCE(
171
+ change.valueChangedFromLabel, change.valueChangedTo
172
+ )
173
+ else null
174
+ end
175
+ ) as valueChangedToLabelDateTimeWTZ : DateTime @(
176
+ title : '{i18n>Changes.valueChangedTo}',
177
+ Common.Timezone: valueTimeZone
178
+ ),
179
+ (
180
+ case
181
+ when valueDataType = 'cds.Time'
182
+ then COALESCE(
183
+ change.valueChangedToLabel, change.valueChangedTo
184
+ )
185
+ else null
186
+ end
187
+ ) as valueChangedToLabelTime : Time @(title: '{i18n>Changes.valueChangedTo}',
188
+ ),
189
+ (
190
+ case
191
+ when valueDataType = 'cds.Date'
192
+ then COALESCE(
193
+ change.valueChangedToLabel, change.valueChangedTo
194
+ )
195
+ else null
196
+ end
197
+ ) as valueChangedToLabelDate : Date @(title: '{i18n>Changes.valueChangedTo}',
198
+ ),
199
+ (
200
+ case
201
+ when valueDataType = 'cds.Timestamp'
202
+ then COALESCE(
203
+ change.valueChangedToLabel, change.valueChangedTo
204
+ )
205
+ else null
206
+ end
207
+ ) as valueChangedToLabelTimestamp : Timestamp @(title: '{i18n>Changes.valueChangedTo}',
208
+ ),
209
+ (
210
+ case
211
+ when valueDataType = 'cds.Decimal'
212
+ then COALESCE(
213
+ change.valueChangedToLabel, change.valueChangedTo
214
+ )
215
+ else null
216
+ end
217
+ ) as valueChangedToLabelDecimal : Decimal @(title: '{i18n>Changes.valueChangedTo}',
218
+ ),
219
+ null as valueTimeZone : String @(
220
+ UI.Hidden,
221
+ Common.IsTimezone
222
+ ),
95
223
  // For the hierarchy
96
- null as LimitedDescendantCount : Int16 @UI.Hidden,
97
- null as DistanceFromRoot : Int16 @UI.Hidden,
98
- null as DrillState : String @UI.Hidden,
99
- null as LimitedRank : Int16 @UI.Hidden,
224
+ null as LimitedDescendantCount : Int16 @UI.Hidden,
225
+ null as DistanceFromRoot : Int16 @UI.Hidden,
226
+ null as DrillState : String @UI.Hidden,
227
+ null as LimitedRank : Int16 @UI.Hidden,
100
228
  };
101
229
 
102
230
  entity i18nKeys {
@@ -105,7 +233,7 @@ entity i18nKeys {
105
233
  text : String(5000);
106
234
  }
107
235
 
108
- // Dummy table necessary for HANA triggers
236
+ // Dummy table necessary for HANA triggers because DUMMY cannot be used in HDI
109
237
  @cds.persistence.skip
110
238
  entity CHANGE_TRACKING_DUMMY {
111
239
  key X : String(5);
@@ -174,11 +302,15 @@ annotate ChangeView with @(UI: {
174
302
  @UI.Importance: #Medium
175
303
  },
176
304
  {
177
- Value : valueChangedToLabel,
305
+ $Type : 'UI.DataFieldForAnnotation',
306
+ Target : '@UI.FieldGroup#valueChangedTo',
307
+ Label : '{i18n>Changes.valueChangedTo}',
178
308
  @UI.Importance: #High
179
309
  },
180
310
  {
181
- Value : valueChangedFromLabel,
311
+ $Type : 'UI.DataFieldForAnnotation',
312
+ Target : '@UI.FieldGroup#valueChangedFrom',
313
+ Label : '{i18n>Changes.valueChangedFrom}',
182
314
  @UI.Importance: #High
183
315
  },
184
316
  {
@@ -191,13 +323,103 @@ annotate ChangeView with @(UI: {
191
323
  },
192
324
  ],
193
325
  DeleteHidden : true,
326
+ FieldGroup #valueChangedFrom : {
327
+ Label: '{i18n>Changes.valueChangedFrom}',
328
+ Data : [
329
+ {
330
+ Value : valueChangedFromLabel,
331
+ @UI.Hidden: ($self.valueDataType = 'cds.Decimal'
332
+ or $self.valueDataType = 'cds.DateTime'
333
+ or $self.valueDataType = 'cds.Date'
334
+ or $self.valueDataType = 'cds.Time'
335
+ or $self.valueDataType = 'cds.Timestamp')
336
+ },
337
+ {
338
+ Value : valueChangedFromLabelDateTime,
339
+ @UI.Hidden: ($self.valueDataType != 'cds.DateTime'
340
+ or $self.valueTimeZone != null)
341
+ },
342
+ {
343
+ Value : valueChangedFromLabelDateTimeWTZ,
344
+ @UI.Hidden: ($self.valueDataType != 'cds.DateTime'
345
+ or $self.valueTimeZone = null)
346
+ },
347
+ {
348
+ Value : valueChangedFromLabelDate,
349
+ @UI.Hidden: ($self.valueDataType != 'cds.Date')
350
+ },
351
+ {
352
+ Value : valueChangedFromLabelTime,
353
+ @UI.Hidden: ($self.valueDataType != 'cds.Time')
354
+ },
355
+ {
356
+ Value : valueChangedFromLabelTimestamp,
357
+ @UI.Hidden: ($self.valueDataType != 'cds.Timestamp')
358
+ },
359
+ {
360
+ Value : valueChangedFromLabelDecimal,
361
+ @UI.Hidden: ($self.valueDataType != 'cds.Decimal')
362
+ }
363
+ ]
364
+ },
365
+ FieldGroup #valueChangedTo : {
366
+ Label: '{i18n>Changes.valueChangedTo}',
367
+ Data : [
368
+ {
369
+ Value : valueChangedToLabel,
370
+ @UI.Hidden: ($self.valueDataType = 'cds.Decimal'
371
+ or $self.valueDataType = 'cds.DateTime'
372
+ or $self.valueDataType = 'cds.Date'
373
+ or $self.valueDataType = 'cds.Time'
374
+ or $self.valueDataType = 'cds.Timestamp')
375
+ },
376
+ {
377
+ Value : valueChangedToLabelDateTime,
378
+ @UI.Hidden: ($self.valueDataType != 'cds.DateTime'
379
+ or $self.valueTimeZone != null)
380
+ },
381
+ {
382
+ Value : valueChangedToLabelDateTimeWTZ,
383
+ @UI.Hidden: ($self.valueDataType != 'cds.DateTime'
384
+ or $self.valueTimeZone = null)
385
+ },
386
+ {
387
+ Value : valueChangedToLabelDate,
388
+ @UI.Hidden: ($self.valueDataType != 'cds.Date')
389
+ },
390
+ {
391
+ Value : valueChangedToLabelTime,
392
+ @UI.Hidden: ($self.valueDataType != 'cds.Time')
393
+ },
394
+ {
395
+ Value : valueChangedToLabelTimestamp,
396
+ @UI.Hidden: ($self.valueDataType != 'cds.Timestamp')
397
+ },
398
+ {
399
+ Value : valueChangedToLabelDecimal,
400
+ @UI.Hidden: ($self.valueDataType != 'cds.Decimal')
401
+ }
402
+ ]
403
+ }
194
404
  }) {
195
- valueChangedFrom @UI.Hidden;
196
- valueChangedTo @UI.Hidden;
197
- parent @UI.Hidden;
198
- entityKey @UI.Hidden;
199
- entity @UI.Hidden;
200
- attribute @UI.Hidden;
405
+ valueChangedFrom @UI.Hidden;
406
+ valueChangedFromLabelDate @UI.AdaptationHidden @UI.Hidden: ($self.valueDataType != 'cds.Date');
407
+ valueChangedFromLabelDateTime @UI.AdaptationHidden @UI.Hidden: ($self.valueDataType != 'cds.DateTime');
408
+ valueChangedFromLabelDateTimeWTZ @UI.AdaptationHidden @UI.Hidden: ($self.valueDataType != 'cds.DateTime');
409
+ valueChangedFromLabelTime @UI.AdaptationHidden @UI.Hidden: ($self.valueDataType != 'cds.Time');
410
+ valueChangedFromLabelTimestamp @UI.AdaptationHidden @UI.Hidden: ($self.valueDataType != 'cds.Timestamp');
411
+ valueChangedFromLabelDecimal @UI.AdaptationHidden @UI.Hidden: ($self.valueDataType != 'cds.Decimal');
412
+ valueChangedTo @UI.Hidden;
413
+ valueChangedToLabelDate @UI.AdaptationHidden @UI.Hidden: ($self.valueDataType != 'cds.Date');
414
+ valueChangedToLabelDateTime @UI.AdaptationHidden @UI.Hidden: ($self.valueDataType != 'cds.DateTime');
415
+ valueChangedToLabelDateTimeWTZ @UI.AdaptationHidden @UI.Hidden: ($self.valueDataType != 'cds.DateTime');
416
+ valueChangedToLabelTime @UI.AdaptationHidden @UI.Hidden: ($self.valueDataType != 'cds.Time');
417
+ valueChangedToLabelTimestamp @UI.AdaptationHidden @UI.Hidden: ($self.valueDataType != 'cds.Timestamp');
418
+ valueChangedToLabelDecimal @UI.AdaptationHidden @UI.Hidden: ($self.valueDataType != 'cds.Decimal');
419
+ parent @UI.Hidden;
420
+ entityKey @UI.Hidden;
421
+ entity @UI.Hidden;
422
+ attribute @UI.Hidden;
201
423
  };
202
424
 
203
425
  annotate ChangeView with @(
@@ -218,14 +440,40 @@ annotate ChangeView with @(
218
440
  'LimitedDescendantCount',
219
441
  'DistanceFromRoot',
220
442
  'DrillState',
221
- 'LimitedRank'
443
+ 'LimitedRank',
444
+ valueChangedFromLabelDate,
445
+ valueChangedFromLabelDateTime,
446
+ valueChangedFromLabelDateTimeWTZ,
447
+ valueChangedFromLabelTime,
448
+ valueChangedFromLabelTimestamp,
449
+ valueChangedFromLabelDecimal,
450
+ valueChangedToLabelDate,
451
+ valueChangedToLabelDateTime,
452
+ valueChangedToLabelDateTimeWTZ,
453
+ valueChangedToLabelTime,
454
+ valueChangedToLabelTimestamp,
455
+ valueChangedToLabelDecimal,
456
+ valueTimeZone
222
457
  ],
223
458
  // Disallow sorting on these properties from Fiori UIs
224
459
  Capabilities.SortRestrictions.NonSortableProperties : [
225
460
  'LimitedDescendantCount',
226
461
  'DistanceFromRoot',
227
462
  'DrillState',
228
- 'LimitedRank'
463
+ 'LimitedRank',
464
+ valueChangedFromLabelDate,
465
+ valueChangedFromLabelDateTime,
466
+ valueChangedFromLabelDateTimeWTZ,
467
+ valueChangedFromLabelTime,
468
+ valueChangedFromLabelTimestamp,
469
+ valueChangedFromLabelDecimal,
470
+ valueChangedToLabelDate,
471
+ valueChangedToLabelDateTime,
472
+ valueChangedToLabelDateTimeWTZ,
473
+ valueChangedToLabelTime,
474
+ valueChangedToLabelTimestamp,
475
+ valueChangedToLabelDecimal,
476
+ valueTimeZone
229
477
  ],
230
478
  );
231
479
 
@@ -0,0 +1,77 @@
1
+ const cds = require('@sap/cds');
2
+ const LOG = cds.log('change-tracking');
3
+ const DEBUG = cds.debug('change-tracking');
4
+
5
+ /**
6
+ * Add side effects annotations for actions to refresh the changes association.
7
+ */
8
+ function addSideEffects(actions, entityName, hierarchyMap, model) {
9
+ const isRootEntity = !hierarchyMap.has(entityName);
10
+
11
+ // If not a root entity, find the parent association name
12
+ let parentAssociationName = null;
13
+ if (!isRootEntity) {
14
+ const parentEntityName = hierarchyMap.get(entityName);
15
+ const parentEntity = model.definitions[parentEntityName];
16
+ if (parentEntity?.elements) {
17
+ // Find the composition element in the parent that points to this entity
18
+ for (const [elemName, elem] of Object.entries(parentEntity.elements)) {
19
+ if (elem.type === 'cds.Composition' && elem.target === entityName) {
20
+ parentAssociationName = elemName;
21
+ break;
22
+ }
23
+ }
24
+ }
25
+ }
26
+
27
+ for (const se of Object.values(actions)) {
28
+ const target = isRootEntity ? 'TargetProperties' : 'TargetEntities';
29
+ const sideEffectAttr = se[`@Common.SideEffects.${target}`];
30
+ const property = isRootEntity ? 'changes' : { '=': `${parentAssociationName}.changes` };
31
+ if (sideEffectAttr?.length >= 0) {
32
+ sideEffectAttr.findIndex((item) => (item['='] ? item['='] : item) === (property['='] ? property['='] : property)) === -1 && sideEffectAttr.push(property);
33
+ } else {
34
+ se[`@Common.SideEffects.${target}`] = [property];
35
+ }
36
+ }
37
+ }
38
+
39
+ function addUIFacet(entity, m) {
40
+ const { 'sap.changelog.aspect': aspect } = m.definitions;
41
+ const {
42
+ '@UI.Facets': [facet],
43
+ elements: { changes }
44
+ } = aspect;
45
+ if (entity['@changelog.disable_facet'] !== undefined) {
46
+ LOG.warn(
47
+ `@changelog.disable_facet is deprecated! You can just define your own Facet for the changes association or annotate the changes association on ${entity.name} with not readable via @Capabilities.NavigationRestrictions.RestrictedProperties`
48
+ );
49
+ }
50
+
51
+ let facets = entity['@UI.Facets'];
52
+
53
+ if (!facets) {
54
+ DEBUG?.(`${entity.name} does not have a @UI.Facets annotation and thus the change tracking section is not added.`);
55
+ }
56
+ // Add UI.Facet for Change History List
57
+ if (
58
+ facets &&
59
+ !entity['@changelog.disable_facet'] &&
60
+ !hasFacetForComp(changes, entity['@UI.Facets']) &&
61
+ !entity['@Capabilities.NavigationRestrictions.RestrictedProperties']?.some((restriction) => restriction.NavigationProperty?.['='] === 'changes' && restriction.ReadRestrictions?.Readable === false)
62
+ ) {
63
+ facets.push(facet);
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Check if a facet already exists for the changes composition.
69
+ */
70
+ function hasFacetForComp(comp, facets) {
71
+ return facets.some((f) => f.Target === `${comp.name}/@UI.LineItem` || (f.Facets && hasFacetForComp(comp, f.Facets)));
72
+ }
73
+
74
+ module.exports = {
75
+ addSideEffects,
76
+ addUIFacet
77
+ };
@@ -0,0 +1,106 @@
1
+ const cds = require('@sap/cds');
2
+ const { isChangeTracked, getBaseEntity, getBaseElement } = require('../utils/entity-collector');
3
+ const DEBUG = cds.debug('change-tracking');
4
+
5
+ /**
6
+ * Dynamic localization, primarily for code list scenarios, where a status field is change tracked but its localized label should be shown.
7
+ * @param {*} serviceName
8
+ * @param {*} m
9
+ */
10
+ function collectTrackedPropertiesWithDynamicLocalization(serviceName, m) {
11
+ const dynamicLocalizationProperties = [];
12
+ for (const name in m.definitions) {
13
+ if (!name.startsWith(serviceName) || m.definitions[name].kind !== 'entity' || !isChangeTracked(m.definitions[name])) {
14
+ continue;
15
+ }
16
+ const entity = m.definitions[name];
17
+ const base = getBaseEntity(entity, m);
18
+ if (!base) continue;
19
+ for (const ele in entity.elements) {
20
+ const element = entity.elements[ele];
21
+ if (!Array.isArray(element['@changelog']) || element['@changelog'].length !== 1 || !element['@changelog'][0]?.['='] || element._foreignKey4) {
22
+ continue;
23
+ }
24
+ const segments = element['@changelog'][0]['='].split('.');
25
+ const baseEleInfo = getBaseElement(ele, entity, m);
26
+ const basePropertyName = baseEleInfo?.baseElement ?? ele;
27
+ // Managed association target or as fallback unmanaged association target
28
+ const target = element.target ?? m.definitions[base.baseRef ?? name].elements[segments[0]].target;
29
+ const basePropertyInUnmanagedOnCondition = m.definitions[base.baseRef ?? name].elements[segments[0]].on?.some((r) => r.ref && r.ref[0] === basePropertyName);
30
+ const isLocalizedField = m.definitions[target].elements?.[segments[1]]?.localized;
31
+ const amountOfKeys = Object.keys(m.definitions[target].elements).filter((e) => m.definitions[target].elements[e].key).length;
32
+ if (!target || (segments[0] !== basePropertyName && !basePropertyInUnmanagedOnCondition) || segments.length !== 2 || !isLocalizedField || amountOfKeys > 1) {
33
+ DEBUG &&
34
+ DEBUG(
35
+ `Dynamic localization lookup is not performed on ${ele} of ${name} for the path "${element['@changelog'][0]['=']}". Only paths which follow the properties association, which only navigate one level deep and where the last property is localized are supported.`
36
+ );
37
+ continue;
38
+ }
39
+
40
+ if (!dynamicLocalizationProperties.some((prop) => prop.property === basePropertyName && prop.entity === base.baseRef)) {
41
+ dynamicLocalizationProperties.push({
42
+ property: basePropertyName,
43
+ entity: base.baseRef,
44
+ dynamicLabel: SELECT.from(target + '.texts')
45
+ .alias('localizationSubSelect')
46
+ .where('1 = 1')
47
+ .columns(segments[1])
48
+ });
49
+ }
50
+ DEBUG && DEBUG(`${ele} of ${name} is change tracked and its logs are visualized using a dynamic localized label lookup targeting ${target + '.texts'} for the label ${segments[1]}.`);
51
+ }
52
+ }
53
+ return dynamicLocalizationProperties;
54
+ }
55
+
56
+ function enhanceChangeViewWithLocalization(serviceName, changeViewName, m) {
57
+ const changeView = m.definitions[changeViewName];
58
+ if (changeView['@changelog.internal.localizationEnhanced']) return;
59
+ DEBUG && DEBUG(`Enhance change view ${changeViewName} with dynamic localization setup.`);
60
+ const localizationProperties = collectTrackedPropertiesWithDynamicLocalization(serviceName, m);
61
+ if (!localizationProperties.length) return;
62
+ const changeViewCqn = changeView.projection ?? changeView.query.SELECT;
63
+ changeViewCqn.columns ??= ['*'];
64
+ changeViewCqn.from.as ??= 'change';
65
+ let valueChangedFromLabel = changeViewCqn.columns.find((c) => c.as && c.as === 'valueChangedFromLabel');
66
+ if (!valueChangedFromLabel) {
67
+ changeViewCqn.columns.push({
68
+ cast: { type: 'cds.String' },
69
+ xpr: [{ ref: ['valueChangedFromLabel'] }],
70
+ as: 'valueChangedFromLabel'
71
+ });
72
+ valueChangedFromLabel = changeViewCqn.columns.at(-1);
73
+ }
74
+ let valueChangedToLabel = changeViewCqn.columns.find((c) => c.as && c.as === 'valueChangedToLabel');
75
+ if (!valueChangedToLabel) {
76
+ changeViewCqn.columns.push({
77
+ cast: { type: 'cds.String' },
78
+ xpr: [{ ref: ['valueChangedToLabel'] }],
79
+ as: 'valueChangedToLabel'
80
+ });
81
+ valueChangedToLabel = changeViewCqn.columns.at(-1);
82
+ }
83
+ const originalValueChangedFrom = valueChangedFromLabel.xpr;
84
+ const originalValueChangedTo = valueChangedToLabel.xpr;
85
+ valueChangedFromLabel.xpr = ['case'];
86
+ valueChangedToLabel.xpr = ['case'];
87
+ for (const localizationProp of localizationProperties) {
88
+ valueChangedFromLabel.xpr.push('when', { ref: ['attribute'] }, '=', { val: localizationProp.property }, 'and', { ref: ['entity'] }, '=', { val: localizationProp.entity }, 'then');
89
+ const subSelect = structuredClone(localizationProp.dynamicLabel);
90
+ const keys = Object.keys(m.definitions[localizationProp.dynamicLabel.SELECT.from.ref[0]].elements).filter((e) => e !== 'locale' && m.definitions[localizationProp.dynamicLabel.SELECT.from.ref[0]].elements[e].key);
91
+ subSelect.SELECT.where = [{ ref: [changeViewCqn.from.as, 'valueChangedFrom'] }, '=', { ref: ['localizationSubSelect', keys[0]] }, 'and', { ref: ['localizationSubSelect', 'locale'] }, '=', { ref: ['$user', 'locale'] }];
92
+ valueChangedFromLabel.xpr.push({ func: 'COALESCE', args: [subSelect, { xpr: originalValueChangedFrom }] });
93
+
94
+ valueChangedToLabel.xpr.push('when', { ref: ['attribute'] }, '=', { val: localizationProp.property }, 'and', { ref: ['entity'] }, '=', { val: localizationProp.entity }, 'then');
95
+ const subSelect2 = structuredClone(localizationProp.dynamicLabel);
96
+ subSelect2.SELECT.where = [{ ref: [changeViewCqn.from.as, 'valueChangedTo'] }, '=', { ref: ['localizationSubSelect', keys[0]] }, 'and', { ref: ['localizationSubSelect', 'locale'] }, '=', { ref: ['$user', 'locale'] }];
97
+ valueChangedToLabel.xpr.push({ func: 'COALESCE', args: [subSelect2, { xpr: originalValueChangedTo }] });
98
+ }
99
+ valueChangedFromLabel.xpr.push('else', { xpr: originalValueChangedFrom }, 'end');
100
+ valueChangedToLabel.xpr.push('else', { xpr: originalValueChangedTo }, 'end');
101
+ changeView['@changelog.internal.localizationEnhanced'] = true;
102
+ }
103
+
104
+ module.exports = {
105
+ enhanceChangeViewWithLocalization
106
+ };
@@ -1,42 +1,10 @@
1
1
  const cds = require('@sap/cds');
2
- const LOG = cds.log('change-tracking');
3
2
  const DEBUG = cds.debug('change-tracking');
4
3
 
5
- const { isChangeTracked, getBaseEntity, analyzeCompositions, getService } = require('./utils/entity-collector.js');
6
-
7
- /**
8
- * Add side effects annotations for actions to refresh the changes association.
9
- */
10
- function addSideEffects(actions, entityName, hierarchyMap, model) {
11
- const isRootEntity = !hierarchyMap.has(entityName);
12
-
13
- // If not a root entity, find the parent association name
14
- let parentAssociationName = null;
15
- if (!isRootEntity) {
16
- const parentEntityName = hierarchyMap.get(entityName);
17
- const parentEntity = model.definitions[parentEntityName];
18
- if (parentEntity?.elements) {
19
- // Find the composition element in the parent that points to this entity
20
- for (const [elemName, elem] of Object.entries(parentEntity.elements)) {
21
- if (elem.type === 'cds.Composition' && elem.target === entityName) {
22
- parentAssociationName = elemName;
23
- break;
24
- }
25
- }
26
- }
27
- }
28
-
29
- for (const se of Object.values(actions)) {
30
- const target = isRootEntity ? 'TargetProperties' : 'TargetEntities';
31
- const sideEffectAttr = se[`@Common.SideEffects.${target}`];
32
- const property = isRootEntity ? 'changes' : { '=': `${parentAssociationName}.changes` };
33
- if (sideEffectAttr?.length >= 0) {
34
- sideEffectAttr.findIndex((item) => (item['='] ? item['='] : item) === (property['='] ? property['='] : property)) === -1 && sideEffectAttr.push(property);
35
- } else {
36
- se[`@Common.SideEffects.${target}`] = [property];
37
- }
38
- }
39
- }
4
+ const { isChangeTracked, getBaseEntity, analyzeCompositions, getService } = require('../utils/entity-collector.js');
5
+ const { addSideEffects, addUIFacet } = require('./annotations.js');
6
+ const { enhanceChangeViewWithTimeZones } = require('./timezoneProperties.js');
7
+ const { enhanceChangeViewWithLocalization } = require('./dynamicLocalization.js');
40
8
 
41
9
  /**
42
10
  * Returns a CQN expression for the composite key of an entity.
@@ -82,13 +50,6 @@ function _replaceTablePlaceholders(on, tableName) {
82
50
  });
83
51
  }
84
52
 
85
- /**
86
- * Check if a facet already exists for the changes composition.
87
- */
88
- function hasFacetForComp(comp, facets) {
89
- return facets.some((f) => f.Target === `${comp.name}/@UI.LineItem` || (f.Facets && hasFacetForComp(comp, f.Facets)));
90
- }
91
-
92
53
  /**
93
54
  * Enhance the CDS model with change tracking associations, facets, and side effects.
94
55
  * Returns the updated hierarchyMap and collectedEntities for use by trigger generation.
@@ -107,7 +68,6 @@ function enhanceModel(m) {
107
68
  const { 'sap.changelog.aspect': aspect } = m.definitions;
108
69
  if (!aspect) return; // some other model
109
70
  const {
110
- '@UI.Facets': [facet],
111
71
  elements: { changes }
112
72
  } = aspect;
113
73
 
@@ -143,6 +103,7 @@ function enhanceModel(m) {
143
103
  m.definitions['sap.changelog.ChangeView'].elements[parents.join('_') + '_' + 'entityKey'] = structuredClone(m.definitions['sap.changelog.ChangeView'].elements.entityKey);
144
104
  m.definitions['sap.changelog.ChangeView'].elements[parents.join('_') + '_' + 'entity'] = structuredClone(m.definitions['sap.changelog.ChangeView'].elements.entity);
145
105
  }
106
+ enhanceChangeViewWithTimeZones(m.definitions['sap.changelog.ChangeView'], m);
146
107
  }
147
108
  for (let name in m.definitions) {
148
109
  const entity = m.definitions[name];
@@ -213,6 +174,7 @@ function enhanceModel(m) {
213
174
  }
214
175
  }
215
176
  }
177
+ enhanceChangeViewWithLocalization(serviceName, `${serviceName}.ChangeView`, m);
216
178
  }
217
179
 
218
180
  DEBUG?.(
@@ -228,35 +190,12 @@ function enhanceModel(m) {
228
190
  (query.columns ??= ['*']).push({ as: 'changes', cast: assoc });
229
191
  entity.elements.changes = assoc;
230
192
  }
231
-
232
- if (entity['@changelog.disable_facet'] !== undefined) {
233
- LOG.warn(
234
- `@changelog.disable_facet is deprecated! You can just define your own Facet for the changes association or annotate the changes association on ${entity.name} with not readable via @Capabilities.NavigationRestrictions.RestrictedProperties`
235
- );
236
- }
237
-
238
- let facets = entity['@UI.Facets'];
239
-
240
- if (!facets) {
241
- DEBUG?.(`${entity.name} does not have a @UI.Facets annotation and thus the change tracking section is not added.`);
242
- }
243
- // Add UI.Facet for Change History List
244
- if (
245
- facets &&
246
- !entity['@changelog.disable_facet'] &&
247
- !hasFacetForComp(changes, entity['@UI.Facets']) &&
248
- !entity['@Capabilities.NavigationRestrictions.RestrictedProperties']?.some((restriction) => restriction.NavigationProperty?.['='] === 'changes' && restriction.ReadRestrictions?.Readable === false)
249
- ) {
250
- facets.push(facet);
251
- }
193
+ addUIFacet(entity, m);
252
194
  }
253
195
 
254
196
  if (entity.actions) {
255
- const baseInfo = getBaseEntity(entity, m);
256
- if (baseInfo) {
257
- const { baseRef: dbEntityName } = baseInfo;
258
- addSideEffects(entity.actions, dbEntityName, hierarchyMap, m);
259
- }
197
+ const { baseRef: dbEntityName } = baseInfo;
198
+ addSideEffects(entity.actions, dbEntityName, hierarchyMap, m);
260
199
  }
261
200
  }
262
201
  }
@@ -0,0 +1,81 @@
1
+ const cds = require('@sap/cds');
2
+ const { compositeKeyExpr: compositeKeyExprSqlite } = require('../sqlite/sql-expressions');
3
+ const { compositeKeyExpr: compositeKeyExprHANA } = require('../hana/sql-expressions');
4
+ const { compositeKeyExpr: compositeKeyExprPG } = require('../postgres/sql-expressions');
5
+ const { isChangeTracked } = require('../utils/entity-collector');
6
+
7
+ function collectTrackedPropertiesWithTimezone(m) {
8
+ const timezoneProperties = [];
9
+ for (const name in m.definitions) {
10
+ const entity = m.definitions[name];
11
+ if (entity.kind !== 'entity' || entity.query || entity.projection || !isChangeTracked(entity)) {
12
+ continue;
13
+ }
14
+ for (const ele in entity.elements) {
15
+ const element = entity.elements[ele];
16
+ if (!element['@Common.Timezone'] || element._foreignKey4) {
17
+ continue;
18
+ }
19
+ timezoneProperties.push({
20
+ property: ele,
21
+ entity: name,
22
+ timezone: element['@Common.Timezone']?.['=']
23
+ ? // Where condition is replaced when select is inserted into ChangeView
24
+ SELECT.from(name).alias('timezoneSubSelect').where('1 = 1').columns(element['@Common.Timezone']['='])
25
+ : element['@Common.Timezone']
26
+ });
27
+ }
28
+ }
29
+ return timezoneProperties;
30
+ }
31
+
32
+ function isDeploy2Check(target) {
33
+ try {
34
+ cds.build?.register(target, class ABC extends cds.build.Plugin {});
35
+ } catch (err) {
36
+ if (err.message.match(/already registered/)) {
37
+ return true;
38
+ }
39
+ }
40
+ return false;
41
+ }
42
+
43
+ function enhanceChangeViewWithTimeZones(changeView, m) {
44
+ const compositeKeyExpr =
45
+ isDeploy2Check('hana') && m.meta.creator.match(/v6/)
46
+ ? compositeKeyExprHANA
47
+ : isDeploy2Check('postgres') && m.meta.creator.match(/v6/)
48
+ ? compositeKeyExprPG
49
+ : cds.env.requires?.db.kind === 'sqlite' && !cds.build
50
+ ? compositeKeyExprSqlite
51
+ : cds.env.requires?.db.kind === 'postgres' && (!cds.build || cds.env.profiles.includes('pg'))
52
+ ? compositeKeyExprPG
53
+ : compositeKeyExprHANA;
54
+ const timezoneProperties = collectTrackedPropertiesWithTimezone(m);
55
+ const timezoneColumn = changeView.query.SELECT.columns.find((c) => c.as && c.as === 'valueTimeZone');
56
+ if (timezoneProperties.length === 0) return;
57
+ delete timezoneColumn.val;
58
+ timezoneColumn.xpr = ['case'];
59
+ for (const timezoneProp of timezoneProperties) {
60
+ timezoneColumn.xpr.push('when', { ref: ['attribute'] }, '=', { val: timezoneProp.property }, 'and', { ref: ['entity'] }, '=', { val: timezoneProp.entity }, 'then');
61
+ if (timezoneProp.timezone.SELECT) {
62
+ const subSelect = structuredClone(timezoneProp.timezone);
63
+ const keys = Object.keys(m.definitions[timezoneProp.entity].elements).filter((e) => m.definitions[timezoneProp.entity].elements[e].key);
64
+ subSelect.SELECT.where = [
65
+ { ref: ['change', 'entityKey'] },
66
+ '=',
67
+ // REVISIT: once HIERARCHY_COMPOSITE_ID is available on all DBs, use native CQN
68
+ compositeKeyExpr(keys)
69
+ ];
70
+ timezoneColumn.xpr.push(subSelect);
71
+ } else {
72
+ timezoneColumn.xpr.push({ val: timezoneProp.timezone });
73
+ }
74
+ }
75
+ timezoneColumn.xpr.push('else', { val: null }, 'end');
76
+ }
77
+
78
+ module.exports = {
79
+ collectTrackedPropertiesWithTimezone,
80
+ enhanceChangeViewWithTimeZones
81
+ };
@@ -18,7 +18,7 @@ function _toSQL(query, model) {
18
18
  return cqn2sql.SELECT(sqlCQN);
19
19
  }
20
20
 
21
- function handleAssocLookup(column, refRow, model) {
21
+ function handleAssocLookup(column, assocPaths, refRow, model) {
22
22
  let bindings = [];
23
23
  let where = {};
24
24
 
@@ -36,11 +36,11 @@ function handleAssocLookup(column, refRow, model) {
36
36
  bindings = column.on.map((assoc) => `${refRow}.getString("${assoc}")`);
37
37
  }
38
38
 
39
- const alt = column.alt.map((s) => s.split('.').slice(1).join('.'));
39
+ const alt = assocPaths.map((s) => s.split('.').slice(1).join('.'));
40
40
  const columns = alt.length === 1 ? alt[0] : utils.buildConcatXpr(alt);
41
41
 
42
42
  // Check if target entity has localized data
43
- const localizedInfo = utils.getLocalizedLookupInfo(column.target, column.alt, model);
43
+ const localizedInfo = utils.getLocalizedLookupInfo(column.target, assocPaths, model);
44
44
 
45
45
  if (localizedInfo) {
46
46
  // Build locale-aware lookup: try .texts table first, fall back to base entity
@@ -676,12 +676,40 @@ function _prepareValueExpression(col, rowVar) {
676
676
 
677
677
  // Returns label expression for a column
678
678
  function _prepareLabelExpression(col, rowVar, model) {
679
- if (col.target && col.alt) {
680
- const { sql, bindings } = handleAssocLookup(col, rowVar, model);
681
- return { sqlExpr: sql, bindings: bindings };
679
+ if (!col.alt || col.alt.length === 0) {
680
+ return { sqlExpr: 'NULL', bindings: [] };
682
681
  }
683
- // No label for scalars or associations without @changelog override
684
- return { sqlExpr: 'NULL', bindings: [] };
682
+
683
+ const sqlParts = [];
684
+ const allBindings = [];
685
+ let assocBatch = [];
686
+
687
+ const flushAssocBatch = () => {
688
+ if (assocBatch.length > 0) {
689
+ const { sql, bindings } = handleAssocLookup(col, assocBatch, rowVar, model);
690
+ sqlParts.push(sql);
691
+ allBindings.push(...bindings);
692
+ assocBatch = [];
693
+ }
694
+ };
695
+
696
+ for (const entry of col.alt) {
697
+ if (entry.source === 'assoc') {
698
+ assocBatch.push(entry.path);
699
+ } else {
700
+ flushAssocBatch();
701
+ sqlParts.push('?');
702
+ allBindings.push(`${rowVar}.getString("${entry.path}")`);
703
+ }
704
+ }
705
+ flushAssocBatch();
706
+
707
+ if (sqlParts.length === 0) {
708
+ return { sqlExpr: 'NULL', bindings: [] };
709
+ }
710
+
711
+ const sqlExpr = sqlParts.length === 1 ? sqlParts[0] : sqlParts.join(" || ', ' || ");
712
+ return { sqlExpr, bindings: allBindings };
685
713
  }
686
714
 
687
715
  function _wrapInTryCatch(sql, bindings) {
@@ -42,6 +42,16 @@ function registerHDICompilerHook() {
42
42
  ret.definitions = ret.definitions.concat(triggers).concat(data);
43
43
  return ret;
44
44
  };
45
+
46
+ // REVISIT: Remove once time casting is fixed in cds-dbs
47
+ cds.on('serving', async () => {
48
+ if (cds.env.requires?.db.kind !== 'hana') return;
49
+ const db = await cds.connect.to('db');
50
+ db.before('*', () => {
51
+ // to_time conversion is necessary else HANA tries to convert to timestamp implicitly causing an SQL crash
52
+ db.class.CQN2SQL.OutputConverters.Time = (e) => `to_char(to_time(${e}), 'HH24:MI:SS')`;
53
+ });
54
+ });
45
55
  }
46
56
 
47
57
  module.exports = { registerHDICompilerHook };
@@ -100,14 +100,9 @@ function getWhereCondition(col, modification) {
100
100
  }
101
101
 
102
102
  /**
103
- * Returns SQL expression for a column's label (looked-up value for associations)
103
+ * Builds scalar subselect for association label lookup with locale awareness
104
104
  */
105
- function getLabelExpr(col, refRow, model) {
106
- if (!(col.target && col.alt)) {
107
- return `NULL`;
108
- }
109
-
110
- // Builds inline SELECT expression for association label lookup with locale support
105
+ function buildAssocLookup(col, assocPaths, refRow, model) {
111
106
  let where = {};
112
107
  if (col.foreignKeys) {
113
108
  where = col.foreignKeys.reduce((acc, k) => {
@@ -121,11 +116,11 @@ function getLabelExpr(col, refRow, model) {
121
116
  }, {});
122
117
  }
123
118
 
124
- const alt = col.alt.map((s) => s.split('.').slice(1).join('.'));
119
+ const alt = assocPaths.map((s) => s.split('.').slice(1).join('.'));
125
120
  const columns = alt.length === 1 ? alt[0] : utils.buildConcatXpr(alt);
126
121
 
127
122
  // Check for localization
128
- const localizedInfo = utils.getLocalizedLookupInfo(col.target, col.alt, model);
123
+ const localizedInfo = utils.getLocalizedLookupInfo(col.target, assocPaths, model);
129
124
  if (localizedInfo) {
130
125
  const textsWhere = { ...where, locale: { func: 'SESSION_CONTEXT', args: [{ val: 'LOCALE' }] } };
131
126
  const textsQuery = SELECT.one.from(localizedInfo.textsEntity).columns(columns).where(textsWhere);
@@ -137,6 +132,35 @@ function getLabelExpr(col, refRow, model) {
137
132
  return `(${toSQL(query, model)})`;
138
133
  }
139
134
 
135
+ /**
136
+ * Returns SQL expression for a column's label (looked-up value for associations)
137
+ */
138
+ function getLabelExpr(col, refRow, model) {
139
+ if (!col.alt || col.alt.length === 0) return `NULL`;
140
+
141
+ const parts = [];
142
+ let assocBatch = [];
143
+
144
+ const flushAssocBatch = () => {
145
+ if (assocBatch.length > 0) {
146
+ parts.push(buildAssocLookup(col, assocBatch, refRow, model));
147
+ assocBatch = [];
148
+ }
149
+ };
150
+
151
+ for (const entry of col.alt) {
152
+ if (entry.source === 'assoc') {
153
+ assocBatch.push(entry.path);
154
+ } else {
155
+ flushAssocBatch();
156
+ parts.push(`TO_NVARCHAR(:${refRow}.${entry.path})`);
157
+ }
158
+ }
159
+ flushAssocBatch();
160
+
161
+ return parts.length === 0 ? `NULL` : parts.join(" || ', ' || ");
162
+ }
163
+
140
164
  /**
141
165
  * Builds SQL expression for objectID (entity display name)
142
166
  * Uses @changelog annotation fields, falling back to entity name
@@ -26,6 +26,18 @@ function registerPostgresCompilerHook() {
26
26
 
27
27
  return ddl;
28
28
  });
29
+
30
+ // REVISIT: Remove once time casting is fixed in cds-dbs
31
+ cds.on('serving', async () => {
32
+ if (cds.env.requires?.db.kind !== 'postgres') return;
33
+ const db = await cds.connect.to('db');
34
+ db.before('*', () => {
35
+ db.class.CQN2SQL.OutputConverters.Date = (e) => `to_date(${e}::text, 'YYYY-MM-DD')`;
36
+ db.class.CQN2SQL.OutputConverters.Time = (e) => `to_timestamp(${e}::text, 'HH24:MI:SS')::TIME`;
37
+ db.class.CQN2SQL.OutputConverters.DateTime = (e) => `to_timestamp(${e}::text, 'YYYY-MM-DD"T"HH24:MI:SS"Z"')::timestamp`;
38
+ db.class.CQN2SQL.OutputConverters.Timestamp = (e) => `to_timestamp(${e}::text, 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"')::timestamp`;
39
+ });
40
+ });
29
41
  }
30
42
 
31
43
  async function deployPostgresLabels() {
@@ -84,9 +84,13 @@ function getWhereCondition(col, modification) {
84
84
  }
85
85
 
86
86
  /**
87
- * Builds scalar subselect for association label lookup with locale support
87
+ * Builds scalar subselect for association label lookup with locale support.
88
+ * @param {Object} column Column entry with target, foreignKeys/on, etc.
89
+ * @param {string[]} assocPaths Array of association paths (format: "assocName.field")
90
+ * @param {string} refRow Trigger row reference ('NEW' or 'OLD')
91
+ * @param {*} model CSN model
88
92
  */
89
- function buildAssocLookup(column, refRow, model) {
93
+ function buildAssocLookup(column, assocPaths, refRow, model) {
90
94
  let where = {};
91
95
  if (column.foreignKeys) {
92
96
  where = column.foreignKeys.reduce((acc, k) => {
@@ -100,11 +104,11 @@ function buildAssocLookup(column, refRow, model) {
100
104
  }, {});
101
105
  }
102
106
 
103
- const alt = column.alt.map((s) => s.split('.').slice(1).join('.'));
107
+ const alt = assocPaths.map((s) => s.split('.').slice(1).join('.'));
104
108
  const columns = alt.length === 1 ? alt[0] : utils.buildConcatXpr(alt);
105
109
 
106
110
  // Check for localization
107
- const localizedInfo = utils.getLocalizedLookupInfo(column.target, column.alt, model);
111
+ const localizedInfo = utils.getLocalizedLookupInfo(column.target, assocPaths, model);
108
112
  if (localizedInfo) {
109
113
  const textsWhere = { ...where, locale: { func: 'current_setting', args: [{ val: 'cap.locale' }, { val: true }] } };
110
114
  const textsQuery = SELECT.one.from(localizedInfo.textsEntity).columns(columns).where(textsWhere);
@@ -117,13 +121,34 @@ function buildAssocLookup(column, refRow, model) {
117
121
  }
118
122
 
119
123
  /**
120
- * Returns SQL expression for a column's label (looked-up value for associations)
124
+ * Returns SQL expression for a column's label (looked-up value for associations).
125
+ * Iterates over col.alt entries in order, grouping consecutive association paths
126
+ * into a single subquery and emitting local references inline from the trigger row.
121
127
  */
122
128
  function getLabelExpr(col, refRow, model) {
123
- if (col.target && col.alt) {
124
- return buildAssocLookup(col, refRow, model);
129
+ if (!col.alt || col.alt.length === 0) return 'NULL';
130
+
131
+ const parts = [];
132
+ let assocBatch = [];
133
+
134
+ const flushAssocBatch = () => {
135
+ if (assocBatch.length > 0) {
136
+ parts.push(buildAssocLookup(col, assocBatch, refRow, model));
137
+ assocBatch = [];
138
+ }
139
+ };
140
+
141
+ for (const entry of col.alt) {
142
+ if (entry.source === 'assoc') {
143
+ assocBatch.push(entry.path);
144
+ } else {
145
+ flushAssocBatch();
146
+ parts.push(`${refRow}.${entry.path}::TEXT`);
147
+ }
125
148
  }
126
- return 'NULL';
149
+ flushAssocBatch();
150
+
151
+ return parts.length === 0 ? 'NULL' : parts.join(" || ', ' || ");
127
152
  }
128
153
 
129
154
  /**
@@ -1,7 +1,9 @@
1
+ const cds = require('@sap/cds');
1
2
  const utils = require('../utils/change-tracking.js');
2
3
  const { getCompositionParentInfo, getGrandParentCompositionInfo } = require('../utils/composition-helpers.js');
3
4
  const { getSkipCheckCondition, compositeKeyExpr, buildObjectIDAssignment, buildInsertBlock, extractTrackedDbColumns } = require('./sql-expressions.js');
4
5
  const { buildCompositionParentBlock } = require('./composition.js');
6
+ const config = cds.env.requires['change-tracking'];
5
7
 
6
8
  /**
7
9
  * Generates the PL/pgSQL function body for the main change tracking trigger
@@ -18,9 +20,9 @@ function buildFunctionBody(entity, columns, objectIDs, rootEntity, rootObjectIDs
18
20
  const deleteBlock = columns.length > 0 ? buildInsertBlock(columns, 'delete', entity, model, hasCompositionParent) : '';
19
21
 
20
22
  // Build composition parent blocks if needed
21
- const createParentBlock = compositionParentInfo ? buildCompositionParentBlock(compositionParentInfo, rootObjectIDs, 'create', model, grandParentCompositionInfo) : '';
22
- const updateParentBlock = compositionParentInfo ? buildCompositionParentBlock(compositionParentInfo, rootObjectIDs, 'update', model, grandParentCompositionInfo) : '';
23
- const deleteParentBlock = compositionParentInfo ? buildCompositionParentBlock(compositionParentInfo, rootObjectIDs, 'delete', model, grandParentCompositionInfo) : '';
23
+ const createParentBlock = compositionParentInfo && !config?.disableCreateTracking ? buildCompositionParentBlock(compositionParentInfo, rootObjectIDs, 'create', model, grandParentCompositionInfo) : '';
24
+ const updateParentBlock = compositionParentInfo && !config?.disableUpdateTracking ? buildCompositionParentBlock(compositionParentInfo, rootObjectIDs, 'update', model, grandParentCompositionInfo) : '';
25
+ const deleteParentBlock = compositionParentInfo && !config?.disableDeleteTracking ? buildCompositionParentBlock(compositionParentInfo, rootObjectIDs, 'delete', model, grandParentCompositionInfo) : '';
24
26
 
25
27
  return `
26
28
  DECLARE
@@ -95,7 +95,7 @@ function getWhereCondition(col, modification) {
95
95
  /**
96
96
  * Builds scalar subselect for association label lookup with locale awareness
97
97
  */
98
- function buildAssocLookup(column, refRow, entityKey, model) {
98
+ function buildAssocLookup(column, assocPaths, refRow, entityKey, model) {
99
99
  const where = column.foreignKeys
100
100
  ? column.foreignKeys.reduce((acc, k) => {
101
101
  acc[k] = { val: `${refRow}.${column.name}_${k}` };
@@ -107,12 +107,12 @@ function buildAssocLookup(column, refRow, entityKey, model) {
107
107
  return acc;
108
108
  }, {});
109
109
 
110
- // Drop the first part of column.alt (association name)
111
- const alt = column.alt.map((s) => s.split('.').slice(1).join('.'));
110
+ // Drop the first part of each path (association name)
111
+ const alt = assocPaths.map((s) => s.split('.').slice(1).join('.'));
112
112
  const columns = alt.length === 1 ? alt[0] : utils.buildConcatXpr(alt);
113
113
 
114
114
  // Check for localization
115
- const localizedInfo = utils.getLocalizedLookupInfo(column.target, column.alt, model);
115
+ const localizedInfo = utils.getLocalizedLookupInfo(column.target, assocPaths, model);
116
116
  if (localizedInfo) {
117
117
  const textsWhere = { ...where, locale: { func: 'session_context', args: [{ val: '$user.locale' }] } };
118
118
  const textsQuery = SELECT.one.from(localizedInfo.textsEntity).columns(columns).where(textsWhere);
@@ -128,10 +128,30 @@ function buildAssocLookup(column, refRow, entityKey, model) {
128
128
  * Returns SQL expression for a column's label (looked-up value for associations)
129
129
  */
130
130
  function getLabelExpr(col, refRow, entityKey, model) {
131
- if (col.target && col.alt) {
132
- return buildAssocLookup(col, refRow, entityKey, model);
131
+ if (!col.alt || col.alt.length === 0) return 'NULL';
132
+
133
+ const parts = [];
134
+ let assocBatch = [];
135
+
136
+ const flushAssocBatch = () => {
137
+ if (assocBatch.length > 0) {
138
+ parts.push(buildAssocLookup(col, assocBatch, refRow, entityKey, model));
139
+ assocBatch = [];
140
+ }
141
+ };
142
+
143
+ for (const entry of col.alt) {
144
+ if (entry.source === 'assoc') {
145
+ assocBatch.push(entry.path);
146
+ } else {
147
+ // local field: flush any pending association batch first, then emit local ref
148
+ flushAssocBatch();
149
+ parts.push(`${refRow}.${entry.path}`);
150
+ }
133
151
  }
134
- return 'NULL';
152
+ flushAssocBatch();
153
+
154
+ return parts.length === 0 ? 'NULL' : parts.join(" || ', ' || ");
135
155
  }
136
156
 
137
157
  /**
@@ -173,7 +173,9 @@ function extractTrackedColumns(entity, model = cds.context?.model ?? cds.model,
173
173
  const changelogPaths = changelogAnnotation.map((c) => c['=']);
174
174
  for (const path of changelogPaths) {
175
175
  const p = validateChangelogPath(entity, path, model);
176
- if (p) alt.push(p);
176
+ if (!p) continue;
177
+ if (p.includes('.')) alt.push({ path: p, source: 'assoc' });
178
+ else alt.push({ path: p, source: 'local' });
177
179
  }
178
180
  if (alt.length > 0) entry.alt = alt;
179
181
  }
@@ -352,6 +354,7 @@ function getCompositionParentBinding(targetEntity, rootEntity) {
352
354
  }
353
355
 
354
356
  function getLocalizedLookupInfo(targetEntityName, altFields, model = cds.context?.model ?? cds.model) {
357
+ if (!altFields || altFields.length === 0) return null;
355
358
  const targetEntity = model.definitions[targetEntityName];
356
359
  if (!targetEntity) return null;
357
360
 
@@ -227,6 +227,25 @@ function getBaseEntity(entity, model) {
227
227
  }
228
228
  }
229
229
 
230
+ function getBaseElement(element, entity, model) {
231
+ const cqn = entity.projection ?? entity.query?.SELECT;
232
+ if (!cqn) return null;
233
+ element = cqn.columns?.find((c) => c.as === element && c.ref)?.ref?.[0] ?? element;
234
+
235
+ const baseRef = cqn.from?.ref?.[0];
236
+ if (!baseRef || !model) return null;
237
+
238
+ const baseEntity = model.definitions[baseRef];
239
+ if (!baseEntity) return null;
240
+ const baseCQN = baseEntity.projection ?? baseEntity.query?.SELECT ?? baseEntity.query?.SET;
241
+ // If base entity is also a projection, recurse
242
+ if (baseCQN) {
243
+ return getBaseElement(element, baseEntity, model);
244
+ } else {
245
+ return { baseRef, baseElement: element };
246
+ }
247
+ }
248
+
230
249
  // Analyze composition hierarchy in CSN
231
250
  function analyzeCompositions(csn) {
232
251
  // First pass: build child -> { parent, compositionField } map
@@ -318,5 +337,6 @@ module.exports = {
318
337
  getBaseEntity,
319
338
  analyzeCompositions,
320
339
  getService,
321
- collectEntities
340
+ collectEntities,
341
+ getBaseElement
322
342
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cap-js/change-tracking",
3
- "version": "2.0.0-beta.3",
3
+ "version": "2.0.0-beta.5",
4
4
  "publishConfig": {
5
5
  "access": "public",
6
6
  "tag": "beta"