@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,394 @@
1
+ const cds = require('@sap/cds');
2
+ const LOG = cds.log('change-tracking');
3
+
4
+ /**
5
+ * Method to validate changelog path on entity
6
+ * Normalizes flattened paths too
7
+ * @param {*} entity CSN entity definition
8
+ * @param {*} path provided changelog path
9
+ * @returns normalized path or null if invalid
10
+ */
11
+ function validateChangelogPath(entity, path, model = cds.context?.model ?? cds.model) {
12
+ const segments = path.split('.');
13
+
14
+ if (segments.length === 1) {
15
+ return entity.elements?.[segments[0]] ? segments[0] : null;
16
+ }
17
+
18
+ let currentEntity = entity;
19
+ const walked = [];
20
+
21
+ for (let i = 0; i < segments.length; i++) {
22
+ const seg = segments[i];
23
+ const element = currentEntity.elements?.[seg];
24
+ if (!element) {
25
+ // try flattened tail on current entity
26
+ const flattened = segments.slice(i).join('_');
27
+ if (currentEntity.elements?.[flattened]) {
28
+ walked.push(flattened);
29
+ return walked.join('.');
30
+ }
31
+ LOG.warn(`Invalid @changelog path '${path}' on entity '${entity.name}': '${seg}' not found. @changelog skipped.`);
32
+ return null;
33
+ }
34
+ walked.push(seg);
35
+
36
+ if ((element.type === 'cds.Association' || element.type === 'cds.Composition' || element.type === 'Country') && element.target) {
37
+ const targetDef = model.definitions[element.target] || element._target;
38
+ if (!targetDef || targetDef.kind !== 'entity') {
39
+ return null;
40
+ }
41
+ currentEntity = targetDef;
42
+ continue;
43
+ }
44
+
45
+ // Check primitive field
46
+ if (i === segments.length - 1) return walked.join('.');
47
+ }
48
+ return null;
49
+ }
50
+
51
+ // Extract foreign key names from CSN ‘foreignKeys’ array
52
+ function extractForeignKeys(keys) {
53
+ if (keys == null) return [];
54
+ const keyArray = [];
55
+ for (const k of keys) {
56
+ keyArray.push(k.name);
57
+ }
58
+ return keyArray;
59
+ }
60
+
61
+ function extractFKFieldsFromOnCondition(on, assocName) {
62
+ const fkFields = [];
63
+ for (let i = 0; i < on.length; i++) {
64
+ const cond = on[i];
65
+ if (cond.ref && cond.ref.length === 2 && cond.ref[0] === assocName) {
66
+ if (i + 1 < on.length && on[i + 1] === '=') {
67
+ const fkRef = on[i + 2];
68
+ if (fkRef?.ref) {
69
+ const fkField = fkRef.ref[fkRef.ref.length - 1];
70
+ if (fkField !== '$self') fkFields.push(fkField);
71
+ }
72
+ }
73
+ }
74
+ }
75
+ return fkFields;
76
+ }
77
+
78
+ // Extract entity key fields (flatten association keys to <name>_<fk>)
79
+ function extractKeys(keys) {
80
+ if (!keys) return [];
81
+ const result = [];
82
+ for (const k of keys) {
83
+ if (k.type === 'cds.Association' && !k._foreignKey4) continue;
84
+ if (k.type === 'cds.Association') {
85
+ const fks = extractForeignKeys(k.foreignKeys).map((fk) => `${k.name}_${fk}`);
86
+ result.push(...fks);
87
+ } else {
88
+ result.push(k.name);
89
+ }
90
+ }
91
+ return result;
92
+ }
93
+ /**
94
+ * Retrieves changetracking columns from entity definition
95
+ * @param {*} entity CSN entity definition
96
+ * @param {*} model CSN model (defaults to cds.model)
97
+ * @param {Object|null} overrideAnnotations Optional merged annotations to use instead of entity's own
98
+ * @returns {{ columns: Array, compositionsOfMany: Array }} Object with regular columns and composition of many info
99
+ */
100
+ function extractTrackedColumns(entity, model = cds.context?.model ?? cds.model, overrideAnnotations = null) {
101
+ const columns = [];
102
+ const compositionsOfMany = [];
103
+
104
+ for (const [name, col] of Object.entries(entity.elements)) {
105
+ // Use override annotation if provided, otherwise use the element's own annotation
106
+ const changelogAnnotation = overrideAnnotations?.elementAnnotations?.[name] ?? col['@changelog'];
107
+
108
+ // Skip non-changelog columns + association columns (we want the generated FKs instead)
109
+ if (!changelogAnnotation || col._foreignKey4 || col['@odata.foreignKey4']) continue;
110
+
111
+ // skip any PersonalData* annotation
112
+ const hasPersonalData = Object.keys(col).some((k) => k.startsWith('@PersonalData'));
113
+ if (hasPersonalData) {
114
+ LOG.warn(`Skipped @changelog for ${name} on entity ${entity.name}: Personal data tracking not supported!`);
115
+ continue;
116
+ }
117
+
118
+ // Skip unsupported data types (Binary, LargeBinary, Vector)
119
+ const unsupportedTypes = ['cds.LargeBinary', 'cds.Binary', 'cds.Vector'];
120
+ if (unsupportedTypes.includes(col.type)) {
121
+ LOG.warn(`Skipped @changelog for ${name} on entity ${entity.name}: ${col.type} change tracking not supported!`);
122
+ continue;
123
+ }
124
+
125
+ const hasChildSideFK = col.type === 'cds.Composition' && (col.is2many || (col.on && col.on.some((c) => c.ref?.includes('$self'))));
126
+ if (hasChildSideFK) {
127
+ const compEntry = {
128
+ name: name,
129
+ target: col.target
130
+ };
131
+
132
+ // Extract objectID paths from annotation (e.g., [books.title] -> ['title'])
133
+ if (Array.isArray(changelogAnnotation) && changelogAnnotation.length > 0) {
134
+ const alt = [];
135
+ const changelogPaths = changelogAnnotation.map((c) => c['=']);
136
+ for (const path of changelogPaths) {
137
+ // Path format is "compositionName.field" (e.g., "books.title")
138
+ // We need to strip the composition prefix and validate on target entity
139
+ const segments = path.split('.');
140
+ if (segments.length >= 2 && segments[0] === name) {
141
+ // Strip the composition name prefix
142
+ const targetPath = segments.slice(1).join('.');
143
+ const targetEntity = model.definitions[col.target];
144
+ if (targetEntity) {
145
+ const validated = validateChangelogPath(targetEntity, targetPath, model);
146
+ if (validated) alt.push(validated);
147
+ }
148
+ }
149
+ }
150
+ if (alt.length > 0) compEntry.alt = alt;
151
+ }
152
+
153
+ compositionsOfMany.push(compEntry);
154
+ continue;
155
+ }
156
+
157
+ const isAssociation = col.target !== undefined; //REVISIT col.type === 'cds.Association' includes cds.common
158
+ if (isAssociation && col.is2many && col.on) {
159
+ // create trigger that leave values empty
160
+ continue;
161
+ }
162
+ const entry = { name: name, type: col.type };
163
+
164
+ if (isAssociation) {
165
+ entry.target = col.target;
166
+ // Use the resolved changelog annotation (which could be from override)
167
+ if (Array.isArray(changelogAnnotation) && changelogAnnotation.length > 0) {
168
+ const alt = [];
169
+ const changelogPaths = changelogAnnotation.map((c) => c['=']);
170
+ for (const path of changelogPaths) {
171
+ const p = validateChangelogPath(entity, path, model);
172
+ if (p) alt.push(p);
173
+ }
174
+ if (alt.length > 0) entry.alt = alt;
175
+ }
176
+
177
+ if (col.keys) {
178
+ // for managed associations with generated foreign keys
179
+ entry.foreignKeys = col.keys.flatMap((k) => k.ref);
180
+ } else if (col.on) {
181
+ // for unmanaged associations (multiple conditions possible)
182
+ const mapping = [];
183
+ for (let i = 0; i < col.on.length; i++) {
184
+ const cond = col.on[i];
185
+ if (cond.ref && cond.ref.length === 2 && cond.ref[0] === name) {
186
+ const targetKey = cond.ref[1];
187
+ // next should be '='
188
+ if (i + 1 < col.on.length && col.on[i + 1] === '=') {
189
+ const fkRef = col.on[i + 2];
190
+ if (fkRef?.ref) {
191
+ // get last segement as foreign key field
192
+ const fkField = fkRef.ref[fkRef.ref.length - 1];
193
+ mapping.push({ targetKey, foreignKeyField: fkField });
194
+ }
195
+ }
196
+ }
197
+ }
198
+ if (mapping.length > 0) entry.on = mapping;
199
+ }
200
+ }
201
+ columns.push(entry);
202
+ }
203
+ return { columns, compositionsOfMany };
204
+ }
205
+
206
+ function getObjectIDs(entity, model = cds.context?.model ?? cds.model, overrideEntityAnnotation = null) {
207
+ if (!entity) return [];
208
+ // Use override annotation if provided, otherwise use the entity's own annotation
209
+ const entityAnnotation = overrideEntityAnnotation ?? entity['@changelog'];
210
+ if (!entityAnnotation || entityAnnotation === true) return [];
211
+ const ids = [];
212
+
213
+ for (const { ['=']: field } of entityAnnotation) {
214
+ if (!field) continue;
215
+
216
+ // Validate and normalize the @changelog path
217
+ const normalized = validateChangelogPath(entity, field, model);
218
+ if (!normalized) continue;
219
+
220
+ // Check if the field is directly included or needs to be computed
221
+ const element = entity.elements?.[normalized];
222
+ const included = !!element && !element['@Core.Computed'];
223
+ ids.push({ name: normalized, included });
224
+ }
225
+ return ids;
226
+ }
227
+
228
+ // Join helper for association lookups
229
+ // "a || ', ' || b || ', ' || c"
230
+ function buildConcatXpr(columns) {
231
+ const parts = [];
232
+ for (let i = 0; i < columns.length; i++) {
233
+ const ref = { ref: columns[i].split('.') };
234
+ parts.push(ref);
235
+ if (i < columns.length - 1) {
236
+ parts.push('||');
237
+ parts.push({ val: ', ' });
238
+ parts.push('||');
239
+ }
240
+ }
241
+ return { xpr: parts, as: 'value' };
242
+ }
243
+
244
+ const transformName = (name) => {
245
+ const quoted = cds.env?.sql?.names === 'quoted';
246
+ return quoted ? `"${name}"` : name.replace(/\./g, '_').toUpperCase();
247
+ };
248
+
249
+ function getRootBinding(childEntity, rootEntity) {
250
+ for (const element of childEntity.elements) {
251
+ if ((element.type === 'cds.Composition' || element.type === 'cds.Association') && element.target === rootEntity.name) {
252
+ // managed composition: use foreignKeys (names)
253
+ const fks = extractForeignKeys(element.foreignKeys);
254
+ if (fks.length > 0) {
255
+ return fks.map((fk) => `${element.name}_${fk}`);
256
+ }
257
+ // unmanaged association: extract FK fields from on condition
258
+ if (element.on) {
259
+ const fkFields = extractFKFieldsFromOnCondition(element.on, element.name);
260
+ if (fkFields.length > 0) return fkFields;
261
+ }
262
+ // fallback: if no explicit foreignKeys, CAP names them as <compName>_<rootKey>
263
+ const rootKeys = Object.entries(rootEntity.elements)
264
+ .filter(([, e]) => e.key)
265
+ .map(([k]) => `${element.name}_${k}`);
266
+ if (rootKeys.length > 0) return { compName: element.name, foreignKeys: rootKeys };
267
+ }
268
+ }
269
+ // Check for up_ link (inline compositions)
270
+ const upElement = childEntity.elements?.up_;
271
+ if (upElement?.type === 'cds.Association') {
272
+ const fks = extractForeignKeys(upElement.foreignKeys);
273
+ if (fks.length > 0) return fks.map((fk) => `up__${fk}`);
274
+ }
275
+
276
+ // handle composition of one: root has FK to child
277
+ for (const element of rootEntity.elements) {
278
+ if (element.type === 'cds.Composition' && element.target === childEntity.name && element.is2one) {
279
+ // Get child entity keys to build the reverse lookup
280
+ const childKeys = extractKeys(childEntity.keys);
281
+ if (childKeys.length > 0) {
282
+ return {
283
+ type: 'compositionOfOne',
284
+ compositionName: element.name,
285
+ childKeys: childKeys,
286
+ rootEntityName: rootEntity.name
287
+ };
288
+ }
289
+ }
290
+ }
291
+
292
+ return null;
293
+ }
294
+
295
+ /**
296
+ * Gets the FK binding from a composition target entity back to its parent/root entity.
297
+ * Used for composition of many tracking where we need to find the parent's key from the child.
298
+ * @param {*} targetEntity The composition target entity (e.g., Books)
299
+ * @param {*} rootEntity The root/parent entity (e.g., BookStores)
300
+ * @returns {Array|null} Array of FK field names on target that point to root, or null if not found
301
+ */
302
+ function getCompositionParentBinding(targetEntity, rootEntity) {
303
+ // Look for association/backlink from target to root
304
+ for (const element of targetEntity.elements) {
305
+ if ((element.type === 'cds.Association' || element.type === 'cds.Composition') && element.target === rootEntity.name) {
306
+ // managed association: use foreignKeys
307
+ const fks = extractForeignKeys(element.foreignKeys);
308
+ if (fks.length > 0) {
309
+ return fks.map((fk) => `${element.name}_${fk}`);
310
+ }
311
+ // unmanaged association: extract FK fields from on condition
312
+ if (element.on) {
313
+ const fkFields = extractFKFieldsFromOnCondition(element.on, element.name);
314
+ if (fkFields.length > 0) return fkFields;
315
+ }
316
+ // fallback: if no explicit foreignKeys, CAP names them as <assocName>_<rootKey>
317
+ const rootKeys = extractKeys(rootEntity.keys);
318
+ if (rootKeys.length > 0) {
319
+ return rootKeys.map((k) => `${element.name}_${k}`);
320
+ }
321
+ }
322
+ }
323
+
324
+ // Check for up_ link (inline compositions)
325
+ const upElement = targetEntity.elements?.up_;
326
+ if (upElement?.type === 'cds.Association') {
327
+ const fks = extractForeignKeys(upElement.foreignKeys);
328
+ if (fks.length > 0) return fks.map((fk) => `up__${fk}`);
329
+ }
330
+
331
+ // Handle composition of one: root has FK to child (reverse lookup needed)
332
+ for (const element of rootEntity.elements) {
333
+ if (element.type === 'cds.Composition' && element.target === targetEntity.name && element.is2one) {
334
+ // Get child entity keys for the reverse lookup
335
+ const childKeys = extractKeys(targetEntity.keys);
336
+ if (childKeys.length > 0) {
337
+ return {
338
+ type: 'compositionOfOne',
339
+ compositionName: element.name,
340
+ childKeys: childKeys,
341
+ rootEntityName: rootEntity.name
342
+ };
343
+ }
344
+ }
345
+ }
346
+
347
+ return null;
348
+ }
349
+
350
+ function getLocalizedLookupInfo(targetEntityName, altFields, model = cds.context?.model ?? cds.model) {
351
+ const targetEntity = model.definitions[targetEntityName];
352
+ if (!targetEntity) return null;
353
+
354
+ // Check if target entity has localized data (i.e., a .texts table exists)
355
+ const textsEntityName = `${targetEntityName}.texts`;
356
+ const textsEntity = model.definitions[textsEntityName];
357
+ if (!textsEntity) return null;
358
+
359
+ // Get the key fields of the target entity (needed for the texts table join)
360
+ const targetKeys = extractKeys(targetEntity.keys);
361
+ if (targetKeys.length === 0) return null;
362
+
363
+ // Check which alt fields are localized on the target entity
364
+ // alt fields are in format "assocName.field" - we need to check if "field" is localized
365
+ const localizedFields = [];
366
+ for (const altField of altFields) {
367
+ const fieldName = altField.split('.').slice(1).join('.');
368
+ if (!fieldName) continue;
369
+
370
+ if (textsEntity.elements?.[fieldName]) {
371
+ localizedFields.push(fieldName);
372
+ }
373
+ }
374
+
375
+ if (localizedFields.length === 0) return null;
376
+
377
+ return {
378
+ textsEntity: textsEntityName,
379
+ localizedFields,
380
+ keys: targetKeys
381
+ };
382
+ }
383
+
384
+ module.exports = {
385
+ extractForeignKeys,
386
+ extractKeys,
387
+ extractTrackedColumns,
388
+ getObjectIDs,
389
+ buildConcatXpr,
390
+ transformName,
391
+ getRootBinding,
392
+ getCompositionParentBinding,
393
+ getLocalizedLookupInfo
394
+ };
@@ -0,0 +1,67 @@
1
+ const utils = require('./change-tracking.js');
2
+
3
+ /**
4
+ * Finds composition parent info for an entity.
5
+ * Checks if root entity has a @changelog annotation on a composition field pointing to this entity.
6
+ *
7
+ * Returns null if not found, or an object with:
8
+ * { parentEntityName, compositionFieldName, parentKeyBinding, isCompositionOfOne }
9
+ */
10
+ function getCompositionParentInfo(entity, rootEntity, rootMergedAnnotations) {
11
+ if (!rootEntity) return null;
12
+
13
+ for (const [elemName, elem] of Object.entries(rootEntity.elements)) {
14
+ if (elem.type !== 'cds.Composition' || elem.target !== entity.name) continue;
15
+
16
+ // Check if this composition has @changelog annotation
17
+ const changelogAnnotation = rootMergedAnnotations?.elementAnnotations?.[elemName] ?? elem['@changelog'];
18
+ if (!changelogAnnotation) continue;
19
+
20
+ // Found a tracked composition - get the FK binding from child to parent
21
+ const parentKeyBinding = utils.getCompositionParentBinding(entity, rootEntity);
22
+ if (!parentKeyBinding) continue;
23
+
24
+ // Handle both array bindings (composition of many) and object bindings (composition of one)
25
+ const isCompositionOfOne = parentKeyBinding.type === 'compositionOfOne';
26
+ if (!isCompositionOfOne && parentKeyBinding.length === 0) continue;
27
+
28
+ return {
29
+ parentEntityName: rootEntity.name,
30
+ compositionFieldName: elemName,
31
+ parentKeyBinding,
32
+ isCompositionOfOne
33
+ };
34
+ }
35
+
36
+ return null;
37
+ }
38
+
39
+ /**
40
+ * Gets grandparent composition info for deep linking of changelog entries.
41
+ * Used when linking a composition's changelog entry to its parent's composition changelog entry.
42
+ *
43
+ * Returns null if not applicable, or:
44
+ * { grandParentEntityName, grandParentCompositionFieldName, grandParentKeyBinding }
45
+ */
46
+ function getGrandParentCompositionInfo(rootEntity, grandParentEntity, grandParentMergedAnnotations, grandParentCompositionField) {
47
+ if (!grandParentEntity || !grandParentCompositionField) return null;
48
+
49
+ // Check if the grandparent's composition field has @changelog annotation
50
+ const elem = grandParentEntity.elements?.[grandParentCompositionField];
51
+ if (!elem || elem.type !== 'cds.Composition' || elem.target !== rootEntity.name) return null;
52
+
53
+ const changelogAnnotation = grandParentMergedAnnotations?.elementAnnotations?.[grandParentCompositionField] ?? elem['@changelog'];
54
+ if (!changelogAnnotation) return null;
55
+
56
+ // Get FK binding from rootEntity to grandParentEntity
57
+ const grandParentKeyBinding = utils.getCompositionParentBinding(rootEntity, grandParentEntity);
58
+ if (!grandParentKeyBinding || grandParentKeyBinding.length === 0) return null;
59
+
60
+ return {
61
+ grandParentEntityName: grandParentEntity.name,
62
+ grandParentCompositionFieldName: grandParentCompositionField,
63
+ grandParentKeyBinding
64
+ };
65
+ }
66
+
67
+ module.exports = { getCompositionParentInfo, getGrandParentCompositionInfo };