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

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.
@@ -0,0 +1,81 @@
1
+ const cds = require('@sap/cds');
2
+ const { compositeKeyExpr: compositeKeyExprSqlite } = require('../sqlite/sql-expressions');
3
+ const { compositeKeyExpr: compositeKeyExprHANA } = require('../hana/sql-expressions');
4
+ const { compositeKeyExpr: compositeKeyExprPG } = require('../postgres/sql-expressions');
5
+ const { isChangeTracked } = require('../utils/entity-collector');
6
+
7
+ function collectTrackedPropertiesWithTimezone(m) {
8
+ const timezoneProperties = [];
9
+ for (const name in m.definitions) {
10
+ const entity = m.definitions[name];
11
+ if (entity.kind !== 'entity' || entity.query || entity.projection || !isChangeTracked(entity)) {
12
+ continue;
13
+ }
14
+ for (const ele in entity.elements) {
15
+ const element = entity.elements[ele];
16
+ if (!element['@Common.Timezone'] || element._foreignKey4) {
17
+ continue;
18
+ }
19
+ timezoneProperties.push({
20
+ property: ele,
21
+ entity: name,
22
+ timezone: element['@Common.Timezone']?.['=']
23
+ ? // Where condition is replaced when select is inserted into ChangeView
24
+ SELECT.from(name).alias('timezoneSubSelect').where('1 = 1').columns(element['@Common.Timezone']['='])
25
+ : element['@Common.Timezone']
26
+ });
27
+ }
28
+ }
29
+ return timezoneProperties;
30
+ }
31
+
32
+ function isDeploy2Check(target) {
33
+ try {
34
+ cds.build?.register(target, class ABC extends cds.build.Plugin {});
35
+ } catch (err) {
36
+ if (err.message.match(/already registered/)) {
37
+ return true;
38
+ }
39
+ }
40
+ return false;
41
+ }
42
+
43
+ function enhanceChangeViewWithTimeZones(changeView, m) {
44
+ const compositeKeyExpr =
45
+ isDeploy2Check('hana') && m.meta.creator.match(/v6/)
46
+ ? compositeKeyExprHANA
47
+ : isDeploy2Check('postgres') && m.meta.creator.match(/v6/)
48
+ ? compositeKeyExprPG
49
+ : cds.env.requires?.db.kind === 'sqlite' && !cds.build
50
+ ? compositeKeyExprSqlite
51
+ : cds.env.requires?.db.kind === 'postgres' && (!cds.build || cds.env.profiles.includes('pg'))
52
+ ? compositeKeyExprPG
53
+ : compositeKeyExprHANA;
54
+ const timezoneProperties = collectTrackedPropertiesWithTimezone(m);
55
+ const timezoneColumn = changeView.query.SELECT.columns.find((c) => c.as && c.as === 'valueTimeZone');
56
+ if (timezoneProperties.length === 0) return;
57
+ delete timezoneColumn.val;
58
+ timezoneColumn.xpr = ['case'];
59
+ for (const timezoneProp of timezoneProperties) {
60
+ timezoneColumn.xpr.push('when', { ref: ['attribute'] }, '=', { val: timezoneProp.property }, 'and', { ref: ['entity'] }, '=', { val: timezoneProp.entity }, 'then');
61
+ if (timezoneProp.timezone.SELECT) {
62
+ const subSelect = structuredClone(timezoneProp.timezone);
63
+ const keys = Object.keys(m.definitions[timezoneProp.entity].elements).filter((e) => m.definitions[timezoneProp.entity].elements[e].key);
64
+ subSelect.SELECT.where = [
65
+ { ref: ['change', 'entityKey'] },
66
+ '=',
67
+ // REVISIT: once HIERARCHY_COMPOSITE_ID is available on all DBs, use native CQN
68
+ compositeKeyExpr(keys)
69
+ ];
70
+ timezoneColumn.xpr.push(subSelect);
71
+ } else {
72
+ timezoneColumn.xpr.push({ val: timezoneProp.timezone });
73
+ }
74
+ }
75
+ timezoneColumn.xpr.push('else', { val: null }, 'end');
76
+ }
77
+
78
+ module.exports = {
79
+ collectTrackedPropertiesWithTimezone,
80
+ enhanceChangeViewWithTimeZones
81
+ };
@@ -709,7 +709,7 @@ function _generateObjectIDCalculation(objectIDs, entity, refRow, model) {
709
709
 
710
710
  for (const oid of objectIDs) {
711
711
  if (oid.included) {
712
- parts.push(`SELECT CAST(? AS VARCHAR) AS val`);
712
+ parts.push(`SELECT COALESCE(CAST(? AS VARCHAR), '<empty>') AS val`);
713
713
  bindings.push(`${refRow}.getString("${oid.name}")`);
714
714
  } else {
715
715
  // Sub-select needed (Lookup)
@@ -1,7 +1,7 @@
1
1
  const cds = require('@sap/cds');
2
- const { fs } = cds.utils;
3
2
 
4
- const { prepareCSNForTriggers, generateTriggersForEntities, writeLabelsCSV, ensureUndeployJsonHasTriggerPattern } = require('../utils/trigger-utils.js');
3
+ const { prepareCSNForTriggers, generateTriggersForEntities, ensureUndeployJsonHasTriggerPattern } = require('../utils/trigger-utils.js');
4
+ const { getLabelTranslations } = require('../localization.js');
5
5
 
6
6
  function registerHDICompilerHook() {
7
7
  const _hdi_migration = cds.compiler.to.hdi.migration;
@@ -10,19 +10,48 @@ function registerHDICompilerHook() {
10
10
  const { runtimeCSN, hierarchy, entities } = prepareCSNForTriggers(csn, true);
11
11
 
12
12
  const triggers = generateTriggersForEntities(runtimeCSN, hierarchy, entities, generateHANATriggers);
13
-
13
+ const data = [];
14
14
  if (triggers.length > 0) {
15
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
16
  ensureUndeployJsonHasTriggerPattern();
17
+
18
+ const labels = getLabelTranslations(entities, runtimeCSN);
19
+ const header = 'ID;locale;text';
20
+ const escape = (v) => {
21
+ const s = String(v ?? '');
22
+ return s.includes(';') || s.includes('\n') || s.includes('"') ? `"${s.replace(/"/g, '""')}"` : s;
23
+ };
24
+ const rows = labels.map((row) => `${escape(row.ID)};${escape(row.locale)};${escape(row.text)}`);
25
+ const i18nContent = [header, ...rows].join('\n') + '\n';
26
+
27
+ data.push(
28
+ {
29
+ name: 'sap.changelog-CHANGE_TRACKING_DUMMY',
30
+ sql: 'X\n1',
31
+ suffix: '.csv'
32
+ },
33
+ {
34
+ name: 'sap.changelog-i18nKeys',
35
+ sql: i18nContent,
36
+ suffix: '.csv'
37
+ }
38
+ );
20
39
  }
21
40
 
22
41
  const ret = _hdi_migration(csn, options, beforeImage);
23
- ret.definitions = [...ret.definitions, ...triggers];
42
+ ret.definitions = ret.definitions.concat(triggers).concat(data);
24
43
  return ret;
25
44
  };
45
+
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
+ });
26
55
  }
27
56
 
28
57
  module.exports = { registerHDICompilerHook };
@@ -152,7 +152,7 @@ function buildObjectIDExpr(objectIDs, entity, rowRef, model) {
152
152
  const parts = [];
153
153
  for (const oid of objectIDs) {
154
154
  if (oid.included) {
155
- parts.push(`TO_NVARCHAR(:${rowRef}.${oid.name})`);
155
+ parts.push(`COALESCE(TO_NVARCHAR(:${rowRef}.${oid.name}), '<empty>')`);
156
156
  } else {
157
157
  const where = keys.reduce((acc, k) => {
158
158
  acc[k] = { val: `:${rowRef}.${k}` };
@@ -26,6 +26,18 @@ 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
+ });
29
41
  }
30
42
 
31
43
  async function deployPostgresLabels() {
@@ -137,7 +137,7 @@ function buildObjectIDAssignment(objectIDs, entity, keys, recVar, targetVar, mod
137
137
  const parts = [];
138
138
  for (const oid of objectIDs) {
139
139
  if (oid.included) {
140
- parts.push(`${recVar}.${oid.name}::TEXT`);
140
+ parts.push(`COALESCE(${recVar}.${oid.name}::TEXT, '<empty>')`);
141
141
  } else {
142
142
  const where = keys.reduce((acc, k) => {
143
143
  acc[k] = { val: `${recVar}.${k}` };
@@ -148,7 +148,7 @@ function buildObjectIDSelect(objectIDs, entityName, entityKeys, refRow, model) {
148
148
  objectID.selectSQL = toSQL(query, model);
149
149
  }
150
150
 
151
- const unionParts = objectIDs.map((id) => (id.included ? `SELECT ${refRow}.${id.name} AS value WHERE ${refRow}.${id.name} IS NOT NULL` : `SELECT (${id.selectSQL}) AS value`));
151
+ const unionParts = objectIDs.map((id) => (id.included ? `SELECT COALESCE(${refRow}.${id.name}, '<empty>') AS value` : `SELECT (${id.selectSQL}) AS value`));
152
152
 
153
153
  return `(SELECT GROUP_CONCAT(value, ', ') FROM (${unionParts.join('\nUNION ALL\n')}))`;
154
154
  }
@@ -105,8 +105,12 @@ function extractTrackedColumns(entity, model = cds.context?.model ?? cds.model,
105
105
  // Use override annotation if provided, otherwise use the element's own annotation
106
106
  const changelogAnnotation = overrideAnnotations?.elementAnnotations?.[name] ?? col['@changelog'];
107
107
 
108
- // Skip non-changelog columns + association columns (we want the generated FKs instead)
109
- if (!changelogAnnotation || col._foreignKey4 || col['@odata.foreignKey4']) continue;
108
+ // Skip foreign key columns (we want the generated FKs instead)
109
+ if (col._foreignKey4 || col['@odata.foreignKey4']) continue;
110
+
111
+ // Auto-include composition elements, unless explicitly defined with @changelog: false
112
+ const isComposition = col.type === 'cds.Composition';
113
+ if ((!changelogAnnotation && !isComposition) || changelogAnnotation === false) continue;
110
114
 
111
115
  // skip any PersonalData* annotation
112
116
  const hasPersonalData = Object.keys(col).some((k) => k.startsWith('@PersonalData'));
@@ -2,8 +2,6 @@ const utils = require('./change-tracking.js');
2
2
 
3
3
  /**
4
4
  * Finds composition parent info for an entity.
5
- * Checks if root entity has a @changelog annotation on a composition field pointing to this entity.
6
- *
7
5
  * Returns null if not found, or an object with:
8
6
  * { parentEntityName, compositionFieldName, parentKeyBinding, isCompositionOfOne }
9
7
  */
@@ -13,9 +11,9 @@ function getCompositionParentInfo(entity, rootEntity, rootMergedAnnotations) {
13
11
  for (const [elemName, elem] of Object.entries(rootEntity.elements)) {
14
12
  if (elem.type !== 'cds.Composition' || elem.target !== entity.name) continue;
15
13
 
16
- // Check if this composition has @changelog annotation
14
+ // Check if this composition has @changelog: false annotation
17
15
  const changelogAnnotation = rootMergedAnnotations?.elementAnnotations?.[elemName] ?? elem['@changelog'];
18
- if (!changelogAnnotation) continue;
16
+ if (changelogAnnotation === false) continue;
19
17
 
20
18
  // Found a tracked composition - get the FK binding from child to parent
21
19
  const parentKeyBinding = utils.getCompositionParentBinding(entity, rootEntity);
@@ -46,12 +44,12 @@ function getCompositionParentInfo(entity, rootEntity, rootMergedAnnotations) {
46
44
  function getGrandParentCompositionInfo(rootEntity, grandParentEntity, grandParentMergedAnnotations, grandParentCompositionField) {
47
45
  if (!grandParentEntity || !grandParentCompositionField) return null;
48
46
 
49
- // Check if the grandparent's composition field has @changelog annotation
47
+ // Check if the grandparent's composition field has @changelog: false annotation
50
48
  const elem = grandParentEntity.elements?.[grandParentCompositionField];
51
49
  if (!elem || elem.type !== 'cds.Composition' || elem.target !== rootEntity.name) return null;
52
50
 
53
51
  const changelogAnnotation = grandParentMergedAnnotations?.elementAnnotations?.[grandParentCompositionField] ?? elem['@changelog'];
54
- if (!changelogAnnotation) return null;
52
+ if (changelogAnnotation === false) return null;
55
53
 
56
54
  // Get FK binding from rootEntity to grandParentEntity
57
55
  const grandParentKeyBinding = utils.getCompositionParentBinding(rootEntity, grandParentEntity);
@@ -8,6 +8,11 @@ function isChangeTracked(entity) {
8
8
  return Object.values(entity.elements).some((e) => e['@changelog']);
9
9
  }
10
10
 
11
+ function _hasTrackedElements(entity) {
12
+ if (!entity?.elements) return false;
13
+ return Object.values(entity.elements).some((e) => e['@changelog'] && e['@changelog'] !== false);
14
+ }
15
+
11
16
  // Compares two @changelog annotation values for equality
12
17
  function _annotationsEqual(a, b) {
13
18
  // Handle null/undefined/false cases
@@ -97,20 +102,27 @@ function _mergeChangelogAnnotations(dbEntity, serviceEntities) {
97
102
  };
98
103
  }
99
104
 
100
- function getEntitiesForTriggerGeneration(model, collected) {
101
- const result = [];
102
- const processedDbEntities = new Set();
105
+ function _extractServiceAnnotations(serviceEntity) {
106
+ const entityAnnotation = serviceEntity['@changelog'];
107
+ const elementAnnotations = {};
108
+ for (const element of serviceEntity.elements) {
109
+ if (element['@changelog'] !== undefined) {
110
+ elementAnnotations[element.name] = element['@changelog'];
111
+ }
112
+ }
113
+ return { entity: serviceEntity, entityAnnotation, elementAnnotations };
114
+ }
103
115
 
104
- // Process collected service entities - resolve entities and annotations from names
116
+ // Resolve collected service entities into DB entities with merged annotations
117
+ function _collectServiceEntities(model, collected, result, processed) {
105
118
  for (const [dbEntityName, serviceEntityNames] of collected) {
106
- processedDbEntities.add(dbEntityName);
119
+ processed.add(dbEntityName);
107
120
  const dbEntity = model[dbEntityName];
108
121
  if (!dbEntity) {
109
122
  DEBUG?.(`DB entity ${dbEntityName} not found in model, skipping`);
110
123
  continue;
111
124
  }
112
125
 
113
- // Resolve service entities and extract their annotations
114
126
  const serviceEntities = [];
115
127
  for (const name of serviceEntityNames) {
116
128
  const serviceEntity = model[name];
@@ -118,21 +130,7 @@ function getEntitiesForTriggerGeneration(model, collected) {
118
130
  DEBUG?.(`Service entity ${name} not found in model, skipping`);
119
131
  continue;
120
132
  }
121
-
122
- // Extract @changelog annotations from the service entity
123
- const entityAnnotation = serviceEntity['@changelog'];
124
- const elementAnnotations = {};
125
- for (const element of serviceEntity.elements) {
126
- if (element['@changelog'] !== undefined) {
127
- elementAnnotations[element.name] = element['@changelog'];
128
- }
129
- }
130
-
131
- serviceEntities.push({
132
- entity: serviceEntity,
133
- entityAnnotation,
134
- elementAnnotations
135
- });
133
+ serviceEntities.push(_extractServiceAnnotations(serviceEntity));
136
134
  }
137
135
 
138
136
  try {
@@ -144,42 +142,68 @@ function getEntitiesForTriggerGeneration(model, collected) {
144
142
  throw error;
145
143
  }
146
144
  }
145
+ }
147
146
 
148
- // Add table entities that have @changelog but weren't collected
147
+ // Include standalone DB entities that have @changelog but no service projection
148
+ function _collectStandaloneEntities(model, result, processed) {
149
149
  for (const def of model) {
150
150
  const isTableEntity = def.kind === 'entity' && !def.query && !def.projection;
151
- if (!isTableEntity || processedDbEntities.has(def.name)) continue;
151
+ if (!isTableEntity || processed.has(def.name)) continue;
152
152
 
153
153
  if (isChangeTracked(def)) {
154
- // No service entities collected, use null for mergedAnnotations (use entity's own annotations)
155
154
  result.push({ dbEntityName: def.name, mergedAnnotations: null });
156
- processedDbEntities.add(def.name);
155
+ processed.add(def.name);
157
156
  DEBUG?.(`Including DB entity ${def.name} directly (no service entities collected)`);
158
157
  }
159
158
  }
159
+ }
160
160
 
161
- // Add composition-of-many target entities that have @changelog on the composition field
162
- for (const { dbEntityName, mergedAnnotations } of [...result]) {
163
- const dbEntity = model[dbEntityName];
161
+ /**
162
+ * Auto-discover composition target entities up to the configured hierarchy depth
163
+ * Compositions are auto-tracked when the parent is tracked and field is not set to @changelog: false, and target has at least one @changelog element (or the field has an explicit @changelog)
164
+ */
165
+ function _discoverCompositionTargets(model, result, processed) {
166
+ const maxDepth = cds.env.requires?.['change-tracking']?.maxDisplayHierarchyDepth ?? 3;
167
+ let currentEntities = [...result];
168
+
169
+ for (let depth = 1; depth < maxDepth; depth++) {
170
+ const newEntities = [];
164
171
 
165
- for (const element of Object.values(dbEntity.elements)) {
166
- if (element.type !== 'cds.Composition' || !element.is2many || !element.target) continue;
172
+ for (const { dbEntityName, mergedAnnotations } of currentEntities) {
173
+ const dbEntity = model[dbEntityName];
174
+ if (!dbEntity) continue;
167
175
 
168
- const changelogAnnotation = mergedAnnotations?.elementAnnotations?.[element.name] ?? element['@changelog'];
169
- if (!changelogAnnotation) continue;
176
+ for (const element of Object.values(dbEntity.elements)) {
177
+ if (element.type !== 'cds.Composition' || !element.target) continue;
178
+ if (processed.has(element.target)) continue;
170
179
 
171
- // Skip if target entity is already processed
172
- if (processedDbEntities.has(element.target)) continue;
180
+ const changelogAnnotation = mergedAnnotations?.elementAnnotations?.[element.name] ?? element['@changelog'];
181
+ if (changelogAnnotation === false) continue;
173
182
 
174
- const targetEntity = model[element.target];
175
- if (!targetEntity) continue;
183
+ const targetEntity = model[element.target];
184
+ if (!targetEntity) continue;
185
+ if (!changelogAnnotation && !_hasTrackedElements(targetEntity)) continue;
176
186
 
177
- // Add target entity with null mergedAnnotations (it uses its own annotations, if any)
178
- result.push({ dbEntityName: element.target, mergedAnnotations: null });
179
- processedDbEntities.add(element.target);
180
- DEBUG?.(`Including composition target ${element.target} for tracked composition ${element.name} on ${dbEntityName}`);
187
+ const entry = { dbEntityName: element.target, mergedAnnotations: null };
188
+ result.push(entry);
189
+ processed.add(element.target);
190
+ newEntities.push(entry);
191
+ DEBUG?.(`Including composition target ${element.target} for ${changelogAnnotation ? 'tracked' : 'auto-tracked'} composition ${element.name} on ${dbEntityName} (depth ${depth})`);
192
+ }
181
193
  }
194
+
195
+ if (newEntities.length === 0) break;
196
+ currentEntities = newEntities;
182
197
  }
198
+ }
199
+
200
+ function getEntitiesForTriggerGeneration(model, collected) {
201
+ const result = [];
202
+ const processed = new Set();
203
+
204
+ _collectServiceEntities(model, collected, result, processed);
205
+ _collectStandaloneEntities(model, result, processed);
206
+ _discoverCompositionTargets(model, result, processed);
183
207
 
184
208
  return result;
185
209
  }
@@ -225,11 +249,12 @@ function analyzeCompositions(csn) {
225
249
 
226
250
  // Second pass: build hierarchy with grandparent info
227
251
  const hierarchy = new Map();
252
+ const maxDepth = cds.env.requires?.['change-tracking']?.maxDisplayHierarchyDepth ?? 3;
228
253
  for (const [childName, parentInfo] of childParentMap) {
229
254
  const { parent: parentName, compositionField } = parentInfo;
230
255
 
231
- // Check if the parent itself has a parent (grandparent)
232
- const grandParentInfo = childParentMap.get(parentName);
256
+ // Only include grandparent info if maxDisplayHierarchyDepth allows it (depth > 2 needed for grandparent)
257
+ const grandParentInfo = maxDepth > 2 ? childParentMap.get(parentName) : null;
233
258
 
234
259
  hierarchy.set(childName, {
235
260
  parent: parentName,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cap-js/change-tracking",
3
- "version": "2.0.0-beta.2",
3
+ "version": "2.0.0-beta.4",
4
4
  "publishConfig": {
5
5
  "access": "public",
6
6
  "tag": "beta"
@@ -44,7 +44,6 @@
44
44
  "requires": {
45
45
  "change-tracking": {
46
46
  "model": "@cap-js/change-tracking",
47
- "considerLocalizedValues": false,
48
47
  "maxDisplayHierarchyDepth": 3,
49
48
  "preserveDeletes": false
50
49
  }