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