@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
|
@@ -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
|
-
*
|
|
102
|
+
* Builds scalar subselect for association label lookup with locale awareness
|
|
104
103
|
*/
|
|
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
|
|
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 =
|
|
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,
|
|
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
|
package/lib/postgres/register.js
CHANGED
|
@@ -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 =
|
|
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
|
package/lib/skipHandlers.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
/**
|