@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 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.5 - tbd
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?.(
@@ -18,7 +18,7 @@ function _toSQL(query, model) {
18
18
  return cqn2sql.SELECT(sqlCQN);
19
19
  }
20
20
 
21
- function handleAssocLookup(column, refRow, model) {
21
+ function handleAssocLookup(column, assocPaths, refRow, model) {
22
22
  let bindings = [];
23
23
  let where = {};
24
24
 
@@ -36,11 +36,11 @@ function handleAssocLookup(column, refRow, model) {
36
36
  bindings = column.on.map((assoc) => `${refRow}.getString("${assoc}")`);
37
37
  }
38
38
 
39
- const alt = column.alt.map((s) => s.split('.').slice(1).join('.'));
39
+ const alt = assocPaths.map((s) => s.split('.').slice(1).join('.'));
40
40
  const columns = alt.length === 1 ? alt[0] : utils.buildConcatXpr(alt);
41
41
 
42
42
  // Check if target entity has localized data
43
- const localizedInfo = utils.getLocalizedLookupInfo(column.target, column.alt, model);
43
+ const localizedInfo = utils.getLocalizedLookupInfo(column.target, assocPaths, model);
44
44
 
45
45
  if (localizedInfo) {
46
46
  // Build locale-aware lookup: try .texts table first, fall back to base entity
@@ -676,12 +676,40 @@ function _prepareValueExpression(col, rowVar) {
676
676
 
677
677
  // Returns label expression for a column
678
678
  function _prepareLabelExpression(col, rowVar, model) {
679
- if (col.target && col.alt) {
680
- const { sql, bindings } = handleAssocLookup(col, rowVar, model);
681
- return { sqlExpr: sql, bindings: bindings };
679
+ if (!col.alt || col.alt.length === 0) {
680
+ return { sqlExpr: 'NULL', bindings: [] };
682
681
  }
683
- // No label for scalars or associations without @changelog override
684
- return { sqlExpr: 'NULL', bindings: [] };
682
+
683
+ const sqlParts = [];
684
+ const allBindings = [];
685
+ let assocBatch = [];
686
+
687
+ const flushAssocBatch = () => {
688
+ if (assocBatch.length > 0) {
689
+ const { sql, bindings } = handleAssocLookup(col, assocBatch, rowVar, model);
690
+ sqlParts.push(sql);
691
+ allBindings.push(...bindings);
692
+ assocBatch = [];
693
+ }
694
+ };
695
+
696
+ for (const entry of col.alt) {
697
+ if (entry.source === 'assoc') {
698
+ assocBatch.push(entry.path);
699
+ } else {
700
+ flushAssocBatch();
701
+ sqlParts.push('?');
702
+ allBindings.push(`${rowVar}.getString("${entry.path}")`);
703
+ }
704
+ }
705
+ flushAssocBatch();
706
+
707
+ if (sqlParts.length === 0) {
708
+ return { sqlExpr: 'NULL', bindings: [] };
709
+ }
710
+
711
+ const sqlExpr = sqlParts.length === 1 ? sqlParts[0] : sqlParts.join(" || ', ' || ");
712
+ return { sqlExpr, bindings: allBindings };
685
713
  }
686
714
 
687
715
  function _wrapInTryCatch(sql, bindings) {
@@ -100,14 +100,9 @@ function getWhereCondition(col, modification) {
100
100
  }
101
101
 
102
102
  /**
103
- * Returns SQL expression for a column's label (looked-up value for associations)
103
+ * Builds scalar subselect for association label lookup with locale awareness
104
104
  */
105
- function getLabelExpr(col, refRow, model) {
106
- if (!(col.target && col.alt)) {
107
- return `NULL`;
108
- }
109
-
110
- // Builds inline SELECT expression for association label lookup with locale support
105
+ function buildAssocLookup(col, assocPaths, refRow, model) {
111
106
  let where = {};
112
107
  if (col.foreignKeys) {
113
108
  where = col.foreignKeys.reduce((acc, k) => {
@@ -121,11 +116,11 @@ function getLabelExpr(col, refRow, model) {
121
116
  }, {});
122
117
  }
123
118
 
124
- const alt = col.alt.map((s) => s.split('.').slice(1).join('.'));
119
+ const alt = assocPaths.map((s) => s.split('.').slice(1).join('.'));
125
120
  const columns = alt.length === 1 ? alt[0] : utils.buildConcatXpr(alt);
126
121
 
127
122
  // Check for localization
128
- const localizedInfo = utils.getLocalizedLookupInfo(col.target, col.alt, model);
123
+ const localizedInfo = utils.getLocalizedLookupInfo(col.target, assocPaths, model);
129
124
  if (localizedInfo) {
130
125
  const textsWhere = { ...where, locale: { func: 'SESSION_CONTEXT', args: [{ val: 'LOCALE' }] } };
131
126
  const textsQuery = SELECT.one.from(localizedInfo.textsEntity).columns(columns).where(textsWhere);
@@ -137,6 +132,35 @@ function getLabelExpr(col, refRow, model) {
137
132
  return `(${toSQL(query, model)})`;
138
133
  }
139
134
 
135
+ /**
136
+ * Returns SQL expression for a column's label (looked-up value for associations)
137
+ */
138
+ function getLabelExpr(col, refRow, model) {
139
+ if (!col.alt || col.alt.length === 0) return `NULL`;
140
+
141
+ const parts = [];
142
+ let assocBatch = [];
143
+
144
+ const flushAssocBatch = () => {
145
+ if (assocBatch.length > 0) {
146
+ parts.push(buildAssocLookup(col, assocBatch, refRow, model));
147
+ assocBatch = [];
148
+ }
149
+ };
150
+
151
+ for (const entry of col.alt) {
152
+ if (entry.source === 'assoc') {
153
+ assocBatch.push(entry.path);
154
+ } else {
155
+ flushAssocBatch();
156
+ parts.push(`TO_NVARCHAR(:${refRow}.${entry.path})`);
157
+ }
158
+ }
159
+ flushAssocBatch();
160
+
161
+ return parts.length === 0 ? `NULL` : parts.join(" || ', ' || ");
162
+ }
163
+
140
164
  /**
141
165
  * Builds SQL expression for objectID (entity display name)
142
166
  * Uses @changelog annotation fields, falling back to entity name
@@ -84,9 +84,13 @@ function getWhereCondition(col, modification) {
84
84
  }
85
85
 
86
86
  /**
87
- * Builds scalar subselect for association label lookup with locale support
87
+ * Builds scalar subselect for association label lookup with locale support.
88
+ * @param {Object} column Column entry with target, foreignKeys/on, etc.
89
+ * @param {string[]} assocPaths Array of association paths (format: "assocName.field")
90
+ * @param {string} refRow Trigger row reference ('NEW' or 'OLD')
91
+ * @param {*} model CSN model
88
92
  */
89
- function buildAssocLookup(column, refRow, model) {
93
+ function buildAssocLookup(column, assocPaths, refRow, model) {
90
94
  let where = {};
91
95
  if (column.foreignKeys) {
92
96
  where = column.foreignKeys.reduce((acc, k) => {
@@ -100,11 +104,11 @@ function buildAssocLookup(column, refRow, model) {
100
104
  }, {});
101
105
  }
102
106
 
103
- const alt = column.alt.map((s) => s.split('.').slice(1).join('.'));
107
+ const alt = assocPaths.map((s) => s.split('.').slice(1).join('.'));
104
108
  const columns = alt.length === 1 ? alt[0] : utils.buildConcatXpr(alt);
105
109
 
106
110
  // Check for localization
107
- const localizedInfo = utils.getLocalizedLookupInfo(column.target, column.alt, model);
111
+ const localizedInfo = utils.getLocalizedLookupInfo(column.target, assocPaths, model);
108
112
  if (localizedInfo) {
109
113
  const textsWhere = { ...where, locale: { func: 'current_setting', args: [{ val: 'cap.locale' }, { val: true }] } };
110
114
  const textsQuery = SELECT.one.from(localizedInfo.textsEntity).columns(columns).where(textsWhere);
@@ -117,13 +121,34 @@ function buildAssocLookup(column, refRow, model) {
117
121
  }
118
122
 
119
123
  /**
120
- * Returns SQL expression for a column's label (looked-up value for associations)
124
+ * Returns SQL expression for a column's label (looked-up value for associations).
125
+ * Iterates over col.alt entries in order, grouping consecutive association paths
126
+ * into a single subquery and emitting local references inline from the trigger row.
121
127
  */
122
128
  function getLabelExpr(col, refRow, model) {
123
- if (col.target && col.alt) {
124
- return buildAssocLookup(col, refRow, model);
129
+ if (!col.alt || col.alt.length === 0) return 'NULL';
130
+
131
+ const parts = [];
132
+ let assocBatch = [];
133
+
134
+ const flushAssocBatch = () => {
135
+ if (assocBatch.length > 0) {
136
+ parts.push(buildAssocLookup(col, assocBatch, refRow, model));
137
+ assocBatch = [];
138
+ }
139
+ };
140
+
141
+ for (const entry of col.alt) {
142
+ if (entry.source === 'assoc') {
143
+ assocBatch.push(entry.path);
144
+ } else {
145
+ flushAssocBatch();
146
+ parts.push(`${refRow}.${entry.path}::TEXT`);
147
+ }
125
148
  }
126
- return 'NULL';
149
+ flushAssocBatch();
150
+
151
+ return parts.length === 0 ? 'NULL' : parts.join(" || ', ' || ");
127
152
  }
128
153
 
129
154
  /**
@@ -1,7 +1,9 @@
1
+ const cds = require('@sap/cds');
1
2
  const utils = require('../utils/change-tracking.js');
2
3
  const { getCompositionParentInfo, getGrandParentCompositionInfo } = require('../utils/composition-helpers.js');
3
4
  const { getSkipCheckCondition, compositeKeyExpr, buildObjectIDAssignment, buildInsertBlock, extractTrackedDbColumns } = require('./sql-expressions.js');
4
5
  const { buildCompositionParentBlock } = require('./composition.js');
6
+ const config = cds.env.requires['change-tracking'];
5
7
 
6
8
  /**
7
9
  * Generates the PL/pgSQL function body for the main change tracking trigger
@@ -18,9 +20,9 @@ function buildFunctionBody(entity, columns, objectIDs, rootEntity, rootObjectIDs
18
20
  const deleteBlock = columns.length > 0 ? buildInsertBlock(columns, 'delete', entity, model, hasCompositionParent) : '';
19
21
 
20
22
  // Build composition parent blocks if needed
21
- const createParentBlock = compositionParentInfo ? buildCompositionParentBlock(compositionParentInfo, rootObjectIDs, 'create', model, grandParentCompositionInfo) : '';
22
- const updateParentBlock = compositionParentInfo ? buildCompositionParentBlock(compositionParentInfo, rootObjectIDs, 'update', model, grandParentCompositionInfo) : '';
23
- const deleteParentBlock = compositionParentInfo ? buildCompositionParentBlock(compositionParentInfo, rootObjectIDs, 'delete', model, grandParentCompositionInfo) : '';
23
+ const createParentBlock = compositionParentInfo && !config?.disableCreateTracking ? buildCompositionParentBlock(compositionParentInfo, rootObjectIDs, 'create', model, grandParentCompositionInfo) : '';
24
+ const updateParentBlock = compositionParentInfo && !config?.disableUpdateTracking ? buildCompositionParentBlock(compositionParentInfo, rootObjectIDs, 'update', model, grandParentCompositionInfo) : '';
25
+ const deleteParentBlock = compositionParentInfo && !config?.disableDeleteTracking ? buildCompositionParentBlock(compositionParentInfo, rootObjectIDs, 'delete', model, grandParentCompositionInfo) : '';
24
26
 
25
27
  return `
26
28
  DECLARE
@@ -95,7 +95,7 @@ function getWhereCondition(col, modification) {
95
95
  /**
96
96
  * Builds scalar subselect for association label lookup with locale awareness
97
97
  */
98
- function buildAssocLookup(column, refRow, entityKey, model) {
98
+ function buildAssocLookup(column, assocPaths, refRow, entityKey, model) {
99
99
  const where = column.foreignKeys
100
100
  ? column.foreignKeys.reduce((acc, k) => {
101
101
  acc[k] = { val: `${refRow}.${column.name}_${k}` };
@@ -107,12 +107,12 @@ function buildAssocLookup(column, refRow, entityKey, model) {
107
107
  return acc;
108
108
  }, {});
109
109
 
110
- // Drop the first part of column.alt (association name)
111
- const alt = column.alt.map((s) => s.split('.').slice(1).join('.'));
110
+ // Drop the first part of each path (association name)
111
+ const alt = assocPaths.map((s) => s.split('.').slice(1).join('.'));
112
112
  const columns = alt.length === 1 ? alt[0] : utils.buildConcatXpr(alt);
113
113
 
114
114
  // Check for localization
115
- const localizedInfo = utils.getLocalizedLookupInfo(column.target, column.alt, model);
115
+ const localizedInfo = utils.getLocalizedLookupInfo(column.target, assocPaths, model);
116
116
  if (localizedInfo) {
117
117
  const textsWhere = { ...where, locale: { func: 'session_context', args: [{ val: '$user.locale' }] } };
118
118
  const textsQuery = SELECT.one.from(localizedInfo.textsEntity).columns(columns).where(textsWhere);
@@ -128,10 +128,30 @@ function buildAssocLookup(column, refRow, entityKey, model) {
128
128
  * Returns SQL expression for a column's label (looked-up value for associations)
129
129
  */
130
130
  function getLabelExpr(col, refRow, entityKey, model) {
131
- if (col.target && col.alt) {
132
- return buildAssocLookup(col, refRow, entityKey, model);
131
+ if (!col.alt || col.alt.length === 0) return 'NULL';
132
+
133
+ const parts = [];
134
+ let assocBatch = [];
135
+
136
+ const flushAssocBatch = () => {
137
+ if (assocBatch.length > 0) {
138
+ parts.push(buildAssocLookup(col, assocBatch, refRow, entityKey, model));
139
+ assocBatch = [];
140
+ }
141
+ };
142
+
143
+ for (const entry of col.alt) {
144
+ if (entry.source === 'assoc') {
145
+ assocBatch.push(entry.path);
146
+ } else {
147
+ // local field: flush any pending association batch first, then emit local ref
148
+ flushAssocBatch();
149
+ parts.push(`${refRow}.${entry.path}`);
150
+ }
133
151
  }
134
- return 'NULL';
152
+ flushAssocBatch();
153
+
154
+ return parts.length === 0 ? 'NULL' : parts.join(" || ', ' || ");
135
155
  }
136
156
 
137
157
  /**
@@ -173,7 +173,9 @@ function extractTrackedColumns(entity, model = cds.context?.model ?? cds.model,
173
173
  const changelogPaths = changelogAnnotation.map((c) => c['=']);
174
174
  for (const path of changelogPaths) {
175
175
  const p = validateChangelogPath(entity, path, model);
176
- if (p) alt.push(p);
176
+ if (!p) continue;
177
+ if (p.includes('.')) alt.push({ path: p, source: 'assoc' });
178
+ else alt.push({ path: p, source: 'local' });
177
179
  }
178
180
  if (alt.length > 0) entry.alt = alt;
179
181
  }
@@ -352,6 +354,7 @@ function getCompositionParentBinding(targetEntity, rootEntity) {
352
354
  }
353
355
 
354
356
  function getLocalizedLookupInfo(targetEntityName, altFields, model = cds.context?.model ?? cds.model) {
357
+ if (!altFields || altFields.length === 0) return null;
355
358
  const targetEntity = model.definitions[targetEntityName];
356
359
  if (!targetEntity) return null;
357
360
 
@@ -227,6 +227,25 @@ function getBaseEntity(entity, model) {
227
227
  }
228
228
  }
229
229
 
230
+ function getBaseElement(element, entity, model) {
231
+ const cqn = entity.projection ?? entity.query?.SELECT;
232
+ if (!cqn) return null;
233
+ element = cqn.columns?.find((c) => c.as === element && c.ref)?.ref?.[0] ?? element;
234
+
235
+ const baseRef = cqn.from?.ref?.[0];
236
+ if (!baseRef || !model) return null;
237
+
238
+ const baseEntity = model.definitions[baseRef];
239
+ if (!baseEntity) return null;
240
+ const baseCQN = baseEntity.projection ?? baseEntity.query?.SELECT ?? baseEntity.query?.SET;
241
+ // If base entity is also a projection, recurse
242
+ if (baseCQN) {
243
+ return getBaseElement(element, baseEntity, model);
244
+ } else {
245
+ return { baseRef, baseElement: element };
246
+ }
247
+ }
248
+
230
249
  // Analyze composition hierarchy in CSN
231
250
  function analyzeCompositions(csn) {
232
251
  // First pass: build child -> { parent, compositionField } map
@@ -318,5 +337,6 @@ module.exports = {
318
337
  getBaseEntity,
319
338
  analyzeCompositions,
320
339
  getService,
321
- collectEntities
340
+ collectEntities,
341
+ getBaseElement
322
342
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cap-js/change-tracking",
3
- "version": "2.0.0-beta.4",
3
+ "version": "2.0.0-beta.5",
4
4
  "publishConfig": {
5
5
  "access": "public",
6
6
  "tag": "beta"