Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 59 additions & 53 deletions scripts/patch-draft-cascade-constraints.cjs
Original file line number Diff line number Diff line change
@@ -1,13 +1,27 @@
// scripts/patch-draft-cascade-constraints.cjs
// Build-time patch: appends ON DELETE CASCADE foreign keys to generated draft
// .hdbtable files so that SAP HANA will enforce cascade-delete of draft rows
// when their DraftAdministrativeData record is removed.
// Build-time generator: emits a standalone *.hdbconstraint artifact per Fiori
// draft table so SAP HANA enforces ON DELETE CASCADE of draft rows when their
// DraftAdministrativeData record is removed (the `dad` draft delete strategy /
// cds.fiori.draft_admin_integrity).
//
// Background (#2993): CAP's `cds.fiori.draft_admin_integrity` flag requires
// that every *_drafts table carries a real HANA FK with ON DELETE CASCADE to
// DRAFT_DRAFTADMINISTRATIVEDATA. The flag stays OFF in package.json (the
// in-memory SQLite unit tier does not understand .hdbconstraint DDL); this
// script makes the constraints deployable without enabling the flag.
// Background (#2993): the FK must NOT be inlined into the generated
// *_drafts.hdbtable — HDI rejects that with error 8250014
// ("this artifact type must not have references to other objects"). The correct
// HDI artifact is a separate .hdbconstraint (plugin com.sap.hana.di.constraint,
// already registered in the generated .hdiconfig).
//
// The referenced table DRAFT_DRAFTADMINISTRATIVEDATA is created by the CAP
// runtime, but it lives in the SAME HDI container schema as the draft tables and
// has primary key DRAFTUUID (verified against the deployed DEV container), so
// the ALTER TABLE ... ADD CONSTRAINT that HDI renders at deploy time resolves.
//
// .hdbconstraint file format (confirmed from SAP-samples: hana-shine-xsa
// core-db/src/data/PO_CONST.hdbconstraint and others) — SQL constraint body with
// NO `ALTER TABLE ... ADD CONSTRAINT` verb:
//
// CONSTRAINT <name>
// ON "<child-table>" FOREIGN KEY ("<fk-col>")
// REFERENCES "<parent-table>" ("<parent-col>") ON DELETE CASCADE
//
// Usage (CLI):
// node scripts/patch-draft-cascade-constraints.cjs [genDir]
Expand All @@ -21,26 +35,30 @@
const fs = require('fs');
const path = require('path');

const FK_COLUMN = 'DraftAdministrativeData_DraftUUID';
// Local column is declared BARE (unquoted) in the generated .hdbtable, so HANA
// stores its name folded to UPPERCASE: DRAFTADMINISTRATIVEDATA_DRAFTUUID.
// The FK reference must therefore quote the UPPERCASE name to resolve correctly.
const FK_CLAUSE =
'FOREIGN KEY ("DRAFTADMINISTRATIVEDATA_DRAFTUUID") REFERENCES "DRAFT_DRAFTADMINISTRATIVEDATA"("DRAFTUUID") ON DELETE CASCADE';
// Bare (unquoted) in the generated .hdbtable → HANA folds to UPPERCASE.
const FK_COLUMN_SRC = 'DraftAdministrativeData_DraftUUID';
const FK_COLUMN = 'DRAFTADMINISTRATIVEDATA_DRAFTUUID';
const PARENT_TABLE = 'DRAFT_DRAFTADMINISTRATIVEDATA';
const PARENT_COLUMN = 'DRAFTUUID';

