@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,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 };
|