@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.
Files changed (70) hide show
  1. package/CHANGELOG.md +40 -1
  2. package/README.md +45 -71
  3. package/_i18n/i18n.properties +10 -22
  4. package/_i18n/i18n_ar.properties +3 -3
  5. package/_i18n/i18n_bg.properties +3 -3
  6. package/_i18n/i18n_cs.properties +3 -3
  7. package/_i18n/i18n_da.properties +3 -3
  8. package/_i18n/i18n_de.properties +3 -3
  9. package/_i18n/i18n_el.properties +3 -3
  10. package/_i18n/i18n_en.properties +3 -3
  11. package/_i18n/i18n_en_US_saptrc.properties +3 -32
  12. package/_i18n/i18n_es.properties +3 -3
  13. package/_i18n/i18n_es_MX.properties +3 -3
  14. package/_i18n/i18n_fi.properties +3 -3
  15. package/_i18n/i18n_fr.properties +3 -3
  16. package/_i18n/i18n_he.properties +3 -3
  17. package/_i18n/i18n_hr.properties +3 -3
  18. package/_i18n/i18n_hu.properties +3 -3
  19. package/_i18n/i18n_it.properties +3 -3
  20. package/_i18n/i18n_ja.properties +3 -3
  21. package/_i18n/i18n_kk.properties +3 -3
  22. package/_i18n/i18n_ko.properties +3 -3
  23. package/_i18n/i18n_ms.properties +3 -3
  24. package/_i18n/i18n_nl.properties +3 -3
  25. package/_i18n/i18n_no.properties +3 -3
  26. package/_i18n/i18n_pl.properties +3 -3
  27. package/_i18n/i18n_pt.properties +3 -3
  28. package/_i18n/i18n_ro.properties +3 -3
  29. package/_i18n/i18n_ru.properties +3 -3
  30. package/_i18n/i18n_sh.properties +3 -3
  31. package/_i18n/i18n_sk.properties +3 -3
  32. package/_i18n/i18n_sl.properties +3 -3
  33. package/_i18n/i18n_sv.properties +3 -3
  34. package/_i18n/i18n_th.properties +3 -3
  35. package/_i18n/i18n_tr.properties +3 -3
  36. package/_i18n/i18n_uk.properties +3 -3
  37. package/_i18n/i18n_vi.properties +3 -3
  38. package/_i18n/i18n_zh_CN.properties +3 -3
  39. package/_i18n/i18n_zh_TW.properties +3 -3
  40. package/cds-plugin.js +16 -263
  41. package/index.cds +187 -76
  42. package/lib/TriggerCQN2SQL.js +42 -0
  43. package/lib/h2/java-codegen.js +833 -0
  44. package/lib/h2/register.js +27 -0
  45. package/lib/h2/triggers.js +41 -0
  46. package/lib/hana/composition.js +248 -0
  47. package/lib/hana/register.js +28 -0
  48. package/lib/hana/sql-expressions.js +213 -0
  49. package/lib/hana/triggers.js +253 -0
  50. package/lib/localization.js +53 -117
  51. package/lib/model-enhancer.js +266 -0
  52. package/lib/postgres/composition.js +190 -0
  53. package/lib/postgres/register.js +44 -0
  54. package/lib/postgres/sql-expressions.js +261 -0
  55. package/lib/postgres/triggers.js +113 -0
  56. package/lib/skipHandlers.js +34 -0
  57. package/lib/sqlite/composition.js +234 -0
  58. package/lib/sqlite/register.js +28 -0
  59. package/lib/sqlite/sql-expressions.js +228 -0
  60. package/lib/sqlite/triggers.js +163 -0
  61. package/lib/utils/change-tracking.js +394 -0
  62. package/lib/utils/composition-helpers.js +67 -0
  63. package/lib/utils/entity-collector.js +297 -0
  64. package/lib/utils/session-variables.js +276 -0
  65. package/lib/utils/trigger-utils.js +94 -0
  66. package/package.json +17 -7
  67. package/lib/change-log.js +0 -538
  68. package/lib/entity-helper.js +0 -217
  69. package/lib/format-options.js +0 -66
  70. 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
+ };