@cap-js/change-tracking 1.1.4 → 2.0.0-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/CHANGELOG.md +40 -1
  2. package/README.md +45 -71
  3. package/_i18n/i18n.properties +10 -22
  4. package/_i18n/i18n_ar.properties +3 -3
  5. package/_i18n/i18n_bg.properties +3 -3
  6. package/_i18n/i18n_cs.properties +3 -3
  7. package/_i18n/i18n_da.properties +3 -3
  8. package/_i18n/i18n_de.properties +3 -3
  9. package/_i18n/i18n_el.properties +3 -3
  10. package/_i18n/i18n_en.properties +3 -3
  11. package/_i18n/i18n_en_US_saptrc.properties +3 -32
  12. package/_i18n/i18n_es.properties +3 -3
  13. package/_i18n/i18n_es_MX.properties +3 -3
  14. package/_i18n/i18n_fi.properties +3 -3
  15. package/_i18n/i18n_fr.properties +3 -3
  16. package/_i18n/i18n_he.properties +3 -3
  17. package/_i18n/i18n_hr.properties +3 -3
  18. package/_i18n/i18n_hu.properties +3 -3
  19. package/_i18n/i18n_it.properties +3 -3
  20. package/_i18n/i18n_ja.properties +3 -3
  21. package/_i18n/i18n_kk.properties +3 -3
  22. package/_i18n/i18n_ko.properties +3 -3
  23. package/_i18n/i18n_ms.properties +3 -3
  24. package/_i18n/i18n_nl.properties +3 -3
  25. package/_i18n/i18n_no.properties +3 -3
  26. package/_i18n/i18n_pl.properties +3 -3
  27. package/_i18n/i18n_pt.properties +3 -3
  28. package/_i18n/i18n_ro.properties +3 -3
  29. package/_i18n/i18n_ru.properties +3 -3
  30. package/_i18n/i18n_sh.properties +3 -3
  31. package/_i18n/i18n_sk.properties +3 -3
  32. package/_i18n/i18n_sl.properties +3 -3
  33. package/_i18n/i18n_sv.properties +3 -3
  34. package/_i18n/i18n_th.properties +3 -3
  35. package/_i18n/i18n_tr.properties +3 -3
  36. package/_i18n/i18n_uk.properties +3 -3
  37. package/_i18n/i18n_vi.properties +3 -3
  38. package/_i18n/i18n_zh_CN.properties +3 -3
  39. package/_i18n/i18n_zh_TW.properties +3 -3
  40. package/cds-plugin.js +16 -263
  41. package/index.cds +187 -76
  42. package/lib/TriggerCQN2SQL.js +42 -0
  43. package/lib/h2/java-codegen.js +833 -0
  44. package/lib/h2/register.js +27 -0
  45. package/lib/h2/triggers.js +41 -0
  46. package/lib/hana/composition.js +248 -0
  47. package/lib/hana/register.js +28 -0
  48. package/lib/hana/sql-expressions.js +213 -0
  49. package/lib/hana/triggers.js +253 -0
  50. package/lib/localization.js +53 -117
  51. package/lib/model-enhancer.js +266 -0
  52. package/lib/postgres/composition.js +190 -0
  53. package/lib/postgres/register.js +44 -0
  54. package/lib/postgres/sql-expressions.js +261 -0
  55. package/lib/postgres/triggers.js +113 -0
  56. package/lib/skipHandlers.js +34 -0
  57. package/lib/sqlite/composition.js +234 -0
  58. package/lib/sqlite/register.js +28 -0
  59. package/lib/sqlite/sql-expressions.js +228 -0
  60. package/lib/sqlite/triggers.js +163 -0
  61. package/lib/utils/change-tracking.js +394 -0
  62. package/lib/utils/composition-helpers.js +67 -0
  63. package/lib/utils/entity-collector.js +297 -0
  64. package/lib/utils/session-variables.js +276 -0
  65. package/lib/utils/trigger-utils.js +94 -0
  66. package/package.json +17 -7
  67. package/lib/change-log.js +0 -538
  68. package/lib/entity-helper.js +0 -217
  69. package/lib/format-options.js +0 -66
  70. package/lib/template-processor.js +0 -115
@@ -0,0 +1,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 };