Skip to content

Commit a959d68

Browse files
authored
fix: adjust generation to resolve issues with MTX (#325)
Related to #321 - Add per-tenant `cds.xt.DeploymentService.after('deploy')` hook so artifacts are deployed against the freshly created tenant schema (skipping the `t0` metadata tenant) - The changes for SQLite and Postgres ensures that the plugins doesn't crash for multitenant-applications; however it solves symptoms that occurs because the triggers are generated during the on served event (SQLite -> `cds.compile.to.sql` runs in parallel and lead to the issue that triggers were registered before the table exist) - Only emit `Changes_CT_INDEX` / `Changes_CT_parent_INDEX` `.hdbindex` artifacts when the Changes table is in the compiled model (fixes `requires db://SAP_CHANGELOG_CHANGES … not provided by any file` HDI deploy errors) - Make `enhanceModel` work on `xtended` runtime CSNs (extensibility upgrades) - Invoke `enhanceModel` inside `cds.compiler.to.hdi.migration` and on `cds.xt.ModelProviderService.{getCsn, getExtCsn}` so HANA HDI builds and MPS consumers always see ChangeView (fixes `invalid column name CHANGEVIEW_0.PARENT_ENTITYKEY` on upgrades) - Skip `ensureUndeployJsonHasTriggerPattern()` in MTX (no local `undeploy.json` to maintain) - Add new mtx tests in `tests/mtx/mtx.test.js`
1 parent b67286a commit a959d68

21 files changed

Lines changed: 694 additions & 400 deletions

File tree

.github/actions/integration-tests/action.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,8 @@ runs:
6767
shell: bash
6868
run: |
6969
npm pkg set "overrides.@cap-js/hana=^2"
70+
npm pkg set "overrides.@cap-js/sqlite=^2"
71+
npm pkg set "overrides.@cap-js/postgres=^2"
7072
npm pkg set "overrides.@sap/cds-mtxs=^3"
7173
npm pkg set "devDependencies.@sap/cds=^9"
7274
npm pkg set --workspace=tests/bookshop "dependencies.@sap/cds=^9"

.github/workflows/test.yml

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,15 +43,15 @@ jobs:
4343
- name: Pin CDS 9 compatible stack
4444
if: matrix.cds-version == '9'
4545
run: |
46+
npm pkg set "overrides.@cap-js/hana=^2"
4647
npm pkg set "overrides.@cap-js/sqlite=^2"
48+
npm pkg set "overrides.@cap-js/postgres=^2"
4749
npm pkg set "overrides.@sap/cds-mtxs=^3"
4850
npm pkg set "devDependencies.@sap/cds=^9"
4951
npm pkg set --workspace=tests/bookshop "dependencies.@sap/cds=^9"
5052
npm pkg set --workspace=tests/bookshop-mtx "dependencies.@sap/cds=^9"
5153
- run: npm i -g @sap/cds-dk@${{ matrix.cds-version }}
5254
- run: npm i
53-
- run: cd tests/bookshop && npm i
54-
- run: cd tests/bookshop-mtx && npm i
5555
- run: cds v -i
5656
- name: Run SQLite tests
5757
run: npm run test
@@ -80,10 +80,13 @@ jobs:
8080
- name: Pin CDS 9 compatible stack
8181
if: matrix.cds-version == '9'
8282
run: |
83+
npm pkg set "overrides.@cap-js/hana=^2"
84+
npm pkg set "overrides.@cap-js/sqlite=^2"
8385
npm pkg set "overrides.@cap-js/postgres=^2"
8486
npm pkg set "overrides.@sap/cds-mtxs=^3"
8587
npm pkg set "devDependencies.@sap/cds=^9"
8688
npm pkg set --workspace=tests/bookshop "dependencies.@sap/cds=^9"
89+
npm pkg set --workspace=tests/bookshop-mtx "dependencies.@sap/cds=^9"
8790
- run: npm i -g @sap/cds-dk@${{ matrix.cds-version }}
8891
- run: npm i
8992
- run: cds v -i

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,15 @@ All notable changes to this project will be documented in this file.
44
This project adheres to [Semantic Versioning](http://semver.org/).
55
The format is based on [Keep a Changelog](http://keepachangelog.com/).
66

7+
## Version 2.1.0 - 2026-07-28
8+
9+
### Fixed
10+
- Prevent SQLite sidecar crash on startup in multi-tenant applications due to trigger/index deployment running before tenant tables exist
11+
- Invoke `enhanceModel` in after-deploy handler of `cds.xt.DeploymentService` because the `loaded` handler is not invoked on the tenant CSN from `ModelProviderService.getCsn()`
12+
- Fix HDI deployment failure (sap.changelog.Changes_CT_INDEX.hdbindex requires db://SAP_CHANGELOG_CHANGES which is not provided by any file) caused by adding `.hdbindex` artifacts even when Changes table does not exist in the compiled model
13+
- HDI deployment failure (`invalid column name: CHANGEVIEW_0.PARENT_ENTITYKEY`) during extensibility upgrades since `enhanceModel` was not called on the tenant CSN before HANA compilation
14+
- Support model enhancement with `xtended` CSN flavor to support extensibility and feature toggle setups
15+
716
## Version 2.0.3 - 2026-07-21
817

918
### Fixed

cds-plugin.js

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,32 @@ const cds = require('@sap/cds');
22

33
const { enhanceModel } = require('./lib/csn-enhancements');
44
const { registerSessionVariableHandlers } = require('./lib/skipHandlers.js');
5-
const { deploySQLiteTriggers } = require('./lib/sqlite/register.js');
5+
const { registerSQLiteDeploymentHandler, deploySQLiteTriggers } = require('./lib/sqlite/register.js');
66
const { registerPostgresCompilerHook, deployPostgresLabels } = require('./lib/postgres/register.js');
77
const { registerH2CompilerHook } = require('./lib/h2/register.js');
88
const { registerHDICompilerHook } = require('./lib/hana/register.js');
99

1010
cds.on('loaded', enhanceModel);
11+
cds.on('compile.to.edmx', enhanceModel);
12+
//cds.on('compile.to.runtime', enhanceModel);
13+
14+
// REVISIT: Remove once `compile.for.runtime` is reliably called for tenant-specific models
15+
// enhance CSNs returned by cds.xt.ModelProviderService.getExtCsn
16+
cds.on('serving', (srv) => {
17+
if (srv.name !== 'cds.xt.ModelProviderService') return;
18+
srv.after(['getCsn', 'getExtCsn'], (csn) => {
19+
if (!csn || typeof csn !== 'object' || !csn.definitions) return;
20+
enhanceModel(csn);
21+
});
22+
});
23+
1124
cds.on('listening', registerSessionVariableHandlers);
1225
cds.once('served', async () => {
1326
await deploySQLiteTriggers();
1427
await deployPostgresLabels();
1528
});
1629

30+
registerSQLiteDeploymentHandler();
1731
registerH2CompilerHook();
1832
registerPostgresCompilerHook();
1933
registerHDICompilerHook();

lib/csn-enhancements/index.js

Lines changed: 76 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -64,12 +64,6 @@ function _replaceTablePlaceholders(on, tableName) {
6464
* Returns the updated hierarchyMap and collectedEntities for use by trigger generation.
6565
*/
6666
function enhanceModel(m) {
67-
if (m.meta?.flavor !== 'inferred') {
68-
// In MTX scenarios with extensibility the runtime model for deployed apps is not
69-
// inferred but xtended and the logic requires inferred.
70-
DEBUG?.(`Skipping model enhancement because model flavour is '${m.meta?.flavor}' and not 'inferred'`);
71-
return;
72-
}
7367
const _enhanced = 'sap.changelog.enhanced';
7468
if (m.meta?.[_enhanced]) return; // already enhanced
7569

@@ -80,7 +74,11 @@ function enhanceModel(m) {
8074
elements: { changes }
8175
} = aspect;
8276

83-
const hierarchyMap = analyzeCompositions(m);
77+
// For xtended models, compile to inferred to get .elements on projections.
78+
// All work is done on inferredCSN; mutations are copied back to m at the end.
79+
const inferredCSN = m.meta?.flavor === 'xtended' ? cds.compile({ 'csn.csn': JSON.stringify(m) }) : m;
80+
81+
const hierarchyMap = analyzeCompositions(inferredCSN);
8482
const collectedEntities = new Map();
8583

8684
const replaceReferences = (xpr, depth) => {
@@ -96,10 +94,11 @@ function enhanceModel(m) {
9694
};
9795
if (cds.env.requires['change-tracking'].maxDisplayHierarchyDepth > 1) {
9896
const depth = cds.env.requires['change-tracking'].maxDisplayHierarchyDepth;
97+
const changeView = inferredCSN.definitions['sap.changelog.ChangeView'];
9998
const parents = [];
10099
for (let i = 1; i < depth; i++) {
101100
parents.push('parent');
102-
m.definitions['sap.changelog.ChangeView'].query.SELECT.columns.push(
101+
changeView.query.SELECT.columns.push(
103102
{
104103
ref: ['change', ...parents, 'entityKey'],
105104
as: parents.join('_') + '_' + 'entityKey'
@@ -109,18 +108,18 @@ function enhanceModel(m) {
109108
as: parents.join('_') + '_' + 'entity'
110109
}
111110
);
112-
m.definitions['sap.changelog.ChangeView'].elements[parents.join('_') + '_' + 'entityKey'] = structuredClone(m.definitions['sap.changelog.ChangeView'].elements.entityKey);
113-
m.definitions['sap.changelog.ChangeView'].elements[parents.join('_') + '_' + 'entity'] = structuredClone(m.definitions['sap.changelog.ChangeView'].elements.entity);
111+
changeView.elements[parents.join('_') + '_' + 'entityKey'] = structuredClone(changeView.elements.entityKey);
112+
changeView.elements[parents.join('_') + '_' + 'entity'] = structuredClone(changeView.elements.entity);
114113
}
115-
enhanceChangeViewWithTimeZones(m.definitions['sap.changelog.ChangeView'], m);
114+
enhanceChangeViewWithTimeZones(changeView, inferredCSN);
116115
}
117-
for (let name in m.definitions) {
118-
const entity = m.definitions[name];
116+
for (let name in inferredCSN.definitions) {
117+
const entity = inferredCSN.definitions[name];
119118
const isServiceEntity = entity.kind === 'entity' && !!(entity.query || entity.projection);
120-
const serviceName = getService(name, m);
121-
if (isServiceEntity && isChangeTracked(entity) && serviceName) {
119+
const serviceName = getService(name, inferredCSN);
120+
if (isServiceEntity && isChangeTracked(entity, inferredCSN) && serviceName) {
122121
// Collect change-tracked service entity name with its underlying DB entity name
123-
const baseInfo = getBaseEntity(entity, m);
122+
const baseInfo = getBaseEntity(entity, inferredCSN);
124123
if (!baseInfo) continue;
125124
const { baseRef: dbEntityName } = baseInfo;
126125

@@ -134,41 +133,41 @@ function enhanceModel(m) {
134133
DEBUG?.(`Skipping changes association for ${name} - inline composition target with no independent key`);
135134
} else if (!entity['@changelog.disable_assoc']) {
136135
// Add association to ChangeView
137-
const keys = entityKey4(entity, m);
136+
const keys = entityKey4(entity, inferredCSN);
138137
if (!keys.length) continue; // skip if no key attribute is defined
139138

140139
const onCondition = changes.on.flatMap((p) => (p?.ref && p.ref[0] === 'ID' ? keys : [p]));
141-
const tableName = (entity.projection ?? entity.query?.SELECT)?.from?.ref[0];
140+
const tableName = (entity.projection ?? entity.query?.SELECT)?.from?.ref?.[0];
142141
const onTemplate = _replaceTablePlaceholders(onCondition, tableName);
143142
const on = cds.env.requires['change-tracking'].maxDisplayHierarchyDepth > 1 ? [{ xpr: structuredClone(onTemplate) }] : onTemplate;
144143
for (let i = 1; i < cds.env.requires['change-tracking'].maxDisplayHierarchyDepth; i++) {
145144
on.push('or', { xpr: replaceReferences(structuredClone(onTemplate), i) });
146145
}
147146
const assoc = new cds.builtin.classes.Association({ ...changes, on });
148147
assoc.target = `${serviceName}.ChangeView`;
149-
if (!m.definitions[`${serviceName}.ChangeView`]) {
150-
m.definitions[`${serviceName}.ChangeView`] = structuredClone(m.definitions['sap.changelog.ChangeView']);
151-
m.definitions[`${serviceName}.ChangeView`].query = {
148+
if (!inferredCSN.definitions[`${serviceName}.ChangeView`]) {
149+
const srvChangeView = structuredClone(inferredCSN.definitions['sap.changelog.ChangeView']);
150+
srvChangeView.query = {
152151
SELECT: {
153152
from: {
154153
ref: ['sap.changelog.ChangeView']
155154
},
156155
columns: ['*']
157156
}
158157
};
159-
160-
for (const ele in m.definitions[`${serviceName}.ChangeView`].elements) {
161-
if (m.definitions[`${serviceName}.ChangeView`].elements[ele]?.target && !m.definitions[`${serviceName}.ChangeView`].elements[ele]?.target.startsWith(serviceName)) {
162-
const target = m.definitions[`${serviceName}.ChangeView`].elements[ele]?.target;
163-
const serviceEntity = Object.keys(m.definitions)
158+
inferredCSN.definitions[`${serviceName}.ChangeView`] = srvChangeView;
159+
for (const ele in srvChangeView.elements) {
160+
if (srvChangeView.elements[ele]?.target && !srvChangeView.elements[ele]?.target.startsWith(serviceName)) {
161+
const target = srvChangeView.elements[ele]?.target;
162+
const serviceEntity = Object.keys(inferredCSN.definitions)
164163
.filter((e) => e.startsWith(serviceName))
165164
.find((e) => {
166165
let baseE = e;
167166
while (baseE) {
168167
if (baseE === target) {
169168
return true;
170169
}
171-
const artefact = m.definitions[baseE];
170+
const artefact = inferredCSN.definitions[baseE];
172171
const cqn = artefact.projection ?? artefact.query?.SELECT;
173172
if (!cqn) {
174173
return false;
@@ -179,14 +178,14 @@ function enhanceModel(m) {
179178
return false;
180179
});
181180
if (serviceEntity) {
182-
m.definitions[`${serviceName}.ChangeView`].elements[ele].target = serviceEntity;
181+
srvChangeView.elements[ele].target = serviceEntity;
183182
}
184183
}
185184
}
186-
enhanceChangeViewWithLocalization(serviceName, `${serviceName}.ChangeView`, m);
187-
m.definitions[`${serviceName}.ChangeView`]['@Capabilities.ReadRestrictions.Readable'] = false;
185+
enhanceChangeViewWithLocalization(serviceName, `${serviceName}.ChangeView`, inferredCSN);
186+
inferredCSN.definitions[`${serviceName}.ChangeView`]['@Capabilities.ReadRestrictions.Readable'] = false;
188187
} else {
189-
enhanceChangeViewWithLocalization(serviceName, `${serviceName}.ChangeView`, m);
188+
enhanceChangeViewWithLocalization(serviceName, `${serviceName}.ChangeView`, inferredCSN);
190189
}
191190

192191
DEBUG?.(
@@ -199,7 +198,10 @@ function enhanceModel(m) {
199198

200199
const query = entity.projection || entity.query?.SELECT;
201200
if (query) {
202-
(query.columns ??= ['*']).push({ as: 'changes', cast: assoc });
201+
query.columns ??= ['*'];
202+
if (!query.columns.some((c) => c?.as === 'changes')) {
203+
query.columns.push({ as: 'changes', cast: assoc });
204+
}
203205
entity.elements.changes = assoc;
204206
entity['@Capabilities.NavigationRestrictions.RestrictedProperties'] ??= [];
205207
const alreadyRestricted = entity['@Capabilities.NavigationRestrictions.RestrictedProperties'].some((p) => p.NavigationProperty?.['='] === 'changes');
@@ -212,16 +214,55 @@ function enhanceModel(m) {
212214
});
213215
}
214216
}
215-
addUIFacet(entity, m);
217+
addUIFacet(entity, inferredCSN);
216218
}
217219

218220
if (entity.actions) {
219-
const { baseRef: dbEntityName } = baseInfo;
220-
addSideEffects(entity.actions, dbEntityName, hierarchyMap, m);
221+
addSideEffects(entity.actions, dbEntityName, hierarchyMap, inferredCSN);
221222
}
222223
}
223224
}
225+
226+
if (inferredCSN !== m) _syncMutationsToOriginal(m, inferredCSN);
224227
(m.meta ??= {})[_enhanced] = true;
225228
}
226229

230+
/**
231+
* Copy mutations from the inferred (compiled) CSN back to the original xtended model.
232+
*/
233+
function _syncMutationsToOriginal(m, inferredCSN) {
234+
for (const name of Object.keys(inferredCSN.definitions)) {
235+
const artefact = inferredCSN.definitions[name];
236+
if (!m.definitions[name] && name.endsWith('.ChangeView')) {
237+
m.definitions[name] = artefact;
238+
} else if (m.definitions[name]) {
239+
// append 'changes' column to query/projection columns
240+
const inferredQuery = artefact.projection ?? artefact.query?.SELECT;
241+
const mQuery = m.definitions[name].projection ?? m.definitions[name].query?.SELECT;
242+
if (inferredQuery?.columns && mQuery) {
243+
const changesCol = inferredQuery.columns.find((c) => c.as === 'changes');
244+
if (changesCol && !mQuery.columns?.some((c) => c.as === 'changes')) {
245+
(mQuery.columns ??= ['*']).push(changesCol);
246+
}
247+
}
248+
// copy added annotations
249+
for (const key of Object.keys(artefact)) {
250+
if (key.startsWith('@') && m.definitions[name]?.[key] === undefined) {
251+
m.definitions[name][key] = artefact[key];
252+
}
253+
}
254+
}
255+
}
256+
// Append parent_entityKey/parent_entity columns to ChangeView
257+
if (m.definitions['sap.changelog.ChangeView'] && inferredCSN.definitions['sap.changelog.ChangeView']) {
258+
const mColumns = m.definitions['sap.changelog.ChangeView'].query?.SELECT?.columns;
259+
const inferredColumns = inferredCSN.definitions['sap.changelog.ChangeView'].query?.SELECT?.columns;
260+
for (const col of inferredColumns) {
261+
if (col.as && col.as.startsWith('parent_') && !mColumns.some((c) => c.as === col.as)) {
262+
mColumns.push(col);
263+
}
264+
}
265+
}
266+
}
267+
227268
module.exports = { enhanceModel };

lib/hana/register.js

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ const cds = require('@sap/cds');
22

33
const { prepareCSNForTriggers, generateTriggersForEntities, ensureUndeployJsonHasTriggerPattern } = require('../utils/trigger-utils.js');
44
const { getLabelTranslations } = require('../localization.js');
5+
const { enhanceModel } = require('../csn-enhancements');
56

67
const MIGRATION_TABLE_PATH = cds.utils.path.join('db', 'src', 'sap.changelog.Changes.hdbmigrationtable');
78

@@ -17,6 +18,10 @@ function registerHDICompilerHook() {
1718
return _hdi_migration(csn, options, beforeImage);
1819
}
1920

21+
// Ensure CSN has ChangeView enhancements when compiled at runtime (extensibility upgrades)
22+
// At build time, enhanceModel already ran via cds.on('loaded') and the idempotency check skips it.
23+
enhanceModel(csn);
24+
2025
const config = cds.env.requires?.['change-tracking'];
2126
// Trigger style: statement-level by default; opt-in row-level via config flag.
2227
// REVISIT: Remove rowLevelTriggers feature flag in 2027 – the underlying HANA issue is fixed with version 2026.2.17 (QRC 1/2026).
@@ -29,7 +34,7 @@ function registerHDICompilerHook() {
2934
if (config?.rowLevelTriggers) {
3035
delete csn.definitions['sap.changelog.CHANGE_TRACKING_DUMMY']['@cds.persistence.skip'];
3136
}
32-
ensureUndeployJsonHasTriggerPattern();
37+
if (!cds.requires.multitenancy) ensureUndeployJsonHasTriggerPattern();
3338

3439
const labels = getLabelTranslations(entities, runtimeCSN);
3540
const header = 'ID;locale;text';

lib/postgres/register.js

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,9 @@ function registerPostgresCompilerHook() {
2121

2222
// Handle standard compilation (array) or delta compilation (object with createsAndAlters/drops)
2323
if (Array.isArray(ddl)) {
24-
return [...ddl, ...triggers];
24+
ddl.push(...triggers);
2525
} else if (ddl.createsAndAlters) {
2626
ddl.createsAndAlters.push(...triggers);
27-
return ddl;
2827
}
2928

3029
return ddl;

0 commit comments

Comments
 (0)