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