diff --git a/scripts/patch-draft-cascade-constraints.cjs b/scripts/patch-draft-cascade-constraints.cjs index 306173299..248971f22 100644 --- a/scripts/patch-draft-cascade-constraints.cjs +++ b/scripts/patch-draft-cascade-constraints.cjs @@ -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 +// ON "" FOREIGN KEY ("") +// REFERENCES "" ("") ON DELETE CASCADE // // Usage (CLI): // node scripts/patch-draft-cascade-constraints.cjs [genDir] @@ -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 (` (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` @@ -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 }; @@ -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); diff --git a/test/unit/patch-draft-cascade-constraints.test.js b/test/unit/patch-draft-cascade-constraints.test.js index d8786e0d8..6ddfdc91e 100644 --- a/test/unit/patch-draft-cascade-constraints.test.js +++ b/test/unit/patch-draft-cascade-constraints.test.js @@ -1,6 +1,9 @@ // 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'; @@ -8,77 +11,72 @@ 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 / ON "" 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', () => {