@cap-js/change-tracking 2.0.0-beta.1 → 2.0.0-beta.3

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.
@@ -3,26 +3,18 @@ const { CT_SKIP_VAR, getEntitySkipVarName, getElementSkipVarName } = require('..
3
3
  const { createTriggerCQN2SQL } = require('../TriggerCQN2SQL.js');
4
4
 
5
5
  const cqn4sql = require('@cap-js/db-service/lib/cqn4sql');
6
- let SQLiteCQN2SQL;
7
- let model;
6
+ const _cqn2sqlCache = new WeakMap();
8
7
 
9
- function setModel(m) {
10
- model = m;
11
- SQLiteCQN2SQL = null;
12
- }
13
-
14
- function getModel() {
15
- return model;
16
- }
17
-
18
- function toSQL(query) {
19
- if (!SQLiteCQN2SQL) {
8
+ function toSQL(query, model) {
9
+ let cqn2sql = _cqn2sqlCache.get(model);
10
+ if (!cqn2sql) {
20
11
  const SQLiteService = require('@cap-js/sqlite');
21
12
  const TriggerCQN2SQL = createTriggerCQN2SQL(SQLiteService.CQN2SQL);
22
- SQLiteCQN2SQL = new TriggerCQN2SQL({ model: model });
13
+ cqn2sql = new TriggerCQN2SQL({ model: model });
14
+ _cqn2sqlCache.set(model, cqn2sql);
23
15
  }
24
16
  const sqlCQN = cqn4sql(query, model);
25
- return SQLiteCQN2SQL.SELECT(sqlCQN);
17
+ return cqn2sql.SELECT(sqlCQN);
26
18
  }
27
19
 
28
20
  /**
@@ -103,7 +95,7 @@ function getWhereCondition(col, modification) {
103
95
  /**
104
96
  * Builds scalar subselect for association label lookup with locale awareness
105
97
  */
106
- function buildAssocLookup(column, refRow, entityKey) {
98
+ function buildAssocLookup(column, refRow, entityKey, model) {
107
99
  const where = column.foreignKeys
108
100
  ? column.foreignKeys.reduce((acc, k) => {
109
101
  acc[k] = { val: `${refRow}.${column.name}_${k}` };
@@ -125,52 +117,52 @@ function buildAssocLookup(column, refRow, entityKey) {
125
117
  const textsWhere = { ...where, locale: { func: 'session_context', args: [{ val: '$user.locale' }] } };
126
118
  const textsQuery = SELECT.one.from(localizedInfo.textsEntity).columns(columns).where(textsWhere);
127
119
  const baseQuery = SELECT.one.from(column.target).columns(columns).where(where);
128
- return `(SELECT COALESCE((${toSQL(textsQuery)}), (${toSQL(baseQuery)})))`;
120
+ return `(SELECT COALESCE((${toSQL(textsQuery, model)}), (${toSQL(baseQuery, model)})))`;
129
121
  }
130
122
 
131
123
  const query = SELECT.one.from(column.target).columns(columns).where(where);
132
- return `(${toSQL(query)})`;
124
+ return `(${toSQL(query, model)})`;
133
125
  }
134
126
 
135
127
  /**
136
128
  * Returns SQL expression for a column's label (looked-up value for associations)
137
129
  */
138
- function getLabelExpr(col, refRow, entityKey) {
130
+ function getLabelExpr(col, refRow, entityKey, model) {
139
131
  if (col.target && col.alt) {
140
- return buildAssocLookup(col, refRow, entityKey);
132
+ return buildAssocLookup(col, refRow, entityKey, model);
141
133
  }
142
134
  return 'NULL';
143
135
  }
144
136
 
145
137
  /**
146
138
  * Builds SQL expression for objectID (entity display name)
147
- * Uses @changelog annotation fields, falling back to entity name
139
+ * Uses @changelog annotation fields, falling back to entity keys
148
140
  */
149
- function buildObjectIDSelect(objectIDs, entityName, entityKeys, refRow) {
150
- if (objectIDs.length === 0) return `'${entityName}'`;
141
+ function buildObjectIDSelect(objectIDs, entityName, entityKeys, refRow, model) {
142
+ if (objectIDs.length === 0) return null;
151
143
 
152
144
  for (const objectID of objectIDs) {
153
145
  if (objectID.included) continue;
154
146
  const where = buildKeyWhere(entityKeys, refRow);
155
147
  const query = SELECT.one.from(entityName).columns(objectID.name).where(where);
156
- objectID.selectSQL = toSQL(query);
148
+ objectID.selectSQL = toSQL(query, model);
157
149
  }
158
150
 
159
- 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`));
160
152
 
161
153
  return `(SELECT GROUP_CONCAT(value, ', ') FROM (${unionParts.join('\nUNION ALL\n')}))`;
162
154
  }
163
155
 
164
- function buildTriggerContext(entity, objectIDs, refRow, compositionParentInfo = null) {
156
+ function buildTriggerContext(entity, objectIDs, refRow, model, compositionParentInfo = null) {
165
157
  const keys = utils.extractKeys(entity.keys);
166
158
  const entityKey = compositeKeyExpr(keys.map((k) => `${refRow}.${k}`));
167
- const objectID = buildObjectIDSelect(objectIDs, entity.name, keys, refRow) ?? entityKey;
159
+ const objectID = buildObjectIDSelect(objectIDs, entity.name, keys, refRow, model) ?? entityKey;
168
160
  const parentLookupExpr = compositionParentInfo ? 'PARENT_LOOKUP_PLACEHOLDER' : null;
169
161
 
170
162
  return { keys, entityKey, objectID, parentLookupExpr };
171
163
  }
172
164
 
173
- function buildInsertSQL(entity, columns, modification, ctx) {
165
+ function buildInsertSQL(entity, columns, modification, ctx, model) {
174
166
  const unionQuery = columns
175
167
  .map((col) => {
176
168
  const whereCondition = getWhereCondition(col, modification);
@@ -193,8 +185,8 @@ function buildInsertSQL(entity, columns, modification, ctx) {
193
185
 
194
186
  const oldVal = modification === 'create' ? 'NULL' : getValueExpr(col, 'old');
195
187
  const newVal = modification === 'delete' ? 'NULL' : getValueExpr(col, 'new');
196
- const oldLabel = modification === 'create' ? 'NULL' : getLabelExpr(col, 'old', ctx.entityKey);
197
- const newLabel = modification === 'delete' ? 'NULL' : getLabelExpr(col, 'new', ctx.entityKey);
188
+ const oldLabel = modification === 'create' ? 'NULL' : getLabelExpr(col, 'old', ctx.entityKey, model);
189
+ const newLabel = modification === 'delete' ? 'NULL' : getLabelExpr(col, 'new', ctx.entityKey, model);
198
190
 
199
191
  return `SELECT '${col.name}' AS attribute, ${oldVal} AS valueChangedFrom, ${newVal} AS valueChangedTo, ${oldLabel} AS valueChangedFromLabel, ${newLabel} AS valueChangedToLabel, '${col.type}' AS valueDataType WHERE ${fullWhere}`;
200
192
  })
@@ -223,8 +215,6 @@ function buildInsertSQL(entity, columns, modification, ctx) {
223
215
  }
224
216
 
225
217
  module.exports = {
226
- setModel,
227
- getModel,
228
218
  toSQL,
229
219
  getSkipCheckCondition,
230
220
  getElementSkipCondition,
@@ -1,12 +1,12 @@
1
1
  const utils = require('../utils/change-tracking.js');
2
2
  const config = require('@sap/cds').env.requires['change-tracking'];
3
3
  const { getCompositionParentInfo, getGrandParentCompositionInfo } = require('../utils/composition-helpers.js');
4
- const { setModel, getSkipCheckCondition, buildTriggerContext, buildInsertSQL } = require('./sql-expressions.js');
4
+ const { getSkipCheckCondition, buildTriggerContext, buildInsertSQL } = require('./sql-expressions.js');
5
5
  const { buildCompositionParentContext } = require('./composition.js');
6
6
 
7
- function generateCreateTrigger(entity, columns, objectIDs, rootObjectIDs, compositionParentInfo = null, grandParentCompositionInfo = null) {
8
- const compositionParentContext = compositionParentInfo ? buildCompositionParentContext(compositionParentInfo, rootObjectIDs, 'create', 'new', grandParentCompositionInfo) : null;
9
- const ctx = buildTriggerContext(entity, objectIDs, 'new', compositionParentInfo);
7
+ function generateCreateTrigger(entity, columns, objectIDs, rootObjectIDs, model, compositionParentInfo = null, grandParentCompositionInfo = null) {
8
+ const compositionParentContext = compositionParentInfo ? buildCompositionParentContext(compositionParentInfo, rootObjectIDs, 'create', 'new', model, grandParentCompositionInfo) : null;
9
+ const ctx = buildTriggerContext(entity, objectIDs, 'new', model, compositionParentInfo);
10
10
 
11
11
  // Replace placeholder with actual parent lookup expression if needed
12
12
  if (compositionParentContext && ctx.parentLookupExpr) {
@@ -18,10 +18,10 @@ function generateCreateTrigger(entity, columns, objectIDs, rootObjectIDs, compos
18
18
  if (columns.length === 0 && compositionParentContext) {
19
19
  bodySQL = compositionParentContext.insertSQL;
20
20
  } else if (compositionParentContext) {
21
- const insertSQL = buildInsertSQL(entity, columns, 'create', ctx);
21
+ const insertSQL = buildInsertSQL(entity, columns, 'create', ctx, model);
22
22
  bodySQL = `${compositionParentContext.insertSQL}\n ${insertSQL}`;
23
23
  } else {
24
- bodySQL = buildInsertSQL(entity, columns, 'create', ctx);
24
+ bodySQL = buildInsertSQL(entity, columns, 'create', ctx, model);
25
25
  }
26
26
 
27
27
  return `CREATE TRIGGER IF NOT EXISTS ${utils.transformName(entity.name)}_ct_create AFTER INSERT
@@ -32,9 +32,9 @@ function generateCreateTrigger(entity, columns, objectIDs, rootObjectIDs, compos
32
32
  END;`;
33
33
  }
34
34
 
35
- function generateUpdateTrigger(entity, columns, objectIDs, rootObjectIDs, compositionParentInfo = null, grandParentCompositionInfo = null) {
36
- const compositionParentContext = compositionParentInfo ? buildCompositionParentContext(compositionParentInfo, rootObjectIDs, 'update', 'new', grandParentCompositionInfo) : null;
37
- const ctx = buildTriggerContext(entity, objectIDs, 'new', compositionParentInfo);
35
+ function generateUpdateTrigger(entity, columns, objectIDs, rootObjectIDs, model, compositionParentInfo = null, grandParentCompositionInfo = null) {
36
+ const compositionParentContext = compositionParentInfo ? buildCompositionParentContext(compositionParentInfo, rootObjectIDs, 'update', 'new', model, grandParentCompositionInfo) : null;
37
+ const ctx = buildTriggerContext(entity, objectIDs, 'new', model, compositionParentInfo);
38
38
 
39
39
  // Replace placeholder with actual parent lookup expression if needed
40
40
  if (compositionParentContext && ctx.parentLookupExpr) {
@@ -46,10 +46,10 @@ function generateUpdateTrigger(entity, columns, objectIDs, rootObjectIDs, compos
46
46
  if (columns.length === 0 && compositionParentContext) {
47
47
  bodySQL = compositionParentContext.insertSQL;
48
48
  } else if (compositionParentContext) {
49
- const insertSQL = buildInsertSQL(entity, columns, 'update', ctx);
49
+ const insertSQL = buildInsertSQL(entity, columns, 'update', ctx, model);
50
50
  bodySQL = `${compositionParentContext.insertSQL}\n ${insertSQL}`;
51
51
  } else {
52
- bodySQL = buildInsertSQL(entity, columns, 'update', ctx);
52
+ bodySQL = buildInsertSQL(entity, columns, 'update', ctx, model);
53
53
  }
54
54
 
55
55
  // Build OF clause for targeted update trigger
@@ -69,9 +69,9 @@ function generateUpdateTrigger(entity, columns, objectIDs, rootObjectIDs, compos
69
69
  END;`;
70
70
  }
71
71
 
72
- function generateDeleteTriggerPreserve(entity, columns, objectIDs, rootObjectIDs, compositionParentInfo = null, grandParentCompositionInfo = null) {
73
- const compositionParentContext = compositionParentInfo ? buildCompositionParentContext(compositionParentInfo, rootObjectIDs, 'delete', 'old', grandParentCompositionInfo) : null;
74
- const ctx = buildTriggerContext(entity, objectIDs, 'old', compositionParentInfo);
72
+ function generateDeleteTriggerPreserve(entity, columns, objectIDs, rootObjectIDs, model, compositionParentInfo = null, grandParentCompositionInfo = null) {
73
+ const compositionParentContext = compositionParentInfo ? buildCompositionParentContext(compositionParentInfo, rootObjectIDs, 'delete', 'old', model, grandParentCompositionInfo) : null;
74
+ const ctx = buildTriggerContext(entity, objectIDs, 'old', model, compositionParentInfo);
75
75
 
76
76
  // Replace placeholder with actual parent lookup expression if needed
77
77
  if (compositionParentContext && ctx.parentLookupExpr) {
@@ -83,10 +83,10 @@ function generateDeleteTriggerPreserve(entity, columns, objectIDs, rootObjectIDs
83
83
  if (columns.length === 0 && compositionParentContext) {
84
84
  bodySQL = compositionParentContext.insertSQL;
85
85
  } else if (compositionParentContext) {
86
- const insertSQL = buildInsertSQL(entity, columns, 'delete', ctx);
86
+ const insertSQL = buildInsertSQL(entity, columns, 'delete', ctx, model);
87
87
  bodySQL = `${compositionParentContext.insertSQL}\n ${insertSQL}`;
88
88
  } else {
89
- bodySQL = buildInsertSQL(entity, columns, 'delete', ctx);
89
+ bodySQL = buildInsertSQL(entity, columns, 'delete', ctx, model);
90
90
  }
91
91
 
92
92
  return `CREATE TRIGGER IF NOT EXISTS ${utils.transformName(entity.name)}_ct_delete AFTER DELETE
@@ -97,9 +97,9 @@ function generateDeleteTriggerPreserve(entity, columns, objectIDs, rootObjectIDs
97
97
  END;`;
98
98
  }
99
99
 
100
- function generateDeleteTrigger(entity, columns, objectIDs, rootObjectIDs, compositionParentInfo = null, grandParentCompositionInfo = null) {
101
- const compositionParentContext = compositionParentInfo ? buildCompositionParentContext(compositionParentInfo, rootObjectIDs, 'delete', 'old', grandParentCompositionInfo) : null;
102
- const ctx = buildTriggerContext(entity, objectIDs, 'old', compositionParentInfo);
100
+ function generateDeleteTrigger(entity, columns, objectIDs, rootObjectIDs, model, compositionParentInfo = null, grandParentCompositionInfo = null) {
101
+ const compositionParentContext = compositionParentInfo ? buildCompositionParentContext(compositionParentInfo, rootObjectIDs, 'delete', 'old', model, grandParentCompositionInfo) : null;
102
+ const ctx = buildTriggerContext(entity, objectIDs, 'old', model, compositionParentInfo);
103
103
 
104
104
  // Replace placeholder with actual parent lookup expression if needed
105
105
  if (compositionParentContext && ctx.parentLookupExpr) {
@@ -113,10 +113,10 @@ function generateDeleteTrigger(entity, columns, objectIDs, rootObjectIDs, compos
113
113
  if (columns.length === 0 && compositionParentContext) {
114
114
  bodySQL = `${deleteSQL}\n ${compositionParentContext.insertSQL}`;
115
115
  } else if (compositionParentContext) {
116
- const insertSQL = buildInsertSQL(entity, columns, 'delete', ctx);
116
+ const insertSQL = buildInsertSQL(entity, columns, 'delete', ctx, model);
117
117
  bodySQL = `${deleteSQL}\n ${compositionParentContext.insertSQL}\n ${insertSQL}`;
118
118
  } else {
119
- const insertSQL = buildInsertSQL(entity, columns, 'delete', ctx);
119
+ const insertSQL = buildInsertSQL(entity, columns, 'delete', ctx, model);
120
120
  bodySQL = `${deleteSQL}\n ${insertSQL}`;
121
121
  }
122
122
 
@@ -129,7 +129,6 @@ function generateDeleteTrigger(entity, columns, objectIDs, rootObjectIDs, compos
129
129
  }
130
130
 
131
131
  function generateSQLiteTrigger(csn, entity, rootEntity, mergedAnnotations = null, rootMergedAnnotations = null, grandParentContext = {}) {
132
- setModel(csn);
133
132
  const triggers = [];
134
133
  const { columns: trackedColumns } = utils.extractTrackedColumns(entity, csn, mergedAnnotations);
135
134
  const objectIDs = utils.getObjectIDs(entity, csn, mergedAnnotations?.entityAnnotation);
@@ -147,14 +146,14 @@ function generateSQLiteTrigger(csn, entity, rootEntity, mergedAnnotations = null
147
146
 
148
147
  if (shouldGenerateTriggers) {
149
148
  if (!config?.disableCreateTracking) {
150
- triggers.push(generateCreateTrigger(entity, trackedColumns, objectIDs, rootObjectIDs, compositionParentInfo, grandParentCompositionInfo));
149
+ triggers.push(generateCreateTrigger(entity, trackedColumns, objectIDs, rootObjectIDs, csn, compositionParentInfo, grandParentCompositionInfo));
151
150
  }
152
151
  if (!config?.disableUpdateTracking) {
153
- triggers.push(generateUpdateTrigger(entity, trackedColumns, objectIDs, rootObjectIDs, compositionParentInfo, grandParentCompositionInfo));
152
+ triggers.push(generateUpdateTrigger(entity, trackedColumns, objectIDs, rootObjectIDs, csn, compositionParentInfo, grandParentCompositionInfo));
154
153
  }
155
154
  if (!config?.disableDeleteTracking) {
156
155
  const generateDeleteTriggerFunc = config?.preserveDeletes ? generateDeleteTriggerPreserve : generateDeleteTrigger;
157
- triggers.push(generateDeleteTriggerFunc(entity, trackedColumns, objectIDs, rootObjectIDs, compositionParentInfo, grandParentCompositionInfo));
156
+ triggers.push(generateDeleteTriggerFunc(entity, trackedColumns, objectIDs, rootObjectIDs, csn, compositionParentInfo, grandParentCompositionInfo));
158
157
  }
159
158
  }
160
159
 
@@ -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'));
@@ -207,7 +211,7 @@ function getObjectIDs(entity, model = cds.context?.model ?? cds.model, overrideE
207
211
  if (!entity) return [];
208
212
  // Use override annotation if provided, otherwise use the entity's own annotation
209
213
  const entityAnnotation = overrideEntityAnnotation ?? entity['@changelog'];
210
- if (!entityAnnotation) return [];
214
+ if (!entityAnnotation || entityAnnotation === true) return [];
211
215
  const ids = [];
212
216
 
213
217
  for (const { ['=']: field } of entityAnnotation) {
@@ -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,
@@ -9,19 +9,17 @@ const CT_SKIP_VAR = 'ct.skip';
9
9
  const CT_SKIP_ENTITY_PREFIX = 'ct.skip_entity.';
10
10
  const CT_SKIP_ELEMENT_PREFIX = 'ct.skip_element.';
11
11
 
12
- const _resolveTableName = (entity) => cds.db?.resolve?.table(entity) || _getTableNameFallback(entity);
13
-
14
- function _getTableNameFallback(entity) {
15
- const baseRef = entity.query?.SELECT?.from?.ref?.[0];
12
+ function _resolveView(entity) {
16
13
  const model = cds.context?.model ?? cds.model;
14
+ const baseRef = entity.query?._target;
17
15
  if (!baseRef || !model) return null;
18
16
 
19
- const baseEntity = model.definitions[baseRef];
17
+ const baseEntity = model.definitions[baseRef.name];
20
18
  if (!baseEntity) return null;
21
19
 
22
20
  // If base entity is also a projection, recurse
23
- if (baseEntity.query?.SELECT?.from?.ref) {
24
- return _getTableNameFallback(baseEntity);
21
+ if (baseEntity.query?._target) {
22
+ return _resolveView(baseEntity);
25
23
  }
26
24
 
27
25
  return baseEntity;
@@ -38,7 +36,7 @@ function getElementSkipVarName(entityName, elementName) {
38
36
  function _findServiceEntity(service, dbEntity) {
39
37
  if (!service || !dbEntity) return null;
40
38
  for (const def of service.entities) {
41
- const projectionTarget = _resolveTableName(def)?.name;
39
+ const projectionTarget = _resolveView(def)?.name;
42
40
  if (projectionTarget === dbEntity.name) return def;
43
41
  }
44
42
  return null;
@@ -66,7 +64,7 @@ function _collectDeepEntities(entity, data, service, toSkip) {
66
64
 
67
65
  function _collectSkipEntities(rootTarget, query, service) {
68
66
  const toSkip = new Set();
69
- const dbEntity = _resolveTableName(rootTarget);
67
+ const dbEntity = _resolveView(rootTarget);
70
68
 
71
69
  // Check root entity annotation
72
70
  if (rootTarget['@changelog'] === false || rootTarget['@changelog'] === null) {
@@ -105,7 +103,7 @@ function _collectSkipElements(serviceEntity, query, service) {
105
103
  const toSkip = [];
106
104
  if (!serviceEntity?.elements) return toSkip;
107
105
 
108
- const dbEntity = _resolveTableName(serviceEntity);
106
+ const dbEntity = _resolveView(serviceEntity);
109
107
  if (!dbEntity) return toSkip;
110
108
 
111
109
  for (const [elementName, element] of Object.entries(serviceEntity.elements)) {
@@ -241,7 +239,7 @@ function resetSkipSessionVariables(req) {
241
239
 
242
240
  // --- Auto-skip for non-opted-in service entities ---
243
241
  function _shouldSkipServiceEntity(serviceEntity, collectedEntities) {
244
- const dbEntityName = _resolveTableName(serviceEntity)?.name;
242
+ const dbEntityName = _resolveView(serviceEntity)?.name;
245
243
  if (!dbEntityName) return false;
246
244
 
247
245
  const srvEntities = collectedEntities.get(dbEntityName);
@@ -252,7 +250,7 @@ function _shouldSkipServiceEntity(serviceEntity, collectedEntities) {
252
250
  }
253
251
 
254
252
  function _setAutoSkipForServiceEntity(req) {
255
- const dbEntity = _resolveTableName(req.target);
253
+ const dbEntity = _resolveView(req.target);
256
254
  if (!dbEntity) return null;
257
255
 
258
256
  const varName = getEntitySkipVarName(dbEntity.name);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cap-js/change-tracking",
3
- "version": "2.0.0-beta.1",
3
+ "version": "2.0.0-beta.3",
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
  }