diff --git a/.github/actions/deploy-central-snapshot/action.yml b/.github/actions/deploy-central-snapshot/action.yml new file mode 100644 index 000000000..0d9bbd755 --- /dev/null +++ b/.github/actions/deploy-central-snapshot/action.yml @@ -0,0 +1,86 @@ +name: Deploy Snapshot to Central Portal +description: "Deploys a Maven SNAPSHOT package to Sonatype Central Portal Snapshots repository." + +inputs: + user: + description: "Sonatype Central Portal username (same as Maven Central)" + required: true + password: + description: "Sonatype Central Portal password (same as Maven Central)" + required: true + pgp-pub-key: + description: "The public pgp key ID (optional for snapshots but recommended)" + required: false + pgp-private-key: + description: "The private pgp key (optional for snapshots but recommended)" + required: false + pgp-passphrase: + description: "The passphrase for pgp (optional for snapshots but recommended)" + required: false + +runs: + using: composite + steps: + - name: "Setup Java" + uses: actions/setup-java@v4 + with: + distribution: 'sapmachine' + java-version: '21' + cache: maven + server-id: central + server-username: CENTRAL_USER + server-password: CENTRAL_PASSWORD + + - name: "Import GPG Key (if provided)" + if: ${{ inputs.pgp-private-key != '' }} + run: | + set +x + echo "::add-mask::$PGP_PRIVATE_KEY" + echo "::add-mask::$PASSPHRASE" + echo "$PGP_PRIVATE_KEY" | gpg --batch --passphrase "$PASSPHRASE" --import + shell: bash + env: + PGP_PRIVATE_KEY: ${{ inputs.pgp-private-key }} + PASSPHRASE: ${{ inputs.pgp-passphrase }} + + - name: "Verify SNAPSHOT version" + run: | + VERSION=$(mvn help:evaluate -Dexpression=project.version -q -DforceStdout) + echo "Current version: $VERSION" + if [[ ! "$VERSION" == *-SNAPSHOT ]]; then + echo "Error: Version $VERSION is not a SNAPSHOT version!" + echo "Central Portal Snapshots repository only accepts SNAPSHOT versions." + exit 1 + fi + echo "✅ Version $VERSION is a valid SNAPSHOT" + shell: bash + + - name: "Deploy Snapshot to Central Portal" + run: | + set +x + echo "::add-mask::$CENTRAL_USER" + echo "::add-mask::$CENTRAL_PASSWORD" + [ -n "$GPG_PASSPHRASE" ] && echo "::add-mask::$GPG_PASSPHRASE" + [ -n "$GPG_PUB_KEY" ] && echo "::add-mask::$GPG_PUB_KEY" + echo "🚀 Deploying SNAPSHOT to Sonatype Central Portal..." + if [ -n "$GPG_PASSPHRASE" ] && [ -n "$GPG_PUB_KEY" ]; then + mvn -B -ntp --show-version \ + -Dmaven.install.skip=true \ + -Dmaven.test.skip=true \ + -Dgpg.passphrase="$GPG_PASSPHRASE" \ + -Dgpg.keyname="$GPG_PUB_KEY" \ + clean deploy -P deploy-central-snapshot + else + mvn -B -ntp --show-version \ + -Dmaven.install.skip=true \ + -Dmaven.test.skip=true \ + -Dgpg.skip=true \ + clean deploy -P deploy-central-snapshot + fi + echo "✅ Snapshot deployed successfully!" + shell: bash + env: + CENTRAL_USER: ${{ inputs.user }} + CENTRAL_PASSWORD: ${{ inputs.password }} + GPG_PASSPHRASE: ${{ inputs.pgp-passphrase }} + GPG_PUB_KEY: ${{ inputs.pgp-pub-key }} diff --git a/.github/workflows/deploy-central-snapshot.yml b/.github/workflows/deploy-central-snapshot.yml new file mode 100644 index 000000000..606a33d7c --- /dev/null +++ b/.github/workflows/deploy-central-snapshot.yml @@ -0,0 +1,125 @@ +name: Deploy Snapshot to Central Portal + +env: + JAVA_VERSION: '21' + +on: + # Manual trigger - select any branch from GitHub UI + workflow_dispatch: + inputs: + sign_artifacts: + description: 'Sign artifacts with GPG' + required: false + default: 'true' + type: choice + options: + - 'true' + - 'false' + + # Auto-trigger on push to testing branch + push: + branches: + - RBSDMS-NoCqnSnapshot-feature + + +permissions: + contents: read + packages: read + +jobs: + verify-snapshot: + runs-on: ubuntu-latest + outputs: + is_snapshot: ${{ steps.check.outputs.is_snapshot }} + version: ${{ steps.check.outputs.version }} + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Set up Java + uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: ${{ env.JAVA_VERSION }} + cache: maven + + - name: Check version is SNAPSHOT + id: check + run: | + VERSION=$(mvn help:evaluate -Dexpression=project.version -q -DforceStdout) + echo "version=$VERSION" >> $GITHUB_OUTPUT + if [[ "$VERSION" == *-SNAPSHOT ]]; then + echo "is_snapshot=true" >> $GITHUB_OUTPUT + echo "✅ Version $VERSION is a SNAPSHOT" + else + echo "is_snapshot=false" >> $GITHUB_OUTPUT + echo "❌ Version $VERSION is NOT a SNAPSHOT - deployment will be skipped" + fi + + build: + runs-on: ubuntu-latest + needs: verify-snapshot + if: needs.verify-snapshot.outputs.is_snapshot == 'true' + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Set up Java + uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: ${{ env.JAVA_VERSION }} + cache: maven + + - name: Build + run: | + echo "🔨 Building SNAPSHOT version: ${{ needs.verify-snapshot.outputs.version }}" + mvn clean install -P unit-tests -DskipIntegrationTests + echo "✅ Build completed successfully!" + + deploy: + name: Deploy Snapshot to Central Portal + runs-on: ubuntu-latest + needs: [verify-snapshot, build] + if: needs.verify-snapshot.outputs.is_snapshot == 'true' && needs.build.result == 'success' + environment: maven-central + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Deploy Snapshot (with GPG signing) + if: github.event.inputs.sign_artifacts != 'false' + uses: ./.github/actions/deploy-central-snapshot + with: + user: ${{ secrets.CENTRAL_REPOSITORY_USER }} + password: ${{ secrets.CENTRAL_REPOSITORY_PASS }} + pgp-pub-key: ${{ secrets.PGP_PUB_KEY }} + pgp-private-key: ${{ secrets.PGP_PRIVATE_KEY }} + pgp-passphrase: ${{ secrets.PGP_PASSPHRASE }} + + - name: Deploy Snapshot (without GPG signing) + if: github.event.inputs.sign_artifacts == 'false' + uses: ./.github/actions/deploy-central-snapshot + with: + user: ${{ secrets.CENTRAL_REPOSITORY_USER }} + password: ${{ secrets.CENTRAL_REPOSITORY_PASS }} + + - name: Summary + if: success() + run: | + echo "## 🚀 Snapshot Deployed to Central Portal" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "**Version:** ${{ needs.verify-snapshot.outputs.version }}" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "**Repository:** https://central.sonatype.com/repository/maven-snapshots/" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "### Usage" >> $GITHUB_STEP_SUMMARY + echo '```xml' >> $GITHUB_STEP_SUMMARY + echo '' >> $GITHUB_STEP_SUMMARY + echo ' ' >> $GITHUB_STEP_SUMMARY + echo ' central-snapshots' >> $GITHUB_STEP_SUMMARY + echo ' https://central.sonatype.com/repository/maven-snapshots/' >> $GITHUB_STEP_SUMMARY + echo ' true' >> $GITHUB_STEP_SUMMARY + echo ' ' >> $GITHUB_STEP_SUMMARY + echo '' >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY diff --git a/pom.xml b/pom.xml index 250ac7fe1..c319e0129 100644 --- a/pom.xml +++ b/pom.xml @@ -23,7 +23,7 @@ - 1.9.2 + 1.9.3-SNAPSHOT 17 ${java.version} ${java.version} @@ -346,6 +346,34 @@ + + deploy-central-snapshot + + + + central + Sonatype Central Portal Snapshots + https://central.sonatype.com/repository/maven-snapshots/ + + + + disabled-release + file:///dev/null + + + + + + org.sonatype.central + central-publishing-maven-plugin + false + + true + + + + + diff --git a/sdm/pom.xml b/sdm/pom.xml index 8210bbc7d..58ceff0fd 100644 --- a/sdm/pom.xml +++ b/sdm/pom.xml @@ -97,6 +97,20 @@ + + deploy-central-snapshot + + + central + Sonatype Central Portal Snapshots + https://central.sonatype.com/repository/maven-snapshots/ + + + disabled-release + file:///dev/null + + + diff --git a/sdm/src/main/java/com/sap/cds/sdm/handler/applicationservice/SDMCreateAttachmentsHandler.java b/sdm/src/main/java/com/sap/cds/sdm/handler/applicationservice/SDMCreateAttachmentsHandler.java index 01021a263..a89ed7ac4 100644 --- a/sdm/src/main/java/com/sap/cds/sdm/handler/applicationservice/SDMCreateAttachmentsHandler.java +++ b/sdm/src/main/java/com/sap/cds/sdm/handler/applicationservice/SDMCreateAttachmentsHandler.java @@ -73,24 +73,41 @@ public void updateActiveEntitySdmMetadata(CdsCreateEventContext _context) { } private void handleUpdateActiveEntitySdmMetadata() { + logger.debug( + "[CREATE] handleUpdateActiveEntitySdmMetadata: checking ThreadLocal for SDM metadata"); Map metadata = SDMAttachmentsServiceHandler.SDM_METADATA_THREADLOCAL.get(); if (metadata == null) { + logger.debug( + "[CREATE] handleUpdateActiveEntitySdmMetadata: no ThreadLocal metadata found, skipping"); return; } try { SDMAttachmentsServiceHandler.SDM_METADATA_THREADLOCAL.remove(); + logger.debug( + "[CREATE] handleUpdateActiveEntitySdmMetadata: ThreadLocal metadata keys: {}", + metadata.keySet()); com.sap.cds.reflect.CdsEntity attachmentEntity = (com.sap.cds.reflect.CdsEntity) metadata.get("attachmentEntity"); if (attachmentEntity == null) { logger.warn("No attachmentEntity in ThreadLocal metadata, skipping post-INSERT update"); return; } + logger.debug( + "[CREATE] handleUpdateActiveEntitySdmMetadata: attachmentEntity={}", + attachmentEntity.getQualifiedName()); CmisDocument cmisDocument = new CmisDocument(); cmisDocument.setAttachmentId((String) metadata.get("attachmentId")); cmisDocument.setObjectId((String) metadata.get("objectId")); cmisDocument.setFolderId((String) metadata.get("folderId")); cmisDocument.setMimeType((String) metadata.get("mimeType")); cmisDocument.setUploadStatus((String) metadata.get("uploadStatus")); + logger.debug( + "[CREATE] handleUpdateActiveEntitySdmMetadata: cmisDocument attachmentId={} objectId={} folderId={} mimeType={} uploadStatus={}", + cmisDocument.getAttachmentId(), + cmisDocument.getObjectId(), + cmisDocument.getFolderId(), + cmisDocument.getMimeType(), + cmisDocument.getUploadStatus()); logger.info( "Post-INSERT: Updating active entity attachment {} with objectId {}", cmisDocument.getAttachmentId(), @@ -109,7 +126,7 @@ public void processBefore(CdsCreateEventContext context, List data) thr logger.info( "START: Process attachments before persistence for entity: {}", context.getTarget().getQualifiedName()); - logger.debug("Number of entities to process: {}", data.size()); + logger.info("Number of entities to process: {}", data.size()); for (CdsData entityData : data) { Map> attachmentCompositionDetails = @@ -119,7 +136,7 @@ public void processBefore(CdsCreateEventContext context, List data) thr persistenceService, context.getTarget().getQualifiedName(), entityData); - logger.debug("Attachment compositions present: {}", attachmentCompositionDetails.keySet()); + logger.info("Attachment compositions found: {}", attachmentCompositionDetails.keySet()); updateName(context, data, attachmentCompositionDetails); // Remove uploadStatus from attachment data to prevent validation errors cleanupReadonlyContextsForAttachments(context, entityData, attachmentCompositionDetails); @@ -151,32 +168,45 @@ public void processAfter(CdsCreateEventContext context, List data) { Optional attachmentEntity = context.getModel().findEntity(attachmentCompositionDefinition); - if (attachmentEntity.isPresent()) { - String targetEntity = context.getTarget().getQualifiedName(); - List> attachments = - AttachmentsHandlerUtils.fetchAttachments( - targetEntity, entityData, attachmentCompositionName); + if (!attachmentEntity.isPresent()) { + logger.warn( + "[SDM] CREATE: Attachment entity '{}' not found in CDS model — skipping uploadStatus persistence for composition '{}'", + attachmentCompositionDefinition, + attachmentCompositionName); + continue; + } - if (attachments != null) { - logger.debug( - "Processing {} attachments for composition: {}", - attachments.size(), - attachmentCompositionName); - for (Map attachment : attachments) { - String id = (String) attachment.get("ID"); - String uploadStatus = (String) attachment.get("uploadStatus"); - if (id != null) { - CmisDocument cmisDocument = new CmisDocument(); - cmisDocument.setAttachmentId(id); - cmisDocument.setUploadStatus(uploadStatus); - logger.debug("Saving uploadStatus: {} for attachment ID: {}", uploadStatus, id); - // Update uploadStatus to Success in database if it was InProgress - dbQuery.saveUploadStatusToAttachment( - attachmentEntity.get(), persistenceService, cmisDocument); - totalProcessed++; - } + String targetEntity = context.getTarget().getQualifiedName(); + List> attachments = + AttachmentsHandlerUtils.fetchAttachments( + targetEntity, entityData, attachmentCompositionName); + + if (attachments != null && !attachments.isEmpty()) { + logger.info( + "[SDM] CREATE: Persisting uploadStatus for {} attachment(s) in composition '{}'", + attachments.size(), + attachmentCompositionName); + for (Map attachment : attachments) { + String id = (String) attachment.get("ID"); + String uploadStatus = (String) attachment.get("uploadStatus"); + if (id != null) { + logger.debug("Saving uploadStatus '{}' for attachment ID: {}", uploadStatus, id); + CmisDocument cmisDocument = new CmisDocument(); + cmisDocument.setAttachmentId(id); + cmisDocument.setUploadStatus(uploadStatus); + dbQuery.saveUploadStatusToAttachment( + attachmentEntity.get(), persistenceService, cmisDocument); + totalProcessed++; + } else { + logger.warn( + "[SDM] CREATE: Attachment in composition '{}' has no ID — skipping uploadStatus persistence", + attachmentCompositionName); } } + } else { + logger.debug( + "No attachments in payload for composition '{}' during post-processing", + attachmentCompositionName); } } } @@ -188,9 +218,12 @@ public void processAfter(CdsCreateEventContext context, List data) { public void preserveUploadStatus(CdsCreateEventContext context, List data) { // Preserve uploadStatus before CDS removes readonly fields logger.debug( - "Preserving readonly fields (uploadStatus) for entity: {} before CDS capability check", - context.getTarget().getQualifiedName()); + "[CREATE] preserveUploadStatus: entity={} dataSize={}", + context.getTarget().getQualifiedName(), + data.size()); SDMUtils.preserveReadonlyFields(context.getTarget(), data); + logger.debug( + "[CREATE] preserveUploadStatus: SDM_READONLY_CONTEXT set on attachment maps via CdsDataProcessor"); } public void updateName( @@ -218,9 +251,18 @@ public void updateName( Optional attachmentEntity = context.getModel().findEntity(attachmentCompositionDefinition); + logger.debug( + "[CREATE] updateName: processing composition={} entityFound={}", + attachmentCompositionName, + attachmentEntity.isPresent()); isError = AttachmentsHandlerUtils.validateFileNames( context, data, attachmentCompositionName, contextInfo, attachmentEntity); + if (isError) { + logger.debug( + "[CREATE] updateName: filename validation failed for composition={}, skipping SDM update", + attachmentCompositionName); + } if (!isError) { List fileNameWithRestrictedCharacters = new ArrayList<>(); List duplicateFileNameList = new ArrayList<>(); @@ -292,6 +334,10 @@ private void processEntity( List uploadInProgressFiles = new ArrayList<>(); if (attachments != null) { + logger.debug( + "[CREATE] processEntity: composition={} attachmentCount={}", + attachmentCompositionName, + attachments.size()); for (Map attachment : attachments) { processAttachment( context, @@ -312,6 +358,10 @@ private void processEntity( // Throw exception if any files failed scan or upload in progress String errorMessage = buildErrorMessage(scanFailedFiles, uploadInProgressFiles); if (!errorMessage.isEmpty()) { + logger.debug( + "[CREATE] processEntity: blocking — scanFailed={} uploadInProgress={}", + scanFailedFiles, + uploadInProgressFiles); throw new ServiceException(errorMessage); } @@ -469,7 +519,12 @@ private void updateAndSendToSDM( dbQuery.getPropertiesForID( attachmentEntity.get(), persistenceService, id, secondaryTypeProperties); - logger.debug("Processing attachment creation - ID: {}, objectId: {}", id, objectId); + logger.debug( + "[CREATE] updateAndSendToSDM: ID={} objectId={} secondaryTypeProperties={} propertiesInDB={}", + id, + objectId, + secondaryTypeProperties.keySet(), + propertiesInDB.keySet()); Map updatedSecondaryProperties = SDMUtils.getUpdatedSecondaryProperties( @@ -491,6 +546,8 @@ private void updateAndSendToSDM( updatedSecondaryProperties, false); + logger.debug( + "[CREATE] updateAndSendToSDM: updatedSecondaryProperties={}", updatedSecondaryProperties); logger.debug( "Creating attachment in SDM - ID: {}, fileName: {}, properties count: {}", id, @@ -657,7 +714,42 @@ private void cleanupReadonlyContextsForAttachments( } } } else { - logger.debug("No attachments found for composition: {}", attachmentCompositionName); + logger.warn( + "[SDM] CREATE: fetchAttachments returned no results for composition '{}' on entity '{}'. " + + "This may indicate a deeply nested composition whose property name does not match the entity name. " + + "Fallback recursive cleanup will handle SDM_READONLY_CONTEXT removal.", + attachmentCompositionName, + targetEntity); + } + } + // Use CdsDataProcessor to mirror the exact traversal path used by preserveReadonlyFields. + // This handles cases where CdsData stores composition data internally (e.g. during + // draftActivate) in a way that plain Map.values() iteration cannot reach. + SDMUtils.removeReadonlyFields(context.getTarget(), List.of(CdsData.create(entityData))); + // Plain-map recursive fallback as secondary safety net for any remaining entries. + removeReadonlyContextRecursively(entityData); + } + + @SuppressWarnings("unchecked") + private void removeReadonlyContextRecursively(Map data) { + if (data == null) { + return; + } + if (data.containsKey(SDM_READONLY_CONTEXT)) { + logger.warn( + "[SDM] CREATE: Fallback removed SDM_READONLY_CONTEXT from map with keys: {}. " + + "This entry was not cleaned up by the composition-based path — " + + "likely a deeply nested or mismatched composition name.", + data.keySet()); + data.remove(SDM_READONLY_CONTEXT); + } + for (Object value : data.values()) { + if (value instanceof List) { + for (Object item : (List) value) { + if (item instanceof Map) { + removeReadonlyContextRecursively((Map) item); + } + } } } } diff --git a/sdm/src/main/java/com/sap/cds/sdm/handler/applicationservice/SDMReadAttachmentsHandler.java b/sdm/src/main/java/com/sap/cds/sdm/handler/applicationservice/SDMReadAttachmentsHandler.java index d8b543292..241e28531 100644 --- a/sdm/src/main/java/com/sap/cds/sdm/handler/applicationservice/SDMReadAttachmentsHandler.java +++ b/sdm/src/main/java/com/sap/cds/sdm/handler/applicationservice/SDMReadAttachmentsHandler.java @@ -1,10 +1,14 @@ package com.sap.cds.sdm.handler.applicationservice; +import com.sap.cds.CdsData; +import com.sap.cds.Result; import com.sap.cds.ql.CQL; import com.sap.cds.ql.Predicate; import com.sap.cds.ql.cqn.CqnSelect; import com.sap.cds.ql.cqn.Modifier; +import com.sap.cds.reflect.CdsAnnotation; import com.sap.cds.reflect.CdsAssociationType; +import com.sap.cds.reflect.CdsElement; import com.sap.cds.reflect.CdsElementDefinition; import com.sap.cds.reflect.CdsEntity; import com.sap.cds.reflect.CdsModel; @@ -26,12 +30,14 @@ import com.sap.cds.services.cds.CdsReadEventContext; import com.sap.cds.services.draft.Drafts; import com.sap.cds.services.handler.EventHandler; +import com.sap.cds.services.handler.annotations.After; import com.sap.cds.services.handler.annotations.Before; import com.sap.cds.services.handler.annotations.HandlerOrder; import com.sap.cds.services.handler.annotations.ServiceName; import com.sap.cds.services.persistence.PersistenceService; import java.io.IOException; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Map.Entry; @@ -405,4 +411,312 @@ private RepoValue checkRepositoryTypeWithFallback( return null; } } + + /** + * After reading a parent entity, counts its attachments per composition facet and sets the + * corresponding virtual uploadable flag (e.g. {@code isAttachmentsUploadable}) in each result + * row. Values are computed at read time so no flag is ever written to the consumer's database. + */ + @After + @HandlerOrder(HandlerOrder.LATE) + public void populateUploadableFlags(CdsReadEventContext context, List data) { + if (data == null || data.isEmpty()) return; + + CdsEntity target = context.getTarget(); + logger.info( + "populateUploadableFlags: entity={} rows={}", target.getQualifiedName(), data.size()); + + List facets = findFacetsWithMaxCount(target); + if (!facets.isEmpty()) { + logger.debug( + "populateUploadableFlags Path1: entity={} facets={}", + target.getQualifiedName(), + facets.size()); + + String keyField = + target + .elements() + .filter(CdsElement::isKey) + .filter(e -> !"IsActiveEntity".equals(e.getName())) + .map(CdsElement::getName) + .findFirst() + .orElse(null); + if (keyField == null) return; + + long keyFieldCount = + target + .elements() + .filter(CdsElement::isKey) + .filter(e -> !"IsActiveEntity".equals(e.getName())) + .count(); + if (keyFieldCount > 1) { + logger.warn( + "populateUploadableFlags Path1: entity={} has {} key fields; only '{}' is used for parentId lookup", + target.getQualifiedName(), + keyFieldCount, + keyField); + } + + CdsModel model = context.getModel(); + // Cache keyed by "facetName|parentId|isDraft" to avoid one DB query per row per facet. + Map uploadableCache = new HashMap<>(); + + // Non-draft entities have no IsActiveEntity; treat every row as active (isDraft=false). + boolean entityHasDraftSupport = target.findElement("IsActiveEntity").isPresent(); + + for (CdsData row : data) { + // Determine draft state per row — a single result set can mix active and draft records. + // On a non-draft entity IsActiveEntity is absent; default to false (active). + boolean rowIsDraft = + entityHasDraftSupport && Boolean.FALSE.equals(row.get("IsActiveEntity")); + Object keyVal = row.get(keyField); + if (keyVal == null) { + logger.debug("populateUploadableFlags Path1: skipping row with null keyVal"); + continue; + } + String parentId = keyVal.toString(); + + for (FacetInfo facet : facets) { + String attachmentEntityBase = target.getQualifiedName() + "." + facet.facetName; + CdsEntity attachmentEntity = + resolveAttachmentEntityForCount(model, attachmentEntityBase, rowIsDraft); + if (attachmentEntity == null) { + logger.debug( + "populateUploadableFlags Path1: entity not found, skipping facet={}", + facet.facetName); + continue; + } + + String upIdKey = SDMUtils.getUpIdKey(attachmentEntity); + if (upIdKey.isEmpty()) continue; + + String cacheKey = facet.facetName + "|" + parentId + "|" + rowIsDraft; + boolean isUploadable = + uploadableCache.computeIfAbsent( + cacheKey, + k -> + dbQuery + .getAttachmentsForUPID( + attachmentEntity, persistenceService, parentId, upIdKey) + .rowCount() + < facet.maxCount); + logger.debug( + "Path1: entity={} parentId={} facet={} uploadable={}", + target.getQualifiedName(), + parentId, + facet.facetName, + isUploadable); + row.put(facet.virtualFieldName, isUploadable); + } + } + return; + } + + logger.info( + "populateUploadableFlags Path2: entity={} checking for up_ expansion", + target.getQualifiedName()); + populateUploadableFlagsViaUp(context, target, data); + } + + /** + * Populates {@code up_.isXxxUploadable} on attachment entity result rows that carry an expanded + * {@code up_} navigation property. Called when the target entity is an attachment (not a parent) + * and Fiori requested {@code $expand=up_} to evaluate the Insert button state. + */ + private void populateUploadableFlagsViaUp( + CdsReadEventContext context, CdsEntity attachmentEntity, List data) { + String entityQName = attachmentEntity.getQualifiedName(); + boolean hasUpData = data.stream().anyMatch(row -> row.get("up_") != null); + logger.info( + "populateUploadableFlagsViaUp: entity={} rows={} hasUpData={}", + entityQName, + data.size(), + hasUpData); + if (!hasUpData) return; + + // CAP names draft sibling tables with a "_drafts" suffix — a stable framework convention. + boolean isDraft = entityQName.endsWith("_drafts"); + logger.debug("populateUploadableFlagsViaUp: isDraft={}", isDraft); + String baseEntityName = + isDraft ? entityQName.substring(0, entityQName.length() - 7) : entityQName; + + int lastDot = baseEntityName.lastIndexOf('.'); + if (lastDot < 0) { + logger.debug( + "populateUploadableFlagsViaUp: no dot in entity name={}, skipping", baseEntityName); + return; + } + String facetName = baseEntityName.substring(lastDot + 1); + String parentBaseEntityName = baseEntityName.substring(0, lastDot); + logger.info( + "populateUploadableFlagsViaUp: facetName={} parentEntity={}", + facetName, + parentBaseEntityName); + + CdsModel model = context.getModel(); + CdsEntity baseParentEntity = model.findEntity(parentBaseEntityName).orElse(null); + if (baseParentEntity == null) { + logger.debug( + "populateUploadableFlagsViaUp: parent entity not found={}", parentBaseEntityName); + return; + } + + Optional> maxCountAnnotation = + baseParentEntity + .compositions() + .filter(c -> facetName.equals(c.getName())) + .findFirst() + .flatMap(c -> c.findAnnotation(SDMConstants.ATTACHMENT_MAXCOUNT)); + if (!maxCountAnnotation.isPresent()) { + logger.info( + "populateUploadableFlagsViaUp: no maxCount for facet={} on entity={}", + facetName, + parentBaseEntityName); + return; + } + + long maxCount; + try { + maxCount = Long.parseLong(String.valueOf(maxCountAnnotation.get().getValue())); + } catch (NumberFormatException e) { + logger.debug( + "populateUploadableFlagsViaUp: invalid maxCount value={} for facet={}", + maxCountAnnotation.get().getValue(), + facetName); + return; + } + if (maxCount <= 0) { + logger.debug( + "populateUploadableFlagsViaUp: maxCount={} is non-positive for facet={}, skipping", + maxCount, + facetName); + return; + } + logger.debug("populateUploadableFlagsViaUp: maxCount={} facet={}", maxCount, facetName); + + String virtualFieldName = toVirtualFieldName(facetName); + logger.debug("populateUploadableFlagsViaUp: virtualField={}", virtualFieldName); + + String upIdKey = SDMUtils.getUpIdKey(attachmentEntity); + logger.debug("populateUploadableFlagsViaUp: upIdKey={}", upIdKey); + if (upIdKey.isEmpty()) return; + + Map uploadableCache = new HashMap<>(); + for (CdsData row : data) { + Object upDataObj = row.get("up_"); + if (!(upDataObj instanceof Map)) continue; + + @SuppressWarnings("unchecked") + Map upMap = (Map) upDataObj; + + Object parentIdObj = row.get(upIdKey); + if (parentIdObj == null) continue; + String parentId = parentIdObj.toString(); + + boolean isUploadable = + uploadableCache.computeIfAbsent( + parentId, + id -> { + Result countResult = + dbQuery.getAttachmentsForUPID( + attachmentEntity, persistenceService, id, upIdKey); + return countResult.rowCount() < maxCount; + }); + + logger.debug( + "up_ expansion: entity={} parentId={} facet={} virtualField={} uploadable={}", + entityQName, + parentId, + facetName, + virtualFieldName, + isUploadable); + // Written into the up_ map, not into row: Fiori evaluates the Insert button state from + // up_.isXxxUploadable via the $expand=up_ response, not from the attachment row itself. + upMap.put(virtualFieldName, isUploadable); + } + } + + private List findFacetsWithMaxCount(CdsEntity target) { + List result = new ArrayList<>(); + List compositions = target.compositions().collect(Collectors.toList()); + for (CdsElementDefinition composition : compositions) { + String facetName = composition.getName(); + logger.debug("findFacetsWithMaxCount: checking composition={}", facetName); + Optional> maxCountAnnotation = + composition.findAnnotation(SDMConstants.ATTACHMENT_MAXCOUNT); + if (!maxCountAnnotation.isPresent()) { + logger.debug( + "findFacetsWithMaxCount: no maxCount annotation for composition={}", facetName); + continue; + } + + long maxCount; + try { + maxCount = Long.parseLong(String.valueOf(maxCountAnnotation.get().getValue())); + } catch (NumberFormatException e) { + logger.debug( + "findFacetsWithMaxCount: invalid maxCount value for composition={}", facetName); + continue; + } + if (maxCount <= 0) { + logger.debug( + "findFacetsWithMaxCount: maxCount={} is non-positive for composition={}, skipping", + maxCount, + facetName); + continue; + } + + String virtualFieldName = toVirtualFieldName(facetName); + logger.debug( + "findFacetsWithMaxCount: facet={} virtualField={} maxCount={}", + facetName, + virtualFieldName, + maxCount); + result.add(new FacetInfo(facetName, virtualFieldName, maxCount)); + } + logger.debug("findFacetsWithMaxCount: found {} facet(s) with maxCount", result.size()); + return result; + } + + private CdsEntity resolveAttachmentEntityForCount( + CdsModel model, String baseEntityName, boolean isDraft) { + logger.debug("resolveAttachmentEntityForCount: base={} isDraft={}", baseEntityName, isDraft); + if (isDraft) { + Optional draftOpt = model.findEntity(baseEntityName + "_drafts"); + if (draftOpt.isPresent()) { + logger.debug( + "resolveAttachmentEntityForCount: resolved to draft entity={}", + baseEntityName + "_drafts"); + return draftOpt.get(); + } + logger.warn( + "resolveAttachmentEntityForCount: _drafts entity not found for '{}', falling back to active entity", + baseEntityName); + } + CdsEntity active = model.findEntity(baseEntityName).orElse(null); + logger.debug( + "resolveAttachmentEntityForCount: resolved to active entity={} found={}", + baseEntityName, + active != null); + return active; + } + + private static String toVirtualFieldName(String facetName) { + return "is" + + Character.toUpperCase(facetName.charAt(0)) + + facetName.substring(1) + + "Uploadable"; + } + + private static final class FacetInfo { + final String facetName; + final String virtualFieldName; + final long maxCount; + + FacetInfo(String facetName, String virtualFieldName, long maxCount) { + this.facetName = facetName; + this.virtualFieldName = virtualFieldName; + this.maxCount = maxCount; + } + } } diff --git a/sdm/src/main/java/com/sap/cds/sdm/handler/applicationservice/SDMUpdateAttachmentsHandler.java b/sdm/src/main/java/com/sap/cds/sdm/handler/applicationservice/SDMUpdateAttachmentsHandler.java index 2f07d0643..5751f8eb4 100644 --- a/sdm/src/main/java/com/sap/cds/sdm/handler/applicationservice/SDMUpdateAttachmentsHandler.java +++ b/sdm/src/main/java/com/sap/cds/sdm/handler/applicationservice/SDMUpdateAttachmentsHandler.java @@ -85,32 +85,45 @@ public void processAfter(CdsUpdateEventContext context, List data) { Optional attachmentEntity = context.getModel().findEntity(attachmentCompositionDefinition); - if (attachmentEntity.isPresent()) { - String targetEntity = context.getTarget().getQualifiedName(); - List> attachments = - AttachmentsHandlerUtils.fetchAttachments( - targetEntity, entityData, attachmentCompositionName); + if (!attachmentEntity.isPresent()) { + logger.warn( + "[SDM] UPDATE: Attachment entity '{}' not found in CDS model — skipping uploadStatus persistence for composition '{}'", + attachmentCompositionDefinition, + attachmentCompositionName); + continue; + } - if (attachments != null) { - logger.debug( - "Processing {} attachments for composition: {}", - attachments.size(), - attachmentCompositionName); - for (Map attachment : attachments) { - String id = (String) attachment.get("ID"); - String uploadStatus = (String) attachment.get("uploadStatus"); - if (id != null) { - CmisDocument cmisDocument = new CmisDocument(); - cmisDocument.setAttachmentId(id); - cmisDocument.setUploadStatus(uploadStatus); - // Update uploadStatus to Success in database if it was InProgress - logger.debug("Saving uploadStatus: {} for attachment ID: {}", uploadStatus, id); - dbQuery.saveUploadStatusToAttachment( - attachmentEntity.get(), persistenceService, cmisDocument); - totalProcessed++; - } + String targetEntity = context.getTarget().getQualifiedName(); + List> attachments = + AttachmentsHandlerUtils.fetchAttachments( + targetEntity, entityData, attachmentCompositionName); + + if (attachments != null && !attachments.isEmpty()) { + logger.info( + "[SDM] UPDATE: Persisting uploadStatus for {} attachment(s) in composition '{}'", + attachments.size(), + attachmentCompositionName); + for (Map attachment : attachments) { + String id = (String) attachment.get("ID"); + String uploadStatus = (String) attachment.get("uploadStatus"); + if (id != null) { + logger.debug("Saving uploadStatus '{}' for attachment ID: {}", uploadStatus, id); + CmisDocument cmisDocument = new CmisDocument(); + cmisDocument.setAttachmentId(id); + cmisDocument.setUploadStatus(uploadStatus); + dbQuery.saveUploadStatusToAttachment( + attachmentEntity.get(), persistenceService, cmisDocument); + totalProcessed++; + } else { + logger.warn( + "[SDM] UPDATE: Attachment in composition '{}' has no ID — skipping uploadStatus persistence", + attachmentCompositionName); } } + } else { + logger.debug( + "No attachments in payload for composition '{}' during post-processing", + attachmentCompositionName); } } } @@ -123,7 +136,7 @@ public void processBefore(CdsUpdateEventContext context, List data) thr logger.info( "START: Process attachments before persistence for entity: {}", context.getTarget().getQualifiedName()); - logger.debug("Number of entities to update: {}", data.size()); + logger.info("Number of entities to update: {}", data.size()); // Get comprehensive attachment composition details for each entity for (CdsData entityData : data) { @@ -134,7 +147,7 @@ public void processBefore(CdsUpdateEventContext context, List data) thr persistenceService, context.getTarget().getQualifiedName(), entityData); - logger.debug("Attachment compositions present: {}", attachmentCompositionDetails.keySet()); + logger.info("Attachment compositions found: {}", attachmentCompositionDetails.keySet()); updateName(context, data, attachmentCompositionDetails); @@ -168,9 +181,18 @@ public void updateName( if (context.getModel() != null) { attachmentEntity = context.getModel().findEntity(attachmentCompositionDefinition); } + logger.debug( + "[UPDATE] updateName: processing composition={} entityFound={}", + attachmentCompositionName, + attachmentEntity.isPresent()); isError = AttachmentsHandlerUtils.validateFileNames( context, data, attachmentCompositionName, contextInfo, attachmentEntity); + if (isError) { + logger.debug( + "[UPDATE] updateName: filename validation failed for composition={}, skipping rename", + attachmentCompositionName); + } if (!isError) { renameDocument( attachmentEntity, @@ -208,6 +230,10 @@ private void renameDocument( if (attachments != null && !attachments.isEmpty()) { propertyTitles = SDMUtils.getPropertyTitles(attachmentEntity, attachments.get(0)); } else { + logger.info( + "[SDM] UPDATE: No attachments in payload for composition '{}' on entity '{}' — skipping rename", + attachmentCompositionName, + targetEntity); propertyTitles = null; } if (attachments != null && !attachments.isEmpty()) { @@ -366,7 +392,10 @@ public void processAttachment( propertiesInDB); if (updatedSecondaryProperties.isEmpty()) { - logger.debug("No changes detected for attachment ID: {}, skipping SDM update", id); + logger.info( + "[SDM] UPDATE: No property changes detected for attachment ID: {} (fileName: '{}') — skipping SDM call", + id, + filenameInRequest); return; } @@ -474,6 +503,11 @@ private Map prepareUpdatedProperties( AttachmentsHandlerUtils.updateDescriptionProperty( null, descriptionInRequest, descriptionInDB, updatedSecondaryProperties, true); + logger.debug( + "[UPDATE] prepareUpdatedProperties: secondaryTypeProperties={} propertiesInDB={} updatedSecondaryProperties={}", + secondaryTypeProperties.keySet(), + propertiesInDB.keySet(), + updatedSecondaryProperties); return updatedSecondaryProperties; } @@ -513,7 +547,7 @@ private void updateAttachmentInSDM( secondaryPropertiesWithInvalidDefinitions, context.getUserInfo().isSystemUser()); - logger.debug("SDM update response code: {} for attachment ID: {}", responseCode, id); + logger.info("SDM update response code: {} for attachment ID: {}", responseCode, id); AttachmentsHandlerUtils.handleSDMUpdateResponse( responseCode, @@ -655,7 +689,42 @@ private void cleanupReadonlyContextsForAttachments( } } } else { - logger.debug("No attachments found for composition: {}", attachmentCompositionName); + logger.warn( + "[SDM] UPDATE: fetchAttachments returned no results for composition '{}' on entity '{}'. " + + "This may indicate a deeply nested composition whose property name does not match the entity name. " + + "Fallback recursive cleanup will handle SDM_READONLY_CONTEXT removal.", + attachmentCompositionName, + targetEntity); + } + } + // Use CdsDataProcessor to mirror the exact traversal path used by preserveReadonlyFields. + // This handles cases where CdsData stores composition data internally (e.g. during + // draftActivate) in a way that plain Map.values() iteration cannot reach. + SDMUtils.removeReadonlyFields(context.getTarget(), List.of(CdsData.create(entityData))); + // Plain-map recursive fallback as secondary safety net for any remaining entries. + removeReadonlyContextRecursively(entityData); + } + + @SuppressWarnings("unchecked") + private void removeReadonlyContextRecursively(Map data) { + if (data == null) { + return; + } + if (data.containsKey(SDM_READONLY_CONTEXT)) { + logger.warn( + "[SDM] UPDATE: Fallback removed SDM_READONLY_CONTEXT from map with keys: {}. " + + "This entry was not cleaned up by the composition-based path — " + + "likely a deeply nested or mismatched composition name.", + data.keySet()); + data.remove(SDM_READONLY_CONTEXT); + } + for (Object value : data.values()) { + if (value instanceof List) { + for (Object item : (List) value) { + if (item instanceof Map) { + removeReadonlyContextRecursively((Map) item); + } + } } } } diff --git a/sdm/src/main/java/com/sap/cds/sdm/utilities/SDMUtils.java b/sdm/src/main/java/com/sap/cds/sdm/utilities/SDMUtils.java index 66b68944c..42e388447 100644 --- a/sdm/src/main/java/com/sap/cds/sdm/utilities/SDMUtils.java +++ b/sdm/src/main/java/com/sap/cds/sdm/utilities/SDMUtils.java @@ -278,6 +278,19 @@ public static void preserveReadonlyFields(CdsEntity target, List data) CdsDataProcessor.create().addValidator(mediaContentFilter, validator).process(data, target); } + public static void removeReadonlyFields(CdsEntity target, List data) { + CdsDataProcessor.Filter mediaContentFilter = + (path, element, type) -> element.findAnnotation("Core.MediaType").isPresent(); + + CdsDataProcessor.Validator validator = + (path, element, value) -> { + Map values = path.target().values(); + values.remove(SDM_READONLY_CONTEXT); + }; + + CdsDataProcessor.create().addValidator(mediaContentFilter, validator).process(data, target); + } + public static String getErrorMessage(String errorKey) { ErrorMessageKey errorMessageKey = new ErrorMessageKey(); errorMessageKey.setKey(errorKey); diff --git a/sdm/src/test/java/unit/com/sap/cds/sdm/handler/applicationservice/SDMCreateAttachmentsHandlerTest.java b/sdm/src/test/java/unit/com/sap/cds/sdm/handler/applicationservice/SDMCreateAttachmentsHandlerTest.java index 719a22edc..33cc1d179 100644 --- a/sdm/src/test/java/unit/com/sap/cds/sdm/handler/applicationservice/SDMCreateAttachmentsHandlerTest.java +++ b/sdm/src/test/java/unit/com/sap/cds/sdm/handler/applicationservice/SDMCreateAttachmentsHandlerTest.java @@ -2,6 +2,7 @@ import static org.junit.Assert.assertNull; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyBoolean; import static org.mockito.ArgumentMatchers.anyList; @@ -11,6 +12,7 @@ import com.sap.cds.CdsData; import com.sap.cds.reflect.*; import com.sap.cds.sdm.caching.CacheConfig; +import com.sap.cds.sdm.constants.SDMConstants; import com.sap.cds.sdm.handler.TokenHandler; import com.sap.cds.sdm.handler.applicationservice.SDMCreateAttachmentsHandler; import com.sap.cds.sdm.handler.applicationservice.helper.AttachmentsHandlerUtils; @@ -1024,4 +1026,116 @@ public void testUpdateActiveEntitySdmMetadata_CorrectFieldMapping() { return true; })); } + + // --- Tests for removeReadonlyContextRecursively fallback --- + + @Test + public void testCleanupReadonlyContexts_DirectAttachment_RemovesSDMReadonlyContext() + throws Exception { + CdsCreateEventContext ctx = mock(CdsCreateEventContext.class); + CdsEntity entity = mock(CdsEntity.class); + when(ctx.getTarget()).thenReturn(entity); + when(entity.getQualifiedName()).thenReturn("AdminService.Books"); + + Map attachment = new HashMap<>(); + attachment.put("ID", "att1"); + attachment.put(SDMConstants.SDM_READONLY_CONTEXT, Map.of("uploadStatus", "Success")); + + Map entityData = new HashMap<>(); + entityData.put("attachments", List.of(attachment)); + + Map> compositionDetails = new HashMap<>(); + Map info = new HashMap<>(); + info.put("name", "AdminService.Books.attachments"); + compositionDetails.put("AdminService.Books.attachments", info); + + java.lang.reflect.Method method = + SDMCreateAttachmentsHandler.class.getDeclaredMethod( + "cleanupReadonlyContextsForAttachments", + CdsCreateEventContext.class, + Map.class, + Map.class); + method.setAccessible(true); + method.invoke(handler, ctx, entityData, compositionDetails); + + assertFalse( + attachment.containsKey(SDMConstants.SDM_READONLY_CONTEXT), + "SDM_READONLY_CONTEXT should be removed from flat attachment"); + } + + @Test + public void testCleanupReadonlyContexts_DeeplyNestedAttachment_FallbackRemovesSDMReadonlyContext() + throws Exception { + // Simulates customer scenario: Books → chapters → sections → attachments + // fetchAttachments fails because parentKey 'Sections' (entity name) != 'sections' (property + // name) + CdsCreateEventContext ctx = mock(CdsCreateEventContext.class); + CdsEntity entity = mock(CdsEntity.class); + when(ctx.getTarget()).thenReturn(entity); + when(entity.getQualifiedName()).thenReturn("AdminService.Books"); + + Map attachment = new HashMap<>(); + attachment.put("ID", "att-nested"); + attachment.put(SDMConstants.SDM_READONLY_CONTEXT, Map.of("uploadStatus", "Success")); + + Map section = new HashMap<>(); + section.put("ID", "sec1"); + section.put("attachments", List.of(attachment)); + + Map chapter = new HashMap<>(); + chapter.put("ID", "chap1"); + chapter.put("sections", List.of(section)); // property name 'sections' != entity name 'Sections' + + Map entityData = new HashMap<>(); + entityData.put("cHapters", List.of(chapter)); + + Map> compositionDetails = new HashMap<>(); + Map info = new HashMap<>(); + info.put( + "name", + "AdminService.Sections.attachments"); // parentKeyFromComposition = 'Sections' — mismatch + compositionDetails.put("AdminService.Sections.attachments", info); + + java.lang.reflect.Method method = + SDMCreateAttachmentsHandler.class.getDeclaredMethod( + "cleanupReadonlyContextsForAttachments", + CdsCreateEventContext.class, + Map.class, + Map.class); + method.setAccessible(true); + method.invoke(handler, ctx, entityData, compositionDetails); + + assertFalse( + attachment.containsKey(SDMConstants.SDM_READONLY_CONTEXT), + "SDM_READONLY_CONTEXT should be removed from deeply nested attachment by fallback"); + } + + @Test + public void testCleanupReadonlyContexts_EmptyCompositionDetails_FallbackStillCleansUp() + throws Exception { + CdsCreateEventContext ctx = mock(CdsCreateEventContext.class); + CdsEntity entity = mock(CdsEntity.class); + when(ctx.getTarget()).thenReturn(entity); + when(entity.getQualifiedName()).thenReturn("AdminService.Books"); + + Map attachment = new HashMap<>(); + attachment.put("ID", "att1"); + attachment.put(SDMConstants.SDM_READONLY_CONTEXT, Map.of("uploadStatus", "InProgress")); + + Map entityData = new HashMap<>(); + entityData.put("attachments", List.of(attachment)); + + java.lang.reflect.Method method = + SDMCreateAttachmentsHandler.class.getDeclaredMethod( + "cleanupReadonlyContextsForAttachments", + CdsCreateEventContext.class, + Map.class, + Map.class); + method.setAccessible(true); + method.invoke(handler, ctx, entityData, new HashMap<>()); + + assertFalse( + attachment.containsKey(SDMConstants.SDM_READONLY_CONTEXT), + "SDM_READONLY_CONTEXT should be removed even when compositionDetails is empty"); + } } diff --git a/sdm/src/test/java/unit/com/sap/cds/sdm/handler/applicationservice/SDMUpdateAttachmentsHandlerTest.java b/sdm/src/test/java/unit/com/sap/cds/sdm/handler/applicationservice/SDMUpdateAttachmentsHandlerTest.java index 745887f8d..c3f53384e 100644 --- a/sdm/src/test/java/unit/com/sap/cds/sdm/handler/applicationservice/SDMUpdateAttachmentsHandlerTest.java +++ b/sdm/src/test/java/unit/com/sap/cds/sdm/handler/applicationservice/SDMUpdateAttachmentsHandlerTest.java @@ -1,5 +1,6 @@ package unit.com.sap.cds.sdm.handler.applicationservice; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyBoolean; @@ -945,6 +946,126 @@ public void testRenameWithNoAttachments() throws IOException { // } // } + // --- Tests for removeReadonlyContextRecursively fallback --- + + @Test + public void testCleanupReadonlyContexts_DirectAttachment_RemovesSDMReadonlyContext() + throws Exception { + // SDM_READONLY_CONTEXT on a direct (flat) attachment is removed + CdsUpdateEventContext ctx = mock(CdsUpdateEventContext.class); + CdsEntity entity = mock(CdsEntity.class); + when(ctx.getTarget()).thenReturn(entity); + when(entity.getQualifiedName()).thenReturn("AdminService.Books"); + + Map attachment = new HashMap<>(); + attachment.put("ID", "att1"); + attachment.put(SDMConstants.SDM_READONLY_CONTEXT, Map.of("uploadStatus", "Success")); + + List> attachments = new ArrayList<>(); + attachments.add(attachment); + + Map entityData = new HashMap<>(); + entityData.put("attachments", attachments); + + Map> compositionDetails = new HashMap<>(); + Map info = new HashMap<>(); + info.put("name", "AdminService.Books.attachments"); + compositionDetails.put("AdminService.Books.attachments", info); + + java.lang.reflect.Method method = + SDMUpdateAttachmentsHandler.class.getDeclaredMethod( + "cleanupReadonlyContextsForAttachments", + CdsUpdateEventContext.class, + Map.class, + Map.class); + method.setAccessible(true); + method.invoke(handler, ctx, entityData, compositionDetails); + + assertFalse( + attachment.containsKey(SDMConstants.SDM_READONLY_CONTEXT), + "SDM_READONLY_CONTEXT should be removed from flat attachment"); + } + + @Test + public void testCleanupReadonlyContexts_DeeplyNestedAttachment_FallbackRemovesSDMReadonlyContext() + throws Exception { + // Simulates customer scenario: Books → chapters → sections → attachments + // fetchAttachments fails to find the attachment because parentKey 'Sections' (entity name) + // does not match 'sections' (property name in payload) — fallback must clean it up + CdsUpdateEventContext ctx = mock(CdsUpdateEventContext.class); + CdsEntity entity = mock(CdsEntity.class); + when(ctx.getTarget()).thenReturn(entity); + when(entity.getQualifiedName()).thenReturn("AdminService.Books"); + + Map attachment = new HashMap<>(); + attachment.put("ID", "att-nested"); + attachment.put(SDMConstants.SDM_READONLY_CONTEXT, Map.of("uploadStatus", "Success")); + + List> attachments = new ArrayList<>(); + attachments.add(attachment); + + Map section = new HashMap<>(); + section.put("ID", "sec1"); + section.put("attachments", attachments); + + Map chapter = new HashMap<>(); + chapter.put("ID", "chap1"); + chapter.put("sections", List.of(section)); // property name 'sections' != entity name 'Sections' + + Map entityData = new HashMap<>(); + entityData.put("cHapters", List.of(chapter)); + + // Composition name uses entity name 'Sections' — parentKeyFromComposition = 'Sections' + // but entityData key is 'sections' → fetchAttachments returns empty → fallback must handle it + Map> compositionDetails = new HashMap<>(); + Map info = new HashMap<>(); + info.put("name", "AdminService.Sections.attachments"); + compositionDetails.put("AdminService.Sections.attachments", info); + + java.lang.reflect.Method method = + SDMUpdateAttachmentsHandler.class.getDeclaredMethod( + "cleanupReadonlyContextsForAttachments", + CdsUpdateEventContext.class, + Map.class, + Map.class); + method.setAccessible(true); + method.invoke(handler, ctx, entityData, compositionDetails); + + assertFalse( + attachment.containsKey(SDMConstants.SDM_READONLY_CONTEXT), + "SDM_READONLY_CONTEXT should be removed from deeply nested attachment by fallback"); + } + + @Test + public void testCleanupReadonlyContexts_EmptyCompositionDetails_FallbackStillCleansUp() + throws Exception { + // When getAttachmentCompositionDetails returns empty (e.g. on error), fallback still cleans up + CdsUpdateEventContext ctx = mock(CdsUpdateEventContext.class); + CdsEntity entity = mock(CdsEntity.class); + when(ctx.getTarget()).thenReturn(entity); + when(entity.getQualifiedName()).thenReturn("AdminService.Books"); + + Map attachment = new HashMap<>(); + attachment.put("ID", "att1"); + attachment.put(SDMConstants.SDM_READONLY_CONTEXT, Map.of("uploadStatus", "InProgress")); + + Map entityData = new HashMap<>(); + entityData.put("attachments", List.of(attachment)); + + java.lang.reflect.Method method = + SDMUpdateAttachmentsHandler.class.getDeclaredMethod( + "cleanupReadonlyContextsForAttachments", + CdsUpdateEventContext.class, + Map.class, + Map.class); + method.setAccessible(true); + method.invoke(handler, ctx, entityData, new HashMap<>()); + + assertFalse( + attachment.containsKey(SDMConstants.SDM_READONLY_CONTEXT), + "SDM_READONLY_CONTEXT should be removed even when compositionDetails is empty"); + } + private List prepareMockAttachmentData(String... fileNames) { List data = new ArrayList<>(); for (String fileName : fileNames) { diff --git a/sdm/src/test/java/unit/com/sap/cds/sdm/utilities/SDMUtilsTest.java b/sdm/src/test/java/unit/com/sap/cds/sdm/utilities/SDMUtilsTest.java index 8cc8484b3..28d2859ed 100644 --- a/sdm/src/test/java/unit/com/sap/cds/sdm/utilities/SDMUtilsTest.java +++ b/sdm/src/test/java/unit/com/sap/cds/sdm/utilities/SDMUtilsTest.java @@ -1704,6 +1704,61 @@ void testGetUpdatedSecondaryProperties_WithBooleanValues_ConvertsToString() { assertEquals("false", result.get("Property 2")); } + // --- Tests for removeReadonlyFields --- + + @Test + @SuppressWarnings("unchecked") + void testRemoveReadonlyFields_removesSDMReadonlyContextWhenPresent() { + // Arrange: an attachment CdsData with SDM_READONLY_CONTEXT set + // (simulating preserveReadonlyFields) + Map attachment = new HashMap<>(); + attachment.put("ID", "att1"); + attachment.put("uploadStatus", "InProgress"); + attachment.put("content", new byte[0]); + attachment.put(SDMConstants.SDM_READONLY_CONTEXT, Map.of("uploadStatus", "InProgress")); + + CdsData data = CdsData.create(attachment); + + CdsElement contentElement = mock(CdsElement.class); + CdsAnnotation mediaTypeAnnotation = mock(CdsAnnotation.class); + when(contentElement.getName()).thenReturn("content"); + when(contentElement.findAnnotation("Core.MediaType")) + .thenReturn(Optional.of(mediaTypeAnnotation)); + + // CdsDataProcessor needs elements() to find the annotated element; return a stream with it + when(mockEntity.elements()).thenReturn(Stream.of(contentElement)); + + // Act + SDMUtils.removeReadonlyFields(mockEntity, List.of(data)); + + // Assert: SDM_READONLY_CONTEXT is removed + assertFalse( + data.containsKey(SDMConstants.SDM_READONLY_CONTEXT), + "removeReadonlyFields should remove SDM_READONLY_CONTEXT from attachment"); + } + + @Test + @SuppressWarnings("unchecked") + void testRemoveReadonlyFields_noopWhenSDMReadonlyContextAbsent() { + Map attachment = new HashMap<>(); + attachment.put("ID", "att2"); + attachment.put("uploadStatus", "Success"); + + CdsData data = CdsData.create(attachment); + + CdsElement contentElement = mock(CdsElement.class); + CdsAnnotation mediaTypeAnnotation = mock(CdsAnnotation.class); + when(contentElement.getName()).thenReturn("content"); + when(contentElement.findAnnotation("Core.MediaType")) + .thenReturn(Optional.of(mediaTypeAnnotation)); + when(mockEntity.elements()).thenReturn(Stream.of(contentElement)); + + // Should not throw and data is unchanged + SDMUtils.removeReadonlyFields(mockEntity, List.of(data)); + + assertFalse(data.containsKey(SDMConstants.SDM_READONLY_CONTEXT)); + } + @Test void testGetUpdatedSecondaryProperties_WithEmptyPropertiesInDB_AddsAll() { Map attachment = new HashMap<>();