@cap-js/change-tracking 2.0.0-beta.5 → 2.0.0-beta.6

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 CHANGED
@@ -4,7 +4,7 @@ All notable changes to this project will be documented in this file.
4
4
  This project adheres to [Semantic Versioning](http://semver.org/).
5
5
  The format is based on [Keep a Changelog](http://keepachangelog.com/).
6
6
 
7
- ## Version 2.0.0-beta.6 - tbd
7
+ ## Version 2.0.0-beta.7 - tbd
8
8
 
9
9
  ### Added
10
10
 
@@ -12,6 +12,21 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/).
12
12
 
13
13
  ### Changed
14
14
 
15
+ ## Version 2.0.0-beta.6 - 26.03.26
16
+
17
+ ### Added
18
+ - Provide detailed plan for v1 to v2 HANA migration
19
+ - Generation of `.hdbmigrationtable` and updating `undeploy.json` via `cds add change-tracking-migration`
20
+ - HANA procedure `SAP_CHANGELOG_RESTORE_BACKLINKS` to restore parent-child hierarchy for composition changes
21
+
22
+ ### Fixed
23
+ - Explicit type casts for Date, DateTime, Time, Timestamp and Decimal fields in `ChangeView` to avoid conversion errors
24
+ - Lazy load database adapters to prevent crashes when optional dependencies are not installed
25
+ - Skip changelogs referencing association targets annotated with `@cds.persistence.skip`
26
+ - Cast single entity keys to `cds.String` to prevent type conversion errors
27
+ - Dynamic localization now verifies `.texts` entity existence before attempting localized lookup
28
+
29
+
15
30
  ## Version 2.0.0-beta.5 - 17.03.26
16
31
 
17
32
  ### Added
package/cds-plugin.js CHANGED
@@ -17,3 +17,5 @@ cds.once('served', async () => {
17
17
  registerH2CompilerHook();
18
18
  registerPostgresCompilerHook();
19
19
  registerHDICompilerHook();
20
+
21
+ cds.add?.register?.('change-tracking-migration', require('./lib/addMigrationTable.js'));
package/index.cds CHANGED
@@ -45,17 +45,11 @@ view ChangeView as
45
45
  key change.ID @UI.Hidden,
46
46
  change.parent : redirected to ChangeView,
47
47
  change.children : redirected to ChangeView,
48
- change.attribute,
49
- change.valueChangedFrom,
50
- change.valueChangedTo,
51
- change.entity,
52
- change.entityKey,
53
- change.objectID,
54
- change.modification,
55
- change.valueDataType,
56
- change.createdAt,
57
- change.createdBy,
58
- change.transactionID,
48
+ // Needed to make the * possible
49
+ attributeI18n.locale @UI.Hidden,
50
+ attributeI18n.text @UI.Hidden,
51
+ // * is important to allow for application extensions of Changes
52
+ *,
59
53
  COALESCE(
60
54
  attributeI18n.text, (
61
55
  select text from i18nKeys
@@ -89,9 +83,9 @@ view ChangeView as
89
83
  (
90
84
  case
91
85
  when valueDataType = 'cds.DateTime'
92
- then COALESCE(
86
+ then cast(COALESCE(
93
87
  change.valueChangedFromLabel, change.valueChangedFrom
94
- )
88
+ ) as DateTime)
95
89
  else null
96
90
  end
97
91
  ) as valueChangedFromLabelDateTime : DateTime @(title: '{i18n>Changes.valueChangedFrom}',
@@ -99,9 +93,9 @@ view ChangeView as
99
93
  (
100
94
  case
101
95
  when valueDataType = 'cds.DateTime' or valueDataType = 'cds.Timestamp'
102
- then COALESCE(
96
+ then cast(COALESCE(
103
97
  change.valueChangedFromLabel, change.valueChangedFrom
104
- )
98
+ ) as DateTime)
105
99
  else null
106
100
  end
107
101
  ) as valueChangedFromLabelDateTimeWTZ : DateTime @(
@@ -111,9 +105,9 @@ view ChangeView as
111
105
  (
112
106
  case
113
107
  when valueDataType = 'cds.Time'
114
- then COALESCE(
108
+ then cast(COALESCE(
115
109
  change.valueChangedFromLabel, change.valueChangedFrom
116
- )
110
+ ) as Time)
117
111
  else null
118
112
  end
119
113
  ) as valueChangedFromLabelTime : Time @(title: '{i18n>Changes.valueChangedFrom}',
@@ -121,9 +115,9 @@ view ChangeView as
121
115
  (
122
116
  case
123
117
  when valueDataType = 'cds.Date'
124
- then COALESCE(
118
+ then cast(COALESCE(
125
119
  change.valueChangedFromLabel, change.valueChangedFrom
126
- )
120
+ ) as Date)
127
121
  else null
128
122
  end
129
123
  ) as valueChangedFromLabelDate : Date @(title: '{i18n>Changes.valueChangedFrom}',
@@ -131,9 +125,9 @@ view ChangeView as
131
125
  (
132
126
  case
133
127
  when valueDataType = 'cds.Timestamp'
134
- then COALESCE(
128
+ then cast(COALESCE(
135
129
  change.valueChangedFromLabel, change.valueChangedFrom
136
- )
130
+ ) as Timestamp)
137
131
  else null
138
132
  end
139
133
  ) as valueChangedFromLabelTimestamp : Timestamp @(title: '{i18n>Changes.valueChangedFrom}',
@@ -141,9 +135,9 @@ view ChangeView as
141
135
  (
142
136
  case
143
137
  when valueDataType = 'cds.Decimal'
144
- then COALESCE(
138
+ then cast(COALESCE(
145
139
  change.valueChangedFromLabel, change.valueChangedFrom
146
- )
140
+ ) as Decimal)
147
141
  else null
148
142
  end
149
143
  ) as valueChangedFromLabelDecimal : Decimal @(title: '{i18n>Changes.valueChangedFrom}',
@@ -157,9 +151,9 @@ view ChangeView as
157
151
  (
158
152
  case
159
153
  when valueDataType = 'cds.DateTime'
160
- then COALESCE(
154
+ then cast(COALESCE(
161
155
  change.valueChangedToLabel, change.valueChangedTo
162
- )
156
+ ) as DateTime)
163
157
  else null
164
158
  end
165
159
  ) as valueChangedToLabelDateTime : DateTime @(title: '{i18n>Changes.valueChangedTo}',
@@ -167,9 +161,9 @@ view ChangeView as
167
161
  (
168
162
  case
169
163
  when valueDataType = 'cds.DateTime' or valueDataType = 'cds.Timestamp'
170
- then COALESCE(
171
- change.valueChangedFromLabel, change.valueChangedTo
172
- )
164
+ then cast(COALESCE(
165
+ change.valueChangedToLabel, change.valueChangedTo
166
+ ) as DateTime)
173
167
  else null
174
168
  end
175
169
  ) as valueChangedToLabelDateTimeWTZ : DateTime @(
@@ -179,9 +173,9 @@ view ChangeView as
179
173
  (
180
174
  case
181
175
  when valueDataType = 'cds.Time'
182
- then COALESCE(
176
+ then cast(COALESCE(
183
177
  change.valueChangedToLabel, change.valueChangedTo
184
- )
178
+ ) as Time)
185
179
  else null
186
180
  end
187
181
  ) as valueChangedToLabelTime : Time @(title: '{i18n>Changes.valueChangedTo}',
@@ -189,9 +183,9 @@ view ChangeView as
189
183
  (
190
184
  case
191
185
  when valueDataType = 'cds.Date'
192
- then COALESCE(
186
+ then cast(COALESCE(
193
187
  change.valueChangedToLabel, change.valueChangedTo
194
- )
188
+ ) as Date)
195
189
  else null
196
190
  end
197
191
  ) as valueChangedToLabelDate : Date @(title: '{i18n>Changes.valueChangedTo}',
@@ -199,9 +193,9 @@ view ChangeView as
199
193
  (
200
194
  case
201
195
  when valueDataType = 'cds.Timestamp'
202
- then COALESCE(
196
+ then cast(COALESCE(
203
197
  change.valueChangedToLabel, change.valueChangedTo
204
- )
198
+ ) as Timestamp)
205
199
  else null
206
200
  end
207
201
  ) as valueChangedToLabelTimestamp : Timestamp @(title: '{i18n>Changes.valueChangedTo}',
@@ -209,9 +203,9 @@ view ChangeView as
209
203
  (
210
204
  case
211
205
  when valueDataType = 'cds.Decimal'
212
- then COALESCE(
206
+ then cast(COALESCE(
213
207
  change.valueChangedToLabel, change.valueChangedTo
214
- )
208
+ ) as Decimal)
215
209
  else null
216
210
  end
217
211
  ) as valueChangedToLabelDecimal : Decimal @(title: '{i18n>Changes.valueChangedTo}',
@@ -0,0 +1,59 @@
1
+ const cds = require('@sap/cds');
2
+ const { fs, path } = cds.utils;
3
+ const { join } = path;
4
+
5
+ const MIGRATION_TABLE_PATH = join('db', 'src', 'sap.changelog.Changes.hdbmigrationtable');
6
+ const UNDEPLOY_JSON_PATH = join('db', 'undeploy.json');
7
+
8
+ const UNDEPLOY_ENTRIES = ['src/gen/**/sap.changelog.Changes.hdbtable', 'src/gen/**/sap.changelog.ChangeLog.hdbtable'];
9
+
10
+ const LOG = cds.log('change-tracking');
11
+
12
+ const { getMigrationTableSQL } = require('./hana/migrationTable.js');
13
+
14
+ module.exports = class extends cds.add.Plugin {
15
+ async run() {
16
+ if (fs.existsSync(MIGRATION_TABLE_PATH)) {
17
+ const existing = fs.readFileSync(MIGRATION_TABLE_PATH, 'utf8');
18
+ const versionMatch = [...existing.matchAll(/==\s*version=(\d+)/g)];
19
+ const latestVersion = versionMatch.length > 0 ? Math.max(...versionMatch.map((m) => parseInt(m[1]))) : 1;
20
+
21
+ if (latestVersion >= 2) {
22
+ LOG.warn(`Migration table already exists at ${MIGRATION_TABLE_PATH} (latest version: ${latestVersion}). ` + `Only the initial v1 -> v2 migration is supported by this command. ` + `Please add new migration steps manually.`);
23
+ return;
24
+ }
25
+
26
+ // Rewrite file with v2 DDL and migration (replaces v1 content)
27
+ fs.writeFileSync(MIGRATION_TABLE_PATH, getMigrationTableSQL());
28
+ LOG.info(`Updated ${MIGRATION_TABLE_PATH} with v2 migration`);
29
+ } else {
30
+ // Write the migration table file
31
+ const dir = join('db', 'src');
32
+ if (!fs.existsSync(dir)) {
33
+ fs.mkdirSync(dir, { recursive: true });
34
+ }
35
+ fs.writeFileSync(MIGRATION_TABLE_PATH, getMigrationTableSQL());
36
+ LOG.info(`Created ${MIGRATION_TABLE_PATH}`);
37
+ }
38
+
39
+ // Update undeploy.json
40
+ let undeploy = [];
41
+ if (fs.existsSync(UNDEPLOY_JSON_PATH)) {
42
+ undeploy = JSON.parse(fs.readFileSync(UNDEPLOY_JSON_PATH, 'utf8'));
43
+ }
44
+ if (!Array.isArray(undeploy)) undeploy = [];
45
+
46
+ let changed = false;
47
+ for (const entry of UNDEPLOY_ENTRIES) {
48
+ if (!undeploy.includes(entry)) {
49
+ undeploy.push(entry);
50
+ changed = true;
51
+ }
52
+ }
53
+
54
+ if (changed) {
55
+ fs.writeFileSync(UNDEPLOY_JSON_PATH, JSON.stringify(undeploy, null, 4) + '\n');
56
+ LOG.info(`Updated ${UNDEPLOY_JSON_PATH} with old .hdbtable entries`);
57
+ }
58
+ }
59
+ };
@@ -37,7 +37,7 @@ function collectTrackedPropertiesWithDynamicLocalization(serviceName, m) {
37
37
  continue;
38
38
  }
39
39
 
40
- if (!dynamicLocalizationProperties.some((prop) => prop.property === basePropertyName && prop.entity === base.baseRef)) {
40
+ if (!dynamicLocalizationProperties.some((prop) => prop.property === basePropertyName && prop.entity === base.baseRef) && m.definitions[target + '.texts']) {
41
41
  dynamicLocalizationProperties.push({
42
42
  property: basePropertyName,
43
43
  entity: base.baseRef,
@@ -21,7 +21,7 @@ function entityKey4(entity) {
21
21
  keys.push({ ref: [k] });
22
22
  }
23
23
  }
24
- if (keys.length <= 1) return keys;
24
+ if (keys.length <= 1) return keys.map((k) => ({ ...k, cast: { type: 'cds.String' } }));
25
25
 
26
26
  const xpr = [];
27
27
  for (let i = 0; i < keys.length; i++) {
@@ -46,9 +46,9 @@ function enhanceChangeViewWithTimeZones(changeView, m) {
46
46
  ? compositeKeyExprHANA
47
47
  : isDeploy2Check('postgres') && m.meta.creator.match(/v6/)
48
48
  ? compositeKeyExprPG
49
- : cds.env.requires?.db.kind === 'sqlite' && !cds.build
49
+ : cds.env.requires?.db?.kind === 'sqlite' && !cds.build
50
50
  ? compositeKeyExprSqlite
51
- : cds.env.requires?.db.kind === 'postgres' && (!cds.build || cds.env.profiles.includes('pg'))
51
+ : cds.env.requires?.db?.kind === 'postgres' && (!cds.build || cds.env.profiles.includes('pg'))
52
52
  ? compositeKeyExprPG
53
53
  : compositeKeyExprHANA;
54
54
  const timezoneProperties = collectTrackedPropertiesWithTimezone(m);
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Returns the .hdbmigrationtable content for migrating sap.changelog.Changes from v1 to v2.
3
+ */
4
+
5
+ function getMigrationTableSQL() {
6
+ return `== version=2
7
+ COLUMN TABLE sap_changelog_Changes (
8
+ ID NVARCHAR(36) NOT NULL,
9
+ parent_ID NVARCHAR(36),
10
+ attribute NVARCHAR(127),
11
+ valueChangedFrom NVARCHAR(5000),
12
+ valueChangedTo NVARCHAR(5000),
13
+ valueChangedFromLabel NVARCHAR(5000),
14
+ valueChangedToLabel NVARCHAR(5000),
15
+ entity NVARCHAR(150),
16
+ entityKey NVARCHAR(5000),
17
+ objectID NVARCHAR(5000),
18
+ modification NVARCHAR(6),
19
+ valueDataType NVARCHAR(5000),
20
+ createdAt TIMESTAMP,
21
+ createdBy NVARCHAR(255),
22
+ transactionID BIGINT,
23
+ PRIMARY KEY(ID)
24
+ )
25
+
26
+ == migration=2
27
+ RENAME COLUMN sap_changelog_Changes.entityID TO objectID;
28
+
29
+ ALTER TABLE sap_changelog_Changes ADD (parent_ID NVARCHAR(36), valueChangedFromLabel NVARCHAR(5000), valueChangedToLabel NVARCHAR(5000), transactionID BIGINT);
30
+
31
+ -- Adjust entityKey structure
32
+ RENAME COLUMN sap_changelog_Changes.keys TO entityKey;
33
+ UPDATE SAP_CHANGELOG_CHANGES
34
+ SET entityKey =
35
+ CASE
36
+ WHEN LOCATE(entityKey, '=') > 0 THEN
37
+ TRIM(SUBSTRING(entityKey, LOCATE(entityKey, '=') + 1))
38
+ ELSE
39
+ NULL
40
+ END;
41
+
42
+ -- Copy changelog_ID into transactionID
43
+ UPDATE SAP_CHANGELOG_CHANGES SET transactionID = CAST(SECONDS_BETWEEN(createdAt, TO_TIMESTAMP('1970-01-01 00:00:00')) * -1000 AS BIGINT);
44
+
45
+ -- Column migration for attribute, entity and modification
46
+ ALTER TABLE sap_changelog_Changes ADD (attribute_tmp NVARCHAR(127), entity_tmp NVARCHAR(150), modification_tmp NVARCHAR(6));
47
+
48
+ -- Copy data into temp columns
49
+ UPDATE sap_changelog_Changes SET attribute_tmp = attribute;
50
+ UPDATE sap_changelog_Changes SET entity_tmp = entity;
51
+ UPDATE sap_changelog_Changes SET modification_tmp = modification;
52
+
53
+ ALTER TABLE sap_changelog_Changes DROP (attribute, entity, modification);
54
+
55
+ ALTER TABLE sap_changelog_Changes ADD (attribute NVARCHAR(127), entity NVARCHAR(150), modification NVARCHAR(6));
56
+
57
+ -- Restore data from temp columns
58
+ UPDATE sap_changelog_Changes SET attribute = attribute_tmp;
59
+ UPDATE sap_changelog_Changes SET entity = entity_tmp;
60
+ UPDATE sap_changelog_Changes SET modification = modification_tmp;
61
+
62
+ ALTER TABLE sap_changelog_Changes DROP (attribute_tmp, entity_tmp, modification_tmp);
63
+
64
+ -- Drop columns that are no longer needed
65
+ ALTER TABLE sap_changelog_Changes DROP (serviceEntity, parentEntityID, parentKey, serviceEntityPath, changeLog_ID);
66
+ `;
67
+ }
68
+
69
+ module.exports = { getMigrationTableSQL };
@@ -3,6 +3,12 @@ const cds = require('@sap/cds');
3
3
  const { prepareCSNForTriggers, generateTriggersForEntities, ensureUndeployJsonHasTriggerPattern } = require('../utils/trigger-utils.js');
4
4
  const { getLabelTranslations } = require('../localization.js');
5
5
 
6
+ const MIGRATION_TABLE_PATH = cds.utils.path.join('db', 'src', 'sap.changelog.Changes.hdbmigrationtable');
7
+
8
+ function hasMigrationTable() {
9
+ return cds.utils.fs.existsSync(MIGRATION_TABLE_PATH);
10
+ }
11
+
6
12
  function registerHDICompilerHook() {
7
13
  const _hdi_migration = cds.compiler.to.hdi.migration;
8
14
  cds.compiler.to.hdi.migration = function (csn, options, beforeImage) {
@@ -38,20 +44,37 @@ function registerHDICompilerHook() {
38
44
  );
39
45
  }
40
46
 
47
+ const config = cds.env.requires?.['change-tracking'];
48
+
49
+ // Generate restore backlinks procedure if enabled via feature flag
50
+ if (config?.procedureForRestoringBacklinks) {
51
+ const { generateRestoreBacklinksProcedure } = require('./restoreProcedure.js');
52
+ const procedure = generateRestoreBacklinksProcedure(runtimeCSN, hierarchy, entities);
53
+ if (procedure) data.push(procedure);
54
+ }
55
+
56
+ // Auto-detect migration table created by `cds add change-tracking`
57
+ if (hasMigrationTable()) {
58
+ csn.definitions['sap.changelog.Changes']['@cds.persistence.journal'] = true;
59
+ }
60
+
41
61
  const ret = _hdi_migration(csn, options, beforeImage);
42
62
  ret.definitions = ret.definitions.concat(triggers).concat(data);
43
63
  return ret;
44
64
  };
45
65
 
46
- // REVISIT: Remove once time casting is fixed in cds-dbs
47
- cds.on('serving', async () => {
48
- if (cds.env.requires?.db.kind !== 'hana') return;
49
- const db = await cds.connect.to('db');
50
- db.before('*', () => {
51
- // to_time conversion is necessary else HANA tries to convert to timestamp implicitly causing an SQL crash
52
- db.class.CQN2SQL.OutputConverters.Time = (e) => `to_char(to_time(${e}), 'HH24:MI:SS')`;
53
- });
54
- });
66
+ // When a migration table file exists in db/src/, strip compiler-generated changesets for sap.changelog.Changes
67
+ // Prevent auto-generation of additional migration steps by the build
68
+ const _compile_to_hana = cds.compile.to.hana;
69
+ cds.compile.to.hana = function (csn, o, beforeCsn) {
70
+ if (hasMigrationTable() && beforeCsn) {
71
+ // Remove the Changes entity from the beforeImage so the compiler
72
+ const beforeClone = structuredClone(beforeCsn);
73
+ delete beforeClone.definitions?.['sap.changelog.Changes'];
74
+ return _compile_to_hana.call(this, csn, o, beforeClone);
75
+ }
76
+ return _compile_to_hana.call(this, csn, o, beforeCsn);
77
+ };
55
78
  }
56
79
 
57
80
  module.exports = { registerHDICompilerHook };
@@ -0,0 +1,342 @@
1
+ const utils = require('../utils/change-tracking.js');
2
+ const { getCompositionParentInfo, getGrandParentCompositionInfo } = require('../utils/composition-helpers.js');
3
+ const { compositeKeyExpr } = require('./sql-expressions.js');
4
+
5
+ /**
6
+ * Generates a HANA stored procedure that restores parent backlinks for composition changes.
7
+ *
8
+ * The procedure:
9
+ * 1. Finds all change entries for composition child entities that have no parent_ID set
10
+ * 2. Uses the child data table to resolve the parent entity key via FK lookup
11
+ * 3. Creates a parent composition entry (valueDataType='cds.Composition') if one doesn't exist
12
+ * 4. Updates child entries to set parent_ID pointing to the parent composition entry
13
+ * 5. Links composition entries to their grandparent composition entries (for deep hierarchies)
14
+ *
15
+ */
16
+ function generateRestoreBacklinksProcedure(runtimeCSN, hierarchy, entities) {
17
+ const compositions = _collectCompositionInfo(runtimeCSN, hierarchy, entities);
18
+ if (compositions.length === 0) return null;
19
+
20
+ const blocks = compositions.map((comp) => _generateCompositionBlock(comp));
21
+
22
+ const procedureSQL = `PROCEDURE "SAP_CHANGELOG_RESTORE_BACKLINKS" ()
23
+ LANGUAGE SQLSCRIPT
24
+ SQL SECURITY INVOKER
25
+ AS
26
+ BEGIN
27
+ ${blocks.join('\n')}
28
+ END`;
29
+
30
+ return {
31
+ name: 'SAP_CHANGELOG_RESTORE_BACKLINKS',
32
+ sql: procedureSQL,
33
+ suffix: '.hdbprocedure'
34
+ };
35
+ }
36
+
37
+ function _collectCompositionInfo(runtimeCSN, hierarchy, entities) {
38
+ const result = [];
39
+
40
+ for (const [childEntityName, hierarchyInfo] of hierarchy) {
41
+ const { parent: parentEntityName, compositionField } = hierarchyInfo;
42
+ if (!parentEntityName || !compositionField) continue;
43
+
44
+ const childEntity = runtimeCSN.definitions[childEntityName];
45
+ const parentEntity = runtimeCSN.definitions[parentEntityName];
46
+ if (!childEntity || !parentEntity) continue;
47
+
48
+ // Check if this entity is actually tracked (in our entities list)
49
+ const isTracked = entities.some((e) => e.dbEntityName === childEntityName);
50
+ if (!isTracked) continue;
51
+
52
+ // Get the FK binding from child to parent
53
+ const parentMergedAnnotations = entities.find((e) => e.dbEntityName === parentEntityName)?.mergedAnnotations;
54
+ const compositionParentInfo = getCompositionParentInfo(childEntity, parentEntity, parentMergedAnnotations);
55
+ if (!compositionParentInfo) continue;
56
+
57
+ const { parentKeyBinding } = compositionParentInfo;
58
+
59
+ // Skip composition of one - they have reverse FK direction and different handling
60
+ if (parentKeyBinding.type === 'compositionOfOne') continue;
61
+
62
+ const childKeys = utils.extractKeys(childEntity.keys);
63
+ const parentKeys = utils.extractKeys(parentEntity.keys);
64
+ const rootObjectIDs = utils.getObjectIDs(parentEntity, runtimeCSN, parentMergedAnnotations?.entityAnnotation);
65
+
66
+ // Collect child entity's objectIDs for restoring objectID on orphaned child entries
67
+ const childMergedAnnotations = entities.find((e) => e.dbEntityName === childEntityName)?.mergedAnnotations;
68
+ const childObjectIDs = utils.getObjectIDs(childEntity, runtimeCSN, childMergedAnnotations?.entityAnnotation);
69
+
70
+ // Collect grandparent info for deep hierarchies (e.g., Level2 -> Level1 -> Root)
71
+ const grandParentEntityName = hierarchyInfo?.grandParent ?? null;
72
+ const grandParentEntity = grandParentEntityName ? runtimeCSN.definitions[grandParentEntityName] : null;
73
+ const grandParentMergedAnnotations = grandParentEntityName ? entities.find((e) => e.dbEntityName === grandParentEntityName)?.mergedAnnotations : null;
74
+ const grandParentCompositionField = hierarchyInfo?.grandParentCompositionField ?? null;
75
+ const grandParentCompositionInfo = getGrandParentCompositionInfo(parentEntity, grandParentEntity, grandParentMergedAnnotations, grandParentCompositionField);
76
+
77
+ // If there's a grandparent, collect its keys, table name, and objectIDs for entry creation
78
+ let grandParentKeys, grandParentTableName, grandParentObjectIDs;
79
+ if (grandParentCompositionInfo && grandParentEntity) {
80
+ grandParentKeys = utils.extractKeys(grandParentEntity.keys);
81
+ grandParentTableName = utils.transformName(grandParentEntityName);
82
+ grandParentObjectIDs = utils.getObjectIDs(grandParentEntity, runtimeCSN, grandParentMergedAnnotations?.entityAnnotation);
83
+ }
84
+
85
+ result.push({
86
+ childEntityName,
87
+ parentEntityName,
88
+ compositionField,
89
+ childTableName: utils.transformName(childEntityName),
90
+ parentTableName: utils.transformName(parentEntityName),
91
+ fkFields: parentKeyBinding,
92
+ childKeys,
93
+ parentKeys,
94
+ rootObjectIDs,
95
+ childObjectIDs,
96
+ grandParentCompositionInfo,
97
+ grandParentKeys,
98
+ grandParentTableName,
99
+ grandParentObjectIDs
100
+ });
101
+ }
102
+
103
+ return result;
104
+ }
105
+
106
+ /**
107
+ * Builds the JOIN condition between Changes.entityKey and the child data table.
108
+ * Handles both v2 format (HIERARCHY_COMPOSITE_ID for multi-key) and v1 migrated format (single key only).
109
+ */
110
+ function _buildChildKeyJoinCondition(childKeys, alias, changesAlias) {
111
+ const compositeExpr = compositeKeyExpr(childKeys.map((k) => `${alias}.${k}`));
112
+
113
+ // Single key: straightforward join
114
+ if (childKeys.length <= 1) {
115
+ return `${changesAlias}.ENTITYKEY = ${compositeExpr}`;
116
+ }
117
+
118
+ // Composite keys: support both v2 format (HIERARCHY_COMPOSITE_ID) and v1 migrated format (single ID only)
119
+ // v1 migrated data may have stored only the last key segment as entityKey
120
+ const lastKey = childKeys[childKeys.length - 1];
121
+ return `(${changesAlias}.ENTITYKEY = ${compositeExpr} OR ${changesAlias}.ENTITYKEY = TO_NVARCHAR(${alias}.${lastKey}))`;
122
+ }
123
+
124
+ /**
125
+ * Generates the SQL block for a single composition relationship.
126
+ */
127
+ function _generateCompositionBlock(comp) {
128
+ const {
129
+ childEntityName,
130
+ parentEntityName,
131
+ compositionField,
132
+ childTableName,
133
+ parentTableName,
134
+ fkFields,
135
+ childKeys,
136
+ parentKeys,
137
+ rootObjectIDs,
138
+ childObjectIDs,
139
+ grandParentCompositionInfo,
140
+ grandParentKeys,
141
+ grandParentTableName,
142
+ grandParentObjectIDs
143
+ } = comp;
144
+
145
+ // Build JOIN condition handling both v2 composite keys and v1 migrated simple keys
146
+ const childKeyJoinStep1 = _buildChildKeyJoinCondition(childKeys, 'child_data', 'c');
147
+ const childKeyJoinStep2 = _buildChildKeyJoinCondition(childKeys, 'child_data', 'c2');
148
+
149
+ // Expression to compute the parent's entity key from the child data table's FK columns
150
+ const parentKeyFromChild = compositeKeyExpr(fkFields.map((fk) => `child_data.${fk}`));
151
+
152
+ // ObjectID expression for the parent composition entry
153
+ // Only use objectIDs that are direct columns on the parent table (not association paths requiring JOINs)
154
+ const simpleObjectIDs = rootObjectIDs?.filter((oid) => !oid.name.includes('.')) ?? [];
155
+ let objectIDExpr;
156
+ if (simpleObjectIDs.length > 0) {
157
+ const parts = simpleObjectIDs.map((oid) => `COALESCE(TO_NVARCHAR((SELECT ${oid.name} FROM ${parentTableName} WHERE ${parentKeys.map((pk) => `${pk} = grp.PARENT_ENTITYKEY`).join(' AND ')})), '')`);
158
+ const concatExpr = parts.length > 1 ? parts.join(" || ', ' || ") : parts[0];
159
+ objectIDExpr = `COALESCE(NULLIF(${concatExpr}, ''), grp.PARENT_ENTITYKEY)`;
160
+ } else {
161
+ objectIDExpr = 'grp.PARENT_ENTITYKEY';
162
+ }
163
+
164
+ // Child objectID expression for restoring objectID on orphaned child entries
165
+ // Uses @changelog fields if available, otherwise falls back to the child's entity key
166
+ const simpleChildObjectIDs = childObjectIDs?.filter((oid) => !oid.name.includes('.')) ?? [];
167
+ const childEntityKeyExpr = compositeKeyExpr(childKeys.map((k) => `child_data.${k}`));
168
+ let childObjectIDExpr;
169
+ if (simpleChildObjectIDs.length > 0) {
170
+ const childParts = simpleChildObjectIDs.map((oid) => `COALESCE(TO_NVARCHAR(child_data.${oid.name}), '<empty>')`);
171
+ const childConcatExpr = childParts.length > 1 ? childParts.join(" || ', ' || ") : childParts[0];
172
+ childObjectIDExpr = `COALESCE(NULLIF(${childConcatExpr}, ''), ${childEntityKeyExpr})`;
173
+ } else {
174
+ childObjectIDExpr = childEntityKeyExpr;
175
+ }
176
+
177
+ // Modification: 'create' if the parent entity was created in the same tx, 'update' otherwise
178
+ const modificationExpr = `CASE WHEN EXISTS (
179
+ SELECT 1 FROM SAP_CHANGELOG_CHANGES
180
+ WHERE entity = '${parentEntityName}'
181
+ AND entityKey = grp.PARENT_ENTITYKEY
182
+ AND modification = 'create'
183
+ AND transactionID = grp.TRANSACTIONID
184
+ ) THEN 'create' ELSE 'update' END`;
185
+
186
+ let block = `
187
+ -- ============================================================================
188
+ -- Restore backlinks: ${childEntityName} -> ${parentEntityName}.${compositionField}
189
+ -- ============================================================================
190
+
191
+ -- Step 1: Create parent composition entries where missing
192
+ INSERT INTO SAP_CHANGELOG_CHANGES
193
+ (ID, parent_ID, attribute, entity, entityKey, objectID, createdAt, createdBy, valueDataType, modification, transactionID)
194
+ SELECT
195
+ SYSUUID,
196
+ NULL,
197
+ '${compositionField}',
198
+ '${parentEntityName}',
199
+ grp.PARENT_ENTITYKEY,
200
+ ${objectIDExpr},
201
+ grp.MIN_CREATEDAT,
202
+ grp.CREATEDBY,
203
+ 'cds.Composition',
204
+ ${modificationExpr},
205
+ grp.TRANSACTIONID
206
+ FROM (
207
+ SELECT
208
+ ${parentKeyFromChild} AS PARENT_ENTITYKEY,
209
+ c.TRANSACTIONID,
210
+ MIN(c.CREATEDAT) AS MIN_CREATEDAT,
211
+ MIN(c.CREATEDBY) AS CREATEDBY
212
+ FROM SAP_CHANGELOG_CHANGES c
213
+ INNER JOIN ${childTableName} child_data
214
+ ON ${childKeyJoinStep1}
215
+ WHERE c.entity = '${childEntityName}'
216
+ AND c.parent_ID IS NULL
217
+ AND c.valueDataType != 'cds.Composition'
218
+ AND NOT EXISTS (
219
+ SELECT 1 FROM SAP_CHANGELOG_CHANGES p
220
+ WHERE p.entity = '${parentEntityName}'
221
+ AND p.attribute = '${compositionField}'
222
+ AND p.valueDataType = 'cds.Composition'
223
+ AND p.transactionID = c.transactionID
224
+ AND p.entityKey = ${parentKeyFromChild}
225
+ )
226
+ GROUP BY ${parentKeyFromChild}, c.TRANSACTIONID, c.CREATEDBY
227
+ ) grp;
228
+
229
+ -- Step 2: Link orphaned child entries to their parent composition entry and restore objectID
230
+ MERGE INTO SAP_CHANGELOG_CHANGES AS c
231
+ USING (
232
+ SELECT c2.ID AS CHILD_ID, p.ID AS PARENT_ID, ${childObjectIDExpr} AS CHILD_OBJECTID
233
+ FROM SAP_CHANGELOG_CHANGES c2
234
+ INNER JOIN ${childTableName} child_data
235
+ ON ${childKeyJoinStep2}
236
+ INNER JOIN SAP_CHANGELOG_CHANGES p
237
+ ON p.entity = '${parentEntityName}'
238
+ AND p.attribute = '${compositionField}'
239
+ AND p.valueDataType = 'cds.Composition'
240
+ AND p.transactionID = c2.transactionID
241
+ AND p.entityKey = ${parentKeyFromChild}
242
+ WHERE c2.entity = '${childEntityName}'
243
+ AND c2.parent_ID IS NULL
244
+ AND c2.valueDataType != 'cds.Composition'
245
+ ) AS matched
246
+ ON c.ID = matched.CHILD_ID
247
+ WHEN MATCHED THEN UPDATE SET c.parent_ID = matched.PARENT_ID, c.objectID = matched.CHILD_OBJECTID;`;
248
+
249
+ // Create grandparent composition entries and link to them (for deep hierarchies)
250
+ if (grandParentCompositionInfo) {
251
+ const { grandParentEntityName, grandParentCompositionFieldName, grandParentKeyBinding } = grandParentCompositionInfo;
252
+
253
+ // Build expression to resolve the grandparent entity key from the parent data table's FK columns
254
+ const grandParentKeyFromParent = compositeKeyExpr(grandParentKeyBinding.map((fk) => `parent_data.${fk}`));
255
+
256
+ // Build grandparent objectID expression
257
+ const simpleGPObjectIDs = grandParentObjectIDs?.filter((oid) => !oid.name.includes('.')) ?? [];
258
+ let gpObjectIDExpr;
259
+ if (simpleGPObjectIDs.length > 0) {
260
+ const gpParts = simpleGPObjectIDs.map((oid) => `COALESCE(TO_NVARCHAR((SELECT ${oid.name} FROM ${grandParentTableName} WHERE ${grandParentKeys.map((pk) => `${pk} = grp2.GP_ENTITYKEY`).join(' AND ')})), '')`);
261
+ const gpConcatExpr = gpParts.length > 1 ? gpParts.join(" || ', ' || ") : gpParts[0];
262
+ gpObjectIDExpr = `COALESCE(NULLIF(${gpConcatExpr}, ''), grp2.GP_ENTITYKEY)`;
263
+ } else {
264
+ gpObjectIDExpr = 'grp2.GP_ENTITYKEY';
265
+ }
266
+
267
+ // Modification for grandparent: 'create' if grandparent was created in same tx, 'update' otherwise
268
+ const gpModificationExpr = `CASE WHEN EXISTS (
269
+ SELECT 1 FROM SAP_CHANGELOG_CHANGES
270
+ WHERE entity = '${grandParentEntityName}'
271
+ AND entityKey = grp2.GP_ENTITYKEY
272
+ AND modification = 'create'
273
+ AND transactionID = grp2.TRANSACTIONID
274
+ ) THEN 'create' ELSE 'update' END`;
275
+
276
+ block += `
277
+
278
+ -- Step 3a: Create grandparent composition entries where missing
279
+ INSERT INTO SAP_CHANGELOG_CHANGES
280
+ (ID, parent_ID, attribute, entity, entityKey, objectID, createdAt, createdBy, valueDataType, modification, transactionID)
281
+ SELECT
282
+ SYSUUID,
283
+ NULL,
284
+ '${grandParentCompositionFieldName}',
285
+ '${grandParentEntityName}',
286
+ grp2.GP_ENTITYKEY,
287
+ ${gpObjectIDExpr},
288
+ grp2.MIN_CREATEDAT,
289
+ grp2.CREATEDBY,
290
+ 'cds.Composition',
291
+ ${gpModificationExpr},
292
+ grp2.TRANSACTIONID
293
+ FROM (
294
+ SELECT
295
+ ${grandParentKeyFromParent} AS GP_ENTITYKEY,
296
+ comp2.TRANSACTIONID,
297
+ MIN(comp2.CREATEDAT) AS MIN_CREATEDAT,
298
+ MIN(comp2.CREATEDBY) AS CREATEDBY
299
+ FROM SAP_CHANGELOG_CHANGES comp2
300
+ INNER JOIN ${parentTableName} parent_data
301
+ ON comp2.ENTITYKEY = ${compositeKeyExpr(parentKeys.map((pk) => `parent_data.${pk}`))}
302
+ WHERE comp2.entity = '${parentEntityName}'
303
+ AND comp2.attribute = '${compositionField}'
304
+ AND comp2.valueDataType = 'cds.Composition'
305
+ AND comp2.parent_ID IS NULL
306
+ AND NOT EXISTS (
307
+ SELECT 1 FROM SAP_CHANGELOG_CHANGES gp
308
+ WHERE gp.entity = '${grandParentEntityName}'
309
+ AND gp.attribute = '${grandParentCompositionFieldName}'
310
+ AND gp.valueDataType = 'cds.Composition'
311
+ AND gp.transactionID = comp2.transactionID
312
+ AND gp.entityKey = ${grandParentKeyFromParent}
313
+ )
314
+ GROUP BY ${grandParentKeyFromParent}, comp2.TRANSACTIONID, comp2.CREATEDBY
315
+ ) grp2;
316
+
317
+ -- Step 3b: Link composition entries to their grandparent composition entries
318
+ MERGE INTO SAP_CHANGELOG_CHANGES AS comp
319
+ USING (
320
+ SELECT comp2.ID AS COMP_ID, gp.ID AS GRANDPARENT_ID
321
+ FROM SAP_CHANGELOG_CHANGES comp2
322
+ INNER JOIN ${parentTableName} parent_data
323
+ ON comp2.ENTITYKEY = ${compositeKeyExpr(parentKeys.map((pk) => `parent_data.${pk}`))}
324
+ INNER JOIN SAP_CHANGELOG_CHANGES gp
325
+ ON gp.entity = '${grandParentEntityName}'
326
+ AND gp.attribute = '${grandParentCompositionFieldName}'
327
+ AND gp.valueDataType = 'cds.Composition'
328
+ AND gp.transactionID = comp2.transactionID
329
+ AND gp.entityKey = ${grandParentKeyFromParent}
330
+ WHERE comp2.entity = '${parentEntityName}'
331
+ AND comp2.attribute = '${compositionField}'
332
+ AND comp2.valueDataType = 'cds.Composition'
333
+ AND comp2.parent_ID IS NULL
334
+ ) AS matched
335
+ ON comp.ID = matched.COMP_ID
336
+ WHEN MATCHED THEN UPDATE SET comp.parent_ID = matched.GRANDPARENT_ID;`;
337
+ }
338
+
339
+ return block;
340
+ }
341
+
342
+ module.exports = { generateRestoreBacklinksProcedure };
@@ -1,15 +1,14 @@
1
1
  const utils = require('../utils/change-tracking.js');
2
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
3
  const { createTriggerCQN2SQL } = require('../TriggerCQN2SQL.js');
7
4
 
8
- const TriggerCQN2SQL = createTriggerCQN2SQL(HANAService.CQN2SQL);
9
5
  let HANACQN2SQL;
10
6
 
11
7
  function toSQL(query, model) {
8
+ const cqn4sql = require('@cap-js/db-service/lib/cqn4sql');
12
9
  if (!HANACQN2SQL) {
10
+ const { CQN2SQL } = require('@cap-js/hana');
11
+ const TriggerCQN2SQL = createTriggerCQN2SQL(CQN2SQL);
13
12
  HANACQN2SQL = new TriggerCQN2SQL();
14
13
  }
15
14
  const sqlCQN = cqn4sql(query, model);
@@ -27,7 +26,7 @@ function getElementSkipCondition(entityName, elementName) {
27
26
  }
28
27
 
29
28
  function compositeKeyExpr(parts) {
30
- if (parts.length <= 1) return parts[0];
29
+ if (parts.length <= 1) return `TO_NVARCHAR(${parts[0]})`;
31
30
  return `HIERARCHY_COMPOSITE_ID(${parts.join(', ')})`;
32
31
  }
33
32
 
@@ -26,18 +26,6 @@ function registerPostgresCompilerHook() {
26
26
 
27
27
  return ddl;
28
28
  });
29
-
30
- // REVISIT: Remove once time casting is fixed in cds-dbs
31
- cds.on('serving', async () => {
32
- if (cds.env.requires?.db.kind !== 'postgres') return;
33
- const db = await cds.connect.to('db');
34
- db.before('*', () => {
35
- db.class.CQN2SQL.OutputConverters.Date = (e) => `to_date(${e}::text, 'YYYY-MM-DD')`;
36
- db.class.CQN2SQL.OutputConverters.Time = (e) => `to_timestamp(${e}::text, 'HH24:MI:SS')::TIME`;
37
- db.class.CQN2SQL.OutputConverters.DateTime = (e) => `to_timestamp(${e}::text, 'YYYY-MM-DD"T"HH24:MI:SS"Z"')::timestamp`;
38
- db.class.CQN2SQL.OutputConverters.Timestamp = (e) => `to_timestamp(${e}::text, 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"')::timestamp`;
39
- });
40
- });
41
29
  }
42
30
 
43
31
  async function deployPostgresLabels() {
@@ -1,13 +1,13 @@
1
1
  const cds = require('@sap/cds');
2
2
  const utils = require('../utils/change-tracking.js');
3
3
  const config = cds.env.requires['change-tracking'];
4
- const cqn4sql = require('@cap-js/db-service/lib/cqn4sql');
5
4
  const { CT_SKIP_VAR, getEntitySkipVarName, getElementSkipVarName } = require('../utils/session-variables.js');
6
5
  const { createTriggerCQN2SQL } = require('../TriggerCQN2SQL.js');
7
6
 
8
7
  const _cqn2sqlCache = new WeakMap();
9
8
 
10
9
  function toSQL(query, model) {
10
+ const cqn4sql = require('@cap-js/db-service/lib/cqn4sql');
11
11
  let cqn2sql = _cqn2sqlCache.get(model);
12
12
  if (!cqn2sql) {
13
13
  const Service = require('@cap-js/postgres');
@@ -7,7 +7,7 @@ const { setSkipSessionVariables, resetSkipSessionVariables, resetAutoSkipForServ
7
7
  * Register db handlers for setting/resetting session variables on INSERT/UPDATE/DELETE.
8
8
  */
9
9
  function registerSessionVariableHandlers() {
10
- cds.db.before(['INSERT', 'UPDATE', 'DELETE'], async (req) => {
10
+ cds.db?.before(['INSERT', 'UPDATE', 'DELETE'], async (req) => {
11
11
  const model = cds.context?.model ?? cds.model;
12
12
  const collectedEntities = model.collectEntities || (model.collectEntities = collectEntities(model).collectedEntities);
13
13
  if (!req.target || req.target.name.endsWith('.drafts')) return;
@@ -16,7 +16,7 @@ function registerSessionVariableHandlers() {
16
16
  setSkipSessionVariables(req, srv, collectedEntities);
17
17
  });
18
18
 
19
- cds.db.after(['INSERT', 'UPDATE', 'DELETE'], async (_, req) => {
19
+ cds.db?.after(['INSERT', 'UPDATE', 'DELETE'], async (_, req) => {
20
20
  if (!req.target || req.target.name.endsWith('.drafts')) return;
21
21
 
22
22
  // Reset auto-skip variable if it was set
@@ -2,10 +2,10 @@ const utils = require('../utils/change-tracking.js');
2
2
  const { CT_SKIP_VAR, getEntitySkipVarName, getElementSkipVarName } = require('../utils/session-variables.js');
3
3
  const { createTriggerCQN2SQL } = require('../TriggerCQN2SQL.js');
4
4
 
5
- const cqn4sql = require('@cap-js/db-service/lib/cqn4sql');
6
5
  const _cqn2sqlCache = new WeakMap();
7
6
 
8
7
  function toSQL(query, model) {
8
+ const cqn4sql = require('@cap-js/db-service/lib/cqn4sql');
9
9
  let cqn2sql = _cqn2sqlCache.get(model);
10
10
  if (!cqn2sql) {
11
11
  const SQLiteService = require('@cap-js/sqlite');
@@ -166,6 +166,12 @@ function extractTrackedColumns(entity, model = cds.context?.model ?? cds.model,
166
166
  const entry = { name: name, type: col.type };
167
167
 
168
168
  if (isAssociation) {
169
+ // Check if association target has a table
170
+ const targetEntity = model.definitions[col.target];
171
+ if (!targetEntity || targetEntity['@cds.persistence.skip'] === true) {
172
+ LOG.warn(`Skipped @changelog for ${name} on entity ${entity.name}: Association target "${col.target}" is annotated with @cds.persistence.skip!`);
173
+ continue;
174
+ }
169
175
  entry.target = col.target;
170
176
  // Use the resolved changelog annotation (which could be from override)
171
177
  if (Array.isArray(changelogAnnotation) && changelogAnnotation.length > 0) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cap-js/change-tracking",
3
- "version": "2.0.0-beta.5",
3
+ "version": "2.0.0-beta.6",
4
4
  "publishConfig": {
5
5
  "access": "public",
6
6
  "tag": "beta"
@@ -45,7 +45,8 @@
45
45
  "change-tracking": {
46
46
  "model": "@cap-js/change-tracking",
47
47
  "maxDisplayHierarchyDepth": 3,
48
- "preserveDeletes": false
48
+ "preserveDeletes": false,
49
+ "procedureForRestoringBacklinks": true
49
50
  }
50
51
  }
51
52
  },