@cap-js/change-tracking 1.1.4 → 2.0.0-beta.2
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 +40 -1
- package/README.md +45 -71
- package/_i18n/i18n.properties +10 -22
- package/_i18n/i18n_ar.properties +3 -3
- package/_i18n/i18n_bg.properties +3 -3
- package/_i18n/i18n_cs.properties +3 -3
- package/_i18n/i18n_da.properties +3 -3
- package/_i18n/i18n_de.properties +3 -3
- package/_i18n/i18n_el.properties +3 -3
- package/_i18n/i18n_en.properties +3 -3
- package/_i18n/i18n_en_US_saptrc.properties +3 -32
- package/_i18n/i18n_es.properties +3 -3
- package/_i18n/i18n_es_MX.properties +3 -3
- package/_i18n/i18n_fi.properties +3 -3
- package/_i18n/i18n_fr.properties +3 -3
- package/_i18n/i18n_he.properties +3 -3
- package/_i18n/i18n_hr.properties +3 -3
- package/_i18n/i18n_hu.properties +3 -3
- package/_i18n/i18n_it.properties +3 -3
- package/_i18n/i18n_ja.properties +3 -3
- package/_i18n/i18n_kk.properties +3 -3
- package/_i18n/i18n_ko.properties +3 -3
- package/_i18n/i18n_ms.properties +3 -3
- package/_i18n/i18n_nl.properties +3 -3
- package/_i18n/i18n_no.properties +3 -3
- package/_i18n/i18n_pl.properties +3 -3
- package/_i18n/i18n_pt.properties +3 -3
- package/_i18n/i18n_ro.properties +3 -3
- package/_i18n/i18n_ru.properties +3 -3
- package/_i18n/i18n_sh.properties +3 -3
- package/_i18n/i18n_sk.properties +3 -3
- package/_i18n/i18n_sl.properties +3 -3
- package/_i18n/i18n_sv.properties +3 -3
- package/_i18n/i18n_th.properties +3 -3
- package/_i18n/i18n_tr.properties +3 -3
- package/_i18n/i18n_uk.properties +3 -3
- package/_i18n/i18n_vi.properties +3 -3
- package/_i18n/i18n_zh_CN.properties +3 -3
- package/_i18n/i18n_zh_TW.properties +3 -3
- package/cds-plugin.js +16 -263
- package/index.cds +187 -76
- package/lib/TriggerCQN2SQL.js +42 -0
- package/lib/h2/java-codegen.js +833 -0
- package/lib/h2/register.js +27 -0
- package/lib/h2/triggers.js +41 -0
- package/lib/hana/composition.js +248 -0
- package/lib/hana/register.js +28 -0
- package/lib/hana/sql-expressions.js +213 -0
- package/lib/hana/triggers.js +253 -0
- package/lib/localization.js +53 -117
- package/lib/model-enhancer.js +266 -0
- package/lib/postgres/composition.js +190 -0
- package/lib/postgres/register.js +44 -0
- package/lib/postgres/sql-expressions.js +261 -0
- package/lib/postgres/triggers.js +113 -0
- package/lib/skipHandlers.js +34 -0
- package/lib/sqlite/composition.js +234 -0
- package/lib/sqlite/register.js +28 -0
- package/lib/sqlite/sql-expressions.js +228 -0
- package/lib/sqlite/triggers.js +163 -0
- package/lib/utils/change-tracking.js +394 -0
- package/lib/utils/composition-helpers.js +67 -0
- package/lib/utils/entity-collector.js +297 -0
- package/lib/utils/session-variables.js +276 -0
- package/lib/utils/trigger-utils.js +94 -0
- package/package.json +17 -7
- package/lib/change-log.js +0 -538
- package/lib/entity-helper.js +0 -217
- package/lib/format-options.js +0 -66
- package/lib/template-processor.js +0 -115
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
const cds = require('@sap/cds');
|
|
2
|
+
const LOG = cds.log('change-tracking');
|
|
3
|
+
const DEBUG = cds.debug('change-tracking');
|
|
4
|
+
|
|
5
|
+
function isChangeTracked(entity) {
|
|
6
|
+
if (entity['@changelog'] === false) return false;
|
|
7
|
+
if (entity['@changelog']) return true;
|
|
8
|
+
return Object.values(entity.elements).some((e) => e['@changelog']);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// Compares two @changelog annotation values for equality
|
|
12
|
+
function _annotationsEqual(a, b) {
|
|
13
|
+
// Handle null/undefined/false cases
|
|
14
|
+
if (a === b) return true;
|
|
15
|
+
if (a == null || b == null) return false;
|
|
16
|
+
// Deep equality via structuredClone + comparison (order-safe)
|
|
17
|
+
return JSON.stringify(structuredClone(a)) === JSON.stringify(structuredClone(b));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Maps service element name to DB element name (handles renaming in projections)
|
|
21
|
+
function _getDbElementName(serviceEntity, elementName) {
|
|
22
|
+
const columns = (serviceEntity.projection ?? serviceEntity.query?.SELECT)?.columns;
|
|
23
|
+
if (!columns) return elementName;
|
|
24
|
+
|
|
25
|
+
for (const col of columns) {
|
|
26
|
+
// Check for a renamed column: { ref: ['title'], as: 'adminTitle' }
|
|
27
|
+
if (typeof col === 'object' && col.as === elementName && col.ref?.length > 0) {
|
|
28
|
+
return col.ref[0];
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return elementName;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function _mergeChangelogAnnotations(dbEntity, serviceEntities) {
|
|
35
|
+
// Track merged annotations for conflict detection
|
|
36
|
+
let mergedEntityAnnotation = dbEntity['@changelog'];
|
|
37
|
+
let mergedEntityAnnotationSource = mergedEntityAnnotation ? dbEntity.name : null;
|
|
38
|
+
const mergedElementAnnotations = new Map(); // Map<dbElementName, { annotation, sourceName }>
|
|
39
|
+
|
|
40
|
+
// Initialize with DB entity element annotations
|
|
41
|
+
for (const element of dbEntity.elements) {
|
|
42
|
+
if (element['@changelog'] !== undefined) {
|
|
43
|
+
mergedElementAnnotations.set(element.name, {
|
|
44
|
+
annotation: element['@changelog'],
|
|
45
|
+
sourceName: dbEntity.name
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Merge annotations from each service entity
|
|
51
|
+
for (const { entity: srvEntity, entityAnnotation, elementAnnotations } of serviceEntities) {
|
|
52
|
+
// Merge entity-level @changelog (ObjectID definition)
|
|
53
|
+
if (entityAnnotation !== undefined) {
|
|
54
|
+
if (mergedEntityAnnotation !== undefined && !_annotationsEqual(mergedEntityAnnotation, entityAnnotation)) {
|
|
55
|
+
throw new Error(
|
|
56
|
+
`Conflicting @changelog annotations on entity '${dbEntity.name}': ` + `'${mergedEntityAnnotationSource}' has ${JSON.stringify(mergedEntityAnnotation)} but ` + `'${srvEntity.name}' has ${JSON.stringify(entityAnnotation)}`
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
if (mergedEntityAnnotation === undefined) {
|
|
60
|
+
mergedEntityAnnotation = entityAnnotation;
|
|
61
|
+
mergedEntityAnnotationSource = srvEntity.name;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Merge element-level @changelog annotations
|
|
66
|
+
for (const [srvElemName, annotation] of Object.entries(elementAnnotations)) {
|
|
67
|
+
// Map service element name to DB element name (handles renaming)
|
|
68
|
+
const dbElemName = _getDbElementName(srvEntity, srvElemName);
|
|
69
|
+
|
|
70
|
+
// Skip if annotation is false/null (explicit opt-out)
|
|
71
|
+
if (annotation === false || annotation === null) continue;
|
|
72
|
+
|
|
73
|
+
const existing = mergedElementAnnotations.get(dbElemName);
|
|
74
|
+
if (existing && !_annotationsEqual(existing.annotation, annotation)) {
|
|
75
|
+
throw new Error(
|
|
76
|
+
`Conflicting @changelog annotations on element '${dbElemName}' of entity '${dbEntity.name}': ` + `'${existing.sourceName}' has ${JSON.stringify(existing.annotation)} but ` + `'${srvEntity.name}' has ${JSON.stringify(annotation)}`
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
if (!existing) {
|
|
80
|
+
mergedElementAnnotations.set(dbElemName, {
|
|
81
|
+
annotation,
|
|
82
|
+
sourceName: srvEntity.name
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Convert Map to plain object for elementAnnotations
|
|
89
|
+
const elementAnnotationsObj = {};
|
|
90
|
+
for (const [elemName, { annotation }] of mergedElementAnnotations) {
|
|
91
|
+
elementAnnotationsObj[elemName] = annotation;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
entityAnnotation: mergedEntityAnnotation,
|
|
96
|
+
elementAnnotations: elementAnnotationsObj
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function getEntitiesForTriggerGeneration(model, collected) {
|
|
101
|
+
const result = [];
|
|
102
|
+
const processedDbEntities = new Set();
|
|
103
|
+
|
|
104
|
+
// Process collected service entities - resolve entities and annotations from names
|
|
105
|
+
for (const [dbEntityName, serviceEntityNames] of collected) {
|
|
106
|
+
processedDbEntities.add(dbEntityName);
|
|
107
|
+
const dbEntity = model[dbEntityName];
|
|
108
|
+
if (!dbEntity) {
|
|
109
|
+
DEBUG?.(`DB entity ${dbEntityName} not found in model, skipping`);
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Resolve service entities and extract their annotations
|
|
114
|
+
const serviceEntities = [];
|
|
115
|
+
for (const name of serviceEntityNames) {
|
|
116
|
+
const serviceEntity = model[name];
|
|
117
|
+
if (!serviceEntity) {
|
|
118
|
+
DEBUG?.(`Service entity ${name} not found in model, skipping`);
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Extract @changelog annotations from the service entity
|
|
123
|
+
const entityAnnotation = serviceEntity['@changelog'];
|
|
124
|
+
const elementAnnotations = {};
|
|
125
|
+
for (const element of serviceEntity.elements) {
|
|
126
|
+
if (element['@changelog'] !== undefined) {
|
|
127
|
+
elementAnnotations[element.name] = element['@changelog'];
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
serviceEntities.push({
|
|
132
|
+
entity: serviceEntity,
|
|
133
|
+
entityAnnotation,
|
|
134
|
+
elementAnnotations
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
try {
|
|
139
|
+
const mergedAnnotations = _mergeChangelogAnnotations(dbEntity, serviceEntities);
|
|
140
|
+
result.push({ dbEntityName, mergedAnnotations });
|
|
141
|
+
DEBUG?.(`Merged annotations for ${dbEntityName} from ${serviceEntities.length} service entities`);
|
|
142
|
+
} catch (error) {
|
|
143
|
+
LOG.error(error.message);
|
|
144
|
+
throw error;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Add table entities that have @changelog but weren't collected
|
|
149
|
+
for (const def of model) {
|
|
150
|
+
const isTableEntity = def.kind === 'entity' && !def.query && !def.projection;
|
|
151
|
+
if (!isTableEntity || processedDbEntities.has(def.name)) continue;
|
|
152
|
+
|
|
153
|
+
if (isChangeTracked(def)) {
|
|
154
|
+
// No service entities collected, use null for mergedAnnotations (use entity's own annotations)
|
|
155
|
+
result.push({ dbEntityName: def.name, mergedAnnotations: null });
|
|
156
|
+
processedDbEntities.add(def.name);
|
|
157
|
+
DEBUG?.(`Including DB entity ${def.name} directly (no service entities collected)`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Add composition-of-many target entities that have @changelog on the composition field
|
|
162
|
+
for (const { dbEntityName, mergedAnnotations } of [...result]) {
|
|
163
|
+
const dbEntity = model[dbEntityName];
|
|
164
|
+
|
|
165
|
+
for (const element of Object.values(dbEntity.elements)) {
|
|
166
|
+
if (element.type !== 'cds.Composition' || !element.is2many || !element.target) continue;
|
|
167
|
+
|
|
168
|
+
const changelogAnnotation = mergedAnnotations?.elementAnnotations?.[element.name] ?? element['@changelog'];
|
|
169
|
+
if (!changelogAnnotation) continue;
|
|
170
|
+
|
|
171
|
+
// Skip if target entity is already processed
|
|
172
|
+
if (processedDbEntities.has(element.target)) continue;
|
|
173
|
+
|
|
174
|
+
const targetEntity = model[element.target];
|
|
175
|
+
if (!targetEntity) continue;
|
|
176
|
+
|
|
177
|
+
// Add target entity with null mergedAnnotations (it uses its own annotations, if any)
|
|
178
|
+
result.push({ dbEntityName: element.target, mergedAnnotations: null });
|
|
179
|
+
processedDbEntities.add(element.target);
|
|
180
|
+
DEBUG?.(`Including composition target ${element.target} for tracked composition ${element.name} on ${dbEntityName}`);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return result;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Recursively find the base entity of a projection (needed for loaded lifecycle hook)
|
|
188
|
+
function getBaseEntity(entity, model) {
|
|
189
|
+
const cqn = entity.projection ?? entity.query?.SELECT;
|
|
190
|
+
if (!cqn) return null;
|
|
191
|
+
|
|
192
|
+
const baseRef = cqn.from?.ref?.[0];
|
|
193
|
+
if (!baseRef || !model) return null;
|
|
194
|
+
|
|
195
|
+
const baseEntity = model.definitions[baseRef];
|
|
196
|
+
if (!baseEntity) return null;
|
|
197
|
+
const baseCQN = baseEntity.projection ?? baseEntity.query?.SELECT ?? baseEntity.query?.SET;
|
|
198
|
+
// If base entity is also a projection, recurse
|
|
199
|
+
if (baseCQN) {
|
|
200
|
+
return getBaseEntity(baseEntity, model);
|
|
201
|
+
} else {
|
|
202
|
+
return { baseRef, baseEntity };
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Analyze composition hierarchy in CSN
|
|
207
|
+
function analyzeCompositions(csn) {
|
|
208
|
+
// First pass: build child -> { parent, compositionField } map
|
|
209
|
+
const childParentMap = new Map();
|
|
210
|
+
|
|
211
|
+
for (const [name, def] of Object.entries(csn.definitions)) {
|
|
212
|
+
if (def.kind !== 'entity') continue;
|
|
213
|
+
|
|
214
|
+
if (def.elements) {
|
|
215
|
+
for (const [elemName, element] of Object.entries(def.elements)) {
|
|
216
|
+
if (element.type === 'cds.Composition' && element.target) {
|
|
217
|
+
childParentMap.set(element.target, {
|
|
218
|
+
parent: name,
|
|
219
|
+
compositionField: elemName
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Second pass: build hierarchy with grandparent info
|
|
227
|
+
const hierarchy = new Map();
|
|
228
|
+
for (const [childName, parentInfo] of childParentMap) {
|
|
229
|
+
const { parent: parentName, compositionField } = parentInfo;
|
|
230
|
+
|
|
231
|
+
// Check if the parent itself has a parent (grandparent)
|
|
232
|
+
const grandParentInfo = childParentMap.get(parentName);
|
|
233
|
+
|
|
234
|
+
hierarchy.set(childName, {
|
|
235
|
+
parent: parentName,
|
|
236
|
+
compositionField,
|
|
237
|
+
grandParent: grandParentInfo?.parent ?? null,
|
|
238
|
+
grandParentCompositionField: grandParentInfo?.compositionField ?? null
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return hierarchy;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function getService(name, model) {
|
|
246
|
+
const nameSegments = name.split('.');
|
|
247
|
+
let service;
|
|
248
|
+
let serviceName = '';
|
|
249
|
+
|
|
250
|
+
// Goining in reverse to ensure that in scenarios where one service includes another service name
|
|
251
|
+
// in its namespace the correct service is chosen
|
|
252
|
+
while (!service && nameSegments.length) {
|
|
253
|
+
nameSegments.pop();
|
|
254
|
+
serviceName = nameSegments.join('.');
|
|
255
|
+
service = model.definitions[serviceName];
|
|
256
|
+
if (service?.kind !== 'service') {
|
|
257
|
+
service = null;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
if (!service) {
|
|
261
|
+
serviceName = '';
|
|
262
|
+
}
|
|
263
|
+
return serviceName;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* REVISIT!
|
|
268
|
+
* Collect change-tracked service entities from a model, grouped by their underlying DB entity
|
|
269
|
+
*/
|
|
270
|
+
function collectEntities(model) {
|
|
271
|
+
const collectedEntities = new Map();
|
|
272
|
+
const hierarchyMap = analyzeCompositions(model);
|
|
273
|
+
|
|
274
|
+
for (const name in model.definitions) {
|
|
275
|
+
const entity = model.definitions[name];
|
|
276
|
+
const isServiceEntity = entity.kind === 'entity' && !!(entity.query || entity.projection);
|
|
277
|
+
if (isServiceEntity && isChangeTracked(entity)) {
|
|
278
|
+
const baseInfo = getBaseEntity(entity, model);
|
|
279
|
+
if (!baseInfo) continue;
|
|
280
|
+
const { baseRef: dbEntityName } = baseInfo;
|
|
281
|
+
|
|
282
|
+
if (!collectedEntities.has(dbEntityName)) collectedEntities.set(dbEntityName, []);
|
|
283
|
+
collectedEntities.get(dbEntityName).push(name);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
return { collectedEntities, hierarchyMap };
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
module.exports = {
|
|
291
|
+
isChangeTracked,
|
|
292
|
+
getEntitiesForTriggerGeneration,
|
|
293
|
+
getBaseEntity,
|
|
294
|
+
analyzeCompositions,
|
|
295
|
+
getService,
|
|
296
|
+
collectEntities
|
|
297
|
+
};
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
const cds = require('@sap/cds');
|
|
2
|
+
const DEBUG = cds.debug('change-tracking');
|
|
3
|
+
|
|
4
|
+
const { isChangeTracked } = require('./entity-collector.js');
|
|
5
|
+
|
|
6
|
+
// Session context variable names for skipping change tracking
|
|
7
|
+
// Using 'ct.' prefix for all session variables to be PostgreSQL compatible
|
|
8
|
+
const CT_SKIP_VAR = 'ct.skip';
|
|
9
|
+
const CT_SKIP_ENTITY_PREFIX = 'ct.skip_entity.';
|
|
10
|
+
const CT_SKIP_ELEMENT_PREFIX = 'ct.skip_element.';
|
|
11
|
+
|
|
12
|
+
function _resolveView(entity) {
|
|
13
|
+
const model = cds.context?.model ?? cds.model;
|
|
14
|
+
const baseRef = entity.query?._target;
|
|
15
|
+
if (!baseRef || !model) return null;
|
|
16
|
+
|
|
17
|
+
const baseEntity = model.definitions[baseRef.name];
|
|
18
|
+
if (!baseEntity) return null;
|
|
19
|
+
|
|
20
|
+
// If base entity is also a projection, recurse
|
|
21
|
+
if (baseEntity.query?._target) {
|
|
22
|
+
return _resolveView(baseEntity);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return baseEntity;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function getEntitySkipVarName(entityName) {
|
|
29
|
+
return `${CT_SKIP_ENTITY_PREFIX}${entityName.replace(/\./g, '_')}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function getElementSkipVarName(entityName, elementName) {
|
|
33
|
+
return `${CT_SKIP_ELEMENT_PREFIX}${entityName.replace(/\./g, '_')}.${elementName.replace(/\./g, '_')}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function _findServiceEntity(service, dbEntity) {
|
|
37
|
+
if (!service || !dbEntity) return null;
|
|
38
|
+
for (const def of service.entities) {
|
|
39
|
+
const projectionTarget = _resolveView(def)?.name;
|
|
40
|
+
if (projectionTarget === dbEntity.name) return def;
|
|
41
|
+
}
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function _collectDeepEntities(entity, data, service, toSkip) {
|
|
46
|
+
if (!entity.compositions) return;
|
|
47
|
+
for (const comp of entity.compositions) {
|
|
48
|
+
const compData = data[comp.name];
|
|
49
|
+
if (compData === undefined) continue;
|
|
50
|
+
|
|
51
|
+
const targetEntity = comp._target || (cds.context?.model ?? cds.model).definitions[comp.target];
|
|
52
|
+
if (!targetEntity) continue;
|
|
53
|
+
|
|
54
|
+
// Check annotations of target entity (on service level)
|
|
55
|
+
const serviceEntity = _findServiceEntity(service, targetEntity);
|
|
56
|
+
if (serviceEntity && (serviceEntity['@changelog'] === false || serviceEntity['@changelog'] === null)) {
|
|
57
|
+
toSkip.add(targetEntity.name);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Recurse for nested compositions
|
|
61
|
+
_collectDeepEntities(targetEntity, compData, service, toSkip);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function _collectSkipEntities(rootTarget, query, service) {
|
|
66
|
+
const toSkip = new Set();
|
|
67
|
+
const dbEntity = _resolveView(rootTarget);
|
|
68
|
+
|
|
69
|
+
// Check root entity annotation
|
|
70
|
+
if (rootTarget['@changelog'] === false || rootTarget['@changelog'] === null) {
|
|
71
|
+
toSkip.add(dbEntity.name);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// For deep operations, extract data from query and traverse compositions
|
|
75
|
+
const data = query?.INSERT?.entries || query?.UPDATE?.data || query?.UPDATE?.with;
|
|
76
|
+
if (!data || !dbEntity?.compositions) return toSkip;
|
|
77
|
+
|
|
78
|
+
// Filter all compositions inside data and map on composition target
|
|
79
|
+
const dataArray = Array.isArray(data) ? data : [data];
|
|
80
|
+
for (const row of dataArray) {
|
|
81
|
+
_collectDeepEntities(dbEntity, row, service, toSkip);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return Array.from(toSkip);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Maps service element name to corresponding column name (considers renaming in projections)
|
|
88
|
+
function _getDbElementName(serviceEntity, elementName) {
|
|
89
|
+
const columns = (serviceEntity.projection ?? serviceEntity.query?.SELECT)?.columns;
|
|
90
|
+
if (!columns) return elementName;
|
|
91
|
+
|
|
92
|
+
for (const col of columns) {
|
|
93
|
+
// Check for a renamed column: { ref: ['title'], as: 'adminTitle' }
|
|
94
|
+
if (typeof col === 'object' && col.as === elementName && col.ref?.length > 0) {
|
|
95
|
+
return col.ref[0];
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return elementName;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function _collectSkipElements(serviceEntity, query, service) {
|
|
103
|
+
const toSkip = [];
|
|
104
|
+
if (!serviceEntity?.elements) return toSkip;
|
|
105
|
+
|
|
106
|
+
const dbEntity = _resolveView(serviceEntity);
|
|
107
|
+
if (!dbEntity) return toSkip;
|
|
108
|
+
|
|
109
|
+
for (const [elementName, element] of Object.entries(serviceEntity.elements)) {
|
|
110
|
+
if (element['@changelog'] === false || element['@changelog'] === null) {
|
|
111
|
+
// Get the actual column name (handles renaming in projections)
|
|
112
|
+
const dbElementName = _getDbElementName(serviceEntity, elementName);
|
|
113
|
+
|
|
114
|
+
toSkip.push({
|
|
115
|
+
dbEntityName: dbEntity.name,
|
|
116
|
+
dbElementName: dbElementName
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
DEBUG?.(`Found element to skip: ${dbEntity.name}.${dbElementName} (service element: ${elementName})`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Handle nested compositions for deep skip elements
|
|
124
|
+
const data = query?.INSERT?.entries || query?.UPDATE?.data || query?.UPDATE?.with;
|
|
125
|
+
if (!data || !dbEntity?.compositions) return toSkip;
|
|
126
|
+
|
|
127
|
+
// Filter all compositions inside data and map on composition target
|
|
128
|
+
const dataArray = Array.isArray(data) ? data : [data];
|
|
129
|
+
for (const row of dataArray) {
|
|
130
|
+
_collectDeepSkipElements(dbEntity, row, service, toSkip);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return toSkip;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function _collectDeepSkipElements(entity, data, service, toSkip) {
|
|
137
|
+
if (!entity.compositions) return;
|
|
138
|
+
|
|
139
|
+
for (const comp of entity.compositions) {
|
|
140
|
+
const compData = data[comp.name];
|
|
141
|
+
if (compData === undefined) continue;
|
|
142
|
+
|
|
143
|
+
const targetEntity = comp._target || (cds.context?.model ?? cds.model).definitions[comp.target];
|
|
144
|
+
if (!targetEntity) continue;
|
|
145
|
+
|
|
146
|
+
// Find the service entity for this composition target
|
|
147
|
+
const serviceEntity = _findServiceEntity(service, targetEntity);
|
|
148
|
+
if (serviceEntity) {
|
|
149
|
+
// Collect skip elements from this service entity
|
|
150
|
+
const skipElements = _collectSkipElements(serviceEntity);
|
|
151
|
+
for (const el of skipElements) {
|
|
152
|
+
toSkip.push(el);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Recurse for nested compositions
|
|
157
|
+
const compDataArray = Array.isArray(compData) ? compData : [compData];
|
|
158
|
+
for (const row of compDataArray) {
|
|
159
|
+
_collectDeepSkipElements(targetEntity, row, service, toSkip);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Sets session variables to skip change tracking for a service/entities/elements.
|
|
166
|
+
* Called in the before handler for INSERT/UPDATE/DELETE.
|
|
167
|
+
*/
|
|
168
|
+
function setSkipSessionVariables(req, srv, collectedEntities) {
|
|
169
|
+
// Check if this service entity should be skipped automatically
|
|
170
|
+
const srvEntityIsChangeTracked = isChangeTracked(req.target);
|
|
171
|
+
if (!srvEntityIsChangeTracked && _shouldSkipServiceEntity(req.target, collectedEntities)) {
|
|
172
|
+
req._ctAutoSkipEntity = _setAutoSkipForServiceEntity(req);
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
// Only proceed with explicit skip handling if service entity is change-tracked
|
|
176
|
+
if (!srvEntityIsChangeTracked) return;
|
|
177
|
+
|
|
178
|
+
// Check if request is for a service to skip
|
|
179
|
+
if (srv['@changelog'] === false || srv['@changelog'] === null) {
|
|
180
|
+
DEBUG?.(`Set skip session variable for service ${srv.name} to true!`);
|
|
181
|
+
req._tx.set({ [CT_SKIP_VAR]: 'true' });
|
|
182
|
+
req._ctSkipWasSet = true;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const entitiesToSkip = _collectSkipEntities(req.target, req.query, srv);
|
|
186
|
+
if (entitiesToSkip.length > 0) {
|
|
187
|
+
const skipVars = {};
|
|
188
|
+
for (const name of entitiesToSkip) {
|
|
189
|
+
const varName = getEntitySkipVarName(name);
|
|
190
|
+
skipVars[varName] = 'true';
|
|
191
|
+
DEBUG?.(`Set skip session variable for entity ${name} to true!`);
|
|
192
|
+
}
|
|
193
|
+
req._tx.set(skipVars);
|
|
194
|
+
req._ctSkipEntities = entitiesToSkip;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Collect elements to skip from the root service entity
|
|
198
|
+
const elementsToSkip = _collectSkipElements(req.target, req.query, srv);
|
|
199
|
+
if (elementsToSkip.length > 0) {
|
|
200
|
+
const skipVars = {};
|
|
201
|
+
for (const { dbEntityName, dbElementName } of elementsToSkip) {
|
|
202
|
+
const varName = getElementSkipVarName(dbEntityName, dbElementName);
|
|
203
|
+
skipVars[varName] = 'true';
|
|
204
|
+
DEBUG?.(`Set skip session variable for element ${dbEntityName}.${dbElementName} to true!`);
|
|
205
|
+
}
|
|
206
|
+
req._tx.set(skipVars);
|
|
207
|
+
req._ctSkipElements = elementsToSkip;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Resets session variables after change tracking operations.
|
|
213
|
+
* Called in the after handler for INSERT/UPDATE/DELETE.
|
|
214
|
+
*/
|
|
215
|
+
function resetSkipSessionVariables(req) {
|
|
216
|
+
if (req._ctSkipWasSet) {
|
|
217
|
+
req._tx.set({ [CT_SKIP_VAR]: 'false' });
|
|
218
|
+
delete req._ctSkipWasSet;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (req._ctSkipEntities) {
|
|
222
|
+
const resetVars = {};
|
|
223
|
+
for (const name of req._ctSkipEntities) {
|
|
224
|
+
resetVars[getEntitySkipVarName(name)] = 'false';
|
|
225
|
+
}
|
|
226
|
+
req._tx.set(resetVars);
|
|
227
|
+
delete req._ctSkipEntities;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (req._ctSkipElements) {
|
|
231
|
+
const resetVars = {};
|
|
232
|
+
for (const { dbEntityName, dbElementName } of req._ctSkipElements) {
|
|
233
|
+
resetVars[getElementSkipVarName(dbEntityName, dbElementName)] = 'false';
|
|
234
|
+
}
|
|
235
|
+
req._tx.set(resetVars);
|
|
236
|
+
delete req._ctSkipElements;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// --- Auto-skip for non-opted-in service entities ---
|
|
241
|
+
function _shouldSkipServiceEntity(serviceEntity, collectedEntities) {
|
|
242
|
+
const dbEntityName = _resolveView(serviceEntity)?.name;
|
|
243
|
+
if (!dbEntityName) return false;
|
|
244
|
+
|
|
245
|
+
const srvEntities = collectedEntities.get(dbEntityName);
|
|
246
|
+
if (!srvEntities) return false; // No triggers for this DB entity, nothing to skip
|
|
247
|
+
|
|
248
|
+
// If this service entity is NOT collected (didn't opt-in), skip it
|
|
249
|
+
return !srvEntities.includes(serviceEntity.name);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function _setAutoSkipForServiceEntity(req) {
|
|
253
|
+
const dbEntity = _resolveView(req.target);
|
|
254
|
+
if (!dbEntity) return null;
|
|
255
|
+
|
|
256
|
+
const varName = getEntitySkipVarName(dbEntity.name);
|
|
257
|
+
DEBUG?.(`Auto-skip: Service entity ${req.target.name} didn't opt-in, skipping DB entity ${dbEntity.name}`);
|
|
258
|
+
req._tx.set({ [varName]: 'true' });
|
|
259
|
+
return dbEntity.name;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function resetAutoSkipForServiceEntity(req, autoSkipEntityName) {
|
|
263
|
+
if (!autoSkipEntityName) return;
|
|
264
|
+
req._tx.set({ [getEntitySkipVarName(autoSkipEntityName)]: 'false' });
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Export what's needed by trigger modules and cds-plugin.js
|
|
268
|
+
module.exports = {
|
|
269
|
+
CT_SKIP_VAR,
|
|
270
|
+
getEntitySkipVarName,
|
|
271
|
+
getElementSkipVarName,
|
|
272
|
+
setSkipSessionVariables,
|
|
273
|
+
resetSkipSessionVariables,
|
|
274
|
+
// Auto-skip functions for service entity handling
|
|
275
|
+
resetAutoSkipForServiceEntity
|
|
276
|
+
};
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
const cds = require('@sap/cds');
|
|
2
|
+
const LOG = cds.log('change-tracking');
|
|
3
|
+
|
|
4
|
+
const { fs } = cds.utils;
|
|
5
|
+
|
|
6
|
+
const { getEntitiesForTriggerGeneration, analyzeCompositions, collectEntities } = require('./entity-collector.js');
|
|
7
|
+
const { getLabelTranslations } = require('../localization.js');
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Prepare CSN for trigger generation by cloning, compiling for Node.js,
|
|
11
|
+
* and analyzing compositions.
|
|
12
|
+
*/
|
|
13
|
+
function prepareCSNForTriggers(csn, preserveSources = false) {
|
|
14
|
+
const clonedCSN = structuredClone(csn);
|
|
15
|
+
if (preserveSources) clonedCSN.$sources = csn.$sources;
|
|
16
|
+
const runtimeCSN = cds.compile.for.nodejs(clonedCSN);
|
|
17
|
+
if (preserveSources) runtimeCSN.$sources = csn.$sources;
|
|
18
|
+
const { collectedEntities } = collectEntities(runtimeCSN);
|
|
19
|
+
const hierarchy = analyzeCompositions(runtimeCSN);
|
|
20
|
+
const entities = getEntitiesForTriggerGeneration(runtimeCSN.definitions, collectedEntities);
|
|
21
|
+
return { runtimeCSN, hierarchy, entities };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Generate triggers for all collected entities using the provided generator function.
|
|
26
|
+
*/
|
|
27
|
+
function generateTriggersForEntities(runtimeCSN, hierarchy, entities, generator) {
|
|
28
|
+
const triggers = [];
|
|
29
|
+
for (const { dbEntityName, mergedAnnotations } of entities) {
|
|
30
|
+
const entity = runtimeCSN.definitions[dbEntityName];
|
|
31
|
+
if (!entity) continue;
|
|
32
|
+
|
|
33
|
+
const hierarchyInfo = hierarchy.get(dbEntityName);
|
|
34
|
+
const rootEntityName = hierarchyInfo?.parent ?? null;
|
|
35
|
+
const rootEntity = rootEntityName ? runtimeCSN.definitions[rootEntityName] : null;
|
|
36
|
+
const rootMergedAnnotations = rootEntityName ? entities.find((d) => d.dbEntityName === rootEntityName)?.mergedAnnotations : null;
|
|
37
|
+
|
|
38
|
+
// Get grandparent info for deep linking
|
|
39
|
+
const grandParentEntityName = hierarchyInfo?.grandParent ?? null;
|
|
40
|
+
const grandParentEntity = grandParentEntityName ? runtimeCSN.definitions[grandParentEntityName] : null;
|
|
41
|
+
const grandParentMergedAnnotations = grandParentEntityName ? entities.find((d) => d.dbEntityName === grandParentEntityName)?.mergedAnnotations : null;
|
|
42
|
+
const grandParentCompositionField = hierarchyInfo?.grandParentCompositionField ?? null;
|
|
43
|
+
|
|
44
|
+
const result = generator(runtimeCSN, entity, rootEntity, mergedAnnotations, rootMergedAnnotations, {
|
|
45
|
+
grandParentEntity,
|
|
46
|
+
grandParentMergedAnnotations,
|
|
47
|
+
grandParentCompositionField
|
|
48
|
+
});
|
|
49
|
+
if (result) triggers.push(...(Array.isArray(result) ? result : [result]));
|
|
50
|
+
}
|
|
51
|
+
return triggers;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Write i18n labels CSV file for H2/HDI deployments.
|
|
56
|
+
*/
|
|
57
|
+
function writeLabelsCSV(entities, model) {
|
|
58
|
+
const labels = getLabelTranslations(entities, model);
|
|
59
|
+
const header = 'ID;locale;text';
|
|
60
|
+
const rows = labels.map((row) => `${row.ID};${row.locale};${row.text}`);
|
|
61
|
+
const content = [header, ...rows].join('\n') + '\n';
|
|
62
|
+
const dir = 'db/src/gen/data/';
|
|
63
|
+
if (!fs.existsSync(dir)) {
|
|
64
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
65
|
+
}
|
|
66
|
+
fs.writeFileSync(`${dir}/sap.changelog-i18nKeys.csv`, content);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Ensures the undeploy.json file contains the hdbtrigger pattern.
|
|
71
|
+
*/
|
|
72
|
+
function ensureUndeployJsonHasTriggerPattern() {
|
|
73
|
+
const undeployPath = 'db/undeploy.json';
|
|
74
|
+
const triggerPattern = 'src/gen/**/*.hdbtrigger';
|
|
75
|
+
|
|
76
|
+
let undeploy = [];
|
|
77
|
+
if (fs.existsSync(undeployPath)) {
|
|
78
|
+
undeploy = JSON.parse(fs.readFileSync(undeployPath, 'utf8'));
|
|
79
|
+
}
|
|
80
|
+
if (!Array.isArray(undeploy)) return;
|
|
81
|
+
|
|
82
|
+
if (!undeploy.includes(triggerPattern)) {
|
|
83
|
+
undeploy.push(triggerPattern);
|
|
84
|
+
fs.writeFileSync(undeployPath, JSON.stringify(undeploy, null, 4) + '\n');
|
|
85
|
+
LOG.info(`Added '${triggerPattern}' to ${undeployPath}`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
module.exports = {
|
|
90
|
+
prepareCSNForTriggers,
|
|
91
|
+
generateTriggersForEntities,
|
|
92
|
+
writeLabelsCSV,
|
|
93
|
+
ensureUndeployJsonHasTriggerPattern
|
|
94
|
+
};
|