@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,27 @@
|
|
|
1
|
+
const cds = require('@sap/cds');
|
|
2
|
+
|
|
3
|
+
const { prepareCSNForTriggers, generateTriggersForEntities, writeLabelsCSV } = require('../utils/trigger-utils.js');
|
|
4
|
+
|
|
5
|
+
function registerH2CompilerHook() {
|
|
6
|
+
const _sql_original = cds.compile.to.sql;
|
|
7
|
+
cds.compile.to.sql = function (csn, options) {
|
|
8
|
+
let ret = _sql_original.call(this, csn, options);
|
|
9
|
+
const kind = options?.kind ?? options?.to;
|
|
10
|
+
if (kind !== 'h2') return ret;
|
|
11
|
+
|
|
12
|
+
const { runtimeCSN, hierarchy, entities } = prepareCSNForTriggers(csn, true);
|
|
13
|
+
const { generateH2Triggers } = require('./triggers.js');
|
|
14
|
+
const triggers = generateTriggersForEntities(runtimeCSN, hierarchy, entities, generateH2Triggers);
|
|
15
|
+
|
|
16
|
+
if (triggers.length > 0) {
|
|
17
|
+
writeLabelsCSV(entities, runtimeCSN);
|
|
18
|
+
}
|
|
19
|
+
// Add semicolon at the end of each DDL statement if not already present
|
|
20
|
+
ret = ret.map((s) => (s.endsWith(';') ? s : s + ';'));
|
|
21
|
+
|
|
22
|
+
return ret.concat(triggers);
|
|
23
|
+
};
|
|
24
|
+
Object.assign(cds.compile.to.sql, _sql_original);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
module.exports = { registerH2CompilerHook };
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
const utils = require('../utils/change-tracking.js');
|
|
2
|
+
const config = require('@sap/cds').env.requires['change-tracking'];
|
|
3
|
+
const { getCompositionParentInfo, getGrandParentCompositionInfo } = require('../utils/composition-helpers.js');
|
|
4
|
+
const { _generateJavaMethod, _generateCreateBody, _generateUpdateBody, _generateDeleteBody, _generateDeleteBodyPreserve } = require('./java-codegen.js');
|
|
5
|
+
|
|
6
|
+
function generateH2Trigger(csn, entity, rootEntity, mergedAnnotations = null, rootMergedAnnotations = null, grandParentContext = {}) {
|
|
7
|
+
const { columns: trackedColumns } = utils.extractTrackedColumns(entity, csn, mergedAnnotations);
|
|
8
|
+
const objectIDs = utils.getObjectIDs(entity, csn, mergedAnnotations?.entityAnnotation);
|
|
9
|
+
const rootObjectIDs = utils.getObjectIDs(rootEntity, csn, rootMergedAnnotations?.entityAnnotation);
|
|
10
|
+
|
|
11
|
+
// Check if this entity is a tracked composition target (composition-of-many)
|
|
12
|
+
const compositionParentInfo = getCompositionParentInfo(entity, rootEntity, rootMergedAnnotations);
|
|
13
|
+
|
|
14
|
+
// Get grandparent info for deep linking (e.g., OrderItemNote -> OrderItem.notes -> Order.orderItems)
|
|
15
|
+
const { grandParentEntity, grandParentMergedAnnotations, grandParentCompositionField } = grandParentContext;
|
|
16
|
+
const grandParentCompositionInfo = getGrandParentCompositionInfo(rootEntity, grandParentEntity, grandParentMergedAnnotations, grandParentCompositionField);
|
|
17
|
+
|
|
18
|
+
// Generate triggers if we have tracked columns OR if this is a composition target
|
|
19
|
+
const shouldGenerateTriggers = trackedColumns.length > 0 || compositionParentInfo;
|
|
20
|
+
if (!shouldGenerateTriggers) return null;
|
|
21
|
+
|
|
22
|
+
// Generate the Java code for each section
|
|
23
|
+
const createBody = !config?.disableCreateTracking ? _generateCreateBody(entity, trackedColumns, objectIDs, rootEntity, rootObjectIDs, csn, compositionParentInfo, grandParentCompositionInfo) : '';
|
|
24
|
+
const updateBody = !config?.disableUpdateTracking ? _generateUpdateBody(entity, trackedColumns, objectIDs, rootEntity, rootObjectIDs, csn, compositionParentInfo, grandParentCompositionInfo) : '';
|
|
25
|
+
let deleteBody = '';
|
|
26
|
+
if (!config?.disableDeleteTracking) {
|
|
27
|
+
deleteBody = config?.preserveDeletes
|
|
28
|
+
? _generateDeleteBodyPreserve(entity, trackedColumns, objectIDs, rootEntity, rootObjectIDs, csn, compositionParentInfo, grandParentCompositionInfo)
|
|
29
|
+
: _generateDeleteBody(entity, trackedColumns, objectIDs, rootEntity, rootObjectIDs, csn, compositionParentInfo, grandParentCompositionInfo);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Define the full Create Trigger SQL
|
|
33
|
+
return `CREATE TRIGGER ${utils.transformName(entity.name)}_ct
|
|
34
|
+
AFTER INSERT, UPDATE, DELETE ON ${utils.transformName(entity.name)}
|
|
35
|
+
FOR EACH ROW
|
|
36
|
+
AS $$
|
|
37
|
+
${_generateJavaMethod(createBody, updateBody, deleteBody, entity.name, compositionParentInfo, grandParentCompositionInfo)}
|
|
38
|
+
$$;;`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
module.exports = { generateH2Trigger };
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
const utils = require('../utils/change-tracking.js');
|
|
2
|
+
const { toSQL, compositeKeyExpr, buildGrandParentObjectIDExpr } = 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(TO_NVARCHAR((${toSQL(query, model)})), '')`);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const concatLogic = parts.join(" || ', ' || ");
|
|
27
|
+
|
|
28
|
+
return `COALESCE(NULLIF(${concatLogic}, ''), ${rootEntityKeyExpr})`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Builds context for composition of one parent changelog entry.
|
|
33
|
+
* In composition of one, the parent entity has FK to the child (e.g., BookStores.registry_ID -> BookStoreRegistry.ID)
|
|
34
|
+
* So we need to do a reverse lookup: find the parent record that has FK pointing to this child.
|
|
35
|
+
*/
|
|
36
|
+
function buildCompositionOfOneParentContext(compositionParentInfo, rootObjectIDs, modification, rowRef, model) {
|
|
37
|
+
const { parentEntityName, compositionFieldName, parentKeyBinding } = compositionParentInfo;
|
|
38
|
+
const { compositionName, childKeys } = parentKeyBinding;
|
|
39
|
+
|
|
40
|
+
// Build the FK field names on the parent that point to this child
|
|
41
|
+
// For composition of one, CAP generates <compositionName>_<childKey> fields
|
|
42
|
+
const parentFKFields = childKeys.map((k) => `${compositionName}_${k}`);
|
|
43
|
+
|
|
44
|
+
// Build WHERE clause to find the parent entity that has this child
|
|
45
|
+
const parentEntity = model.definitions[parentEntityName];
|
|
46
|
+
const parentKeys = utils.extractKeys(parentEntity.keys);
|
|
47
|
+
const parentWhereClause = parentFKFields.map((fk, i) => `${fk} = :${rowRef}.${childKeys[i]}`).join(' AND ');
|
|
48
|
+
|
|
49
|
+
// Build the parent key expression via subquery (reverse lookup)
|
|
50
|
+
const parentKeyExpr = compositeKeyExpr(parentKeys.map((pk) => `(SELECT ${pk} FROM ${utils.transformName(parentEntityName)} WHERE ${parentWhereClause})`));
|
|
51
|
+
|
|
52
|
+
// Build rootObjectID expression for the parent entity
|
|
53
|
+
let rootObjectIDExpr;
|
|
54
|
+
if (rootObjectIDs?.length > 0) {
|
|
55
|
+
const oidSelects = rootObjectIDs.map((oid) => `(SELECT ${oid.name} FROM ${utils.transformName(parentEntityName)} WHERE ${parentWhereClause})`);
|
|
56
|
+
rootObjectIDExpr = oidSelects.length > 1 ? oidSelects.join(" || ', ' || ") : oidSelects[0];
|
|
57
|
+
} else {
|
|
58
|
+
rootObjectIDExpr = parentKeyExpr;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Add parent_modification to declares for dynamic determination
|
|
62
|
+
const declares = 'DECLARE parent_id NVARCHAR(36); DECLARE parent_modification NVARCHAR(10);';
|
|
63
|
+
|
|
64
|
+
// Determine modification dynamically: 'create' if parent was just created, 'update' otherwise
|
|
65
|
+
// Note: For composition of one, we check if a composition entry already exists for this transaction
|
|
66
|
+
// to avoid duplicates when both parent UPDATE and child DELETE triggers fire
|
|
67
|
+
const insertSQL = `
|
|
68
|
+
SELECT CASE WHEN COUNT(*) > 0 THEN 'create' ELSE 'update' END INTO parent_modification
|
|
69
|
+
FROM SAP_CHANGELOG_CHANGES
|
|
70
|
+
WHERE entity = '${parentEntityName}'
|
|
71
|
+
AND entityKey = ${parentKeyExpr}
|
|
72
|
+
AND modification = 'create'
|
|
73
|
+
AND transactionID = CURRENT_UPDATE_TRANSACTION();
|
|
74
|
+
INSERT INTO SAP_CHANGELOG_CHANGES
|
|
75
|
+
(ID, parent_ID, attribute, entity, entityKey, objectID, createdAt, createdBy, valueDataType, modification, transactionID)
|
|
76
|
+
SELECT
|
|
77
|
+
parent_id,
|
|
78
|
+
NULL,
|
|
79
|
+
'${compositionFieldName}',
|
|
80
|
+
'${parentEntityName}',
|
|
81
|
+
${parentKeyExpr},
|
|
82
|
+
${rootObjectIDExpr},
|
|
83
|
+
CURRENT_TIMESTAMP,
|
|
84
|
+
SESSION_CONTEXT('APPLICATIONUSER'),
|
|
85
|
+
'cds.Composition',
|
|
86
|
+
parent_modification,
|
|
87
|
+
CURRENT_UPDATE_TRANSACTION()
|
|
88
|
+
FROM SAP_CHANGELOG_CHANGE_TRACKING_DUMMY
|
|
89
|
+
WHERE EXISTS (
|
|
90
|
+
SELECT 1 FROM ${utils.transformName(parentEntityName)} WHERE ${parentWhereClause}
|
|
91
|
+
)
|
|
92
|
+
AND NOT EXISTS (
|
|
93
|
+
SELECT 1 FROM SAP_CHANGELOG_CHANGES
|
|
94
|
+
WHERE entity = '${parentEntityName}'
|
|
95
|
+
AND entityKey = ${parentKeyExpr}
|
|
96
|
+
AND attribute = '${compositionFieldName}'
|
|
97
|
+
AND valueDataType = 'cds.Composition'
|
|
98
|
+
AND transactionID = CURRENT_UPDATE_TRANSACTION()
|
|
99
|
+
);`;
|
|
100
|
+
|
|
101
|
+
return { declares, insertSQL, parentEntityName, compositionFieldName, parentKeyExpr };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function buildCompositionParentContext(compositionParentInfo, rootObjectIDs, modification, rowRef, model, grandParentCompositionInfo = null) {
|
|
105
|
+
const { parentEntityName, compositionFieldName, parentKeyBinding } = compositionParentInfo;
|
|
106
|
+
|
|
107
|
+
// Handle composition of one (parent has FK to child - need reverse lookup)
|
|
108
|
+
if (parentKeyBinding.type === 'compositionOfOne') {
|
|
109
|
+
return buildCompositionOfOneParentContext(compositionParentInfo, rootObjectIDs, modification, rowRef, model);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const parentKeyExpr = compositeKeyExpr(parentKeyBinding.map((k) => `:${rowRef}.${k}`));
|
|
113
|
+
|
|
114
|
+
// Build rootObjectID expression for the parent entity
|
|
115
|
+
const rootEntity = model.definitions[parentEntityName];
|
|
116
|
+
const rootObjectIDExpr = buildCompOfManyRootObjectIDSelect(rootEntity, rootObjectIDs, parentKeyBinding, rowRef, model);
|
|
117
|
+
|
|
118
|
+
let declares, insertSQL;
|
|
119
|
+
|
|
120
|
+
if (grandParentCompositionInfo) {
|
|
121
|
+
// When we have grandparent info, we need to:
|
|
122
|
+
// 1. Create grandparent entry (Order.orderItems) for current transaction if not exists
|
|
123
|
+
// 2. Create parent entry (OrderItem.notes) linking to the grandparent entry
|
|
124
|
+
const { grandParentEntityName, grandParentCompositionFieldName, grandParentKeyBinding } = grandParentCompositionInfo;
|
|
125
|
+
|
|
126
|
+
// Build grandparent key expression by looking up from parent entity
|
|
127
|
+
const parentEntity = model.definitions[parentEntityName];
|
|
128
|
+
const parentKeys = utils.extractKeys(parentEntity.keys);
|
|
129
|
+
const parentWhere = parentKeys.map((pk, i) => `${pk} = :${rowRef}.${parentKeyBinding[i]}`).join(' AND ');
|
|
130
|
+
const grandParentKeyExpr = compositeKeyExpr(grandParentKeyBinding.map((k) => `(SELECT ${k} FROM ${utils.transformName(parentEntityName)} WHERE ${parentWhere})`));
|
|
131
|
+
|
|
132
|
+
// Build grandparent objectID expression
|
|
133
|
+
const grandParentEntity = model.definitions[grandParentEntityName];
|
|
134
|
+
const grandParentObjectIDs = utils.getObjectIDs(grandParentEntity, model);
|
|
135
|
+
const grandParentObjectIDExpr = grandParentObjectIDs?.length > 0 ? buildGrandParentObjectIDExpr(grandParentObjectIDs, grandParentEntity, parentEntityName, parentKeyBinding, grandParentKeyBinding, rowRef, model) : grandParentKeyExpr;
|
|
136
|
+
|
|
137
|
+
// Add grandparent_id to declares
|
|
138
|
+
declares = 'DECLARE parent_id NVARCHAR(36);\n\tDECLARE grandparent_id NVARCHAR(36);';
|
|
139
|
+
|
|
140
|
+
insertSQL = `SELECT MAX(ID) INTO grandparent_id FROM SAP_CHANGELOG_CHANGES
|
|
141
|
+
WHERE entity = '${grandParentEntityName}'
|
|
142
|
+
AND entityKey = ${grandParentKeyExpr}
|
|
143
|
+
AND attribute = '${grandParentCompositionFieldName}'
|
|
144
|
+
AND valueDataType = 'cds.Composition'
|
|
145
|
+
AND transactionID = CURRENT_UPDATE_TRANSACTION();
|
|
146
|
+
IF grandparent_id IS NULL THEN
|
|
147
|
+
grandparent_id := SYSUUID;
|
|
148
|
+
INSERT INTO SAP_CHANGELOG_CHANGES
|
|
149
|
+
(ID, parent_ID, attribute, entity, entityKey, objectID, createdAt, createdBy, valueDataType, modification, transactionID)
|
|
150
|
+
VALUES (
|
|
151
|
+
grandparent_id,
|
|
152
|
+
NULL,
|
|
153
|
+
'${grandParentCompositionFieldName}',
|
|
154
|
+
'${grandParentEntityName}',
|
|
155
|
+
${grandParentKeyExpr},
|
|
156
|
+
${grandParentObjectIDExpr},
|
|
157
|
+
CURRENT_TIMESTAMP,
|
|
158
|
+
SESSION_CONTEXT('APPLICATIONUSER'),
|
|
159
|
+
'cds.Composition',
|
|
160
|
+
'update',
|
|
161
|
+
CURRENT_UPDATE_TRANSACTION()
|
|
162
|
+
);
|
|
163
|
+
END IF;
|
|
164
|
+
|
|
165
|
+
INSERT INTO SAP_CHANGELOG_CHANGES
|
|
166
|
+
(ID, parent_ID, attribute, entity, entityKey, objectID, createdAt, createdBy, valueDataType, modification, transactionID)
|
|
167
|
+
VALUES (
|
|
168
|
+
parent_id,
|
|
169
|
+
grandparent_id,
|
|
170
|
+
'${compositionFieldName}',
|
|
171
|
+
'${parentEntityName}',
|
|
172
|
+
${parentKeyExpr},
|
|
173
|
+
${rootObjectIDExpr},
|
|
174
|
+
CURRENT_TIMESTAMP,
|
|
175
|
+
SESSION_CONTEXT('APPLICATIONUSER'),
|
|
176
|
+
'cds.Composition',
|
|
177
|
+
'${modification}',
|
|
178
|
+
CURRENT_UPDATE_TRANSACTION()
|
|
179
|
+
);`;
|
|
180
|
+
} else {
|
|
181
|
+
// Add parent_modification to declares for dynamic determination
|
|
182
|
+
declares = 'DECLARE parent_id NVARCHAR(36);\n\tDECLARE parent_modification NVARCHAR(10);';
|
|
183
|
+
|
|
184
|
+
// Determine modification dynamically: 'create' if parent was just created, 'update' otherwise
|
|
185
|
+
// This handles both deep insert (parent created in same tx) and independent insert (parent already existed)
|
|
186
|
+
insertSQL = `
|
|
187
|
+
SELECT CASE WHEN COUNT(*) > 0 THEN 'create' ELSE 'update' END INTO parent_modification
|
|
188
|
+
FROM SAP_CHANGELOG_CHANGES
|
|
189
|
+
WHERE entity = '${parentEntityName}'
|
|
190
|
+
AND entityKey = ${parentKeyExpr}
|
|
191
|
+
AND modification = 'create'
|
|
192
|
+
AND transactionID = CURRENT_UPDATE_TRANSACTION();
|
|
193
|
+
INSERT INTO SAP_CHANGELOG_CHANGES
|
|
194
|
+
(ID, parent_ID, attribute, entity, entityKey, objectID, createdAt, createdBy, valueDataType, modification, transactionID)
|
|
195
|
+
VALUES (
|
|
196
|
+
parent_id,
|
|
197
|
+
NULL,
|
|
198
|
+
'${compositionFieldName}',
|
|
199
|
+
'${parentEntityName}',
|
|
200
|
+
${parentKeyExpr},
|
|
201
|
+
${rootObjectIDExpr},
|
|
202
|
+
CURRENT_TIMESTAMP,
|
|
203
|
+
SESSION_CONTEXT('APPLICATIONUSER'),
|
|
204
|
+
'cds.Composition',
|
|
205
|
+
parent_modification,
|
|
206
|
+
CURRENT_UPDATE_TRANSACTION()
|
|
207
|
+
);`;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return { declares, insertSQL, parentEntityName, compositionFieldName, parentKeyExpr };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function buildParentLookupSQL(parentEntityName, parentKeyExpr, compositionFieldName) {
|
|
214
|
+
return `SELECT MAX(ID) INTO parent_id FROM SAP_CHANGELOG_CHANGES
|
|
215
|
+
WHERE entity = '${parentEntityName}'
|
|
216
|
+
AND entityKey = ${parentKeyExpr}
|
|
217
|
+
AND attribute = '${compositionFieldName}'
|
|
218
|
+
AND valueDataType = 'cds.Composition'
|
|
219
|
+
AND transactionID = CURRENT_UPDATE_TRANSACTION();`;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function buildParentLookupOrCreateSQL(compositionParentContext) {
|
|
223
|
+
const { insertSQL: compInsertSQL, parentEntityName, compositionFieldName, parentKeyExpr } = compositionParentContext;
|
|
224
|
+
return `${buildParentLookupSQL(parentEntityName, parentKeyExpr, compositionFieldName)}
|
|
225
|
+
IF parent_id IS NULL THEN
|
|
226
|
+
parent_id := SYSUUID;
|
|
227
|
+
${compInsertSQL}
|
|
228
|
+
END IF;`;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function buildCompositionOnlyBody(entityName, compositionParentContext, prefixSQL = '') {
|
|
232
|
+
const { getSkipCheckCondition } = require('./sql-expressions.js');
|
|
233
|
+
const { declares } = compositionParentContext;
|
|
234
|
+
const prefix = prefixSQL ? `\n\t\t${prefixSQL}` : '';
|
|
235
|
+
return `${declares}
|
|
236
|
+
IF ${getSkipCheckCondition(entityName)} THEN${prefix}
|
|
237
|
+
${buildParentLookupOrCreateSQL(compositionParentContext)}
|
|
238
|
+
END IF;`;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
module.exports = {
|
|
242
|
+
buildCompOfManyRootObjectIDSelect,
|
|
243
|
+
buildCompositionOfOneParentContext,
|
|
244
|
+
buildCompositionParentContext,
|
|
245
|
+
buildParentLookupSQL,
|
|
246
|
+
buildParentLookupOrCreateSQL,
|
|
247
|
+
buildCompositionOnlyBody
|
|
248
|
+
};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
const cds = require('@sap/cds');
|
|
2
|
+
const { fs } = cds.utils;
|
|
3
|
+
|
|
4
|
+
const { prepareCSNForTriggers, generateTriggersForEntities, writeLabelsCSV, ensureUndeployJsonHasTriggerPattern } = require('../utils/trigger-utils.js');
|
|
5
|
+
|
|
6
|
+
function registerHDICompilerHook() {
|
|
7
|
+
const _hdi_migration = cds.compiler.to.hdi.migration;
|
|
8
|
+
cds.compiler.to.hdi.migration = function (csn, options, beforeImage) {
|
|
9
|
+
const { generateHANATriggers } = require('./triggers.js');
|
|
10
|
+
const { runtimeCSN, hierarchy, entities } = prepareCSNForTriggers(csn, true);
|
|
11
|
+
|
|
12
|
+
const triggers = generateTriggersForEntities(runtimeCSN, hierarchy, entities, generateHANATriggers);
|
|
13
|
+
|
|
14
|
+
if (triggers.length > 0) {
|
|
15
|
+
delete csn.definitions['sap.changelog.CHANGE_TRACKING_DUMMY']['@cds.persistence.skip'];
|
|
16
|
+
writeLabelsCSV(entities, runtimeCSN);
|
|
17
|
+
const dir = 'db/src/gen/data/';
|
|
18
|
+
fs.writeFileSync(`${dir}/sap.changelog-CHANGE_TRACKING_DUMMY.csv`, `X\n1`);
|
|
19
|
+
ensureUndeployJsonHasTriggerPattern();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const ret = _hdi_migration(csn, options, beforeImage);
|
|
23
|
+
ret.definitions = [...ret.definitions, ...triggers];
|
|
24
|
+
return ret;
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
module.exports = { registerHDICompilerHook };
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
const utils = require('../utils/change-tracking.js');
|
|
2
|
+
const { CT_SKIP_VAR, getEntitySkipVarName, getElementSkipVarName } = require('../utils/session-variables.js');
|
|
3
|
+
|
|
4
|
+
const HANAService = require('@cap-js/hana');
|
|
5
|
+
const cqn4sql = require('@cap-js/db-service/lib/cqn4sql');
|
|
6
|
+
const { createTriggerCQN2SQL } = require('../TriggerCQN2SQL.js');
|
|
7
|
+
|
|
8
|
+
const TriggerCQN2SQL = createTriggerCQN2SQL(HANAService.CQN2SQL);
|
|
9
|
+
let HANACQN2SQL;
|
|
10
|
+
|
|
11
|
+
function toSQL(query, model) {
|
|
12
|
+
if (!HANACQN2SQL) {
|
|
13
|
+
HANACQN2SQL = new TriggerCQN2SQL();
|
|
14
|
+
}
|
|
15
|
+
const sqlCQN = cqn4sql(query, model);
|
|
16
|
+
return HANACQN2SQL.SELECT(sqlCQN);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function getSkipCheckCondition(entityName) {
|
|
20
|
+
const entitySkipVar = getEntitySkipVarName(entityName);
|
|
21
|
+
return `(COALESCE(SESSION_CONTEXT('${CT_SKIP_VAR}'), 'false') != 'true' AND COALESCE(SESSION_CONTEXT('${entitySkipVar}'), 'false') != 'true')`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function getElementSkipCondition(entityName, elementName) {
|
|
25
|
+
const varName = getElementSkipVarName(entityName, elementName);
|
|
26
|
+
return `COALESCE(SESSION_CONTEXT('${varName}'), 'false') != 'true'`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function compositeKeyExpr(parts) {
|
|
30
|
+
if (parts.length <= 1) return parts[0];
|
|
31
|
+
return `HIERARCHY_COMPOSITE_ID(${parts.join(', ')})`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Truncates large strings: CASE WHEN LENGTH(val) > 5000 THEN LEFT(val, 4997) || '...' ELSE val END
|
|
36
|
+
*/
|
|
37
|
+
function wrapLargeString(val, isLob = false) {
|
|
38
|
+
if (val === 'NULL') return 'NULL';
|
|
39
|
+
// For LOB types, we need to convert to NVARCHAR first
|
|
40
|
+
const expr = isLob ? `TO_NVARCHAR(${val})` : val;
|
|
41
|
+
return `CASE WHEN LENGTH(${expr}) > 5000 THEN LEFT(${expr}, 4997) || '...' ELSE ${expr} END`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Returns SQL expression for a column's raw value
|
|
46
|
+
*/
|
|
47
|
+
function getValueExpr(col, refRow) {
|
|
48
|
+
if (col.type === 'cds.Boolean') {
|
|
49
|
+
return `:${refRow}.${col.name}`;
|
|
50
|
+
}
|
|
51
|
+
if (col.target && col.foreignKeys) {
|
|
52
|
+
return col.foreignKeys.map((fk) => `TO_NVARCHAR(:${refRow}.${col.name}_${fk})`).join(" || ' ' || ");
|
|
53
|
+
}
|
|
54
|
+
if (col.target && col.on) {
|
|
55
|
+
return col.on.map((m) => `TO_NVARCHAR(:${refRow}.${m.foreignKeyField})`).join(" || ' ' || ");
|
|
56
|
+
}
|
|
57
|
+
// Scalar value
|
|
58
|
+
let raw = `:${refRow}.${col.name}`;
|
|
59
|
+
if (col.type === 'cds.LargeString') {
|
|
60
|
+
return wrapLargeString(raw, true);
|
|
61
|
+
}
|
|
62
|
+
if (col.type === 'cds.String') {
|
|
63
|
+
return wrapLargeString(raw, false);
|
|
64
|
+
}
|
|
65
|
+
return `TO_NVARCHAR(${raw})`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Null-safe change detection: (old <> new OR old IS NULL OR new IS NULL) AND NOT (old IS NULL AND new IS NULL)
|
|
70
|
+
*/
|
|
71
|
+
function nullSafeChanged(column, isLob = false) {
|
|
72
|
+
// For LOB types, convert to NVARCHAR before comparison
|
|
73
|
+
const o = isLob ? `TO_NVARCHAR(:old.${column})` : `:old.${column}`;
|
|
74
|
+
const n = isLob ? `TO_NVARCHAR(:new.${column})` : `:new.${column}`;
|
|
75
|
+
return `(${o} <> ${n} OR ${o} IS NULL OR ${n} IS NULL) AND NOT (${o} IS NULL AND ${n} IS NULL)`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Returns SQL WHERE condition for detecting column changes (null-safe comparison)
|
|
80
|
+
*/
|
|
81
|
+
function getWhereCondition(col, modification) {
|
|
82
|
+
const isLob = col.type === 'cds.LargeString';
|
|
83
|
+
if (modification === 'update') {
|
|
84
|
+
const checkCols = col.foreignKeys ? col.foreignKeys.map((fk) => `${col.name}_${fk}`) : col.on ? col.on.map((m) => m.foreignKeyField) : [col.name];
|
|
85
|
+
return checkCols.map((k) => nullSafeChanged(k, isLob)).join(' OR ');
|
|
86
|
+
}
|
|
87
|
+
// CREATE or DELETE: check value is not null
|
|
88
|
+
const rowRef = modification === 'create' ? 'new' : 'old';
|
|
89
|
+
if (col.target && col.foreignKeys) {
|
|
90
|
+
return col.foreignKeys.map((fk) => `:${rowRef}.${col.name}_${fk} IS NOT NULL`).join(' OR ');
|
|
91
|
+
}
|
|
92
|
+
if (col.target && col.on) {
|
|
93
|
+
return col.on.map((m) => `:${rowRef}.${m.foreignKeyField} IS NOT NULL`).join(' OR ');
|
|
94
|
+
}
|
|
95
|
+
// For LOB types, convert to NVARCHAR before null check
|
|
96
|
+
if (isLob) {
|
|
97
|
+
return `TO_NVARCHAR(:${rowRef}.${col.name}) IS NOT NULL`;
|
|
98
|
+
}
|
|
99
|
+
return `:${rowRef}.${col.name} IS NOT NULL`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Returns SQL expression for a column's label (looked-up value for associations)
|
|
104
|
+
*/
|
|
105
|
+
function getLabelExpr(col, refRow, model) {
|
|
106
|
+
if (!(col.target && col.alt)) {
|
|
107
|
+
return `NULL`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Builds inline SELECT expression for association label lookup with locale support
|
|
111
|
+
let where = {};
|
|
112
|
+
if (col.foreignKeys) {
|
|
113
|
+
where = col.foreignKeys.reduce((acc, k) => {
|
|
114
|
+
acc[k] = { val: `:${refRow}.${col.name}_${k}` };
|
|
115
|
+
return acc;
|
|
116
|
+
}, {});
|
|
117
|
+
} else if (col.on) {
|
|
118
|
+
where = col.on.reduce((acc, mapping) => {
|
|
119
|
+
acc[mapping.targetKey] = { val: `:${refRow}.${mapping.foreignKeyField}` };
|
|
120
|
+
return acc;
|
|
121
|
+
}, {});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const alt = col.alt.map((s) => s.split('.').slice(1).join('.'));
|
|
125
|
+
const columns = alt.length === 1 ? alt[0] : utils.buildConcatXpr(alt);
|
|
126
|
+
|
|
127
|
+
// Check for localization
|
|
128
|
+
const localizedInfo = utils.getLocalizedLookupInfo(col.target, col.alt, model);
|
|
129
|
+
if (localizedInfo) {
|
|
130
|
+
const textsWhere = { ...where, locale: { func: 'SESSION_CONTEXT', args: [{ val: 'LOCALE' }] } };
|
|
131
|
+
const textsQuery = SELECT.one.from(localizedInfo.textsEntity).columns(columns).where(textsWhere);
|
|
132
|
+
const baseQuery = SELECT.one.from(col.target).columns(columns).where(where);
|
|
133
|
+
return `COALESCE((${toSQL(textsQuery, model)}), (${toSQL(baseQuery, model)}))`;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const query = SELECT.one.from(col.target).columns(columns).where(where);
|
|
137
|
+
return `(${toSQL(query, model)})`;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Builds SQL expression for objectID (entity display name)
|
|
142
|
+
* Uses @changelog annotation fields, falling back to entity name
|
|
143
|
+
*/
|
|
144
|
+
function buildObjectIDExpr(objectIDs, entity, rowRef, model) {
|
|
145
|
+
const keys = utils.extractKeys(entity.keys);
|
|
146
|
+
const entityKeyExpr = compositeKeyExpr(keys.map((k) => `:${rowRef}.${k}`));
|
|
147
|
+
|
|
148
|
+
if (!objectIDs || objectIDs.length === 0) {
|
|
149
|
+
return entityKeyExpr;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const parts = [];
|
|
153
|
+
for (const oid of objectIDs) {
|
|
154
|
+
if (oid.included) {
|
|
155
|
+
parts.push(`TO_NVARCHAR(:${rowRef}.${oid.name})`);
|
|
156
|
+
} else {
|
|
157
|
+
const where = keys.reduce((acc, k) => {
|
|
158
|
+
acc[k] = { val: `:${rowRef}.${k}` };
|
|
159
|
+
return acc;
|
|
160
|
+
}, {});
|
|
161
|
+
const query = SELECT.one.from(entity.name).columns(oid.name).where(where);
|
|
162
|
+
parts.push(`COALESCE(TO_NVARCHAR((${toSQL(query, model)})), '')`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const concatLogic = parts.join(" || ', ' || ");
|
|
167
|
+
return `COALESCE(NULLIF(${concatLogic}, ''), ${entityKeyExpr})`;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Builds SQL expression for grandparent entity's objectID
|
|
172
|
+
* Used when creating grandparent composition entries for deep linking
|
|
173
|
+
*/
|
|
174
|
+
function buildGrandParentObjectIDExpr(grandParentObjectIDs, grandParentEntity, parentEntityName, parentKeyBinding, grandParentKeyBinding, rowRef, model) {
|
|
175
|
+
// Build WHERE clause to find the parent entity record (e.g., OrderItem from OrderItemNote's FK)
|
|
176
|
+
const parentEntity = model.definitions[parentEntityName];
|
|
177
|
+
const parentKeys = utils.extractKeys(parentEntity.keys);
|
|
178
|
+
const parentWhere = parentKeys.map((pk, i) => `${pk} = :${rowRef}.${parentKeyBinding[i]}`).join(' AND ');
|
|
179
|
+
|
|
180
|
+
// Build WHERE clause to find the grandparent entity record from parent
|
|
181
|
+
const grandParentKeys = utils.extractKeys(grandParentEntity.keys);
|
|
182
|
+
const grandParentWhereSubquery = grandParentKeys
|
|
183
|
+
.map((gk, i) => {
|
|
184
|
+
const fkField = grandParentKeyBinding[i];
|
|
185
|
+
return `${gk} = (SELECT ${fkField} FROM ${utils.transformName(parentEntityName)} WHERE ${parentWhere})`;
|
|
186
|
+
})
|
|
187
|
+
.join(' AND ');
|
|
188
|
+
|
|
189
|
+
const parts = [];
|
|
190
|
+
for (const oid of grandParentObjectIDs) {
|
|
191
|
+
// Since we're using a raw WHERE string, we need to construct this manually
|
|
192
|
+
const selectSQL = `SELECT ${oid.name} FROM ${utils.transformName(grandParentEntity.name)} WHERE ${grandParentWhereSubquery}`;
|
|
193
|
+
parts.push(`COALESCE(TO_NVARCHAR((${selectSQL})), '')`);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const concatLogic = parts.join(" || ', ' || ");
|
|
197
|
+
const grandParentKeyExpr = compositeKeyExpr(grandParentKeyBinding.map((k) => `(SELECT ${k} FROM ${utils.transformName(parentEntityName)} WHERE ${parentWhere})`));
|
|
198
|
+
|
|
199
|
+
return `COALESCE(NULLIF(${concatLogic}, ''), ${grandParentKeyExpr})`;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
module.exports = {
|
|
203
|
+
toSQL,
|
|
204
|
+
getSkipCheckCondition,
|
|
205
|
+
getElementSkipCondition,
|
|
206
|
+
compositeKeyExpr,
|
|
207
|
+
wrapLargeString,
|
|
208
|
+
getValueExpr,
|
|
209
|
+
getWhereCondition,
|
|
210
|
+
getLabelExpr,
|
|
211
|
+
buildObjectIDExpr,
|
|
212
|
+
buildGrandParentObjectIDExpr
|
|
213
|
+
};
|