@cap-js/change-tracking 1.1.3 → 2.0.0-beta.1

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 +36 -1
  2. package/README.md +45 -71
  3. package/_i18n/i18n.properties +12 -20
  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 +17 -260
  41. package/index.cds +199 -75
  42. package/lib/TriggerCQN2SQL.js +42 -0
  43. package/lib/h2/java-codegen.js +843 -0
  44. package/lib/h2/register.js +27 -0
  45. package/lib/h2/triggers.js +42 -0
  46. package/lib/hana/composition.js +250 -0
  47. package/lib/hana/register.js +28 -0
  48. package/lib/hana/sql-expressions.js +224 -0
  49. package/lib/hana/triggers.js +254 -0
  50. package/lib/localization.js +59 -115
  51. package/lib/model-enhancer.js +266 -0
  52. package/lib/postgres/composition.js +191 -0
  53. package/lib/postgres/register.js +44 -0
  54. package/lib/postgres/sql-expressions.js +271 -0
  55. package/lib/postgres/triggers.js +115 -0
  56. package/lib/skipHandlers.js +34 -0
  57. package/lib/sqlite/composition.js +235 -0
  58. package/lib/sqlite/register.js +28 -0
  59. package/lib/sqlite/sql-expressions.js +238 -0
  60. package/lib/sqlite/triggers.js +164 -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 +278 -0
  65. package/lib/utils/trigger-utils.js +94 -0
  66. package/package.json +57 -39
  67. package/lib/change-log.js +0 -575
  68. package/lib/entity-helper.js +0 -216
  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
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,191 @@
1
+ const utils = require('../utils/change-tracking.js');
2
+ const { getModel, toSQL, compositeKeyExpr } = require('./sql-expressions.js');
3
+
4
+ /**
5
+ * Builds rootObjectID select for composition of many
6
+ */
7
+ function buildCompOfManyRootObjectIDSelect(rootEntity, rootObjectIDs, binding, refRow) {
8
+ if (!rootObjectIDs || rootObjectIDs.length === 0) return `'${rootEntity.name}'`;
9
+
10
+ const rootKeys = utils.extractKeys(rootEntity.keys);
11
+ if (rootKeys.length !== binding.length) return `'${rootEntity.name}'`;
12
+
13
+ const where = {};
14
+ for (let i = 0; i < rootKeys.length; i++) {
15
+ where[rootKeys[i]] = { val: `${refRow}.${binding[i]}` };
16
+ }
17
+
18
+ const parts = [];
19
+ for (const oid of rootObjectIDs) {
20
+ const query = SELECT.one.from(rootEntity.name).columns(oid.name).where(where);
21
+ parts.push(`COALESCE((${toSQL(query)})::TEXT, '')`);
22
+ }
23
+
24
+ const concatLogic = `CONCAT_WS(', ', ${parts.join(', ')})`;
25
+ const rootEntityKeyExpr = compositeKeyExpr(binding.map((k) => `${refRow}.${k}`));
26
+
27
+ return `COALESCE(NULLIF(${concatLogic}, ''), ${rootEntityKeyExpr})`;
28
+ }
29
+
30
+ function buildCompositionOfOneParentBlock(compositionParentInfo, rootObjectIDs) {
31
+ const model = getModel();
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, grandParentCompositionInfo = null) {
88
+ const model = getModel();
89
+ const { parentEntityName, compositionFieldName, parentKeyBinding } = compositionParentInfo;
90
+
91
+ // Handle composition of one (parent has FK to child - need reverse lookup)
92
+ if (parentKeyBinding.type === 'compositionOfOne') {
93
+ return buildCompositionOfOneParentBlock(compositionParentInfo, rootObjectIDs);
94
+ }
95
+
96
+ const parentKeyExpr = compositeKeyExpr(parentKeyBinding.map((k) => `rec.${k}`));
97
+
98
+ // Build rootObjectID expression for the parent entity
99
+ const rootEntity = model.definitions[parentEntityName];
100
+ const rootObjectIDExpr = buildCompOfManyRootObjectIDSelect(rootEntity, rootObjectIDs, parentKeyBinding, 'rec');
101
+
102
+ let grandparentBlock = '';
103
+ let grandparentLookupExpr = 'NULL';
104
+
105
+ if (grandParentCompositionInfo) {
106
+ // When we have grandparent info, we need to:
107
+ // 1. Create grandparent entry (Order.orderItems) for current transaction if not exists
108
+ // 2. Create parent entry (OrderItem.notes) linking to the grandparent entry
109
+ const { grandParentEntityName, grandParentCompositionFieldName, grandParentKeyBinding } = grandParentCompositionInfo;
110
+
111
+ // Build WHERE clause to find the parent entity record
112
+ const parentEntity = model.definitions[parentEntityName];
113
+ const parentKeys = utils.extractKeys(parentEntity.keys);
114
+ const parentWhere = parentKeys.map((pk, i) => `${pk} = rec.${parentKeyBinding[i]}`).join(' AND ');
115
+
116
+ // Build the grandparent key expression from the parent record
117
+ const grandParentKeyExpr = compositeKeyExpr(grandParentKeyBinding.map((k) => `(SELECT ${k} FROM ${utils.transformName(parentEntityName)} WHERE ${parentWhere})`));
118
+
119
+ // Create grandparent entry if not exists in this transaction
120
+ grandparentBlock = `-- First ensure grandparent entry exists for this transaction
121
+ SELECT id INTO comp_grandparent_id FROM sap_changelog_changes WHERE entity = '${grandParentEntityName}'
122
+ AND entitykey = ${grandParentKeyExpr}
123
+ AND attribute = '${grandParentCompositionFieldName}'
124
+ AND valuedatatype = 'cds.Composition'
125
+ AND transactionid = transaction_id;
126
+ IF comp_grandparent_id IS NULL THEN
127
+ comp_grandparent_id := gen_random_uuid();
128
+ INSERT INTO sap_changelog_changes
129
+ (ID, PARENT_ID, ATTRIBUTE, ENTITY, ENTITYKEY, OBJECTID, CREATEDAT, CREATEDBY, VALUEDATATYPE, MODIFICATION, TRANSACTIONID)
130
+ VALUES (
131
+ comp_grandparent_id,
132
+ NULL,
133
+ '${grandParentCompositionFieldName}',
134
+ '${grandParentEntityName}',
135
+ ${grandParentKeyExpr},
136
+ ${grandParentKeyExpr},
137
+ now(),
138
+ user_id,
139
+ 'cds.Composition',
140
+ 'update',
141
+ transaction_id
142
+ );
143
+ END IF;`;
144
+
145
+ grandparentLookupExpr = 'comp_grandparent_id';
146
+ }
147
+
148
+ // Determine modification dynamically: 'create' if parent was just created, 'update' otherwise
149
+ // This handles both deep insert (parent created in same tx) and independent insert (parent already existed)
150
+ const modificationExpr = grandParentCompositionInfo
151
+ ? `'${modification}'` // When grandparent exists, use provided modification
152
+ : `CASE WHEN EXISTS (
153
+ SELECT 1 FROM sap_changelog_changes
154
+ WHERE entity = '${parentEntityName}'
155
+ AND entitykey = ${parentKeyExpr}
156
+ AND modification = 'create'
157
+ AND transactionid = transaction_id
158
+ ) THEN 'create' ELSE 'update' END`;
159
+
160
+ // PL/pgSQL block that checks for existing parent entry and creates one if needed
161
+ return `${grandparentBlock}
162
+ SELECT id INTO comp_parent_id FROM sap_changelog_changes WHERE entity = '${parentEntityName}'
163
+ AND entitykey = ${parentKeyExpr}
164
+ AND attribute = '${compositionFieldName}'
165
+ AND valuedatatype = 'cds.Composition'
166
+ AND transactionid = transaction_id;
167
+ IF comp_parent_id IS NULL THEN
168
+ comp_parent_id := gen_random_uuid();
169
+ INSERT INTO sap_changelog_changes
170
+ (ID, PARENT_ID, ATTRIBUTE, ENTITY, ENTITYKEY, OBJECTID, CREATEDAT, CREATEDBY, VALUEDATATYPE, MODIFICATION, TRANSACTIONID)
171
+ VALUES (
172
+ comp_parent_id,
173
+ ${grandparentLookupExpr},
174
+ '${compositionFieldName}',
175
+ '${parentEntityName}',
176
+ ${parentKeyExpr},
177
+ ${rootObjectIDExpr},
178
+ now(),
179
+ user_id,
180
+ 'cds.Composition',
181
+ ${modificationExpr},
182
+ transaction_id
183
+ );
184
+ END IF;`;
185
+ }
186
+
187
+ module.exports = {
188
+ buildCompOfManyRootObjectIDSelect,
189
+ buildCompositionOfOneParentBlock,
190
+ buildCompositionParentBlock
191
+ };
@@ -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 };