@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.
@@ -0,0 +1,342 @@
1
+ const utils = require('../utils/change-tracking.js');
2
+ const { getCompositionParentInfo, getGrandParentCompositionInfo } = require('../utils/composition-helpers.js');
3
+ const { compositeKeyExpr } = require('./sql-expressions.js');
4
+
5
+ /**
6
+ * Generates a HANA stored procedure that restores parent backlinks for composition changes.
7
+ *
8
+ * The procedure:
9
+ * 1. Finds all change entries for composition child entities that have no parent_ID set
10
+ * 2. Uses the child data table to resolve the parent entity key via FK lookup
11
+ * 3. Creates a parent composition entry (valueDataType='cds.Composition') if one doesn't exist
12
+ * 4. Updates child entries to set parent_ID pointing to the parent composition entry
13
+ * 5. Links composition entries to their grandparent composition entries (for deep hierarchies)
14
+ *
15
+ */
16
+ function generateRestoreBacklinksProcedure(runtimeCSN, hierarchy, entities) {
17
+ const compositions = _collectCompositionInfo(runtimeCSN, hierarchy, entities);
18
+ if (compositions.length === 0) return null;
19
+
20
+ const blocks = compositions.map((comp) => _generateCompositionBlock(comp));
21
+
22
+ const procedureSQL = `PROCEDURE "SAP_CHANGELOG_RESTORE_BACKLINKS" ()
23
+ LANGUAGE SQLSCRIPT
24
+ SQL SECURITY INVOKER
25
+ AS
26
+ BEGIN
27
+ ${blocks.join('\n')}
28
+ END`;
29
+
30
+ return {
31
+ name: 'SAP_CHANGELOG_RESTORE_BACKLINKS',
32
+ sql: procedureSQL,
33
+ suffix: '.hdbprocedure'
34
+ };
35
+ }
36
+
37
+ function _collectCompositionInfo(runtimeCSN, hierarchy, entities) {
38
+ const result = [];
39
+
40
+ for (const [childEntityName, hierarchyInfo] of hierarchy) {
41
+ const { parent: parentEntityName, compositionField } = hierarchyInfo;
42
+ if (!parentEntityName || !compositionField) continue;
43
+
44
+ const childEntity = runtimeCSN.definitions[childEntityName];
45
+ const parentEntity = runtimeCSN.definitions[parentEntityName];
46
+ if (!childEntity || !parentEntity) continue;
47
+
48
+ // Check if this entity is actually tracked (in our entities list)
49
+ const isTracked = entities.some((e) => e.dbEntityName === childEntityName);
50
+ if (!isTracked) continue;
51
+
52
+ // Get the FK binding from child to parent
53
+ const parentMergedAnnotations = entities.find((e) => e.dbEntityName === parentEntityName)?.mergedAnnotations;
54
+ const compositionParentInfo = getCompositionParentInfo(childEntity, parentEntity, parentMergedAnnotations);
55
+ if (!compositionParentInfo) continue;
56
+
57
+ const { parentKeyBinding } = compositionParentInfo;
58
+
59
+ // Skip composition of one - they have reverse FK direction and different handling
60
+ if (parentKeyBinding.type === 'compositionOfOne') continue;
61
+
62
+ const childKeys = utils.extractKeys(childEntity.keys);
63
+ const parentKeys = utils.extractKeys(parentEntity.keys);
64
+ const rootObjectIDs = utils.getObjectIDs(parentEntity, runtimeCSN, parentMergedAnnotations?.entityAnnotation);
65
+
66
+ // Collect child entity's objectIDs for restoring objectID on orphaned child entries
67
+ const childMergedAnnotations = entities.find((e) => e.dbEntityName === childEntityName)?.mergedAnnotations;
68
+ const childObjectIDs = utils.getObjectIDs(childEntity, runtimeCSN, childMergedAnnotations?.entityAnnotation);
69
+
70
+ // Collect grandparent info for deep hierarchies (e.g., Level2 -> Level1 -> Root)
71
+ const grandParentEntityName = hierarchyInfo?.grandParent ?? null;
72
+ const grandParentEntity = grandParentEntityName ? runtimeCSN.definitions[grandParentEntityName] : null;
73
+ const grandParentMergedAnnotations = grandParentEntityName ? entities.find((e) => e.dbEntityName === grandParentEntityName)?.mergedAnnotations : null;
74
+ const grandParentCompositionField = hierarchyInfo?.grandParentCompositionField ?? null;
75
+ const grandParentCompositionInfo = getGrandParentCompositionInfo(parentEntity, grandParentEntity, grandParentMergedAnnotations, grandParentCompositionField);
76
+
77
+ // If there's a grandparent, collect its keys, table name, and objectIDs for entry creation
78
+ let grandParentKeys, grandParentTableName, grandParentObjectIDs;
79
+ if (grandParentCompositionInfo && grandParentEntity) {
80
+ grandParentKeys = utils.extractKeys(grandParentEntity.keys);
81
+ grandParentTableName = utils.transformName(grandParentEntityName);
82
+ grandParentObjectIDs = utils.getObjectIDs(grandParentEntity, runtimeCSN, grandParentMergedAnnotations?.entityAnnotation);
83
+ }
84
+
85
+ result.push({
86
+ childEntityName,
87
+ parentEntityName,
88
+ compositionField,
89
+ childTableName: utils.transformName(childEntityName),
90
+ parentTableName: utils.transformName(parentEntityName),
91
+ fkFields: parentKeyBinding,
92
+ childKeys,
93
+ parentKeys,
94
+ rootObjectIDs,
95
+ childObjectIDs,
96
+ grandParentCompositionInfo,
97
+ grandParentKeys,
98
+ grandParentTableName,
99
+ grandParentObjectIDs
100
+ });
101
+ }
102
+
103
+ return result;
104
+ }
105
+
106
+ /**
107
+ * Builds the JOIN condition between Changes.entityKey and the child data table.
108
+ * Handles both v2 format (HIERARCHY_COMPOSITE_ID for multi-key) and v1 migrated format (single key only).
109
+ */
110
+ function _buildChildKeyJoinCondition(childKeys, alias, changesAlias) {
111
+ const compositeExpr = compositeKeyExpr(childKeys.map((k) => `${alias}.${k}`));
112
+
113
+ // Single key: straightforward join
114
+ if (childKeys.length <= 1) {
115
+ return `${changesAlias}.ENTITYKEY = ${compositeExpr}`;
116
+ }
117
+
118
+ // Composite keys: support both v2 format (HIERARCHY_COMPOSITE_ID) and v1 migrated format (single ID only)
119
+ // v1 migrated data may have stored only the last key segment as entityKey
120
+ const lastKey = childKeys[childKeys.length - 1];
121
+ return `(${changesAlias}.ENTITYKEY = ${compositeExpr} OR ${changesAlias}.ENTITYKEY = TO_NVARCHAR(${alias}.${lastKey}))`;
122
+ }
123
+
124
+ /**
125
+ * Generates the SQL block for a single composition relationship.
126
+ */
127
+ function _generateCompositionBlock(comp) {
128
+ const {
129
+ childEntityName,
130
+ parentEntityName,
131
+ compositionField,
132
+ childTableName,
133
+ parentTableName,
134
+ fkFields,
135
+ childKeys,
136
+ parentKeys,
137
+ rootObjectIDs,
138
+ childObjectIDs,
139
+ grandParentCompositionInfo,
140
+ grandParentKeys,
141
+ grandParentTableName,
142
+ grandParentObjectIDs
143
+ } = comp;
144
+
145
+ // Build JOIN condition handling both v2 composite keys and v1 migrated simple keys
146
+ const childKeyJoinStep1 = _buildChildKeyJoinCondition(childKeys, 'child_data', 'c');
147
+ const childKeyJoinStep2 = _buildChildKeyJoinCondition(childKeys, 'child_data', 'c2');
148
+
149
+ // Expression to compute the parent's entity key from the child data table's FK columns
150
+ const parentKeyFromChild = compositeKeyExpr(fkFields.map((fk) => `child_data.${fk}`));
151
+
152
+ // ObjectID expression for the parent composition entry
153
+ // Only use objectIDs that are direct columns on the parent table (not association paths requiring JOINs)
154
+ const simpleObjectIDs = rootObjectIDs?.filter((oid) => !oid.name.includes('.')) ?? [];
155
+ let objectIDExpr;
156
+ if (simpleObjectIDs.length > 0) {
157
+ const parts = simpleObjectIDs.map((oid) => `COALESCE(TO_NVARCHAR((SELECT ${oid.name} FROM ${parentTableName} WHERE ${parentKeys.map((pk) => `${pk} = grp.PARENT_ENTITYKEY`).join(' AND ')})), '')`);
158
+ const concatExpr = parts.length > 1 ? parts.join(" || ', ' || ") : parts[0];
159
+ objectIDExpr = `COALESCE(NULLIF(${concatExpr}, ''), grp.PARENT_ENTITYKEY)`;
160
+ } else {
161
+ objectIDExpr = 'grp.PARENT_ENTITYKEY';
162
+ }
163
+
164
+ // Child objectID expression for restoring objectID on orphaned child entries
165
+ // Uses @changelog fields if available, otherwise falls back to the child's entity key
166
+ const simpleChildObjectIDs = childObjectIDs?.filter((oid) => !oid.name.includes('.')) ?? [];
167
+ const childEntityKeyExpr = compositeKeyExpr(childKeys.map((k) => `child_data.${k}`));
168
+ let childObjectIDExpr;
169
+ if (simpleChildObjectIDs.length > 0) {
170
+ const childParts = simpleChildObjectIDs.map((oid) => `COALESCE(TO_NVARCHAR(child_data.${oid.name}), '<empty>')`);
171
+ const childConcatExpr = childParts.length > 1 ? childParts.join(" || ', ' || ") : childParts[0];
172
+ childObjectIDExpr = `COALESCE(NULLIF(${childConcatExpr}, ''), ${childEntityKeyExpr})`;
173
+ } else {
174
+ childObjectIDExpr = childEntityKeyExpr;
175
+ }
176
+
177
+ // Modification: 'create' if the parent entity was created in the same tx, 'update' otherwise
178
+ const modificationExpr = `CASE WHEN EXISTS (
179
+ SELECT 1 FROM SAP_CHANGELOG_CHANGES
180
+ WHERE entity = '${parentEntityName}'
181
+ AND entityKey = grp.PARENT_ENTITYKEY
182
+ AND modification = 'create'
183
+ AND transactionID = grp.TRANSACTIONID
184
+ ) THEN 'create' ELSE 'update' END`;
185
+
186
+ let block = `
187
+ -- ============================================================================
188
+ -- Restore backlinks: ${childEntityName} -> ${parentEntityName}.${compositionField}
189
+ -- ============================================================================
190
+
191
+ -- Step 1: Create parent composition entries where missing
192
+ INSERT INTO SAP_CHANGELOG_CHANGES
193
+ (ID, parent_ID, attribute, entity, entityKey, objectID, createdAt, createdBy, valueDataType, modification, transactionID)
194
+ SELECT
195
+ SYSUUID,
196
+ NULL,
197
+ '${compositionField}',
198
+ '${parentEntityName}',
199
+ grp.PARENT_ENTITYKEY,
200
+ ${objectIDExpr},
201
+ grp.MIN_CREATEDAT,
202
+ grp.CREATEDBY,
203
+ 'cds.Composition',
204
+ ${modificationExpr},
205
+ grp.TRANSACTIONID
206
+ FROM (
207
+ SELECT
208
+ ${parentKeyFromChild} AS PARENT_ENTITYKEY,
209
+ c.TRANSACTIONID,
210
+ MIN(c.CREATEDAT) AS MIN_CREATEDAT,
211
+ MIN(c.CREATEDBY) AS CREATEDBY
212
+ FROM SAP_CHANGELOG_CHANGES c
213
+ INNER JOIN ${childTableName} child_data
214
+ ON ${childKeyJoinStep1}
215
+ WHERE c.entity = '${childEntityName}'
216
+ AND c.parent_ID IS NULL
217
+ AND c.valueDataType != 'cds.Composition'
218
+ AND NOT EXISTS (
219
+ SELECT 1 FROM SAP_CHANGELOG_CHANGES p
220
+ WHERE p.entity = '${parentEntityName}'
221
+ AND p.attribute = '${compositionField}'
222
+ AND p.valueDataType = 'cds.Composition'
223
+ AND p.transactionID = c.transactionID
224
+ AND p.entityKey = ${parentKeyFromChild}
225
+ )
226
+ GROUP BY ${parentKeyFromChild}, c.TRANSACTIONID, c.CREATEDBY
227
+ ) grp;
228
+
229
+ -- Step 2: Link orphaned child entries to their parent composition entry and restore objectID
230
+ MERGE INTO SAP_CHANGELOG_CHANGES AS c
231
+ USING (
232
+ SELECT c2.ID AS CHILD_ID, p.ID AS PARENT_ID, ${childObjectIDExpr} AS CHILD_OBJECTID
233
+ FROM SAP_CHANGELOG_CHANGES c2
234
+ INNER JOIN ${childTableName} child_data
235
+ ON ${childKeyJoinStep2}
236
+ INNER JOIN SAP_CHANGELOG_CHANGES p
237
+ ON p.entity = '${parentEntityName}'
238
+ AND p.attribute = '${compositionField}'
239
+ AND p.valueDataType = 'cds.Composition'
240
+ AND p.transactionID = c2.transactionID
241
+ AND p.entityKey = ${parentKeyFromChild}
242
+ WHERE c2.entity = '${childEntityName}'
243
+ AND c2.parent_ID IS NULL
244
+ AND c2.valueDataType != 'cds.Composition'
245
+ ) AS matched
246
+ ON c.ID = matched.CHILD_ID
247
+ WHEN MATCHED THEN UPDATE SET c.parent_ID = matched.PARENT_ID, c.objectID = matched.CHILD_OBJECTID;`;
248
+
249
+ // Create grandparent composition entries and link to them (for deep hierarchies)
250
+ if (grandParentCompositionInfo) {
251
+ const { grandParentEntityName, grandParentCompositionFieldName, grandParentKeyBinding } = grandParentCompositionInfo;
252
+
253
+ // Build expression to resolve the grandparent entity key from the parent data table's FK columns
254
+ const grandParentKeyFromParent = compositeKeyExpr(grandParentKeyBinding.map((fk) => `parent_data.${fk}`));
255
+
256
+ // Build grandparent objectID expression
257
+ const simpleGPObjectIDs = grandParentObjectIDs?.filter((oid) => !oid.name.includes('.')) ?? [];
258
+ let gpObjectIDExpr;
259
+ if (simpleGPObjectIDs.length > 0) {
260
+ const gpParts = simpleGPObjectIDs.map((oid) => `COALESCE(TO_NVARCHAR((SELECT ${oid.name} FROM ${grandParentTableName} WHERE ${grandParentKeys.map((pk) => `${pk} = grp2.GP_ENTITYKEY`).join(' AND ')})), '')`);
261
+ const gpConcatExpr = gpParts.length > 1 ? gpParts.join(" || ', ' || ") : gpParts[0];
262
+ gpObjectIDExpr = `COALESCE(NULLIF(${gpConcatExpr}, ''), grp2.GP_ENTITYKEY)`;
263
+ } else {
264
+ gpObjectIDExpr = 'grp2.GP_ENTITYKEY';
265
+ }
266
+
267
+ // Modification for grandparent: 'create' if grandparent was created in same tx, 'update' otherwise
268
+ const gpModificationExpr = `CASE WHEN EXISTS (
269
+ SELECT 1 FROM SAP_CHANGELOG_CHANGES
270
+ WHERE entity = '${grandParentEntityName}'
271
+ AND entityKey = grp2.GP_ENTITYKEY
272
+ AND modification = 'create'
273
+ AND transactionID = grp2.TRANSACTIONID
274
+ ) THEN 'create' ELSE 'update' END`;
275
+
276
+ block += `
277
+
278
+ -- Step 3a: Create grandparent composition entries where missing
279
+ INSERT INTO SAP_CHANGELOG_CHANGES
280
+ (ID, parent_ID, attribute, entity, entityKey, objectID, createdAt, createdBy, valueDataType, modification, transactionID)
281
+ SELECT
282
+ SYSUUID,
283
+ NULL,
284
+ '${grandParentCompositionFieldName}',
285
+ '${grandParentEntityName}',
286
+ grp2.GP_ENTITYKEY,
287
+ ${gpObjectIDExpr},
288
+ grp2.MIN_CREATEDAT,
289
+ grp2.CREATEDBY,
290
+ 'cds.Composition',
291
+ ${gpModificationExpr},
292
+ grp2.TRANSACTIONID
293
+ FROM (
294
+ SELECT
295
+ ${grandParentKeyFromParent} AS GP_ENTITYKEY,
296
+ comp2.TRANSACTIONID,
297
+ MIN(comp2.CREATEDAT) AS MIN_CREATEDAT,
298
+ MIN(comp2.CREATEDBY) AS CREATEDBY
299
+ FROM SAP_CHANGELOG_CHANGES comp2
300
+ INNER JOIN ${parentTableName} parent_data
301
+ ON comp2.ENTITYKEY = ${compositeKeyExpr(parentKeys.map((pk) => `parent_data.${pk}`))}
302
+ WHERE comp2.entity = '${parentEntityName}'
303
+ AND comp2.attribute = '${compositionField}'
304
+ AND comp2.valueDataType = 'cds.Composition'
305
+ AND comp2.parent_ID IS NULL
306
+ AND NOT EXISTS (
307
+ SELECT 1 FROM SAP_CHANGELOG_CHANGES gp
308
+ WHERE gp.entity = '${grandParentEntityName}'
309
+ AND gp.attribute = '${grandParentCompositionFieldName}'
310
+ AND gp.valueDataType = 'cds.Composition'
311
+ AND gp.transactionID = comp2.transactionID
312
+ AND gp.entityKey = ${grandParentKeyFromParent}
313
+ )
314
+ GROUP BY ${grandParentKeyFromParent}, comp2.TRANSACTIONID, comp2.CREATEDBY
315
+ ) grp2;
316
+
317
+ -- Step 3b: Link composition entries to their grandparent composition entries
318
+ MERGE INTO SAP_CHANGELOG_CHANGES AS comp
319
+ USING (
320
+ SELECT comp2.ID AS COMP_ID, gp.ID AS GRANDPARENT_ID
321
+ FROM SAP_CHANGELOG_CHANGES comp2
322
+ INNER JOIN ${parentTableName} parent_data
323
+ ON comp2.ENTITYKEY = ${compositeKeyExpr(parentKeys.map((pk) => `parent_data.${pk}`))}
324
+ INNER JOIN SAP_CHANGELOG_CHANGES gp
325
+ ON gp.entity = '${grandParentEntityName}'
326
+ AND gp.attribute = '${grandParentCompositionFieldName}'
327
+ AND gp.valueDataType = 'cds.Composition'
328
+ AND gp.transactionID = comp2.transactionID
329
+ AND gp.entityKey = ${grandParentKeyFromParent}
330
+ WHERE comp2.entity = '${parentEntityName}'
331
+ AND comp2.attribute = '${compositionField}'
332
+ AND comp2.valueDataType = 'cds.Composition'
333
+ AND comp2.parent_ID IS NULL
334
+ ) AS matched
335
+ ON comp.ID = matched.COMP_ID
336
+ WHEN MATCHED THEN UPDATE SET comp.parent_ID = matched.GRANDPARENT_ID;`;
337
+ }
338
+
339
+ return block;
340
+ }
341
+
342
+ module.exports = { generateRestoreBacklinksProcedure };
@@ -1,15 +1,14 @@
1
1
  const utils = require('../utils/change-tracking.js');