// Extract the physical table name from `COLUMN TABLE <name> (` (first line).
function tableNameOf(hdbtableContent) {
const m = hdbtableContent.match(/COLUMN\s+TABLE\s+("?)([A-Za-z0-9_.]+)\1\s*\(/);
return m ? m[2] : null;
}

/**
* Enumerate all *_drafts.hdbtable files under genDir, and for each one that
* contains the FK column but not yet ON DELETE CASCADE, inject the FK clause
* as the last element inside the COLUMN TABLE (...) body.
* For each *_drafts.hdbtable under genDir that carries the DraftAdministrativeData
* FK column, write a sibling *_drafts.hdbconstraint declaring the ON DELETE
* CASCADE foreign key. Leaves the .hdbtable untouched.
*
* @param {string} genDir Absolute (or cwd-relative) path to the gen dir.
* @param {string} genDir Path to the gen dir (default gen/db/src/gen).
* @returns {{ patched: string[], skipped: string[] }}
* @throws {Error} when no *_drafts.hdbtable files are found in genDir.
* @throws {Error} when no *_drafts.hdbtable files are found.
*/
function patchDraftCascadeConstraints(genDir) {
const entries = fs.readdirSync(genDir);
const draftFiles = entries.filter((f) => f.endsWith('_drafts.hdbtable'));

if (draftFiles.length === 0) {
throw new Error(
`no *_drafts.hdbtable files found in ${genDir} — run cds build --production first`
Expand All @@ -52,51 +70,39 @@ function patchDraftCascadeConstraints(genDir) {

for (const filename of draftFiles) {
const filepath = path.join(genDir, filename);
const original = fs.readFileSync(filepath, 'utf8');
const content = fs.readFileSync(filepath, 'utf8');

// Skip if this table doesn't have the FK column (draft tables from other
// namespaces, or tables generated without draft admin support).
if (!original.includes(FK_COLUMN)) {
// Only tables that actually carry the draft-admin FK column.
if (!content.includes(FK_COLUMN_SRC)) {
skipped.push(filename);
continue;
}

// Idempotency: if OUR specific FK clause is already present, skip.
// Match DRAFTADMINISTRATIVEDATA_DRAFTUUID (the HANA-folded uppercase name we
// emit) + ON DELETE CASCADE. Uses [\s\S]*? because the clause contains
// parentheses that [^)]* cannot cross.
if (/FOREIGN KEY\s*\([^F]*DRAFTADMINISTRATIVEDATA_DRAFTUUID[\s\S]*?ON DELETE CASCADE/i.test(original)) {
const table = tableNameOf(content);
if (!table) {
// Can't determine the table name — skip rather than emit a broken artifact.
skipped.push(filename);
continue;
}

// Inject the FK clause as the last item inside the COLUMN TABLE (...) body.
// Target form:
// PRIMARY KEY(ID),
// FOREIGN KEY ("DRAFTADMINISTRATIVEDATA_DRAFTUUID") REFERENCES ... ON DELETE CASCADE
// )
//
// Strategy: find the last ')' (the table body closing paren). Everything
// before it (trimmed of trailing whitespace/newlines) gets a trailing comma,
// then the FK clause on its own line, then the closing ')'.
const lastParen = original.lastIndexOf(')');
if (lastParen === -1) {
// Malformed file — skip rather than corrupt it.
// Deterministic constraint name, bounded well under HANA's 127-char limit.
const constraintName = `${table}_dadFkCascade`;
const constraintFile = filename.replace(/\.hdbtable$/, '.hdbconstraint');
const constraintPath = path.join(genDir, constraintFile);

const body =
`CONSTRAINT ${constraintName}\n` +
`ON "${table}" FOREIGN KEY ("${FK_COLUMN}")\n` +
`REFERENCES "${PARENT_TABLE}" ("${PARENT_COLUMN}") ON DELETE CASCADE\n`;

// Idempotent: skip rewrite if an identical artifact already exists.
if (fs.existsSync(constraintPath) && fs.readFileSync(constraintPath, 'utf8') === body) {
skipped.push(filename);
continue;
}

// Trim trailing whitespace from the body so the comma trails the last element.
const bodyPart = original.slice(0, lastParen).trimEnd();
const patched_content =
bodyPart +
',\n ' +
FK_CLAUSE +
'\n' +
original.slice(lastParen);

fs.writeFileSync(filepath, patched_content, { encoding: 'utf8' });
patched.push(filename);
fs.writeFileSync(constraintPath, body, { encoding: 'utf8' });
patched.push(constraintFile);
}

return { patched, skipped };
Expand All @@ -107,7 +113,7 @@ if (require.main === module) {
const genDir = process.argv[2] || 'gen/db/src/gen';
try {
const result = patchDraftCascadeConstraints(genDir);
console.log(`patched ${result.patched.length} / skipped ${result.skipped.length}`);
console.log(`wrote ${result.patched.length} .hdbconstraint / skipped ${result.skipped.length}`);
} catch (err) {
console.error(err.message);
process.exit(1);
Expand Down
82 changes: 40 additions & 42 deletions test/unit/patch-draft-cascade-constraints.test.js
Original file line number Diff line number Diff line change
@@ -1,84 +1,82 @@
// test/unit/patch-draft-cascade-constraints.test.js
// Unit tests for the build-time cascade-FK patch script (#2993).
// Unit tests for the build-time cascade-constraint generator (#2993).
// No CDS boot — pure file-system fixture only.
//
// The generator emits a standalone *.hdbconstraint per draft table (NOT an
// inline FK in the .hdbtable — HDI rejects that, error 8250014).

import { describe, it, expect, beforeEach } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { patchDraftCascadeConstraints } from '../../scripts/patch-draft-cascade-constraints.cjs';

// Minimal .hdbtable fixture that carries the FK column
const FIXTURE_WITH_FK = `COLUMN TABLE AdminService_Missions_drafts (
ID NVARCHAR(36) NOT NULL,
IsActiveEntity NVARCHAR(1),
HasActiveEntity NVARCHAR(1),
HasDraftEntity NVARCHAR(1),
IsActiveEntity BOOLEAN,
HasActiveEntity BOOLEAN,
HasDraftEntity BOOLEAN,
DraftAdministrativeData_DraftUUID NVARCHAR(36) NOT NULL,
PRIMARY KEY(ID)
)`;

// Minimal .hdbtable fixture that does NOT carry the FK column
const FIXTURE_WITHOUT_FK = `COLUMN TABLE AdminService_NoFk_drafts (
ID NVARCHAR(36) NOT NULL,
IsActiveEntity NVARCHAR(1),
IsActiveEntity BOOLEAN,
PRIMARY KEY(ID)
)`;

let tmp;
let emptyTmp;

const tablePath = (name) => path.join(tmp, name);
const read = (name) => fs.readFileSync(path.join(tmp, name), 'utf8');
const exists = (name) => fs.existsSync(path.join(tmp, name));

beforeEach(() => {
tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'patch-draft-test-'));
emptyTmp = fs.mkdtempSync(path.join(os.tmpdir(), 'patch-draft-empty-'));

fs.writeFileSync(
path.join(tmp, 'AdminService.Missions_drafts.hdbtable'),
FIXTURE_WITH_FK,
{ encoding: 'utf8' }
);
fs.writeFileSync(
path.join(tmp, 'AdminService.NoFk_drafts.hdbtable'),
FIXTURE_WITHOUT_FK,
{ encoding: 'utf8' }
);
fs.writeFileSync(tablePath('AdminService.Missions_drafts.hdbtable'), FIXTURE_WITH_FK, 'utf8');
fs.writeFileSync(tablePath('AdminService.NoFk_drafts.hdbtable'), FIXTURE_WITHOUT_FK, 'utf8');
});

describe('patchDraftCascadeConstraints', () => {
it('appends ON DELETE CASCADE FK to draft tables that carry the FK column', () => {
it('writes a sibling .hdbconstraint with the ON DELETE CASCADE FK', () => {
const r = patchDraftCascadeConstraints(tmp);
const sql = fs.readFileSync(path.join(tmp, 'AdminService.Missions_drafts.hdbtable'), 'utf8');
// Local column must be quoted UPPERCASE (HANA folds bare column names to upper).
// A mixed-case quoted reference ("DraftAdministrativeData_DraftUUID") would be
// case-sensitive and fail to resolve against the stored UPPERCASE name.
expect(sql).toMatch(
/FOREIGN KEY\s*\("DRAFTADMINISTRATIVEDATA_DRAFTUUID"\)\s*REFERENCES\s+"DRAFT_DRAFTADMINISTRATIVEDATA"\("DRAFTUUID"\)\s+ON DELETE CASCADE/
);
// Regression guard: must NOT emit the mixed-case quoted form.
expect(sql).not.toMatch(/FOREIGN KEY\s*\("DraftAdministrativeData_DraftUUID"\)/);
expect(r.patched).toContain('AdminService.Missions_drafts.hdbtable');
expect(exists('AdminService.Missions_drafts.hdbconstraint')).toBe(true);
expect(r.patched).toContain('AdminService.Missions_drafts.hdbconstraint');

const c = read('AdminService.Missions_drafts.hdbconstraint');
// CONSTRAINT <name> / ON "<child>" FOREIGN KEY (...) / REFERENCES ... CASCADE
expect(c).toMatch(/^CONSTRAINT\s+\S+/m);
expect(c).toMatch(/ON\s+"AdminService_Missions_drafts"\s+FOREIGN KEY\s*\("DRAFTADMINISTRATIVEDATA_DRAFTUUID"\)/);
expect(c).toMatch(/REFERENCES\s+"DRAFT_DRAFTADMINISTRATIVEDATA"\s*\("DRAFTUUID"\)\s+ON DELETE CASCADE/);
// No ALTER TABLE / ADD CONSTRAINT verb — .hdbconstraint is the bare body.
expect(c).not.toMatch(/ALTER\s+TABLE/i);
expect(c).not.toMatch(/ADD\s+CONSTRAINT/i);
// Quoted UPPERCASE column (HANA folds the bare declaration to upper).
expect(c).not.toMatch(/"DraftAdministrativeData_DraftUUID"/);
});

it('comma trails the previous line — no leading-comma style', () => {
it('leaves the .hdbtable completely untouched (HDI rejects inline FK)', () => {
const before = read('AdminService.Missions_drafts.hdbtable');
patchDraftCascadeConstraints(tmp);
const sql = fs.readFileSync(path.join(tmp, 'AdminService.Missions_drafts.hdbtable'), 'utf8');
// The separator comma must be attached to PRIMARY KEY(ID), not on its own line.
expect(sql).toMatch(/PRIMARY KEY\(ID\),\s*\n\s*FOREIGN KEY/i);
// Confirm there is no bare leading comma on its own line before FOREIGN KEY.
expect(sql).not.toMatch(/^\s*,\s*\n\s*FOREIGN KEY/im);
expect(read('AdminService.Missions_drafts.hdbtable')).toBe(before);
expect(before).not.toMatch(/FOREIGN KEY/i);
});

it('skips draft tables without the FK column', () => {
it('skips draft tables without the FK column (no constraint file)', () => {
patchDraftCascadeConstraints(tmp);
const sql = fs.readFileSync(path.join(tmp, 'AdminService.NoFk_drafts.hdbtable'), 'utf8');
expect(sql).not.toMatch(/ON DELETE CASCADE/i);
expect(exists('AdminService.NoFk_drafts.hdbconstraint')).toBe(false);
});

it('is idempotent — second run adds no duplicate FK', () => {
patchDraftCascadeConstraints(tmp);
const first = fs.readFileSync(path.join(tmp, 'AdminService.Missions_drafts.hdbtable'), 'utf8');
it('is idempotent — second run rewrites identical content', () => {
patchDraftCascadeConstraints(tmp);
expect(fs.readFileSync(path.join(tmp, 'AdminService.Missions_drafts.hdbtable'), 'utf8')).toBe(first);
const first = read('AdminService.Missions_drafts.hdbconstraint');
const r2 = patchDraftCascadeConstraints(tmp);
expect(read('AdminService.Missions_drafts.hdbconstraint')).toBe(first);
// Second run detects the identical artifact and skips the rewrite.
expect(r2.skipped).toContain('AdminService.Missions_drafts.hdbtable');
});

it('throws when no draft tables are found', () => {
Expand Down
Loading