import type { TGitLabAuth } from "../gitlab/gitlab-client"; import { fetchRawFile, fetchRepositoryTree } from "../gitlab/gitlab-client"; import type { TGitLabCommitAction } from "../gitlab/gitlab-write-client"; import type { TObjectTypeRepoRef } from "./object-type-discovery"; import { DB_NAMESPACE_CONFIG, STAGING_IDENTITY_KEYS } from "./csn-model-types"; import type { TDbNamespace } from "./csn-model-types"; import { parseCdsEntities } from "./cds-model-reader"; import type { TCdsModelEntity } from "./cds-model-reader"; import { findEntityBlocks, findRelationsInBody } from "./cds-entity-blocks"; import type { TCdsEntityBlock } from "./cds-entity-blocks"; import { importLineReferencesEntity } from "./custom-model-preserver"; import { formatCdsText } from "./cds-pretty-print"; /** * Lets Tool Studio's Deploy Model UI view + edit a `custom-model.cds` (see `custom-model-preserver.ts` * for the read side that keeps these alive across EDMX regenerates) without hand-editing CDS — * view the currently-generated model, add/edit/delete a custom entity + its fields, and attach it * (`Composition of one`) to any existing entity. * * `db/final/custom-model.cds` + `db/staging/custom-model.cds` become fully owned/regenerated by * this module the same way `db/final/-model.cds` is owned by the EDMX generator — every * save rewrites both files from the full current entity list, not a partial patch. The attachment * wiring (import line + composition) into an EXISTING `-model.cds`, by contrast, is a * targeted splice — that file is NOT owned by this feature, so only the specific lines this * feature itself needs are inserted/removed, leaving everything else untouched. */ export type TCustomModelField = { name: string; type: string; isKey: boolean; i18nLabel?: string }; export type TCustomModelEntityView = { name: string; attachedTo?: string; fields: TCustomModelField[] }; export type TCustomModelView = { /** Every entity currently in `db/final/*-model.cds` — the "attach to" picker's candidate list + join-key source. */ generatedEntities: TCdsModelEntity[]; customEntities: TCustomModelEntityView[]; finalNamespace: string; stagingNamespace: string; /** * Extra namespace tiers detected on this repo beyond final/staging — currently `cons`/ * `clone_final` (confirmed real customer repos carry their own INDEPENDENT `custom-model.cds` per * tier, each with a full field re-definition rather than staging's inherit-stub — see * `custom-model-preserver.ts`'s doc comment). Empty when the repo doesn't use consolidation/ * golden-record tiers at all — `isConsolidation`/`isGoldenRecord` config in the legacy tool this * is ported from, detected here by folder presence instead since it's a read-only view. */ extraTiers: Partial>; }; export type TCustomModelFieldInput = { name: string; type: string; isKey?: boolean; i18nLabel?: string }; export type TCustomModelEdit = | { op: "add-entity"; name: string; attachedTo: string; fields: TCustomModelFieldInput[] } | { op: "update-entity"; name: string; attachedTo: string; fields: TCustomModelFieldInput[] } | { op: "delete-entity"; name: string } | { op: "add-field"; entityName: string; field: TCustomModelFieldInput } | { op: "update-field"; entityName: string; field: TCustomModelFieldInput } | { op: "delete-field"; entityName: string; fieldName: string }; const ORDINAL_MODEL_FILE = /^\d+\w{2}-model\.cds$/i; /** Tiers this module actively detects/wires beyond final/staging — `golden_record` is a real `TDbNamespace` value too (legacy's `isGoldenRecord` flag), but the user only asked for `cons`/`clone_final` support; adding it later is just extending this list. */ const EXTRA_TIER_CANDIDATES: TDbNamespace[] = ["cons", "clone_final"]; async function listOrdinalFilePaths(auth: TGitLabAuth, dbRepo: TObjectTypeRepoRef, tierFolder: string): Promise { const tree = await fetchRepositoryTree(auth, dbRepo.projectId, `db/${tierFolder}`, dbRepo.defaultBranch).catch(() => []); return tree.filter((entry) => entry.type === "blob" && ORDINAL_MODEL_FILE.test(entry.name)).map((entry) => `db/${tierFolder}/${entry.name}`); } function parseNamespaceLine(content: string): string | undefined { return content.match(/namespace\s+([\w.]+)\s*;/)?.[1]; } async function loadGeneratedEntities(auth: TGitLabAuth, dbRepo: TObjectTypeRepoRef, tierFolder: string): Promise<{ entities: TCdsModelEntity[]; namespace: string }> { const filePaths = await listOrdinalFilePaths(auth, dbRepo, tierFolder); const entities: TCdsModelEntity[] = []; let namespace = ""; for (const filePath of filePaths) { const content = await fetchRawFile(auth, dbRepo.projectId, filePath, dbRepo.defaultBranch).catch(() => undefined); if (!content) continue; if (!namespace) namespace = parseNamespaceLine(content) ?? ""; entities.push(...parseCdsEntities(content, filePath)); } return { entities, namespace }; } /** Folder-presence detection (same signal `computeObjectTypeDefaults` in `object-type-discovery.ts` already uses for its `isConsolidation` suggestion) — simpler than plumbing the deploy target's persisted `isConsolidationDefault` setting through into this read-only view. */ async function detectExtraTiers(auth: TGitLabAuth, dbRepo: TObjectTypeRepoRef): Promise { const tree = await fetchRepositoryTree(auth, dbRepo.projectId, "db", dbRepo.defaultBranch).catch(() => []); const presentFolders = new Set(tree.filter((entry) => entry.type === "tree").map((entry) => entry.name)); return EXTRA_TIER_CANDIDATES.filter((tier) => presentFolders.has(DB_NAMESPACE_CONFIG[tier].folder)); } function parsePropertiesContent(raw: string): Record { const result: Record = {}; for (const line of raw.split("\n")) { const trimmed = line.trim(); if (!trimmed || trimmed.startsWith("#")) continue; const eq = trimmed.indexOf("="); if (eq === -1) continue; result[trimmed.slice(0, eq).trim()] = trimmed.slice(eq + 1).trim(); } return result; } /** * `db/i18n/custom-model.properties`/`custom-model_en.properties` are DELIBERATELY separate from * the EDMX generator's own `db/i18n/i18n.properties`/`i18n_en.properties` — those two are fully * overwritten from CSN on every deploy (`buildI18nActions` in `csn-i18n.ts`), so writing custom * entity labels into them would just get silently wiped on the next XML upload, the exact class of * bug Part A (`custom-model-preserver.ts`) fixes for the model files. CDS merges every * `*.properties` file under an i18n folder by locale suffix, so a dedicated pair works with zero * collision risk instead of needing the generator to also know about custom entities. */ const CUSTOM_MODEL_I18N_PATH = "db/i18n/custom-model.properties"; const CUSTOM_MODEL_I18N_EN_PATH = "db/i18n/custom-model_en.properties"; async function loadCustomEntities(auth: TGitLabAuth, dbRepo: TObjectTypeRepoRef, generatedEntities: TCdsModelEntity[]): Promise { const customModelRaw = await fetchRawFile(auth, dbRepo.projectId, "db/final/custom-model.cds", dbRepo.defaultBranch).catch(() => undefined); if (!customModelRaw) return []; const parsedEntities = parseCdsEntities(customModelRaw, "db/final/custom-model.cds"); const customEntityNames = new Set(parsedEntities.map((entity) => entity.name)); const i18nRaw = await fetchRawFile(auth, dbRepo.projectId, CUSTOM_MODEL_I18N_EN_PATH, dbRepo.defaultBranch).catch(() => undefined); const i18nLabels = i18nRaw ? parsePropertiesContent(i18nRaw) : {}; const attachedToByCustomName = new Map(); for (const generated of generatedEntities) { for (const relation of generated.compositions) { if (customEntityNames.has(relation.target)) attachedToByCustomName.set(relation.target, generated.name); } } return parsedEntities.map((entity) => ({ name: entity.name, attachedTo: attachedToByCustomName.get(entity.name), fields: entity.fields.map((field) => ({ name: field.name, type: field.type, isKey: entity.keyFields.includes(field.name), i18nLabel: i18nLabels[`${entity.name}.${field.name}`], })), })); } export async function loadCustomModelView(auth: TGitLabAuth, dbRepo: TObjectTypeRepoRef): Promise { const [{ entities: generatedEntities, namespace: finalNamespace }, { namespace: stagingNamespace }, extraTierKeys] = await Promise.all([ loadGeneratedEntities(auth, dbRepo, "final"), loadGeneratedEntities(auth, dbRepo, "staging"), detectExtraTiers(auth, dbRepo), ]); const extraTiers: TCustomModelView["extraTiers"] = {}; await Promise.all( extraTierKeys.map(async (tierKey) => { const { entities, namespace } = await loadGeneratedEntities(auth, dbRepo, DB_NAMESPACE_CONFIG[tierKey].folder); if (entities.length) extraTiers[tierKey] = { namespace, entities }; }), ); const customEntities = await loadCustomEntities(auth, dbRepo, generatedEntities); return { generatedEntities, customEntities, finalNamespace, stagingNamespace, extraTiers }; } function toField(input: TCustomModelFieldInput): TCustomModelField { return { name: input.name, type: input.type, isKey: Boolean(input.isKey), i18nLabel: input.i18nLabel }; } /** Pure in-memory application of `edits` onto the current custom-entity list — throws on an edit that references something that doesn't exist, rather than silently no-op-ing it. */ function applyEdits(entities: TCustomModelEntityView[], edits: TCustomModelEdit[]): TCustomModelEntityView[] { const result = entities.map((entity) => ({ ...entity, fields: entity.fields.map((field) => ({ ...field })) })); const findIndex = (name: string) => { const index = result.findIndex((entity) => entity.name === name); if (index === -1) throw new Error(`Custom entity '${name}' not found.`); return index; }; for (const edit of edits) { switch (edit.op) { case "add-entity": if (result.some((entity) => entity.name === edit.name)) throw new Error(`Custom entity '${edit.name}' already exists.`); result.push({ name: edit.name, attachedTo: edit.attachedTo, fields: edit.fields.map(toField) }); break; case "update-entity": result[findIndex(edit.name)] = { name: edit.name, attachedTo: edit.attachedTo, fields: edit.fields.map(toField) }; break; case "delete-entity": result.splice(findIndex(edit.name), 1); break; case "add-field": { const entity = result[findIndex(edit.entityName)]; if (entity.fields.some((field) => field.name === edit.field.name)) throw new Error(`Field '${edit.field.name}' already exists on '${edit.entityName}'.`); entity.fields.push(toField(edit.field)); break; } case "update-field": { const entity = result[findIndex(edit.entityName)]; const fieldIndex = entity.fields.findIndex((field) => field.name === edit.field.name); if (fieldIndex === -1) throw new Error(`Field '${edit.field.name}' not found on '${edit.entityName}'.`); entity.fields[fieldIndex] = toField(edit.field); break; } case "delete-field": { const entity = result[findIndex(edit.entityName)]; entity.fields = entity.fields.filter((field) => field.name !== edit.fieldName); break; } } } return result; } /** `startCase("someField")` -> `"Some Field"` — mirrors `csn-model-builder.ts`'s own i18n-label fallback for consistency, duplicated locally since it's a 3-line helper not worth exporting across modules for. */ function startCase(value: string): string { return value .replace(/([a-z])([A-Z])/g, "$1 $2") .replace(/^./, (c) => c.toUpperCase()) .trim(); } /** * `final`/`cons`/`clone_final` each get a full, independent entity definition (own fields, own base * aspect) — confirmed against a real customer repo. Note this is a deliberate improvement over the * legacy tool this is ported from: legacy reuses `db/final/custom-model.cds`'s parsed fields to wire * the `...Custom` composition into `cons`/`clone_final` too, but never actually WRITES those tiers' * own `custom-model.cds` file — so its generated `using {...} from './custom-model.cds'` import * would be dangling there unless a customer separately hand-authored that file (exactly what the * real repo this was confirmed against had to do). Writing it here for real avoids that gap. */ function buildFullDefinitionCustomModelCds(namespace: string, baseAspect: string, entities: TCustomModelEntityView[]): string { const lines: string[] = [`namespace ${namespace};`, "", `using core.common.${baseAspect} from '@simplemdg/db_common/db/common-model';`, ""]; for (const entity of entities) { const orderedFields = [...entity.fields.filter((field) => field.isKey), ...entity.fields.filter((field) => !field.isKey)]; lines.push(`@(title: '{i18n>${entity.name}}')`, `entity ${entity.name} : ${baseAspect} {`); for (const field of orderedFields) { lines.push(`${field.isKey ? "key " : ""}${field.name} : ${field.type} @(title: '{i18n>${entity.name}.${field.name}}');`); } lines.push("}", ""); } return formatCdsText(lines); } function buildStagingCustomModelCds(finalNamespace: string, stagingNamespace: string, entities: TCustomModelEntityView[]): string { const lines: string[] = [`namespace ${stagingNamespace};`, ""]; for (const entity of entities) lines.push(`using {${finalNamespace}.${entity.name} as final_${entity.name}} from '../final/custom-model';`); lines.push(`using core.common.business_entity_staging from '@simplemdg/db_common/db/common-model';`, ""); for (const entity of entities) { lines.push(`@(title: '{i18n>${entity.name}}')`, `entity ${entity.name} : final_${entity.name}, business_entity_staging {}`, ""); } return formatCdsText(lines); } function buildCustomModelI18nContent(entities: TCustomModelEntityView[]): string { const lines: string[] = []; for (const entity of entities) { lines.push(`${entity.name}=${entity.name}`); for (const field of entity.fields) lines.push(`${entity.name}.${field.name}=${field.i18nLabel ?? startCase(field.name)}`); lines.push(""); } return lines.join("\n"); } function buildImportLine(namespace: string, customEntityName: string): string { return `using {${namespace}.${customEntityName}} from './custom-model.cds';`; } type TWireTier = TDbNamespace | "staging"; /** Each tier's own identity key(s) used in every composition join on that tier — `objectID` for final, `requestID` for cons, `objectID`+`requestID` for clone_final, `sessionID`+`changeHash` for golden_record (all from `DB_NAMESPACE_CONFIG`, ported from the legacy tool's own per-namespace lookup table), `objectID`+`taskID` for staging (namespace-invariant, see `STAGING_IDENTITY_KEYS`'s doc comment). */ function resolveTierIdentityKeys(tier: TWireTier): string[] { return tier === "staging" ? STAGING_IDENTITY_KEYS : DB_NAMESPACE_CONFIG[tier].identityKeys; } /** Tier identity key(s) first, then the parent's own declared key fields (e.g. `product`) — matches every real customer example (`custom-model-preserver.test.ts`'s fixtures). */ function buildAttachmentCompositionLines(customEntityName: string, parentKeyFields: string[], tier: TWireTier): string[] { const fieldName = `to_${customEntityName}`; const joinFields = [...resolveTierIdentityKeys(tier), ...parentKeyFields]; const clauseLines = joinFields.map((key, index) => `${index === 0 ? "on " : "and"} ${fieldName}.${key} = $self.${key}`); clauseLines[clauseLines.length - 1] += ";"; return [`${fieldName} : Composition of one ${customEntityName}`, ...clauseLines]; } /** Inserts `importLine` right after the file's last top-level `using ...;` statement (or after `namespace ...;` if there are none) — CDS doesn't care where `using` statements sit relative to each other, only that they precede use. No-ops if the exact line is already present. */ function insertAfterLastUsingStatement(content: string, importLine: string): string { if (content.includes(importLine.trim())) return content; const usingRegex = /using\b[^;]*;/g; let lastEnd = -1; let match: RegExpExecArray | null; while ((match = usingRegex.exec(content))) lastEnd = match.index + match[0].length; if (lastEnd === -1) { const namespaceMatch = content.match(/namespace[^;]*;/); lastEnd = namespaceMatch ? (namespaceMatch.index ?? 0) + namespaceMatch[0].length : 0; } return `${content.slice(0, lastEnd)}\n${importLine}${content.slice(lastEnd)}`; } function insertBeforeEntityClose(content: string, block: TCdsEntityBlock, linesToInsert: string[]): string { return `${content.slice(0, block.bodyEnd)}\n${linesToInsert.join("\n")}\n${content.slice(block.bodyEnd)}`; } function addCustomModelAttachment(content: string, parent: TCdsModelEntity, customEntityName: string, namespace: string, tier: TWireTier): string { const withImport = insertAfterLastUsingStatement(content, buildImportLine(namespace, customEntityName)); const block = findEntityBlocks(withImport).find((candidate) => candidate.name === parent.name); if (!block) return withImport; return insertBeforeEntityClose(withImport, block, buildAttachmentCompositionLines(customEntityName, parent.keyFields, tier)); } /** Strips the composition wired to `customEntityName` out of `parentEntityName`'s block, and drops its `using ... from './custom-model.cds'` import line too if nothing else in the file still references it. */ function removeCustomModelAttachment(content: string, parentEntityName: string, customEntityName: string): string { const block = findEntityBlocks(content).find((candidate) => candidate.name === parentEntityName); if (!block) return content; const relation = findRelationsInBody(block.body).find((candidate) => candidate.target === customEntityName); if (!relation) return content; const offsetInBody = block.body.indexOf(relation.fullText); if (offsetInBody === -1) return content; const absoluteStart = block.bodyStart + offsetInBody; const absoluteEnd = absoluteStart + relation.fullText.length; let next = content.slice(0, absoluteStart) + content.slice(absoluteEnd); const stillReferenced = findEntityBlocks(next).some((candidate) => findRelationsInBody(candidate.body).some((r) => r.target === customEntityName)); if (!stillReferenced) { next = next.replace(/using\s*\{[^}]*\}\s*from\s*'\.\/custom-model(?:\.cds)?'\s*;\n?/g, (importLine) => (importLineReferencesEntity(importLine, customEntityName) ? "" : importLine)); } return next; } /** Staging's file always mirrors final's own path 1:1 (`db/final/1st-model.cds` -> `db/staging/1st-model.cds`) since it's derived from final, not independently authored — unlike `cons`/`clone_final`, which have their own independently-parsed entity list (and therefore their own `sourceFile`) in `view.extraTiers`. */ function stagingFilePath(finalSourceFile: string): string { return finalSourceFile.replace(/^db\/final\//, "db/staging/"); } type TWireTarget = { tier: TWireTier; namespace: string; findParent: (name: string) => TCdsModelEntity | undefined }; /** One wire target per tier this repo actually has — final+staging always, plus whichever of `cons`/`clone_final` were detected in `view.extraTiers`. Each knows how to look up a parent entity BY NAME within its own tier (staging reuses final's parsed entity + keyFields, since staging's own text never re-declares `key` — everything's inherited from `final_X`; `cons`/`clone_final` look up their OWN independently-parsed entity, since those tiers DO re-declare every field, including keys). */ function buildWireTargets(view: TCustomModelView): TWireTarget[] { const targets: TWireTarget[] = [ { tier: "final", namespace: view.finalNamespace, findParent: (name) => view.generatedEntities.find((entity) => entity.name === name) }, { tier: "staging", namespace: view.stagingNamespace, findParent: (name) => { const finalParent = view.generatedEntities.find((entity) => entity.name === name); return finalParent ? { ...finalParent, sourceFile: stagingFilePath(finalParent.sourceFile) } : undefined; }, }, ]; for (const [tierKey, tierData] of Object.entries(view.extraTiers) as Array<[TDbNamespace, { namespace: string; entities: TCdsModelEntity[] }]>) { targets.push({ tier: tierKey, namespace: tierData.namespace, findParent: (name) => tierData.entities.find((entity) => entity.name === name) }); } return targets; } /** * Computes the full set of commit actions for one save: fully regenerates `custom-model.cds` (+ the * dedicated i18n pair) for `final`/`staging` and any detected `cons`/`clone_final` tier from the * edited entity list, and targeted-splices each entity's attach/detach/re-attach into whichever * `-model.cds` its parent (before/after the edit) actually lives in, in EVERY tier the * repo has. */ export async function buildCustomModelCommitActions( auth: TGitLabAuth, dbRepo: TObjectTypeRepoRef, view: TCustomModelView, edits: TCustomModelEdit[], ): Promise<{ actions: TGitLabCommitAction[]; warnings: string[] }> { const warnings: string[] = []; const nextEntities = applyEdits(view.customEntities, edits); for (const entity of nextEntities) { if (view.generatedEntities.some((generated) => generated.name === entity.name)) { warnings.push(`'${entity.name}' collides with an existing generated entity name — rename the custom entity to avoid a CDS namespace conflict.`); } } const actions: TGitLabCommitAction[] = [ { action: "update", file_path: "db/final/custom-model.cds", content: buildFullDefinitionCustomModelCds(view.finalNamespace, DB_NAMESPACE_CONFIG.final.childLevelEntity, nextEntities) }, { action: "update", file_path: "db/staging/custom-model.cds", content: buildStagingCustomModelCds(view.finalNamespace, view.stagingNamespace, nextEntities) }, ]; for (const [tierKey, tierData] of Object.entries(view.extraTiers) as Array<[TDbNamespace, { namespace: string; entities: TCdsModelEntity[] }]>) { actions.push({ action: "update", file_path: `db/${DB_NAMESPACE_CONFIG[tierKey].folder}/custom-model.cds`, content: buildFullDefinitionCustomModelCds(tierData.namespace, DB_NAMESPACE_CONFIG[tierKey].childLevelEntity, nextEntities), }); } const i18nContent = buildCustomModelI18nContent(nextEntities); actions.push({ action: "update", file_path: CUSTOM_MODEL_I18N_PATH, content: i18nContent }, { action: "update", file_path: CUSTOM_MODEL_I18N_EN_PATH, content: i18nContent }); const previousByName = new Map(view.customEntities.map((entity) => [entity.name, entity])); const nextByName = new Map(nextEntities.map((entity) => [entity.name, entity])); const touchedNames = new Set([...previousByName.keys(), ...nextByName.keys()]); const wireTargets = buildWireTargets(view); const fileCache = new Map(); const getFile = async (filePath: string): Promise => { if (fileCache.has(filePath)) return fileCache.get(filePath); const content = await fetchRawFile(auth, dbRepo.projectId, filePath, dbRepo.defaultBranch).catch(() => undefined); if (content !== undefined) fileCache.set(filePath, content); return content; }; for (const name of touchedNames) { const previous = previousByName.get(name); const next = nextByName.get(name); if (previous?.attachedTo && previous.attachedTo !== next?.attachedTo) { for (const target of wireTargets) { const parent = target.findParent(previous.attachedTo); if (!parent) continue; const content = await getFile(parent.sourceFile); if (content !== undefined) fileCache.set(parent.sourceFile, removeCustomModelAttachment(content, previous.attachedTo, name)); } } if (next?.attachedTo && next.attachedTo !== previous?.attachedTo) { let attachedAnywhere = false; for (const target of wireTargets) { const parent = target.findParent(next.attachedTo); if (!parent) continue; const content = await getFile(parent.sourceFile); if (content === undefined) continue; fileCache.set(parent.sourceFile, addCustomModelAttachment(content, parent, name, target.namespace, target.tier)); attachedAnywhere = true; } if (!attachedAnywhere) warnings.push(`'${name}' is set to attach to '${next.attachedTo}', but no such entity was found in the current model — attachment skipped.`); } } for (const [filePath, content] of fileCache) { actions.push({ action: "update", file_path: filePath, content: formatCdsText(content.split("\n")) }); } return { actions, warnings }; }