2
2
  const { CT_SKIP_VAR, getEntitySkipVarName, getElementSkipVarName } = require('../utils/session-variables.js');
3
-
4
- const HANAService = require('@cap-js/hana');
5
- const cqn4sql = require('@cap-js/db-service/lib/cqn4sql');
6
3
  const { createTriggerCQN2SQL } = require('../TriggerCQN2SQL.js');
7
4
 
8
- const TriggerCQN2SQL = createTriggerCQN2SQL(HANAService.CQN2SQL);
9
5
  let HANACQN2SQL;
10
6
 
11
7
  function toSQL(query, model) {
8
+ const cqn4sql = require('@cap-js/db-service/lib/cqn4sql');
12
9
  if (!HANACQN2SQL) {
10
+ const { CQN2SQL } = require('@cap-js/hana');
11
+ const TriggerCQN2SQL = createTriggerCQN2SQL(CQN2SQL);
13
12
  HANACQN2SQL = new TriggerCQN2SQL();
14
13
  }
15
14
  const sqlCQN = cqn4sql(query, model);
@@ -27,7 +26,7 @@ function getElementSkipCondition(entityName, elementName) {
27
26
  }
28
27
 
29
28
  function compositeKeyExpr(parts) {
30
- if (parts.length <= 1) return parts[0];
29
+ if (parts.length <= 1) return `TO_NVARCHAR(${parts[0]})`;
31
30
  return `HIERARCHY_COMPOSITE_ID(${parts.join(', ')})`;
32
31
  }
33
32
 
@@ -100,14 +99,9 @@ function getWhereCondition(col, modification) {
100
99
  }
101
100
 
102
101
  /**
103
- * Returns SQL expression for a column's label (looked-up value for associations)
102
+ * Builds scalar subselect for association label lookup with locale awareness
104
103
  */
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
104
+ function buildAssocLookup(col, assocPaths, refRow, model) {
111
105
  let where = {};
112
106
  if (col.foreignKeys) {
113
107
  where = col.foreignKeys.reduce((acc, k) => {
@@ -121,11 +115,11 @@ function getLabelExpr(col, refRow, model) {
121
115
  }, {});
122
116
  }
123
117
 
124
- const alt = col.alt.map((s) => s.split('.').slice(1).join('.'));
118
+ const alt = assocPaths.map((s) => s.split('.').slice(1).join('.'));
125
119
  const columns = alt.length === 1 ? alt[0] : utils.buildConcatXpr(alt);
126
120
 
127
121
  // Check for localization
128
- const localizedInfo = utils.getLocalizedLookupInfo(col.target, col.alt, model);
122
+ const localizedInfo = utils.getLocalizedLookupInfo(col.target, assocPaths, model);
129
123
  if (localizedInfo) {
130
124
  const textsWhere = { ...where, locale: { func: 'SESSION_CONTEXT', args: [{ val: 'LOCALE' }] } };
131
125
  const textsQuery = SELECT.one.from(localizedInfo.textsEntity).columns(columns).where(textsWhere);
@@ -137,6 +131,35 @@ function getLabelExpr(col, refRow, model) {
137
131
  return `(${toSQL(query, model)})`;
138
132
  }
139
133
 
134
+ /**
135
+ * Returns SQL expression for a column's label (looked-up value for associations)
136
+ */
137
+ function getLabelExpr(col, refRow, model) {
138
+ if (!col.alt || col.alt.length === 0) return `NULL`;
139
+
140
+ const parts = [];
141
+ let assocBatch = [];
142
+
143
+ const flushAssocBatch = () => {
144
+ if (assocBatch.length > 0) {
145
+ parts.push(buildAssocLookup(col, assocBatch, refRow, model));
146
+ assocBatch = [];
147
+ }
148
+ };
149
+
150
+ for (const entry of col.alt) {
151
+ if (entry.source === 'assoc') {
152
+ assocBatch.push(entry.path);
153
+ } else {
154
+ flushAssocBatch();
155
+ parts.push(`TO_NVARCHAR(:${refRow}.${entry.path})`);
156
+ }
157
+ }
158
+ flushAssocBatch();
159
+
160
+ return parts.length === 0 ? `NULL` : parts.join(" || ', ' || ");
161
+ }
162
+
140
163
  /**
141
164
  * Builds SQL expression for objectID (entity display name)
142
165
  * Uses @changelog annotation fields, falling back to entity name
@@ -26,18 +26,6 @@ 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
- });
41
29
  }
42
30
 
43
31
  async function deployPostgresLabels() {
@@ -1,13 +1,13 @@
1
1
  const cds = require('@sap/cds');
2
2
  const utils = require('../utils/change-tracking.js');
3
3
  const config = cds.env.requires['change-tracking'];
4
- const cqn4sql = require('@cap-js/db-service/lib/cqn4sql');
5
4
  const { CT_SKIP_VAR, getEntitySkipVarName, getElementSkipVarName } = require('../utils/session-variables.js');
6
5
  const { createTriggerCQN2SQL } = require('../TriggerCQN2SQL.js');
7
6
 
8
7
  const _cqn2sqlCache = new WeakMap();
9
8
 
10
9
  function toSQL(query, model) {
10
+ const cqn4sql = require('@cap-js/db-service/lib/cqn4sql');
11
11
  let cqn2sql = _cqn2sqlCache.get(model);
12
12
  if (!cqn2sql) {
13
13
  const Service = require('@cap-js/postgres');
@@ -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
@@ -7,7 +7,7 @@ const { setSkipSessionVariables, resetSkipSessionVariables, resetAutoSkipForServ
7
7
  * Register db handlers for setting/resetting session variables on INSERT/UPDATE/DELETE.
8
8
  */
9
9
  function registerSessionVariableHandlers() {
10
- cds.db.before(['INSERT', 'UPDATE', 'DELETE'], async (req) => {
10
+ cds.db?.before(['INSERT', 'UPDATE', 'DELETE'], async (req) => {
11
11
  const model = cds.context?.model ?? cds.model;
12
12
  const collectedEntities = model.collectEntities || (model.collectEntities = collectEntities(model).collectedEntities);
13
13
  if (!req.target || req.target.name.endsWith('.drafts')) return;
@@ -16,7 +16,7 @@ function registerSessionVariableHandlers() {
16
16
  setSkipSessionVariables(req, srv, collectedEntities);
17
17
  });
18
18
 
19
- cds.db.after(['INSERT', 'UPDATE', 'DELETE'], async (_, req) => {
19
+ cds.db?.after(['INSERT', 'UPDATE', 'DELETE'], async (_, req) => {
20
20
  if (!req.target || req.target.name.endsWith('.drafts')) return;
21
21
 
22
22
  // Reset auto-skip variable if it was set
@@ -2,10 +2,10 @@ const utils = require('../utils/change-tracking.js');
2
2
  const { CT_SKIP_VAR, getEntitySkipVarName, getElementSkipVarName } = require('../utils/session-variables.js');
3
3
  const { createTriggerCQN2SQL } = require('../TriggerCQN2SQL.js');
4
4
 
5
- const cqn4sql = require('@cap-js/db-service/lib/cqn4sql');
6
5
  const _cqn2sqlCache = new WeakMap();
7
6
 
8
7
  function toSQL(query, model) {
8
+ const cqn4sql = require('@cap-js/db-service/lib/cqn4sql');
9
9
  let cqn2sql = _cqn2sqlCache.get(model);
10
10
  if (!cqn2sql) {
11
11
  const SQLiteService = require('@cap-js/sqlite');
@@ -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
  /**