@cap-js/change-tracking 2.0.0-beta.4 → 2.0.0-beta.6
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 +25 -1
- package/README.md +32 -0
- package/cds-plugin.js +2 -0
- package/index.cds +30 -36
- package/lib/addMigrationTable.js +59 -0
- package/lib/csn-enhancements/dynamicLocalization.js +106 -0
- package/lib/csn-enhancements/index.js +3 -1
- package/lib/csn-enhancements/timezoneProperties.js +2 -2
- package/lib/h2/java-codegen.js +36 -8
- package/lib/hana/migrationTable.js +69 -0
- package/lib/hana/register.js +32 -9
- package/lib/hana/restoreProcedure.js +342 -0
- package/lib/hana/sql-expressions.js +37 -14
- package/lib/postgres/register.js +0 -12
- package/lib/postgres/sql-expressions.js +34 -9
- package/lib/postgres/triggers.js +5 -3
- package/lib/skipHandlers.js +2 -2
- package/lib/sqlite/sql-expressions.js +28 -8
- package/lib/utils/change-tracking.js +10 -1
- package/lib/utils/entity-collector.js +21 -1
- package/package.json +3 -2
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.7 - tbd
|
|
8
8
|
|
|
9
9
|
### Added
|
|
10
10
|
|
|
@@ -12,6 +12,30 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/).
|
|
|
12
12
|
|
|
13
13
|
### Changed
|
|
14
14
|
|
|
15
|
+
## Version 2.0.0-beta.6 - 26.03.26
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
- Provide detailed plan for v1 to v2 HANA migration
|
|
19
|
+
- Generation of `.hdbmigrationtable` and updating `undeploy.json` via `cds add change-tracking-migration`
|
|
20
|
+
- HANA procedure `SAP_CHANGELOG_RESTORE_BACKLINKS` to restore parent-child hierarchy for composition changes
|
|
21
|
+
|
|
22
|
+
### Fixed
|
|
23
|
+
- Explicit type casts for Date, DateTime, Time, Timestamp and Decimal fields in `ChangeView` to avoid conversion errors
|
|
24
|
+
- Lazy load database adapters to prevent crashes when optional dependencies are not installed
|
|
25
|
+
- Skip changelogs referencing association targets annotated with `@cds.persistence.skip`
|
|
26
|
+
- Cast single entity keys to `cds.String` to prevent type conversion errors
|
|
27
|
+
- Dynamic localization now verifies `.texts` entity existence before attempting localized lookup
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
## Version 2.0.0-beta.5 - 17.03.26
|
|
31
|
+
|
|
32
|
+
### Added
|
|
33
|
+
- 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.
|
|
34
|
+
|
|
35
|
+
### Fixed
|
|
36
|
+
- Postgres considers `disable*Tracking` for children changes
|
|
37
|
+
- Human-readable `@changelog` annotation supports combination of direct entity elements and association elements
|
|
38
|
+
|
|
15
39
|
## Version 2.0.0-beta.4 - 16.03.26
|
|
16
40
|
|
|
17
41
|
### Added
|
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
package/index.cds
CHANGED
|
@@ -45,17 +45,11 @@ view ChangeView as
|
|
|
45
45
|
key change.ID @UI.Hidden,
|
|
46
46
|
change.parent : redirected to ChangeView,
|
|
47
47
|
change.children : redirected to ChangeView,
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
change.objectID,
|
|
54
|
-
change.modification,
|
|
55
|
-
change.valueDataType,
|
|
56
|
-
change.createdAt,
|
|
57
|
-
change.createdBy,
|
|
58
|
-
change.transactionID,
|
|
48
|
+
// Needed to make the * possible
|
|
49
|
+
attributeI18n.locale @UI.Hidden,
|
|
50
|
+
attributeI18n.text @UI.Hidden,
|
|
51
|
+
// * is important to allow for application extensions of Changes
|
|
52
|
+
*,
|
|
59
53
|
COALESCE(
|
|
60
54
|
attributeI18n.text, (
|
|
61
55
|
select text from i18nKeys
|
|
@@ -89,9 +83,9 @@ view ChangeView as
|
|
|
89
83
|
(
|
|
90
84
|
case
|
|
91
85
|
when valueDataType = 'cds.DateTime'
|
|
92
|
-
then COALESCE(
|
|
86
|
+
then cast(COALESCE(
|
|
93
87
|
change.valueChangedFromLabel, change.valueChangedFrom
|
|
94
|
-
)
|
|
88
|
+
) as DateTime)
|
|
95
89
|
else null
|
|
96
90
|
end
|
|
97
91
|
) as valueChangedFromLabelDateTime : DateTime @(title: '{i18n>Changes.valueChangedFrom}',
|
|
@@ -99,9 +93,9 @@ view ChangeView as
|
|
|
99
93
|
(
|
|
100
94
|
case
|
|
101
95
|
when valueDataType = 'cds.DateTime' or valueDataType = 'cds.Timestamp'
|
|
102
|
-
then COALESCE(
|
|
96
|
+
then cast(COALESCE(
|
|
103
97
|
change.valueChangedFromLabel, change.valueChangedFrom
|
|
104
|
-
)
|
|
98
|
+
) as DateTime)
|
|
105
99
|
else null
|
|
106
100
|
end
|
|
107
101
|
) as valueChangedFromLabelDateTimeWTZ : DateTime @(
|
|
@@ -111,9 +105,9 @@ view ChangeView as
|
|
|
111
105
|
(
|
|
112
106
|
case
|
|
113
107
|
when valueDataType = 'cds.Time'
|
|
114
|
-
then COALESCE(
|
|
108
|
+
then cast(COALESCE(
|
|
115
109
|
change.valueChangedFromLabel, change.valueChangedFrom
|
|
116
|
-
)
|
|
110
|
+
) as Time)
|
|
117
111
|
else null
|
|
118
112
|
end
|
|
119
113
|
) as valueChangedFromLabelTime : Time @(title: '{i18n>Changes.valueChangedFrom}',
|
|
@@ -121,9 +115,9 @@ view ChangeView as
|
|
|
121
115
|
(
|
|
122
116
|
case
|
|
123
117
|
when valueDataType = 'cds.Date'
|
|
124
|
-
then COALESCE(
|
|
118
|
+
then cast(COALESCE(
|
|
125
119
|
change.valueChangedFromLabel, change.valueChangedFrom
|
|
126
|
-
)
|
|
120
|
+
) as Date)
|
|
127
121
|
else null
|
|
128
122
|
end
|
|
129
123
|
) as valueChangedFromLabelDate : Date @(title: '{i18n>Changes.valueChangedFrom}',
|
|
@@ -131,9 +125,9 @@ view ChangeView as
|
|
|
131
125
|
(
|
|
132
126
|
case
|
|
133
127
|
when valueDataType = 'cds.Timestamp'
|
|
134
|
-
then COALESCE(
|
|
128
|
+
then cast(COALESCE(
|
|
135
129
|
change.valueChangedFromLabel, change.valueChangedFrom
|
|
136
|
-
)
|
|
130
|
+
) as Timestamp)
|
|
137
131
|
else null
|
|
138
132
|
end
|
|
139
133
|
) as valueChangedFromLabelTimestamp : Timestamp @(title: '{i18n>Changes.valueChangedFrom}',
|
|
@@ -141,9 +135,9 @@ view ChangeView as
|
|
|
141
135
|
(
|
|
142
136
|
case
|
|
143
137
|
when valueDataType = 'cds.Decimal'
|
|
144
|
-
then COALESCE(
|
|
138
|
+
then cast(COALESCE(
|
|
145
139
|
change.valueChangedFromLabel, change.valueChangedFrom
|
|
146
|
-
)
|
|
140
|
+
) as Decimal)
|
|
147
141
|
else null
|
|
148
142
|
end
|
|
149
143
|
) as valueChangedFromLabelDecimal : Decimal @(title: '{i18n>Changes.valueChangedFrom}',
|
|
@@ -157,9 +151,9 @@ view ChangeView as
|
|
|
157
151
|
(
|
|
158
152
|
case
|
|
159
153
|
when valueDataType = 'cds.DateTime'
|
|
160
|
-
then COALESCE(
|
|
154
|
+
then cast(COALESCE(
|
|
161
155
|
change.valueChangedToLabel, change.valueChangedTo
|
|
162
|
-
)
|
|
156
|
+
) as DateTime)
|
|
163
157
|
else null
|
|
164
158
|
end
|
|
165
159
|
) as valueChangedToLabelDateTime : DateTime @(title: '{i18n>Changes.valueChangedTo}',
|
|
@@ -167,9 +161,9 @@ view ChangeView as
|
|
|
167
161
|
(
|
|
168
162
|
case
|
|
169
163
|
when valueDataType = 'cds.DateTime' or valueDataType = 'cds.Timestamp'
|
|
170
|
-
then COALESCE(
|
|
171
|
-
change.
|
|
172
|
-
)
|
|
164
|
+
then cast(COALESCE(
|
|
165
|
+
change.valueChangedToLabel, change.valueChangedTo
|
|
166
|
+
) as DateTime)
|
|
173
167
|
else null
|
|
174
168
|
end
|
|
175
169
|
) as valueChangedToLabelDateTimeWTZ : DateTime @(
|
|
@@ -179,9 +173,9 @@ view ChangeView as
|
|
|
179
173
|
(
|
|
180
174
|
case
|
|
181
175
|
when valueDataType = 'cds.Time'
|
|
182
|
-
then COALESCE(
|
|
176
|
+
then cast(COALESCE(
|
|
183
177
|
change.valueChangedToLabel, change.valueChangedTo
|
|
184
|
-
)
|
|
178
|
+
) as Time)
|
|
185
179
|
else null
|
|
186
180
|
end
|
|
187
181
|
) as valueChangedToLabelTime : Time @(title: '{i18n>Changes.valueChangedTo}',
|
|
@@ -189,9 +183,9 @@ view ChangeView as
|
|
|
189
183
|
(
|
|
190
184
|
case
|
|
191
185
|
when valueDataType = 'cds.Date'
|
|
192
|
-
then COALESCE(
|
|
186
|
+
then cast(COALESCE(
|
|
193
187
|
change.valueChangedToLabel, change.valueChangedTo
|
|
194
|
-
)
|
|
188
|
+
) as Date)
|
|
195
189
|
else null
|
|
196
190
|
end
|
|
197
191
|
) as valueChangedToLabelDate : Date @(title: '{i18n>Changes.valueChangedTo}',
|
|
@@ -199,9 +193,9 @@ view ChangeView as
|
|
|
199
193
|
(
|
|
200
194
|
case
|
|
201
195
|
when valueDataType = 'cds.Timestamp'
|
|
202
|
-
then COALESCE(
|
|
196
|
+
then cast(COALESCE(
|
|
203
197
|
change.valueChangedToLabel, change.valueChangedTo
|
|
204
|
-
)
|
|
198
|
+
) as Timestamp)
|
|
205
199
|
else null
|
|
206
200
|
end
|
|
207
201
|
) as valueChangedToLabelTimestamp : Timestamp @(title: '{i18n>Changes.valueChangedTo}',
|
|
@@ -209,9 +203,9 @@ view ChangeView as
|
|
|
209
203
|
(
|
|
210
204
|
case
|
|
211
205
|
when valueDataType = 'cds.Decimal'
|
|
212
|
-
then COALESCE(
|
|
206
|
+
then cast(COALESCE(
|
|
213
207
|
change.valueChangedToLabel, change.valueChangedTo
|
|
214
|
-
)
|
|
208
|
+
) as Decimal)
|
|
215
209
|
else null
|
|
216
210
|
end
|
|
217
211
|
) as valueChangedToLabelDecimal : Decimal @(title: '{i18n>Changes.valueChangedTo}',
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
const cds = require('@sap/cds');
|
|
2
|
+
const { fs, path } = cds.utils;
|
|
3
|
+
const { join } = path;
|
|
4
|
+
|
|
5
|
+
const MIGRATION_TABLE_PATH = join('db', 'src', 'sap.changelog.Changes.hdbmigrationtable');
|
|
6
|
+
const UNDEPLOY_JSON_PATH = join('db', 'undeploy.json');
|
|
7
|
+
|
|
8
|
+
const UNDEPLOY_ENTRIES = ['src/gen/**/sap.changelog.Changes.hdbtable', 'src/gen/**/sap.changelog.ChangeLog.hdbtable'];
|
|
9
|
+
|
|
10
|
+
const LOG = cds.log('change-tracking');
|
|
11
|
+
|
|
12
|
+
const { getMigrationTableSQL } = require('./hana/migrationTable.js');
|
|
13
|
+
|
|
14
|
+
module.exports = class extends cds.add.Plugin {
|
|
15
|
+
async run() {
|
|
16
|
+
if (fs.existsSync(MIGRATION_TABLE_PATH)) {
|
|
17
|
+
const existing = fs.readFileSync(MIGRATION_TABLE_PATH, 'utf8');
|
|
18
|
+
const versionMatch = [...existing.matchAll(/==\s*version=(\d+)/g)];
|
|
19
|
+
const latestVersion = versionMatch.length > 0 ? Math.max(...versionMatch.map((m) => parseInt(m[1]))) : 1;
|
|
20
|
+
|
|
21
|
+
if (latestVersion >= 2) {
|
|
22
|
+
LOG.warn(`Migration table already exists at ${MIGRATION_TABLE_PATH} (latest version: ${latestVersion}). ` + `Only the initial v1 -> v2 migration is supported by this command. ` + `Please add new migration steps manually.`);
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Rewrite file with v2 DDL and migration (replaces v1 content)
|
|
27
|
+
fs.writeFileSync(MIGRATION_TABLE_PATH, getMigrationTableSQL());
|
|
28
|
+
LOG.info(`Updated ${MIGRATION_TABLE_PATH} with v2 migration`);
|
|
29
|
+
} else {
|
|
30
|
+
// Write the migration table file
|
|
31
|
+
const dir = join('db', 'src');
|
|
32
|
+
if (!fs.existsSync(dir)) {
|
|
33
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
34
|
+
}
|
|
35
|
+
fs.writeFileSync(MIGRATION_TABLE_PATH, getMigrationTableSQL());
|
|
36
|
+
LOG.info(`Created ${MIGRATION_TABLE_PATH}`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Update undeploy.json
|
|
40
|
+
let undeploy = [];
|
|
41
|
+
if (fs.existsSync(UNDEPLOY_JSON_PATH)) {
|
|
42
|
+
undeploy = JSON.parse(fs.readFileSync(UNDEPLOY_JSON_PATH, 'utf8'));
|
|
43
|
+
}
|
|
44
|
+
if (!Array.isArray(undeploy)) undeploy = [];
|
|
45
|
+
|
|
46
|
+
let changed = false;
|
|
47
|
+
for (const entry of UNDEPLOY_ENTRIES) {
|
|
48
|
+
if (!undeploy.includes(entry)) {
|
|
49
|
+
undeploy.push(entry);
|
|
50
|
+
changed = true;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (changed) {
|
|
55
|
+
fs.writeFileSync(UNDEPLOY_JSON_PATH, JSON.stringify(undeploy, null, 4) + '\n');
|
|
56
|
+
LOG.info(`Updated ${UNDEPLOY_JSON_PATH} with old .hdbtable entries`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
};
|
|
@@ -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) && m.definitions[target + '.texts']) {
|
|
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
|
+
};
|
|
@@ -4,6 +4,7 @@ const DEBUG = cds.debug('change-tracking');
|
|
|
4
4
|
const { isChangeTracked, getBaseEntity, analyzeCompositions, getService } = require('../utils/entity-collector.js');
|
|
5
5
|
const { addSideEffects, addUIFacet } = require('./annotations.js');
|
|
6
6
|
const { enhanceChangeViewWithTimeZones } = require('./timezoneProperties.js');
|
|
7
|
+
const { enhanceChangeViewWithLocalization } = require('./dynamicLocalization.js');
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* Returns a CQN expression for the composite key of an entity.
|
|
@@ -20,7 +21,7 @@ function entityKey4(entity) {
|
|
|
20
21
|
keys.push({ ref: [k] });
|
|
21
22
|
}
|
|
22
23
|
}
|
|
23
|
-
if (keys.length <= 1) return keys;
|
|
24
|
+
if (keys.length <= 1) return keys.map((k) => ({ ...k, cast: { type: 'cds.String' } }));
|
|
24
25
|
|
|
25
26
|
const xpr = [];
|
|
26
27
|
for (let i = 0; i < keys.length; i++) {
|
|
@@ -173,6 +174,7 @@ function enhanceModel(m) {
|
|
|
173
174
|
}
|
|
174
175
|
}
|
|
175
176
|
}
|
|
177
|
+
enhanceChangeViewWithLocalization(serviceName, `${serviceName}.ChangeView`, m);
|
|
176
178
|
}
|
|
177
179
|
|
|
178
180
|
DEBUG?.(
|
|
@@ -46,9 +46,9 @@ function enhanceChangeViewWithTimeZones(changeView, m) {
|
|
|
46
46
|
? compositeKeyExprHANA
|
|
47
47
|
: isDeploy2Check('postgres') && m.meta.creator.match(/v6/)
|
|
48
48
|
? compositeKeyExprPG
|
|
49
|
-
: cds.env.requires?.db
|
|
49
|
+
: cds.env.requires?.db?.kind === 'sqlite' && !cds.build
|
|
50
50
|
? compositeKeyExprSqlite
|
|
51
|
-
: cds.env.requires?.db
|
|
51
|
+
: cds.env.requires?.db?.kind === 'postgres' && (!cds.build || cds.env.profiles.includes('pg'))
|
|
52
52
|
? compositeKeyExprPG
|
|
53
53
|
: compositeKeyExprHANA;
|
|
54
54
|
const timezoneProperties = collectTrackedPropertiesWithTimezone(m);
|
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) {
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Returns the .hdbmigrationtable content for migrating sap.changelog.Changes from v1 to v2.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
function getMigrationTableSQL() {
|
|
6
|
+
return `== version=2
|
|
7
|
+
COLUMN TABLE sap_changelog_Changes (
|
|
8
|
+
ID NVARCHAR(36) NOT NULL,
|
|
9
|
+
parent_ID NVARCHAR(36),
|
|
10
|
+
attribute NVARCHAR(127),
|
|
11
|
+
valueChangedFrom NVARCHAR(5000),
|
|
12
|
+
valueChangedTo NVARCHAR(5000),
|
|
13
|
+
valueChangedFromLabel NVARCHAR(5000),
|
|
14
|
+
valueChangedToLabel NVARCHAR(5000),
|
|
15
|
+
entity NVARCHAR(150),
|
|
16
|
+
entityKey NVARCHAR(5000),
|
|
17
|
+
objectID NVARCHAR(5000),
|
|
18
|
+
modification NVARCHAR(6),
|
|
19
|
+
valueDataType NVARCHAR(5000),
|
|
20
|
+
createdAt TIMESTAMP,
|
|
21
|
+
createdBy NVARCHAR(255),
|
|
22
|
+
transactionID BIGINT,
|
|
23
|
+
PRIMARY KEY(ID)
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
== migration=2
|
|
27
|
+
RENAME COLUMN sap_changelog_Changes.entityID TO objectID;
|
|
28
|
+
|
|
29
|
+
ALTER TABLE sap_changelog_Changes ADD (parent_ID NVARCHAR(36), valueChangedFromLabel NVARCHAR(5000), valueChangedToLabel NVARCHAR(5000), transactionID BIGINT);
|
|
30
|
+
|
|
31
|
+
-- Adjust entityKey structure
|
|
32
|
+
RENAME COLUMN sap_changelog_Changes.keys TO entityKey;
|
|
33
|
+
UPDATE SAP_CHANGELOG_CHANGES
|
|
34
|
+
SET entityKey =
|
|
35
|
+
CASE
|
|
36
|
+
WHEN LOCATE(entityKey, '=') > 0 THEN
|
|
37
|
+
TRIM(SUBSTRING(entityKey, LOCATE(entityKey, '=') + 1))
|
|
38
|
+
ELSE
|
|
39
|
+
NULL
|
|
40
|
+
END;
|
|
41
|
+
|
|
42
|
+
-- Copy changelog_ID into transactionID
|
|
43
|
+
UPDATE SAP_CHANGELOG_CHANGES SET transactionID = CAST(SECONDS_BETWEEN(createdAt, TO_TIMESTAMP('1970-01-01 00:00:00')) * -1000 AS BIGINT);
|
|
44
|
+
|
|
45
|
+
-- Column migration for attribute, entity and modification
|
|
46
|
+
ALTER TABLE sap_changelog_Changes ADD (attribute_tmp NVARCHAR(127), entity_tmp NVARCHAR(150), modification_tmp NVARCHAR(6));
|
|
47
|
+
|
|
48
|
+
-- Copy data into temp columns
|
|
49
|
+
UPDATE sap_changelog_Changes SET attribute_tmp = attribute;
|
|
50
|
+
UPDATE sap_changelog_Changes SET entity_tmp = entity;
|
|
51
|
+
UPDATE sap_changelog_Changes SET modification_tmp = modification;
|
|
52
|
+
|
|
53
|
+
ALTER TABLE sap_changelog_Changes DROP (attribute, entity, modification);
|
|
54
|
+
|
|
55
|
+
ALTER TABLE sap_changelog_Changes ADD (attribute NVARCHAR(127), entity NVARCHAR(150), modification NVARCHAR(6));
|
|
56
|
+
|
|
57
|
+
-- Restore data from temp columns
|
|
58
|
+
UPDATE sap_changelog_Changes SET attribute = attribute_tmp;
|
|
59
|
+
UPDATE sap_changelog_Changes SET entity = entity_tmp;
|
|
60
|
+
UPDATE sap_changelog_Changes SET modification = modification_tmp;
|
|
61
|
+
|
|
62
|
+
ALTER TABLE sap_changelog_Changes DROP (attribute_tmp, entity_tmp, modification_tmp);
|
|
63
|
+
|
|
64
|
+
-- Drop columns that are no longer needed
|
|
65
|
+
ALTER TABLE sap_changelog_Changes DROP (serviceEntity, parentEntityID, parentKey, serviceEntityPath, changeLog_ID);
|
|
66
|
+
`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
module.exports = { getMigrationTableSQL };
|
package/lib/hana/register.js
CHANGED
|
@@ -3,6 +3,12 @@ const cds = require('@sap/cds');
|
|
|
3
3
|
const { prepareCSNForTriggers, generateTriggersForEntities, ensureUndeployJsonHasTriggerPattern } = require('../utils/trigger-utils.js');
|
|
4
4
|
const { getLabelTranslations } = require('../localization.js');
|
|
5
5
|
|
|
6
|
+
const MIGRATION_TABLE_PATH = cds.utils.path.join('db', 'src', 'sap.changelog.Changes.hdbmigrationtable');
|
|
7
|
+
|
|
8
|
+
function hasMigrationTable() {
|
|
9
|
+
return cds.utils.fs.existsSync(MIGRATION_TABLE_PATH);
|
|
10
|
+
}
|
|
11
|
+
|
|
6
12
|
function registerHDICompilerHook() {
|
|
7
13
|
const _hdi_migration = cds.compiler.to.hdi.migration;
|
|
8
14
|
cds.compiler.to.hdi.migration = function (csn, options, beforeImage) {
|
|
@@ -38,20 +44,37 @@ function registerHDICompilerHook() {
|
|
|
38
44
|
);
|
|
39
45
|
}
|
|
40
46
|
|
|
47
|
+
const config = cds.env.requires?.['change-tracking'];
|
|
48
|
+
|
|
49
|
+
// Generate restore backlinks procedure if enabled via feature flag
|
|
50
|
+
if (config?.procedureForRestoringBacklinks) {
|
|
51
|
+
const { generateRestoreBacklinksProcedure } = require('./restoreProcedure.js');
|
|
52
|
+
const procedure = generateRestoreBacklinksProcedure(runtimeCSN, hierarchy, entities);
|
|
53
|
+
if (procedure) data.push(procedure);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Auto-detect migration table created by `cds add change-tracking`
|
|
57
|
+
if (hasMigrationTable()) {
|
|
58
|
+
csn.definitions['sap.changelog.Changes']['@cds.persistence.journal'] = true;
|
|
59
|
+
}
|
|
60
|
+
|
|
41
61
|
const ret = _hdi_migration(csn, options, beforeImage);
|
|
42
62
|
ret.definitions = ret.definitions.concat(triggers).concat(data);
|
|
43
63
|
return ret;
|
|
44
64
|
};
|
|
45
65
|
|
|
46
|
-
//
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
//
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
66
|
+
// When a migration table file exists in db/src/, strip compiler-generated changesets for sap.changelog.Changes
|
|
67
|
+
// Prevent auto-generation of additional migration steps by the build
|
|
68
|
+
const _compile_to_hana = cds.compile.to.hana;
|
|
69
|
+
cds.compile.to.hana = function (csn, o, beforeCsn) {
|
|
70
|
+
if (hasMigrationTable() && beforeCsn) {
|
|
71
|
+
// Remove the Changes entity from the beforeImage so the compiler
|
|
72
|
+
const beforeClone = structuredClone(beforeCsn);
|
|
73
|
+
delete beforeClone.definitions?.['sap.changelog.Changes'];
|
|
74
|
+
return _compile_to_hana.call(this, csn, o, beforeClone);
|
|
75
|
+
}
|
|
76
|
+
return _compile_to_hana.call(this, csn, o, beforeCsn);
|
|
77
|
+
};
|
|
55
78
|
}
|
|
56
79
|
|
|
57
80
|
module.exports = { registerHDICompilerHook };
|