@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,266 @@
1
+ const cds = require('@sap/cds');
2
+ const LOG = cds.log('change-tracking');
3
+ const DEBUG = cds.debug('change-tracking');
4
+
5
+ const { isChangeTracked, getBaseEntity, analyzeCompositions, getService } = require('./utils/entity-collector.js');
6
+
7
+ /**
8
+ * Add side effects annotations for actions to refresh the changes association.
9
+ */
10
+ function addSideEffects(actions, entityName, hierarchyMap, model) {
11
+ const isRootEntity = !hierarchyMap.has(entityName);
12
+
13
+ // If not a root entity, find the parent association name
14
+ let parentAssociationName = null;
15
+ if (!isRootEntity) {
16
+ const parentEntityName = hierarchyMap.get(entityName);
17
+ const parentEntity = model.definitions[parentEntityName];
18
+ if (parentEntity?.elements) {
19
+ // Find the composition element in the parent that points to this entity
20
+ for (const [elemName, elem] of Object.entries(parentEntity.elements)) {
21
+ if (elem.type === 'cds.Composition' && elem.target === entityName) {
22
+ parentAssociationName = elemName;
23
+ break;
24
+ }
25
+ }
26
+ }
27
+ }
28
+
29
+ for (const se of Object.values(actions)) {
30
+ const target = isRootEntity ? 'TargetProperties' : 'TargetEntities';
31
+ const sideEffectAttr = se[`@Common.SideEffects.${target}`];
32
+ const property = isRootEntity ? 'changes' : { '=': `${parentAssociationName}.changes` };
33
+ if (sideEffectAttr?.length >= 0) {
34
+ sideEffectAttr.findIndex((item) => (item['='] ? item['='] : item) === (property['='] ? property['='] : property)) === -1 && sideEffectAttr.push(property);
35
+ } else {
36
+ se[`@Common.SideEffects.${target}`] = [property];
37
+ }
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Returns a CQN expression for the composite key of an entity.
43
+ * Used for the ON condition when associating changes.
44
+ */
45
+ function entityKey4(entity) {
46
+ const keys = [];
47
+ for (const k in entity.elements) {
48
+ const e = entity.elements[k];
49
+ if (!e.key) continue;
50
+ if (e.type === 'cds.Association') {
51
+ keys.push({ ref: [k, e.keys?.[0]?.ref?.[0]] });
52
+ } else {
53
+ keys.push({ ref: [k] });
54
+ }
55
+ }
56
+ if (keys.length <= 1) return keys;
57
+
58
+ const xpr = [];
59
+ for (let i = 0; i < keys.length; i++) {
60
+ if (i > 0) {
61
+ xpr.push('||');
62
+ xpr.push({ val: ';' });
63
+ xpr.push('||');
64
+ }
65
+ const keyAsStr = { ...keys[i], cast: { type: 'cds.String' } };
66
+ xpr.push({ func: 'LENGTH', args: [keyAsStr] });
67
+ xpr.push('||');
68
+ xpr.push({ val: ',' });
69
+ xpr.push('||');
70
+ xpr.push(keyAsStr);
71
+ }
72
+ return xpr;
73
+ }
74
+
75
+ /**
76
+ * Replace ENTITY placeholders in ON conditions.
77
+ */
78
+ function _replaceTablePlaceholders(on, tableName) {
79
+ return on.map((part) => {
80
+ if (part?.val === 'ENTITY') return { ...part, val: tableName };
81
+ return part;
82
+ });
83
+ }
84
+
85
+ /**
86
+ * Check if a facet already exists for the changes composition.
87
+ */
88
+ function hasFacetForComp(comp, facets) {
89
+ return facets.some((f) => f.Target === `${comp.name}/@UI.LineItem` || (f.Facets && hasFacetForComp(comp, f.Facets)));
90
+ }
91
+
92
+ /**
93
+ * Enhance the CDS model with change tracking associations, facets, and side effects.
94
+ * Returns the updated hierarchyMap and collectedEntities for use by trigger generation.
95
+ */
96
+ function enhanceModel(m) {
97
+ if (m.meta?.flavor !== 'inferred') {
98
+ // In MTX scenarios with extensibility the runtime model for deployed apps is not
99
+ // inferred but xtended and the logic requires inferred.
100
+ DEBUG?.(`Skipping model enhancement because model flavour is '${m.meta?.flavor}' and not 'inferred'`);
101
+ return;
102
+ }
103
+ const _enhanced = 'sap.changelog.enhanced';
104
+ if (m.meta?.[_enhanced]) return; // already enhanced
105
+
106
+ // Get definitions from Dummy entity in our models
107
+ const { 'sap.changelog.aspect': aspect } = m.definitions;
108
+ if (!aspect) return; // some other model
109
+ const {
110
+ '@UI.Facets': [facet],
111
+ elements: { changes }
112
+ } = aspect;
113
+
114
+ const hierarchyMap = analyzeCompositions(m);
115
+ const collectedEntities = new Map();
116
+
117
+ const replaceReferences = (xpr, depth) => {
118
+ const parents = [];
119
+ for (let i = 0; i < depth; i++) parents.push('parent');
120
+ for (const ele of xpr) {
121
+ if (ele.ref && ele.ref[0] === 'changes') {
122
+ const lastEle = ele.ref.pop();
123
+ ele.ref.push(parents.join('_') + '_' + lastEle);
124
+ }
125
+ }
126
+ return xpr;
127
+ };
128
+ if (cds.env.requires['change-tracking'].maxDisplayHierarchyDepth > 1) {
129
+ const depth = cds.env.requires['change-tracking'].maxDisplayHierarchyDepth;
130
+ const parents = [];
131
+ for (let i = 1; i < depth; i++) {
132
+ parents.push('parent');
133
+ m.definitions['sap.changelog.ChangeView'].query.SELECT.columns.push(
134
+ {
135
+ ref: ['change', ...parents, 'entityKey'],
136
+ as: parents.join('_') + '_' + 'entityKey'
137
+ },
138
+ {
139
+ ref: ['change', ...parents, 'entity'],
140
+ as: parents.join('_') + '_' + 'entity'
141
+ }
142
+ );
143
+ m.definitions['sap.changelog.ChangeView'].elements[parents.join('_') + '_' + 'entityKey'] = structuredClone(m.definitions['sap.changelog.ChangeView'].elements.entityKey);
144
+ m.definitions['sap.changelog.ChangeView'].elements[parents.join('_') + '_' + 'entity'] = structuredClone(m.definitions['sap.changelog.ChangeView'].elements.entity);
145
+ }
146
+ }
147
+ for (let name in m.definitions) {
148
+ const entity = m.definitions[name];
149
+ const isServiceEntity = entity.kind === 'entity' && !!(entity.query || entity.projection);
150
+ const serviceName = getService(name, m);
151
+ if (isServiceEntity && isChangeTracked(entity) && serviceName) {
152
+ // Collect change-tracked service entity name with its underlying DB entity name
153
+ const baseInfo = getBaseEntity(entity, m);
154
+ if (!baseInfo) continue;
155
+ const { baseRef: dbEntityName } = baseInfo;
156
+
157
+ if (!collectedEntities.has(dbEntityName)) collectedEntities.set(dbEntityName, []);
158
+ collectedEntities.get(dbEntityName).push(name);
159
+
160
+ // Skip adding association to ChangeView if the entity has only association keys, as it cannot be independently identified and is likely an inline composition target
161
+ const entityKeys = Object.values(entity.elements).filter((e) => e.key);
162
+ const hasOnlyAssociationKeys = entityKeys.length > 0 && entityKeys.every((e) => e.type === 'cds.Association');
163
+ if (hasOnlyAssociationKeys) {
164
+ DEBUG?.(`Skipping changes association for ${name} - inline composition target with no independent key`);
165
+ } else if (!entity['@changelog.disable_assoc']) {
166
+ // Add association to ChangeView
167
+ const keys = entityKey4(entity);
168
+ if (!keys.length) continue; // skip if no key attribute is defined
169
+
170
+ const onCondition = changes.on.flatMap((p) => (p?.ref && p.ref[0] === 'ID' ? keys : [p]));
171
+ const tableName = (entity.projection ?? entity.query?.SELECT)?.from?.ref[0];
172
+ const onTemplate = _replaceTablePlaceholders(onCondition, tableName);
173
+ const on = cds.env.requires['change-tracking'].maxDisplayHierarchyDepth > 1 ? [{ xpr: structuredClone(onTemplate) }] : onTemplate;
174
+ for (let i = 1; i < cds.env.requires['change-tracking'].maxDisplayHierarchyDepth; i++) {
175
+ on.push('or', { xpr: replaceReferences(structuredClone(onTemplate), i) });
176
+ }
177
+ const assoc = new cds.builtin.classes.Association({ ...changes, on });
178
+ assoc.target = `${serviceName}.ChangeView`;
179
+ if (!m.definitions[`${serviceName}.ChangeView`]) {
180
+ m.definitions[`${serviceName}.ChangeView`] = structuredClone(m.definitions['sap.changelog.ChangeView']);
181
+ m.definitions[`${serviceName}.ChangeView`].query = {
182
+ SELECT: {
183
+ from: {
184
+ ref: ['sap.changelog.ChangeView']
185
+ },
186
+ columns: ['*']
187
+ }
188
+ };
189
+
190
+ for (const ele in m.definitions[`${serviceName}.ChangeView`].elements) {
191
+ if (m.definitions[`${serviceName}.ChangeView`].elements[ele]?.target && !m.definitions[`${serviceName}.ChangeView`].elements[ele]?.target.startsWith(serviceName)) {
192
+ const target = m.definitions[`${serviceName}.ChangeView`].elements[ele]?.target;
193
+ const serviceEntity = Object.keys(m.definitions)
194
+ .filter((e) => e.startsWith(serviceName))
195
+ .find((e) => {
196
+ let baseE = e;
197
+ while (baseE) {
198
+ if (baseE === target) {
199
+ return true;
200
+ }
201
+ const artefact = m.definitions[baseE];
202
+ const cqn = artefact.projection ?? artefact.query?.SELECT;
203
+ if (!cqn) {
204
+ return false;
205
+ }
206
+ // from.args is the case for joins //REVISIT: only works in case it is one join, multiple joins and the ref is nested in further args
207
+ baseE = cqn.from?.ref?.[0] ?? cqn.from?.args?.[0]?.ref?.[0];
208
+ }
209
+ return false;
210
+ });
211
+ if (serviceEntity) {
212
+ m.definitions[`${serviceName}.ChangeView`].elements[ele].target = serviceEntity;
213
+ }
214
+ }
215
+ }
216
+ }
217
+
218
+ DEBUG?.(
219
+ `\n
220
+ extend ${name} with {
221
+ changes : Association to many ${assoc.target} on ${assoc.on.map((x) => x.ref?.join('.') || x.val || x).join(' ')};
222
+ }
223
+ `.replace(/ {8}/g, '')
224
+ );
225
+
226
+ const query = entity.projection || entity.query?.SELECT;
227
+ if (query) {
228
+ (query.columns ??= ['*']).push({ as: 'changes', cast: assoc });
229
+ entity.elements.changes = assoc;
230
+ }
231
+
232
+ if (entity['@changelog.disable_facet'] !== undefined) {
233
+ LOG.warn(
234
+ `@changelog.disable_facet is deprecated! You can just define your own Facet for the changes association or annotate the changes association on ${entity.name} with not readable via @Capabilities.NavigationRestrictions.RestrictedProperties`
235
+ );
236
+ }
237
+
238
+ let facets = entity['@UI.Facets'];
239
+
240
+ if (!facets) {
241
+ DEBUG?.(`${entity.name} does not have a @UI.Facets annotation and thus the change tracking section is not added.`);
242
+ }
243
+ // Add UI.Facet for Change History List
244
+ if (
245
+ facets &&
246
+ !entity['@changelog.disable_facet'] &&
247
+ !hasFacetForComp(changes, entity['@UI.Facets']) &&
248
+ !entity['@Capabilities.NavigationRestrictions.RestrictedProperties']?.some((restriction) => restriction.NavigationProperty?.['='] === 'changes' && restriction.ReadRestrictions?.Readable === false)
249
+ ) {
250
+ facets.push(facet);
251
+ }
252
+ }
253
+
254
+ if (entity.actions) {
255
+ const baseInfo = getBaseEntity(entity, m);
256
+ if (baseInfo) {
257
+ const { baseRef: dbEntityName } = baseInfo;
258
+ addSideEffects(entity.actions, dbEntityName, hierarchyMap, m);
259
+ }
260
+ }
261
+ }
262
+ }
263
+ (m.meta ??= {})[_enhanced] = true;
264
+ }
265
+
266
+ module.exports = { enhanceModel };
@@ -0,0 +1,190 @@
1
+ const utils = require('../utils/change-tracking.js');
2
+ const { toSQL, compositeKeyExpr } = require('./sql-expressions.js');
3
+
4
+ /**
5
+ * Builds rootObjectID select for composition of many
6
+ */
7
+ function buildCompOfManyRootObjectIDSelect(rootEntity, rootObjectIDs, binding, refRow, model) {
8
+ const rootEntityKeyExpr = compositeKeyExpr(binding.map((k) => `${refRow}.${k}`));
9
+
10
+ if (!rootObjectIDs || rootObjectIDs.length === 0) return rootEntityKeyExpr;
11
+
12
+ const rootKeys = utils.extractKeys(rootEntity.keys);
13
+ if (rootKeys.length !== binding.length) return rootEntityKeyExpr;
14
+
15
+ const where = {};
16
+ for (let i = 0; i < rootKeys.length; i++) {
17
+ where[rootKeys[i]] = { val: `${refRow}.${binding[i]}` };
18
+ }
19
+
20
+ const parts = [];
21
+ for (const oid of rootObjectIDs) {
22
+ const query = SELECT.one.from(rootEntity.name).columns(oid.name).where(where);
23
+ parts.push(`COALESCE((${toSQL(query, model)})::TEXT, '')`);
24
+ }
25
+
26
+ const concatLogic = `CONCAT_WS(', ', ${parts.join(', ')})`;
27
+
28
+ return `COALESCE(NULLIF(${concatLogic}, ''), ${rootEntityKeyExpr})`;
29
+ }
30
+
31
+ function buildCompositionOfOneParentBlock(compositionParentInfo, rootObjectIDs, model) {
32
+ const { parentEntityName, compositionFieldName, parentKeyBinding } = compositionParentInfo;
33
+ const { compositionName, childKeys } = parentKeyBinding;
34
+
35
+ const parentFKFields = childKeys.map((k) => `${compositionName}_${k}`);
36
+ const parentEntity = model.definitions[parentEntityName];
37
+ const parentKeys = utils.extractKeys(parentEntity.keys);
38
+ const parentWhereClause = parentFKFields.map((fk, i) => `${fk} = rec.${childKeys[i]}`).join(' AND ');
39
+
40
+ // Build the parent key expression via subquery (reverse lookup)
41
+ const parentKeyExpr = compositeKeyExpr(parentKeys.map((pk) => `(SELECT ${pk} FROM ${utils.transformName(parentEntityName)} WHERE ${parentWhereClause})`));
42
+
43
+ // Build rootObjectID expression for the parent entity
44
+ let rootObjectIDExpr;
45
+ if (rootObjectIDs?.length > 0) {
46
+ const oidSelects = rootObjectIDs.map((oid) => `(SELECT ${oid.name}::TEXT FROM ${utils.transformName(parentEntityName)} WHERE ${parentWhereClause})`);
47
+ rootObjectIDExpr = oidSelects.length > 1 ? `CONCAT_WS(', ', ${oidSelects.join(', ')})` : oidSelects[0];
48
+ } else {
49
+ rootObjectIDExpr = parentKeyExpr;
50
+ }
51
+
52
+ // Build the composition parent block with dynamic modification determination
53
+ return `SELECT CASE WHEN COUNT(*) > 0 THEN 'create' ELSE 'update' END INTO comp_parent_modification
54
+ FROM sap_changelog_changes
55
+ WHERE entity = '${parentEntityName}'
56
+ AND entitykey = ${parentKeyExpr}
57
+ AND modification = 'create'
58
+ AND transactionid = transaction_id;
59
+
60
+ IF EXISTS (SELECT 1 FROM ${utils.transformName(parentEntityName)} WHERE ${parentWhereClause}) THEN
61
+ SELECT id INTO comp_parent_id FROM sap_changelog_changes WHERE entity = '${parentEntityName}'
62
+ AND entitykey = ${parentKeyExpr}
63
+ AND attribute = '${compositionFieldName}'
64
+ AND valuedatatype = 'cds.Composition'
65
+ AND transactionid = transaction_id;
66
+ IF comp_parent_id IS NULL THEN
67
+ comp_parent_id := gen_random_uuid();
68
+ INSERT INTO sap_changelog_changes
69
+ (ID, PARENT_ID, ATTRIBUTE, ENTITY, ENTITYKEY, OBJECTID, CREATEDAT, CREATEDBY, VALUEDATATYPE, MODIFICATION, TRANSACTIONID)
70
+ VALUES (
71
+ comp_parent_id,
72
+ NULL,
73
+ '${compositionFieldName}',
74
+ '${parentEntityName}',
75
+ ${parentKeyExpr},
76
+ ${rootObjectIDExpr},
77
+ now(),
78
+ user_id,
79
+ 'cds.Composition',
80
+ comp_parent_modification,
81
+ transaction_id
82
+ );
83
+ END IF;
84
+ END IF;`;
85
+ }
86
+
87
+ function buildCompositionParentBlock(compositionParentInfo, rootObjectIDs, modification, model, grandParentCompositionInfo = null) {
88
+ const { parentEntityName, compositionFieldName, parentKeyBinding } = compositionParentInfo;
89
+
90
+ // Handle composition of one (parent has FK to child - need reverse lookup)
91
+ if (parentKeyBinding.type === 'compositionOfOne') {
92
+ return buildCompositionOfOneParentBlock(compositionParentInfo, rootObjectIDs, model);
93
+ }
94
+
95
+ const parentKeyExpr = compositeKeyExpr(parentKeyBinding.map((k) => `rec.${k}`));
96
+
97
+ // Build rootObjectID expression for the parent entity
98
+ const rootEntity = model.definitions[parentEntityName];
99
+ const rootObjectIDExpr = buildCompOfManyRootObjectIDSelect(rootEntity, rootObjectIDs, parentKeyBinding, 'rec', model);
100
+
101
+ let grandparentBlock = '';
102
+ let grandparentLookupExpr = 'NULL';
103
+
104
+ if (grandParentCompositionInfo) {
105
+ // When we have grandparent info, we need to:
106
+ // 1. Create grandparent entry (Order.orderItems) for current transaction if not exists
107
+ // 2. Create parent entry (OrderItem.notes) linking to the grandparent entry
108
+ const { grandParentEntityName, grandParentCompositionFieldName, grandParentKeyBinding } = grandParentCompositionInfo;
109
+
110
+ // Build WHERE clause to find the parent entity record
111
+ const parentEntity = model.definitions[parentEntityName];
112
+ const parentKeys = utils.extractKeys(parentEntity.keys);
113
+ const parentWhere = parentKeys.map((pk, i) => `${pk} = rec.${parentKeyBinding[i]}`).join(' AND ');
114
+
115
+ // Build the grandparent key expression from the parent record
116
+ const grandParentKeyExpr = compositeKeyExpr(grandParentKeyBinding.map((k) => `(SELECT ${k} FROM ${utils.transformName(parentEntityName)} WHERE ${parentWhere})`));
117
+
118
+ // Create grandparent entry if not exists in this transaction
119
+ grandparentBlock = `-- First ensure grandparent entry exists for this transaction
120
+ SELECT id INTO comp_grandparent_id FROM sap_changelog_changes WHERE entity = '${grandParentEntityName}'
121
+ AND entitykey = ${grandParentKeyExpr}
122
+ AND attribute = '${grandParentCompositionFieldName}'
123
+ AND valuedatatype = 'cds.Composition'
124
+ AND transactionid = transaction_id;
125
+ IF comp_grandparent_id IS NULL THEN
126
+ comp_grandparent_id := gen_random_uuid();
127
+ INSERT INTO sap_changelog_changes
128
+ (ID, PARENT_ID, ATTRIBUTE, ENTITY, ENTITYKEY, OBJECTID, CREATEDAT, CREATEDBY, VALUEDATATYPE, MODIFICATION, TRANSACTIONID)
129
+ VALUES (
130
+ comp_grandparent_id,
131
+ NULL,
132
+ '${grandParentCompositionFieldName}',
133
+ '${grandParentEntityName}',
134
+ ${grandParentKeyExpr},
135
+ ${grandParentKeyExpr},
136
+ now(),
137
+ user_id,
138
+ 'cds.Composition',
139
+ 'update',
140
+ transaction_id
141
+ );
142
+ END IF;`;
143
+
144
+ grandparentLookupExpr = 'comp_grandparent_id';
145
+ }
146
+
147
+ // Determine modification dynamically: 'create' if parent was just created, 'update' otherwise
148
+ // This handles both deep insert (parent created in same tx) and independent insert (parent already existed)
149
+ const modificationExpr = grandParentCompositionInfo
150
+ ? `'${modification}'` // When grandparent exists, use provided modification
151
+ : `CASE WHEN EXISTS (
152
+ SELECT 1 FROM sap_changelog_changes
153
+ WHERE entity = '${parentEntityName}'
154
+ AND entitykey = ${parentKeyExpr}
155
+ AND modification = 'create'
156
+ AND transactionid = transaction_id
157
+ ) THEN 'create' ELSE 'update' END`;
158
+
159
+ // PL/pgSQL block that checks for existing parent entry and creates one if needed
160
+ return `${grandparentBlock}
161
+ SELECT id INTO comp_parent_id FROM sap_changelog_changes WHERE entity = '${parentEntityName}'
162
+ AND entitykey = ${parentKeyExpr}
163
+ AND attribute = '${compositionFieldName}'
164
+ AND valuedatatype = 'cds.Composition'
165
+ AND transactionid = transaction_id;
166
+ IF comp_parent_id IS NULL THEN
167
+ comp_parent_id := gen_random_uuid();
168
+ INSERT INTO sap_changelog_changes
169
+ (ID, PARENT_ID, ATTRIBUTE, ENTITY, ENTITYKEY, OBJECTID, CREATEDAT, CREATEDBY, VALUEDATATYPE, MODIFICATION, TRANSACTIONID)
170
+ VALUES (
171
+ comp_parent_id,
172
+ ${grandparentLookupExpr},
173
+ '${compositionFieldName}',
174
+ '${parentEntityName}',
175
+ ${parentKeyExpr},
176
+ ${rootObjectIDExpr},
177
+ now(),
178
+ user_id,
179
+ 'cds.Composition',
180
+ ${modificationExpr},
181
+ transaction_id
182
+ );
183
+ END IF;`;
184
+ }
185
+
186
+ module.exports = {
187
+ buildCompOfManyRootObjectIDSelect,
188
+ buildCompositionOfOneParentBlock,
189
+ buildCompositionParentBlock
190
+ };
@@ -0,0 +1,44 @@
1
+ const cds = require('@sap/cds');
2
+
3
+ const { getEntitiesForTriggerGeneration, collectEntities } = require('../utils/entity-collector.js');
4
+ const { getLabelTranslations } = require('../localization.js');
5
+ const { prepareCSNForTriggers, generateTriggersForEntities } = require('../utils/trigger-utils.js');
6
+
7
+ function registerPostgresCompilerHook() {
8
+ cds.on('compile.to.dbx', (csn, options, next) => {
9
+ const ddl = next();
10
+ if (options?.dialect !== 'postgres') return ddl;
11
+
12
+ const { generatePostgresTriggers } = require('./triggers.js');
13
+ const { runtimeCSN, hierarchy, entities } = prepareCSNForTriggers(csn);
14
+
15
+ const triggers = generateTriggersForEntities(runtimeCSN, hierarchy, entities, generatePostgresTriggers);
16
+
17
+ if (triggers.length === 0) return ddl;
18
+
19
+ // Handle standard compilation (array) or delta compilation (object with createsAndAlters/drops)
20
+ if (Array.isArray(ddl)) {
21
+ return [...ddl, ...triggers];
22
+ } else if (ddl.createsAndAlters) {
23
+ ddl.createsAndAlters.push(...triggers);
24
+ return ddl;
25
+ }
26
+
27
+ return ddl;
28
+ });
29
+ }
30
+
31
+ async function deployPostgresLabels() {
32
+ const db = cds.env.requires?.db;
33
+ if (db?.kind !== 'postgres') return;
34
+
35
+ const model = cds.context?.model ?? cds.model;
36
+ const { collectedEntities } = collectEntities(model);
37
+ const entities = getEntitiesForTriggerGeneration(model.definitions, collectedEntities);
38
+ const labels = getLabelTranslations(entities, model);
39
+ const { i18nKeys } = cds.entities('sap.changelog');
40
+
41
+ await Promise.all([cds.delete(i18nKeys), cds.insert(labels).into(i18nKeys)]);
42
+ }
43
+
44
+ module.exports = { registerPostgresCompilerHook, deployPostgresLabels };