@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,261 @@
|
|
|
1
|
+
const cds = require('@sap/cds');
|
|
2
|
+
const utils = require('../utils/change-tracking.js');
|
|
3
|
+
const config = cds.env.requires['change-tracking'];
|
|
4
|
+
const cqn4sql = require('@cap-js/db-service/lib/cqn4sql');
|
|
5
|
+
const { CT_SKIP_VAR, getEntitySkipVarName, getElementSkipVarName } = require('../utils/session-variables.js');
|
|
6
|
+
const { createTriggerCQN2SQL } = require('../TriggerCQN2SQL.js');
|
|
7
|
+
|
|
8
|
+
const _cqn2sqlCache = new WeakMap();
|
|
9
|
+
|
|
10
|
+
function toSQL(query, model) {
|
|
11
|
+
let cqn2sql = _cqn2sqlCache.get(model);
|
|
12
|
+
if (!cqn2sql) {
|
|
13
|
+
const Service = require('@cap-js/postgres');
|
|
14
|
+
const TriggerCQN2SQL = createTriggerCQN2SQL(Service.CQN2SQL);
|
|
15
|
+
cqn2sql = new TriggerCQN2SQL({ model });
|
|
16
|
+
_cqn2sqlCache.set(model, cqn2sql);
|
|
17
|
+
}
|
|
18
|
+
const sqlCQN = cqn4sql(query, model);
|
|
19
|
+
return cqn2sql.SELECT(sqlCQN);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function getSkipCheckCondition(entityName) {
|
|
23
|
+
const entitySkipVar = getEntitySkipVarName(entityName);
|
|
24
|
+
return `(COALESCE(current_setting('${CT_SKIP_VAR}', true), 'false') != 'true' AND COALESCE(current_setting('${entitySkipVar}', true), 'false') != 'true')`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function getElementSkipCondition(entityName, elementName) {
|
|
28
|
+
const varName = getElementSkipVarName(entityName, elementName);
|
|
29
|
+
return `COALESCE(current_setting('${varName}', true), 'false') != 'true'`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Truncates large strings: CASE WHEN LENGTH(val) > 5000 THEN LEFT(val, 4997) || '...' ELSE val END
|
|
34
|
+
*/
|
|
35
|
+
function wrapLargeString(val) {
|
|
36
|
+
return `CASE WHEN LENGTH(${val}) > 5000 THEN LEFT(${val}, 4997) || '...' ELSE ${val} END`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function compositeKeyExpr(parts) {
|
|
40
|
+
if (parts.length <= 1) return `${parts[0]}::TEXT`;
|
|
41
|
+
return parts.map((p) => `LENGTH(${p}::TEXT) || ',' || ${p}::TEXT`).join(" || ';' || ");
|
|
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 `CASE WHEN ${refRow}.${col.name} IS TRUE THEN 'true' WHEN ${refRow}.${col.name} IS FALSE THEN 'false' ELSE NULL END`;
|
|
50
|
+
}
|
|
51
|
+
if (col.target && col.foreignKeys) {
|
|
52
|
+
if (col.foreignKeys.length > 1) {
|
|
53
|
+
return col.foreignKeys.map((fk) => `${refRow}.${col.name}_${fk}::TEXT`).join(" || ' ' || ");
|
|
54
|
+
}
|
|
55
|
+
return `${refRow}.${col.name}_${col.foreignKeys[0]}::TEXT`;
|
|
56
|
+
}
|
|
57
|
+
if (col.target && col.on) {
|
|
58
|
+
return col.on.map((m) => `${refRow}.${m.foreignKeyField}::TEXT`).join(" || ' ' || ");
|
|
59
|
+
}
|
|
60
|
+
// Apply truncation for String and LargeString types
|
|
61
|
+
if (col.type === 'cds.String' || col.type === 'cds.LargeString') {
|
|
62
|
+
return wrapLargeString(`${refRow}.${col.name}::TEXT`);
|
|
63
|
+
}
|
|
64
|
+
return `${refRow}.${col.name}::TEXT`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Returns SQL WHERE condition for detecting column changes
|
|
69
|
+
*/
|
|
70
|
+
function getWhereCondition(col, modification) {
|
|
71
|
+
if (modification === 'update') {
|
|
72
|
+
const checkCols = col.foreignKeys ? col.foreignKeys.map((fk) => `${col.name}_${fk}`) : col.on ? col.on.map((m) => m.foreignKeyField) : [col.name];
|
|
73
|
+
return checkCols.map((c) => `NEW.${c} IS DISTINCT FROM OLD.${c}`).join(' OR ');
|
|
74
|
+
}
|
|
75
|
+
// CREATE or DELETE: check value is not null
|
|
76
|
+
const rowRef = modification === 'create' ? 'NEW' : 'OLD';
|
|
77
|
+
if (col.foreignKeys) {
|
|
78
|
+
return col.foreignKeys.map((fk) => `${rowRef}.${col.name}_${fk} IS NOT NULL`).join(' OR ');
|
|
79
|
+
}
|
|
80
|
+
if (col.on) {
|
|
81
|
+
return col.on.map((m) => `${rowRef}.${m.foreignKeyField} IS NOT NULL`).join(' OR ');
|
|
82
|
+
}
|
|
83
|
+
return `${rowRef}.${col.name} IS NOT NULL`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Builds scalar subselect for association label lookup with locale support
|
|
88
|
+
*/
|
|
89
|
+
function buildAssocLookup(column, refRow, model) {
|
|
90
|
+
let where = {};
|
|
91
|
+
if (column.foreignKeys) {
|
|
92
|
+
where = column.foreignKeys.reduce((acc, k) => {
|
|
93
|
+
acc[k] = { val: `${refRow}.${column.name}_${k}` };
|
|
94
|
+
return acc;
|
|
95
|
+
}, {});
|
|
96
|
+
} else if (column.on) {
|
|
97
|
+
where = column.on.reduce((acc, mapping) => {
|
|
98
|
+
acc[mapping.targetKey] = { val: `${refRow}.${mapping.foreignKeyField}` };
|
|
99
|
+
return acc;
|
|
100
|
+
}, {});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const alt = column.alt.map((s) => s.split('.').slice(1).join('.'));
|
|
104
|
+
const columns = alt.length === 1 ? alt[0] : utils.buildConcatXpr(alt);
|
|
105
|
+
|
|
106
|
+
// Check for localization
|
|
107
|
+
const localizedInfo = utils.getLocalizedLookupInfo(column.target, column.alt, model);
|
|
108
|
+
if (localizedInfo) {
|
|
109
|
+
const textsWhere = { ...where, locale: { func: 'current_setting', args: [{ val: 'cap.locale' }, { val: true }] } };
|
|
110
|
+
const textsQuery = SELECT.one.from(localizedInfo.textsEntity).columns(columns).where(textsWhere);
|
|
111
|
+
const baseQuery = SELECT.one.from(column.target).columns(columns).where(where);
|
|
112
|
+
return `(SELECT COALESCE((${toSQL(textsQuery, model)}), (${toSQL(baseQuery, model)})))`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const query = SELECT.one.from(column.target).columns(columns).where(where);
|
|
116
|
+
return `(${toSQL(query, model)})`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Returns SQL expression for a column's label (looked-up value for associations)
|
|
121
|
+
*/
|
|
122
|
+
function getLabelExpr(col, refRow, model) {
|
|
123
|
+
if (col.target && col.alt) {
|
|
124
|
+
return buildAssocLookup(col, refRow, model);
|
|
125
|
+
}
|
|
126
|
+
return 'NULL';
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Builds PL/pgSQL statement for objectID assignment
|
|
131
|
+
*/
|
|
132
|
+
function buildObjectIDAssignment(objectIDs, entity, keys, recVar, targetVar, model) {
|
|
133
|
+
if (!objectIDs || objectIDs.length === 0) {
|
|
134
|
+
return `${targetVar} := entity_key;`;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const parts = [];
|
|
138
|
+
for (const oid of objectIDs) {
|
|
139
|
+
if (oid.included) {
|
|
140
|
+
parts.push(`${recVar}.${oid.name}::TEXT`);
|
|
141
|
+
} else {
|
|
142
|
+
const where = keys.reduce((acc, k) => {
|
|
143
|
+
acc[k] = { val: `${recVar}.${k}` };
|
|
144
|
+
return acc;
|
|
145
|
+
}, {});
|
|
146
|
+
const query = SELECT.one.from(entity.name).columns(oid.name).where(where);
|
|
147
|
+
parts.push(`COALESCE((${toSQL(query, model)})::TEXT, '')`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return `
|
|
152
|
+
SELECT CONCAT_WS(', ', ${parts.join(', ')}) INTO ${targetVar};
|
|
153
|
+
IF ${targetVar} = '' OR ${targetVar} IS NULL THEN
|
|
154
|
+
${targetVar} := entity_key;
|
|
155
|
+
END IF;
|
|
156
|
+
`;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function buildColumnSubquery(col, modification, entity, model) {
|
|
160
|
+
const whereCondition = getWhereCondition(col, modification);
|
|
161
|
+
const elementSkipCondition = getElementSkipCondition(entity.name, col.name);
|
|
162
|
+
let fullWhere = `(${whereCondition}) AND ${elementSkipCondition}`;
|
|
163
|
+
|
|
164
|
+
// For composition-of-one columns, add deduplication check to prevent duplicate entries
|
|
165
|
+
// when child trigger has already created a composition entry for this transaction
|
|
166
|
+
if (col.type === 'cds.Composition') {
|
|
167
|
+
fullWhere += ` AND NOT EXISTS (
|
|
168
|
+
SELECT 1 FROM sap_changelog_changes
|
|
169
|
+
WHERE entity = '${entity.name}'
|
|
170
|
+
AND entitykey = entity_key
|
|
171
|
+
AND attribute = '${col.name}'
|
|
172
|
+
AND valuedatatype = 'cds.Composition'
|
|
173
|
+
AND transactionid = transaction_id
|
|
174
|
+
)`;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const oldVal = modification === 'create' ? 'NULL' : getValueExpr(col, 'OLD');
|
|
178
|
+
const newVal = modification === 'delete' ? 'NULL' : getValueExpr(col, 'NEW');
|
|
179
|
+
const oldLabel = modification === 'create' ? 'NULL' : getLabelExpr(col, 'OLD', model);
|
|
180
|
+
const newLabel = modification === 'delete' ? 'NULL' : getLabelExpr(col, 'NEW', model);
|
|
181
|
+
|
|
182
|
+
return `SELECT '${col.name}' AS attribute, ${oldVal} AS valueChangedFrom, ${newVal} AS valueChangedTo, ${oldLabel} AS valueChangedFromLabel, ${newLabel} AS valueChangedToLabel, '${col.type}' AS valueDataType WHERE ${fullWhere}`;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Generates INSERT SQL for changelog entries from UNION query
|
|
187
|
+
*/
|
|
188
|
+
function buildChangelogInsertSQL(columns, modification, entity, model, hasCompositionParent = false) {
|
|
189
|
+
const unionQuery = columns.map((col) => buildColumnSubquery(col, modification, entity, model)).join('\n UNION ALL\n ');
|
|
190
|
+
const parentIdValue = hasCompositionParent ? 'comp_parent_id' : 'NULL';
|
|
191
|
+
|
|
192
|
+
return `INSERT INTO sap_changelog_changes
|
|
193
|
+
(ID, PARENT_ID, ATTRIBUTE, VALUECHANGEDFROM, VALUECHANGEDTO, VALUECHANGEDFROMLABEL, VALUECHANGEDTOLABEL, ENTITY, ENTITYKEY, OBJECTID, CREATEDAT, CREATEDBY, VALUEDATATYPE, MODIFICATION, TRANSACTIONID)
|
|
194
|
+
SELECT
|
|
195
|
+
gen_random_uuid(),
|
|
196
|
+
${parentIdValue},
|
|
197
|
+
attribute,
|
|
198
|
+
valueChangedFrom,
|
|
199
|
+
valueChangedTo,
|
|
200
|
+
valueChangedFromLabel,
|
|
201
|
+
valueChangedToLabel,
|
|
202
|
+
entity_name,
|
|
203
|
+
entity_key,
|
|
204
|
+
object_id,
|
|
205
|
+
now(),
|
|
206
|
+
user_id,
|
|
207
|
+
valueDataType,
|
|
208
|
+
'${modification}',
|
|
209
|
+
transaction_id
|
|
210
|
+
FROM (
|
|
211
|
+
${unionQuery}
|
|
212
|
+
) AS changes;`;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Generates INSERT block for a modification type (with config check)
|
|
217
|
+
*/
|
|
218
|
+
function buildInsertBlock(columns, modification, entity, model, hasCompositionParent = false) {
|
|
219
|
+
if (!config || (modification === 'create' && config.disableCreateTracking) || (modification === 'update' && config.disableUpdateTracking) || (modification === 'delete' && config.disableDeleteTracking)) {
|
|
220
|
+
return '';
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (modification === 'delete' && !config?.preserveDeletes) {
|
|
224
|
+
const keys = utils.extractKeys(entity.keys);
|
|
225
|
+
const entityKey = compositeKeyExpr(keys.map((k) => `OLD.${k}`));
|
|
226
|
+
const deleteSQL = `DELETE FROM sap_changelog_changes WHERE entity = '${entity.name}' AND entitykey = ${entityKey};`;
|
|
227
|
+
return `${deleteSQL}\n ${buildChangelogInsertSQL(columns, modification, entity, model, hasCompositionParent)}`;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return buildChangelogInsertSQL(columns, modification, entity, model, hasCompositionParent);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Extracts database column names from tracked columns (for UPDATE OF clause)
|
|
235
|
+
*/
|
|
236
|
+
function extractTrackedDbColumns(columns) {
|
|
237
|
+
const dbCols = [];
|
|
238
|
+
for (const col of columns) {
|
|
239
|
+
if (col.foreignKeys && col.foreignKeys.length > 0) {
|
|
240
|
+
col.foreignKeys.forEach((fk) => dbCols.push(`${col.name}_${fk}`.toLowerCase()));
|
|
241
|
+
} else if (col.on && col.on.length > 0) {
|
|
242
|
+
col.on.forEach((m) => dbCols.push(m.foreignKeyField.toLowerCase()));
|
|
243
|
+
} else {
|
|
244
|
+
dbCols.push(col.name.toLowerCase());
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
return [...new Set(dbCols)];
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
module.exports = {
|
|
251
|
+
toSQL,
|
|
252
|
+
getSkipCheckCondition,
|
|
253
|
+
getElementSkipCondition,
|
|
254
|
+
compositeKeyExpr,
|
|
255
|
+
getValueExpr,
|
|
256
|
+
getWhereCondition,
|
|
257
|
+
getLabelExpr,
|
|
258
|
+
buildObjectIDAssignment,
|
|
259
|
+
buildInsertBlock,
|
|
260
|
+
extractTrackedDbColumns
|
|
261
|
+
};
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
const utils = require('../utils/change-tracking.js');
|
|
2
|
+
const { getCompositionParentInfo, getGrandParentCompositionInfo } = require('../utils/composition-helpers.js');
|
|
3
|
+
const { getSkipCheckCondition, compositeKeyExpr, buildObjectIDAssignment, buildInsertBlock, extractTrackedDbColumns } = require('./sql-expressions.js');
|
|
4
|
+
const { buildCompositionParentBlock } = require('./composition.js');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Generates the PL/pgSQL function body for the main change tracking trigger
|
|
8
|
+
*/
|
|
9
|
+
function buildFunctionBody(entity, columns, objectIDs, rootEntity, rootObjectIDs, model, compositionParentInfo = null, grandParentCompositionInfo = null) {
|
|
10
|
+
const keys = utils.extractKeys(entity.keys);
|
|
11
|
+
const entityKeyExpr = compositeKeyExpr(keys.map((k) => `rec.${k}`));
|
|
12
|
+
|
|
13
|
+
const objectIDAssignment = buildObjectIDAssignment(objectIDs, entity, keys, 'rec', 'object_id', model);
|
|
14
|
+
|
|
15
|
+
const hasCompositionParent = compositionParentInfo !== null;
|
|
16
|
+
const createBlock = columns.length > 0 ? buildInsertBlock(columns, 'create', entity, model, hasCompositionParent) : '';
|
|
17
|
+
const updateBlock = columns.length > 0 ? buildInsertBlock(columns, 'update', entity, model, hasCompositionParent) : '';
|
|
18
|
+
const deleteBlock = columns.length > 0 ? buildInsertBlock(columns, 'delete', entity, model, hasCompositionParent) : '';
|
|
19
|
+
|
|
20
|
+
// Build composition parent blocks if needed
|
|
21
|
+
const createParentBlock = compositionParentInfo ? buildCompositionParentBlock(compositionParentInfo, rootObjectIDs, 'create', model, grandParentCompositionInfo) : '';
|
|
22
|
+
const updateParentBlock = compositionParentInfo ? buildCompositionParentBlock(compositionParentInfo, rootObjectIDs, 'update', model, grandParentCompositionInfo) : '';
|
|
23
|
+
const deleteParentBlock = compositionParentInfo ? buildCompositionParentBlock(compositionParentInfo, rootObjectIDs, 'delete', model, grandParentCompositionInfo) : '';
|
|
24
|
+
|
|
25
|
+
return `
|
|
26
|
+
DECLARE
|
|
27
|
+
rec RECORD;
|
|
28
|
+
BEGIN
|
|
29
|
+
IF NOT ${getSkipCheckCondition(entity.name)} THEN
|
|
30
|
+
RETURN NULL;
|
|
31
|
+
END IF;
|
|
32
|
+
|
|
33
|
+
IF (TG_OP = 'DELETE') THEN
|
|
34
|
+
rec := OLD;
|
|
35
|
+
ELSE
|
|
36
|
+
rec := NEW;
|
|
37
|
+
END IF;
|
|
38
|
+
|
|
39
|
+
entity_key := ${entityKeyExpr};
|
|
40
|
+
${objectIDAssignment}
|
|
41
|
+
|
|
42
|
+
IF (TG_OP = 'INSERT') THEN
|
|
43
|
+
${createParentBlock}
|
|
44
|
+
${createBlock}
|
|
45
|
+
ELSIF (TG_OP = 'UPDATE') THEN
|
|
46
|
+
${updateParentBlock}
|
|
47
|
+
${updateBlock}
|
|
48
|
+
ELSIF (TG_OP = 'DELETE') THEN
|
|
49
|
+
${deleteParentBlock}
|
|
50
|
+
${deleteBlock}
|
|
51
|
+
END IF;
|
|
52
|
+
END;`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function generatePostgresTriggers(csn, entity, rootEntity, mergedAnnotations = null, rootMergedAnnotations = null, grandParentContext = {}) {
|
|
56
|
+
const triggers = [];
|
|
57
|
+
const { columns: trackedColumns } = utils.extractTrackedColumns(entity, csn, mergedAnnotations);
|
|
58
|
+
const objectIDs = utils.getObjectIDs(entity, csn, mergedAnnotations?.entityAnnotation);
|
|
59
|
+
const rootObjectIDs = utils.getObjectIDs(rootEntity, csn, rootMergedAnnotations?.entityAnnotation);
|
|
60
|
+
|
|
61
|
+
// Check if this entity is a tracked composition target (composition-of-many)
|
|
62
|
+
const compositionParentInfo = getCompositionParentInfo(entity, rootEntity, rootMergedAnnotations);
|
|
63
|
+
|
|
64
|
+
// Get grandparent info for deep linking (e.g., OrderItemNote -> OrderItem.notes -> Order.orderItems)
|
|
65
|
+
const { grandParentEntity, grandParentMergedAnnotations, grandParentCompositionField } = grandParentContext;
|
|
66
|
+
const grandParentCompositionInfo = getGrandParentCompositionInfo(rootEntity, grandParentEntity, grandParentMergedAnnotations, grandParentCompositionField);
|
|
67
|
+
|
|
68
|
+
// Generate triggers if we have tracked columns OR if this is a composition target
|
|
69
|
+
const shouldGenerateTriggers = trackedColumns.length > 0 || compositionParentInfo;
|
|
70
|
+
if (!shouldGenerateTriggers) return triggers;
|
|
71
|
+
|
|
72
|
+
const tableName = entity.name.replace(/\./g, '_').toLowerCase();
|
|
73
|
+
const triggerName = `${tableName}_tr_change`;
|
|
74
|
+
const functionName = `${tableName}_func_change`;
|
|
75
|
+
|
|
76
|
+
const funcBody = buildFunctionBody(entity, trackedColumns, objectIDs, rootEntity, rootObjectIDs, csn, compositionParentInfo, grandParentCompositionInfo);
|
|
77
|
+
|
|
78
|
+
// Include comp_parent_id, comp_parent_modification and comp_grandparent_id variable declarations if needed
|
|
79
|
+
const parentIdDecl = compositionParentInfo ? 'comp_parent_id UUID := NULL;' : '';
|
|
80
|
+
const parentModificationDecl = compositionParentInfo?.parentKeyBinding?.type === 'compositionOfOne' ? 'comp_parent_modification TEXT;' : '';
|
|
81
|
+
const grandparentIdDecl = grandParentCompositionInfo ? 'comp_grandparent_id UUID := NULL;' : '';
|
|
82
|
+
|
|
83
|
+
const createFunction = `CREATE OR REPLACE FUNCTION ${functionName}() RETURNS TRIGGER AS $$
|
|
84
|
+
DECLARE
|
|
85
|
+
entity_name TEXT := '${entity.name}';
|
|
86
|
+
entity_key TEXT;
|
|
87
|
+
object_id TEXT;
|
|
88
|
+
user_id TEXT := coalesce(current_setting('cap.applicationuser', true), 'anonymous');
|
|
89
|
+
transaction_id BIGINT := txid_current();
|
|
90
|
+
${parentIdDecl}
|
|
91
|
+
${parentModificationDecl}
|
|
92
|
+
${grandparentIdDecl}
|
|
93
|
+
BEGIN
|
|
94
|
+
${funcBody}
|
|
95
|
+
RETURN NULL;
|
|
96
|
+
END;
|
|
97
|
+
$$ LANGUAGE plpgsql;`;
|
|
98
|
+
|
|
99
|
+
triggers.push(createFunction);
|
|
100
|
+
|
|
101
|
+
const trackedDbColumns = extractTrackedDbColumns(trackedColumns);
|
|
102
|
+
const updateOfClause = trackedDbColumns.length > 0 ? `UPDATE OF ${trackedDbColumns.join(', ')}` : 'UPDATE';
|
|
103
|
+
const createTrigger = `CREATE OR REPLACE TRIGGER ${triggerName}
|
|
104
|
+
AFTER INSERT OR ${updateOfClause} OR DELETE ON "${tableName}"
|
|
105
|
+
FOR EACH ROW EXECUTE FUNCTION ${functionName}();
|
|
106
|
+
`;
|
|
107
|
+
|
|
108
|
+
triggers.push(createTrigger);
|
|
109
|
+
|
|
110
|
+
return triggers;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
module.exports = { generatePostgresTriggers };
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
const cds = require('@sap/cds');
|
|
2
|
+
|
|
3
|
+
const { isChangeTracked, collectEntities } = require('./utils/entity-collector.js');
|
|
4
|
+
const { setSkipSessionVariables, resetSkipSessionVariables, resetAutoSkipForServiceEntity } = require('./utils/session-variables.js');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Register db handlers for setting/resetting session variables on INSERT/UPDATE/DELETE.
|
|
8
|
+
*/
|
|
9
|
+
function registerSessionVariableHandlers() {
|
|
10
|
+
cds.db.before(['INSERT', 'UPDATE', 'DELETE'], async (req) => {
|
|
11
|
+
const model = cds.context?.model ?? cds.model;
|
|
12
|
+
const collectedEntities = model.collectEntities || (model.collectEntities = collectEntities(model).collectedEntities);
|
|
13
|
+
if (!req.target || req.target.name.endsWith('.drafts')) return;
|
|
14
|
+
const srv = req.target._service;
|
|
15
|
+
if (!srv) return;
|
|
16
|
+
setSkipSessionVariables(req, srv, collectedEntities);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
cds.db.after(['INSERT', 'UPDATE', 'DELETE'], async (_, req) => {
|
|
20
|
+
if (!req.target || req.target.name.endsWith('.drafts')) return;
|
|
21
|
+
|
|
22
|
+
// Reset auto-skip variable if it was set
|
|
23
|
+
if (req._ctAutoSkipEntity) {
|
|
24
|
+
resetAutoSkipForServiceEntity(req, req._ctAutoSkipEntity);
|
|
25
|
+
delete req._ctAutoSkipEntity;
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (!isChangeTracked(req.target)) return;
|
|
30
|
+
resetSkipSessionVariables(req);
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
module.exports = { registerSessionVariableHandlers };
|
|
@@ -0,0 +1,234 @@
|
|
|
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
|
+
// Clone to avoid mutation
|
|
21
|
+
const oids = rootObjectIDs.map((o) => ({ ...o }));
|
|
22
|
+
for (const oid of oids) {
|
|
23
|
+
const q = SELECT.one.from(rootEntity.name).columns(oid.name).where(where);
|
|
24
|
+
oid.selectSQL = toSQL(q, model);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const unions = oids.map((oid) => `SELECT (${oid.selectSQL}) AS value`).join('\nUNION ALL\n');
|
|
28
|
+
return `(SELECT GROUP_CONCAT(value, ', ') FROM (${unions}))`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function buildCompositionOfOneParentContext(compositionParentInfo, rootObjectIDs, modification, rowRef, 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} = ${rowRef}.${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} FROM ${utils.transformName(parentEntityName)} WHERE ${parentWhereClause})`);
|
|
47
|
+
rootObjectIDExpr = oidSelects.length > 1 ? oidSelects.join(" || ', ' || ") : oidSelects[0];
|
|
48
|
+
} else {
|
|
49
|
+
rootObjectIDExpr = parentKeyExpr;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const modificationExpr = `CASE WHEN EXISTS (
|
|
53
|
+
SELECT 1 FROM sap_changelog_Changes
|
|
54
|
+
WHERE entity = '${parentEntityName}'
|
|
55
|
+
AND entityKey = ${parentKeyExpr}
|
|
56
|
+
AND modification = 'create'
|
|
57
|
+
AND createdBy = session_context('$user.id')
|
|
58
|
+
AND createdAt = session_context('$now')
|
|
59
|
+
) THEN 'create' ELSE 'update' END`;
|
|
60
|
+
|
|
61
|
+
const insertSQL = `INSERT INTO sap_changelog_Changes (ID, parent_ID, attribute, entity, entityKey, objectID, createdAt, createdBy, valueDataType, modification, transactionID)
|
|
62
|
+
SELECT
|
|
63
|
+
hex(randomblob(16)),
|
|
64
|
+
NULL,
|
|
65
|
+
'${compositionFieldName}',
|
|
66
|
+
'${parentEntityName}',
|
|
67
|
+
${parentKeyExpr},
|
|
68
|
+
${rootObjectIDExpr},
|
|
69
|
+
session_context('$now'),
|
|
70
|
+
session_context('$user.id'),
|
|
71
|
+
'cds.Composition',
|
|
72
|
+
${modificationExpr},
|
|
73
|
+
session_context('$now')
|
|
74
|
+
WHERE EXISTS (
|
|
75
|
+
SELECT 1 FROM ${utils.transformName(parentEntityName)} WHERE ${parentWhereClause}
|
|
76
|
+
)
|
|
77
|
+
AND NOT EXISTS (
|
|
78
|
+
SELECT 1 FROM sap_changelog_Changes
|
|
79
|
+
WHERE entity = '${parentEntityName}'
|
|
80
|
+
AND entityKey = ${parentKeyExpr}
|
|
81
|
+
AND attribute = '${compositionFieldName}'
|
|
82
|
+
AND valueDataType = 'cds.Composition'
|
|
83
|
+
AND createdBy = session_context('$user.id')
|
|
84
|
+
AND createdAt = session_context('$now')
|
|
85
|
+
);`;
|
|
86
|
+
|
|
87
|
+
// SELECT SQL to get the parent_ID for child entries
|
|
88
|
+
const parentLookupExpr = `(SELECT ID FROM sap_changelog_Changes
|
|
89
|
+
WHERE entity = '${parentEntityName}'
|
|
90
|
+
AND entityKey = ${parentKeyExpr}
|
|
91
|
+
AND attribute = '${compositionFieldName}'
|
|
92
|
+
AND valueDataType = 'cds.Composition'
|
|
93
|
+
AND createdBy = session_context('$user.id')
|
|
94
|
+
ORDER BY createdAt DESC LIMIT 1)`;
|
|
95
|
+
|
|
96
|
+
return { insertSQL, parentEntityName, compositionFieldName, parentKeyExpr, parentLookupExpr };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function buildCompositionParentContext(compositionParentInfo, rootObjectIDs, modification, rowRef, model, grandParentCompositionInfo = null) {
|
|
100
|
+
const { parentEntityName, compositionFieldName, parentKeyBinding } = compositionParentInfo;
|
|
101
|
+
|
|
102
|
+
// Handle composition of one (parent has FK to child - need reverse lookup)
|
|
103
|
+
if (parentKeyBinding.type === 'compositionOfOne') {
|
|
104
|
+
return buildCompositionOfOneParentContext(compositionParentInfo, rootObjectIDs, modification, rowRef, model);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const parentKeyExpr = compositeKeyExpr(parentKeyBinding.map((k) => `${rowRef}.${k}`));
|
|
108
|
+
|
|
109
|
+
// Build rootObjectID expression for the parent entity
|
|
110
|
+
const rootEntity = model.definitions[parentEntityName];
|
|
111
|
+
const rootObjectIDExpr = buildCompOfManyRootObjectIDSelect(rootEntity, rootObjectIDs, parentKeyBinding, rowRef, model);
|
|
112
|
+
|
|
113
|
+
let insertSQL;
|
|
114
|
+
|
|
115
|
+
if (grandParentCompositionInfo) {
|
|
116
|
+
const { grandParentEntityName, grandParentCompositionFieldName, grandParentKeyBinding } = grandParentCompositionInfo;
|
|
117
|
+
const parentEntity = model.definitions[parentEntityName];
|
|
118
|
+
const parentKeys = utils.extractKeys(parentEntity.keys);
|
|
119
|
+
const parentWhere = parentKeys.map((pk, i) => `${pk} = ${rowRef}.${parentKeyBinding[i]}`).join(' AND ');
|
|
120
|
+
|
|
121
|
+
// Build the grandparent key expression from the parent record
|
|
122
|
+
const grandParentKeyExpr = compositeKeyExpr(grandParentKeyBinding.map((k) => `(SELECT ${k} FROM ${utils.transformName(parentEntityName)} WHERE ${parentWhere})`));
|
|
123
|
+
|
|
124
|
+
// Build expression for grandparent lookup (for linking parent entry to it)
|
|
125
|
+
// Must filter by createdAt to get entry from current transaction
|
|
126
|
+
const grandParentLookupExpr = `(SELECT ID FROM sap_changelog_Changes
|
|
127
|
+
WHERE entity = '${grandParentEntityName}'
|
|
128
|
+
AND entityKey = ${grandParentKeyExpr}
|
|
129
|
+
AND attribute = '${grandParentCompositionFieldName}'
|
|
130
|
+
AND valueDataType = 'cds.Composition'
|
|
131
|
+
AND createdBy = session_context('$user.id')
|
|
132
|
+
AND createdAt = session_context('$now')
|
|
133
|
+
ORDER BY createdAt DESC LIMIT 1)`;
|
|
134
|
+
|
|
135
|
+
// First insert grandparent entry if not exists
|
|
136
|
+
const grandParentInsertSQL = `INSERT INTO sap_changelog_Changes (ID, parent_ID, attribute, entity, entityKey, objectID, createdAt, createdBy, valueDataType, modification, transactionID)
|
|
137
|
+
SELECT
|
|
138
|
+
hex(randomblob(16)),
|
|
139
|
+
NULL,
|
|
140
|
+
'${grandParentCompositionFieldName}',
|
|
141
|
+
'${grandParentEntityName}',
|
|
142
|
+
${grandParentKeyExpr},
|
|
143
|
+
${grandParentKeyExpr},
|
|
144
|
+
session_context('$now'),
|
|
145
|
+
session_context('$user.id'),
|
|
146
|
+
'cds.Composition',
|
|
147
|
+
'update',
|
|
148
|
+
session_context('$now')
|
|
149
|
+
WHERE NOT EXISTS (
|
|
150
|
+
SELECT 1 FROM sap_changelog_Changes
|
|
151
|
+
WHERE entity = '${grandParentEntityName}'
|
|
152
|
+
AND entityKey = ${grandParentKeyExpr}
|
|
153
|
+
AND attribute = '${grandParentCompositionFieldName}'
|
|
154
|
+
AND valueDataType = 'cds.Composition'
|
|
155
|
+
AND createdBy = session_context('$user.id')
|
|
156
|
+
AND createdAt = session_context('$now')
|
|
157
|
+
);`;
|
|
158
|
+
|
|
159
|
+
// Then insert parent entry linking to grandparent
|
|
160
|
+
const parentInsertSQL = `INSERT INTO sap_changelog_Changes (ID, parent_ID, attribute, entity, entityKey, objectID, createdAt, createdBy, valueDataType, modification, transactionID)
|
|
161
|
+
SELECT
|
|
162
|
+
hex(randomblob(16)),
|
|
163
|
+
${grandParentLookupExpr},
|
|
164
|
+
'${compositionFieldName}',
|
|
165
|
+
'${parentEntityName}',
|
|
166
|
+
${parentKeyExpr},
|
|
167
|
+
${rootObjectIDExpr},
|
|
168
|
+
session_context('$now'),
|
|
169
|
+
session_context('$user.id'),
|
|
170
|
+
'cds.Composition',
|
|
171
|
+
'${modification}',
|
|
172
|
+
session_context('$now')
|
|
173
|
+
WHERE NOT EXISTS (
|
|
174
|
+
SELECT 1 FROM sap_changelog_Changes
|
|
175
|
+
WHERE entity = '${parentEntityName}'
|
|
176
|
+
AND entityKey = ${parentKeyExpr}
|
|
177
|
+
AND attribute = '${compositionFieldName}'
|
|
178
|
+
AND valueDataType = 'cds.Composition'
|
|
179
|
+
AND createdBy = session_context('$user.id')
|
|
180
|
+
AND createdAt = session_context('$now')
|
|
181
|
+
);`;
|
|
182
|
+
|
|
183
|
+
insertSQL = `${grandParentInsertSQL}\n ${parentInsertSQL}`;
|
|
184
|
+
} else {
|
|
185
|
+
const modificationExpr = `CASE WHEN EXISTS (
|
|
186
|
+
SELECT 1 FROM sap_changelog_Changes
|
|
187
|
+
WHERE entity = '${parentEntityName}'
|
|
188
|
+
AND entityKey = ${parentKeyExpr}
|
|
189
|
+
AND modification = 'create'
|
|
190
|
+
AND createdBy = session_context('$user.id')
|
|
191
|
+
AND createdAt = session_context('$now')
|
|
192
|
+
) THEN 'create' ELSE 'update' END`;
|
|
193
|
+
|
|
194
|
+
insertSQL = `INSERT INTO sap_changelog_Changes (ID, parent_ID, attribute, entity, entityKey, objectID, createdAt, createdBy, valueDataType, modification, transactionID)
|
|
195
|
+
SELECT
|
|
196
|
+
hex(randomblob(16)),
|
|
197
|
+
NULL,
|
|
198
|
+
'${compositionFieldName}',
|
|
199
|
+
'${parentEntityName}',
|
|
200
|
+
${parentKeyExpr},
|
|
201
|
+
${rootObjectIDExpr},
|
|
202
|
+
session_context('$now'),
|
|
203
|
+
session_context('$user.id'),
|
|
204
|
+
'cds.Composition',
|
|
205
|
+
${modificationExpr},
|
|
206
|
+
session_context('$now')
|
|
207
|
+
WHERE NOT EXISTS (
|
|
208
|
+
SELECT 1 FROM sap_changelog_Changes
|
|
209
|
+
WHERE entity = '${parentEntityName}'
|
|
210
|
+
AND entityKey = ${parentKeyExpr}
|
|
211
|
+
AND attribute = '${compositionFieldName}'
|
|
212
|
+
AND valueDataType = 'cds.Composition'
|
|
213
|
+
AND createdBy = session_context('$user.id')
|
|
214
|
+
AND createdAt = session_context('$now')
|
|
215
|
+
);`;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// SELECT SQL to get the parent_ID for child entries
|
|
219
|
+
const parentLookupExpr = `(SELECT ID FROM sap_changelog_Changes
|
|
220
|
+
WHERE entity = '${parentEntityName}'
|
|
221
|
+
AND entityKey = ${parentKeyExpr}
|
|
222
|
+
AND attribute = '${compositionFieldName}'
|
|
223
|
+
AND valueDataType = 'cds.Composition'
|
|
224
|
+
AND createdBy = session_context('$user.id')
|
|
225
|
+
ORDER BY createdAt DESC LIMIT 1)`;
|
|
226
|
+
|
|
227
|
+
return { insertSQL, parentEntityName, compositionFieldName, parentKeyExpr, parentLookupExpr };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
module.exports = {
|
|
231
|
+
buildCompOfManyRootObjectIDSelect,
|
|
232
|
+
buildCompositionOfOneParentContext,
|
|
233
|
+
buildCompositionParentContext
|
|
234
|
+
};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
const cds = require('@sap/cds');
|
|
2
|
+
|
|
3
|
+
const { getEntitiesForTriggerGeneration, collectEntities } = require('../utils/entity-collector.js');
|
|
4
|
+
const { getLabelTranslations } = require('../localization.js');
|
|
5
|
+
const { generateTriggersForEntities } = require('../utils/trigger-utils.js');
|
|
6
|
+
|
|
7
|
+
async function deploySQLiteTriggers() {
|
|
8
|
+
const db = cds.env.requires?.db;
|
|
9
|
+
if (db?.kind !== 'sqlite') return;
|
|
10
|
+
|
|
11
|
+
const model = cds.context?.model ?? cds.model;
|
|
12
|
+
const { collectedEntities, hierarchyMap } = collectEntities(model);
|
|
13
|
+
const { generateSQLiteTrigger } = require('./triggers.js');
|
|
14
|
+
const entities = getEntitiesForTriggerGeneration(model.definitions, collectedEntities);
|
|
15
|
+
|
|
16
|
+
const triggers = generateTriggersForEntities(model, hierarchyMap, entities, generateSQLiteTrigger);
|
|
17
|
+
let deleteTriggers = triggers.map((t) => t.match(/CREATE\s+TRIGGER\s+IF NOT EXISTS\s+(\w+)/i)).map((m) => `DROP TRIGGER IF EXISTS ${m[1]};`);
|
|
18
|
+
|
|
19
|
+
const labels = getLabelTranslations(entities, model);
|
|
20
|
+
const { i18nKeys } = cds.entities('sap.changelog');
|
|
21
|
+
|
|
22
|
+
// Delete existing triggers
|
|
23
|
+
await Promise.all(deleteTriggers.map((t) => cds.db.run(t)));
|
|
24
|
+
|
|
25
|
+
await Promise.all([...triggers.map((t) => cds.db.run(t)), cds.delete(i18nKeys), cds.insert(labels).into(i18nKeys)]);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
module.exports = { deploySQLiteTriggers };
|