@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.
- package/CHANGELOG.md +27 -2
- package/README.md +87 -244
- package/_i18n/i18n.properties +0 -4
- package/cds-plugin.js +4 -2
- package/index.cds +114 -90
- package/lib/h2/java-codegen.js +34 -44
- package/lib/h2/triggers.js +5 -6
- package/lib/hana/composition.js +13 -15
- package/lib/hana/register.js +26 -7
- package/lib/hana/sql-expressions.js +9 -20
- package/lib/hana/triggers.js +26 -27
- package/lib/model-enhancer.js +1 -1
- package/lib/postgres/composition.js +11 -12
- package/lib/postgres/sql-expressions.js +24 -34
- package/lib/postgres/triggers.js +10 -12
- package/lib/skipHandlers.js +1 -1
- package/lib/sqlite/composition.js +12 -13
- package/lib/sqlite/sql-expressions.js +22 -32
- package/lib/sqlite/triggers.js +24 -25
- package/lib/utils/change-tracking.js +7 -3
- package/lib/utils/composition-helpers.js +4 -6
- package/lib/utils/entity-collector.js +67 -42
- package/lib/utils/session-variables.js +10 -12
- package/package.json +1 -2
|
@@ -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
|
-
|
|
7
|
-
let model;
|
|
6
|
+
const _cqn2sqlCache = new WeakMap();
|
|
8
7
|
|
|
9
|
-
function
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
|
|
13
|
+
cqn2sql = new TriggerCQN2SQL({ model: model });
|
|
14
|
+
_cqn2sqlCache.set(model, cqn2sql);
|
|
23
15
|
}
|
|
24
16
|
const sqlCQN = cqn4sql(query, model);
|
|
25
|
-
return
|
|
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
|
|
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
|
|
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
|
|
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,
|
package/lib/sqlite/triggers.js
CHANGED
|
@@ -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 {
|
|
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
|
|
109
|
-
if (
|
|
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 (
|
|
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 (
|
|
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
|
|
101
|
-
const
|
|
102
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 ||
|
|
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
|
-
|
|
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
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
|
166
|
-
|
|
172
|
+
for (const { dbEntityName, mergedAnnotations } of currentEntities) {
|
|
173
|
+
const dbEntity = model[dbEntityName];
|
|
174
|
+
if (!dbEntity) continue;
|
|
167
175
|
|
|
168
|
-
const
|
|
169
|
-
|
|
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
|
-
|
|
172
|
-
|
|
180
|
+
const changelogAnnotation = mergedAnnotations?.elementAnnotations?.[element.name] ?? element['@changelog'];
|
|
181
|
+
if (changelogAnnotation === false) continue;
|
|
173
182
|
|
|
174
|
-
|
|
175
|
-
|
|
183
|
+
const targetEntity = model[element.target];
|
|
184
|
+
if (!targetEntity) continue;
|
|
185
|
+
if (!changelogAnnotation && !_hasTrackedElements(targetEntity)) continue;
|
|
176
186
|
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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?.
|
|
24
|
-
return
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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.
|
|
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
|
}
|