@cap-js/change-tracking 2.0.0-beta.4 → 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 +10 -1
- package/README.md +32 -0
- package/lib/csn-enhancements/dynamicLocalization.js +106 -0
- package/lib/csn-enhancements/index.js +2 -0
- package/lib/h2/java-codegen.js +36 -8
- package/lib/hana/sql-expressions.js +33 -9
- 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,15 @@ 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
|
+
|
|
15
24
|
## Version 2.0.0-beta.4 - 16.03.26
|
|
16
25
|
|
|
17
26
|
### 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
|
|
@@ -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
|
+
};
|
|
@@ -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.
|
|
@@ -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?.(
|
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) {
|
|
@@ -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
|
|
@@ -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
|
};
|