@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 +16 -1
- package/README.md +32 -0
- package/cds-plugin.js +1 -1
- package/index.cds +277 -29
- package/lib/csn-enhancements/annotations.js +77 -0
- package/lib/csn-enhancements/dynamicLocalization.js +106 -0
- package/lib/{model-enhancer.js → csn-enhancements/index.js} +9 -70
- package/lib/csn-enhancements/timezoneProperties.js +81 -0
- package/lib/h2/java-codegen.js +36 -8
- package/lib/hana/register.js +10 -0
- package/lib/hana/sql-expressions.js +33 -9
- package/lib/postgres/register.js +12 -0
- package/lib/postgres/sql-expressions.js +33 -8
- package/lib/postgres/triggers.js +5 -3
- package/lib/sqlite/sql-expressions.js +27 -7
- package/lib/utils/change-tracking.js +4 -1
- package/lib/utils/entity-collector.js +21 -1
- package/package.json +1 -1
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.
|
|
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/
|
|
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
|
|
46
|
-
change.parent
|
|
47
|
-
change.children
|
|
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
|
|
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
|
|
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
|
|
82
|
+
) as modificationLabel : String(16) @title: '{i18n>Changes.modification}',
|
|
83
83
|
COALESCE(
|
|
84
84
|
change.valueChangedFromLabel, change.valueChangedFrom
|
|
85
|
-
) as valueChangedFromLabel
|
|
86
|
-
|
|
87
|
-
|
|
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
|
|
92
|
-
|
|
93
|
-
|
|
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
|
|
97
|
-
null as DistanceFromRoot
|
|
98
|
-
null as DrillState
|
|
99
|
-
null as LimitedRank
|
|
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
|
-
|
|
305
|
+
$Type : 'UI.DataFieldForAnnotation',
|
|
306
|
+
Target : '@UI.FieldGroup#valueChangedTo',
|
|
307
|
+
Label : '{i18n>Changes.valueChangedTo}',
|
|
178
308
|
@UI.Importance: #High
|
|
179
309
|
},
|
|
180
310
|
{
|
|
181
|
-
|
|
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
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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('
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
|
256
|
-
|
|
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
|
+
};
|
package/lib/h2/java-codegen.js
CHANGED
|
@@ -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 =
|
|
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,
|
|
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.
|
|
680
|
-
|
|
681
|
-
return { sqlExpr: sql, bindings: bindings };
|
|
679
|
+
if (!col.alt || col.alt.length === 0) {
|
|
680
|
+
return { sqlExpr: 'NULL', bindings: [] };
|
|
682
681
|
}
|
|
683
|
-
|
|
684
|
-
|
|
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) {
|
package/lib/hana/register.js
CHANGED
|
@@ -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
|
-
*
|
|
103
|
+
* Builds scalar subselect for association label lookup with locale awareness
|
|
104
104
|
*/
|
|
105
|
-
function
|
|
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 =
|
|
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,
|
|
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
|
package/lib/postgres/register.js
CHANGED
|
@@ -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 =
|
|
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,
|
|
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.
|
|
124
|
-
|
|
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
|
-
|
|
149
|
+
flushAssocBatch();
|
|
150
|
+
|
|
151
|
+
return parts.length === 0 ? 'NULL' : parts.join(" || ', ' || ");
|
|
127
152
|
}
|
|
128
153
|
|
|
129
154
|
/**
|
package/lib/postgres/triggers.js
CHANGED
|
@@ -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
|
|
111
|
-
const alt =
|
|
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,
|
|
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.
|
|
132
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
};
|