From 0836afb67209e6208c4a856d15a6c28cb94eefcf Mon Sep 17 00:00:00 2001 From: "Yashmeet ." Date: Thu, 21 May 2026 20:54:24 +0530 Subject: [PATCH 1/7] Read Handler to disable and re-enable upload button --- .../SDMReadAttachmentsHandler.java | 301 ++++++ ...ntsHandlerPopulateUploadableFlagsTest.java | 901 ++++++++++++++++++ 2 files changed, 1202 insertions(+) create mode 100644 sdm/src/test/java/unit/com/sap/cds/sdm/handler/applicationservice/SDMReadAttachmentsHandlerPopulateUploadableFlagsTest.java 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..91d9c7cd8 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,299 @@ 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()); + + // Path 1: target is a parent entity with maxCount compositions — populate virtual fields + List facets = findFacetsWithMaxCount(target); + if (!facets.isEmpty()) { + logger.info( + "populateUploadableFlags Path1: entity={} facets={}", + target.getQualifiedName(), + facets.size()); + boolean isDraft = + data.stream().anyMatch(row -> Boolean.FALSE.equals(row.get("IsActiveEntity"))); + logger.debug("populateUploadableFlags Path1: isDraft={}", isDraft); + + String keyField = + target + .elements() + .filter(CdsElement::isKey) + .filter(e -> !"IsActiveEntity".equals(e.getName())) + .map(CdsElement::getName) + .findFirst() + .orElse(null); + logger.debug("populateUploadableFlags Path1: keyField={}", keyField); + if (keyField == null) return; + + CdsModel model = context.getModel(); + for (CdsData row : data) { + 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; + logger.debug( + "populateUploadableFlags Path1: resolving entity={} isDraft={}", + attachmentEntityBase, + isDraft); + CdsEntity attachmentEntity = + resolveAttachmentEntityForCount(model, attachmentEntityBase, isDraft); + if (attachmentEntity == null) { + logger.debug( + "populateUploadableFlags Path1: entity not found, skipping facet={}", + facet.facetName); + continue; + } + + String upIdKey = SDMUtils.getUpIdKey(attachmentEntity); + logger.debug( + "populateUploadableFlags Path1: upIdKey={} for facet={}", upIdKey, facet.facetName); + if (upIdKey.isEmpty()) continue; + + Result countResult = + dbQuery.getAttachmentsForUPID( + attachmentEntity, persistenceService, parentId, upIdKey); + boolean isUploadable = countResult.rowCount() < facet.maxCount; + + logger.info( + "Path1: entity={} parentId={} facet={} count={}/{} uploadable={}", + target.getQualifiedName(), + parentId, + facet.facetName, + countResult.rowCount(), + facet.maxCount, + isUploadable); + row.put(facet.virtualFieldName, isUploadable); + } + } + return; + } + + logger.info( + "populateUploadableFlags Path2: entity={} checking for up_ expansion", + target.getQualifiedName()); + // Path 2: target is an attachment entity read with $expand=up_ + // Fiori uses up_.isXxxUploadable (via InsertRestrictions.Insertable) to control the Upload + // button. After an attachment delete, Fiori refreshes the attachment list with $expand=up_ — + // this handler populates the virtual field on the expanded parent so the button re-enables + // immediately without requiring the user to save. + 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; + + 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> maxCountAnno = + baseParentEntity + .compositions() + .filter(c -> facetName.equals(c.getName())) + .findFirst() + .flatMap(c -> c.findAnnotation(SDMConstants.ATTACHMENT_MAXCOUNT)); + if (!maxCountAnno.isPresent()) { + logger.info( + "populateUploadableFlagsViaUp: no maxCount for facet={} on entity={}", + facetName, + parentBaseEntityName); + return; + } + + long maxCount; + try { + maxCount = Long.parseLong(maxCountAnno.get().getValue().toString()); + } catch (NumberFormatException e) { + logger.debug( + "populateUploadableFlagsViaUp: invalid maxCount value={} for facet={}", + maxCountAnno.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 = + "is" + Character.toUpperCase(facetName.charAt(0)) + facetName.substring(1) + "Uploadable"; + 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.info( + "up_ expansion: entity={} parentId={} facet={} virtualField={} uploadable={}", + entityQName, + parentId, + facetName, + virtualFieldName, + isUploadable); + upMap.put(virtualFieldName, isUploadable); + } + } + + private List findFacetsWithMaxCount(CdsEntity target) { + List result = new ArrayList<>(); + target + .compositions() + .forEach( + composition -> { + String compName = composition.getName(); + logger.debug("findFacetsWithMaxCount: checking composition={}", compName); + Optional> maxCountAnno = + composition.findAnnotation(SDMConstants.ATTACHMENT_MAXCOUNT); + if (!maxCountAnno.isPresent()) { + logger.debug( + "findFacetsWithMaxCount: no maxCount annotation for composition={}", compName); + return; + } + + long maxCount; + try { + maxCount = Long.parseLong(maxCountAnno.get().getValue().toString()); + } catch (NumberFormatException e) { + logger.debug( + "findFacetsWithMaxCount: invalid maxCount value for composition={}", compName); + return; + } + if (maxCount <= 0) { + logger.debug( + "findFacetsWithMaxCount: maxCount={} is non-positive for composition={}, skipping", + maxCount, + compName); + return; + } + + String facetName = composition.getName(); + String virtualFieldName = + "is" + + Character.toUpperCase(facetName.charAt(0)) + + facetName.substring(1) + + "Uploadable"; + 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.debug( + "resolveAttachmentEntityForCount: draft entity not found, 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 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/test/java/unit/com/sap/cds/sdm/handler/applicationservice/SDMReadAttachmentsHandlerPopulateUploadableFlagsTest.java b/sdm/src/test/java/unit/com/sap/cds/sdm/handler/applicationservice/SDMReadAttachmentsHandlerPopulateUploadableFlagsTest.java new file mode 100644 index 000000000..b53c7261a --- /dev/null +++ b/sdm/src/test/java/unit/com/sap/cds/sdm/handler/applicationservice/SDMReadAttachmentsHandlerPopulateUploadableFlagsTest.java @@ -0,0 +1,901 @@ +/* + * © 2024 SAP SE or an SAP affiliate company and cds-feature-attachments contributors. + */ +package unit.com.sap.cds.sdm.handler.applicationservice; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +import com.sap.cds.CdsData; +import com.sap.cds.Result; +import com.sap.cds.reflect.CdsAnnotation; +import com.sap.cds.reflect.CdsElement; +import com.sap.cds.reflect.CdsEntity; +import com.sap.cds.reflect.CdsModel; +import com.sap.cds.sdm.constants.SDMConstants; +import com.sap.cds.sdm.handler.TokenHandler; +import com.sap.cds.sdm.handler.applicationservice.SDMReadAttachmentsHandler; +import com.sap.cds.sdm.persistence.DBQuery; +import com.sap.cds.sdm.service.SDMService; +import com.sap.cds.sdm.utilities.SDMUtils; +import com.sap.cds.services.cds.CdsReadEventContext; +import com.sap.cds.services.persistence.PersistenceService; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Stream; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +public class SDMReadAttachmentsHandlerPopulateUploadableFlagsTest { + + @Mock private CdsReadEventContext context; + @Mock private PersistenceService persistenceService; + @Mock private SDMService sdmService; + @Mock private TokenHandler tokenHandler; + @Mock private DBQuery dbQuery; + + private SDMReadAttachmentsHandler cut; + + @BeforeEach + void setUp() { + cut = new SDMReadAttachmentsHandler(persistenceService, sdmService, tokenHandler, dbQuery); + } + + // ── Early returns ───────────────────────────────────────────────────────── + + @Test + void testPopulateUploadableFlags_nullData() { + cut.populateUploadableFlags(context, null); + verifyNoInteractions(context); + } + + @Test + void testPopulateUploadableFlags_emptyData() { + cut.populateUploadableFlags(context, List.of()); + verifyNoInteractions(context); + } + + // ── Path 1: parent entity with maxCount compositions ────────────────────── + + @Test + void testPopulateUploadableFlags_path1_belowMaxCount_setsTrue() { + CdsEntity target = mock(CdsEntity.class); + CdsModel model = mock(CdsModel.class); + CdsEntity attachmentEntity = mock(CdsEntity.class); + Result countResult = mock(Result.class); + + when(context.getTarget()).thenReturn(target); + when(context.getModel()).thenReturn(model); + when(target.getQualifiedName()).thenReturn("sap.capire.Books"); + when(target.compositions()).thenAnswer(inv -> Stream.of(buildComposition("attachments", "2"))); + when(target.elements()).thenAnswer(inv -> Stream.of(buildKeyElement("ID"))); + when(model.findEntity("sap.capire.Books.attachments")) + .thenReturn(Optional.of(attachmentEntity)); + when(countResult.rowCount()).thenReturn(1L); + when(dbQuery.getAttachmentsForUPID( + eq(attachmentEntity), eq(persistenceService), eq("p1"), eq("up__ID"))) + .thenReturn(countResult); + + Map rowMap = new HashMap<>(); + rowMap.put("ID", "p1"); + CdsData row = CdsData.create(rowMap); + + try (MockedStatic sdmUtils = Mockito.mockStatic(SDMUtils.class)) { + sdmUtils.when(() -> SDMUtils.getUpIdKey(attachmentEntity)).thenReturn("up__ID"); + cut.populateUploadableFlags(context, List.of(row)); + } + + assertThat(row.get("isAttachmentsUploadable")).isEqualTo(true); + } + + @Test + void testPopulateUploadableFlags_path1_atMaxCount_setsFalse() { + CdsEntity target = mock(CdsEntity.class); + CdsModel model = mock(CdsModel.class); + CdsEntity attachmentEntity = mock(CdsEntity.class); + Result countResult = mock(Result.class); + + when(context.getTarget()).thenReturn(target); + when(context.getModel()).thenReturn(model); + when(target.getQualifiedName()).thenReturn("sap.capire.Books"); + when(target.compositions()).thenAnswer(inv -> Stream.of(buildComposition("attachments", "2"))); + when(target.elements()).thenAnswer(inv -> Stream.of(buildKeyElement("ID"))); + when(model.findEntity("sap.capire.Books.attachments")) + .thenReturn(Optional.of(attachmentEntity)); + when(countResult.rowCount()).thenReturn(2L); + when(dbQuery.getAttachmentsForUPID( + eq(attachmentEntity), eq(persistenceService), eq("p1"), eq("up__ID"))) + .thenReturn(countResult); + + Map rowMap = new HashMap<>(); + rowMap.put("ID", "p1"); + CdsData row = CdsData.create(rowMap); + + try (MockedStatic sdmUtils = Mockito.mockStatic(SDMUtils.class)) { + sdmUtils.when(() -> SDMUtils.getUpIdKey(attachmentEntity)).thenReturn("up__ID"); + cut.populateUploadableFlags(context, List.of(row)); + } + + assertThat(row.get("isAttachmentsUploadable")).isEqualTo(false); + } + + @Test + void testPopulateUploadableFlags_path1_keyFieldNull_returnsEarly() { + CdsEntity target = mock(CdsEntity.class); + + when(context.getTarget()).thenReturn(target); + when(target.compositions()).thenAnswer(inv -> Stream.of(buildComposition("attachments", "2"))); + when(target.elements()).thenAnswer(inv -> Stream.empty()); + + Map rowMap = new HashMap<>(); + rowMap.put("ID", "p1"); + CdsData row = CdsData.create(rowMap); + + cut.populateUploadableFlags(context, List.of(row)); + + verify(dbQuery, never()).getAttachmentsForUPID(any(), any(), any(), any()); + assertThat(row.get("isAttachmentsUploadable")).isNull(); + } + + @Test + void testPopulateUploadableFlags_path1_keyValNull_rowSkipped() { + CdsEntity target = mock(CdsEntity.class); + CdsModel model = mock(CdsModel.class); + + when(context.getTarget()).thenReturn(target); + when(context.getModel()).thenReturn(model); + when(target.getQualifiedName()).thenReturn("sap.capire.Books"); + when(target.compositions()).thenAnswer(inv -> Stream.of(buildComposition("attachments", "2"))); + when(target.elements()).thenAnswer(inv -> Stream.of(buildKeyElement("ID"))); + + Map rowMap = new HashMap<>(); + // "ID" key absent — row.get("ID") returns null + CdsData row = CdsData.create(rowMap); + + cut.populateUploadableFlags(context, List.of(row)); + + verify(dbQuery, never()).getAttachmentsForUPID(any(), any(), any(), any()); + } + + @Test + void testPopulateUploadableFlags_path1_attachmentEntityNull_facetSkipped() { + CdsEntity target = mock(CdsEntity.class); + CdsModel model = mock(CdsModel.class); + + when(context.getTarget()).thenReturn(target); + when(context.getModel()).thenReturn(model); + when(target.getQualifiedName()).thenReturn("sap.capire.Books"); + when(target.compositions()).thenAnswer(inv -> Stream.of(buildComposition("attachments", "2"))); + when(target.elements()).thenAnswer(inv -> Stream.of(buildKeyElement("ID"))); + when(model.findEntity("sap.capire.Books.attachments")).thenReturn(Optional.empty()); + + Map rowMap = new HashMap<>(); + rowMap.put("ID", "p1"); + CdsData row = CdsData.create(rowMap); + + cut.populateUploadableFlags(context, List.of(row)); + + verify(dbQuery, never()).getAttachmentsForUPID(any(), any(), any(), any()); + assertThat(row.get("isAttachmentsUploadable")).isNull(); + } + + @Test + void testPopulateUploadableFlags_path1_upIdKeyEmpty_facetSkipped() { + CdsEntity target = mock(CdsEntity.class); + CdsModel model = mock(CdsModel.class); + CdsEntity attachmentEntity = mock(CdsEntity.class); + + when(context.getTarget()).thenReturn(target); + when(context.getModel()).thenReturn(model); + when(target.getQualifiedName()).thenReturn("sap.capire.Books"); + when(target.compositions()).thenAnswer(inv -> Stream.of(buildComposition("attachments", "2"))); + when(target.elements()).thenAnswer(inv -> Stream.of(buildKeyElement("ID"))); + when(model.findEntity("sap.capire.Books.attachments")) + .thenReturn(Optional.of(attachmentEntity)); + + Map rowMap = new HashMap<>(); + rowMap.put("ID", "p1"); + CdsData row = CdsData.create(rowMap); + + try (MockedStatic sdmUtils = Mockito.mockStatic(SDMUtils.class)) { + sdmUtils.when(() -> SDMUtils.getUpIdKey(attachmentEntity)).thenReturn(""); + cut.populateUploadableFlags(context, List.of(row)); + } + + verify(dbQuery, never()).getAttachmentsForUPID(any(), any(), any(), any()); + assertThat(row.get("isAttachmentsUploadable")).isNull(); + } + + @Test + void testPopulateUploadableFlags_path1_isDraft_draftEntityFound() { + CdsEntity target = mock(CdsEntity.class); + CdsModel model = mock(CdsModel.class); + CdsEntity draftAttachmentEntity = mock(CdsEntity.class); + Result countResult = mock(Result.class); + + when(context.getTarget()).thenReturn(target); + when(context.getModel()).thenReturn(model); + when(target.getQualifiedName()).thenReturn("sap.capire.Books"); + when(target.compositions()).thenAnswer(inv -> Stream.of(buildComposition("attachments", "3"))); + when(target.elements()).thenAnswer(inv -> Stream.of(buildKeyElement("ID"))); + // isDraft=true because IsActiveEntity=false + when(model.findEntity("sap.capire.Books.attachments_drafts")) + .thenReturn(Optional.of(draftAttachmentEntity)); + when(countResult.rowCount()).thenReturn(1L); + when(dbQuery.getAttachmentsForUPID( + eq(draftAttachmentEntity), eq(persistenceService), eq("p1"), eq("up__ID"))) + .thenReturn(countResult); + + Map rowMap = new HashMap<>(); + rowMap.put("ID", "p1"); + rowMap.put("IsActiveEntity", false); + CdsData row = CdsData.create(rowMap); + + try (MockedStatic sdmUtils = Mockito.mockStatic(SDMUtils.class)) { + sdmUtils.when(() -> SDMUtils.getUpIdKey(draftAttachmentEntity)).thenReturn("up__ID"); + cut.populateUploadableFlags(context, List.of(row)); + } + + verify(model, never()).findEntity("sap.capire.Books.attachments"); + assertThat(row.get("isAttachmentsUploadable")).isEqualTo(true); + } + + @Test + void testPopulateUploadableFlags_path1_isDraft_draftEntityNotFound_fallbackActive() { + CdsEntity target = mock(CdsEntity.class); + CdsModel model = mock(CdsModel.class); + CdsEntity activeAttachmentEntity = mock(CdsEntity.class); + Result countResult = mock(Result.class); + + when(context.getTarget()).thenReturn(target); + when(context.getModel()).thenReturn(model); + when(target.getQualifiedName()).thenReturn("sap.capire.Books"); + when(target.compositions()).thenAnswer(inv -> Stream.of(buildComposition("attachments", "2"))); + when(target.elements()).thenAnswer(inv -> Stream.of(buildKeyElement("ID"))); + when(model.findEntity("sap.capire.Books.attachments_drafts")).thenReturn(Optional.empty()); + when(model.findEntity("sap.capire.Books.attachments")) + .thenReturn(Optional.of(activeAttachmentEntity)); + when(countResult.rowCount()).thenReturn(0L); + when(dbQuery.getAttachmentsForUPID( + eq(activeAttachmentEntity), eq(persistenceService), eq("p1"), eq("up__ID"))) + .thenReturn(countResult); + + Map rowMap = new HashMap<>(); + rowMap.put("ID", "p1"); + rowMap.put("IsActiveEntity", false); + CdsData row = CdsData.create(rowMap); + + try (MockedStatic sdmUtils = Mockito.mockStatic(SDMUtils.class)) { + sdmUtils.when(() -> SDMUtils.getUpIdKey(activeAttachmentEntity)).thenReturn("up__ID"); + cut.populateUploadableFlags(context, List.of(row)); + } + + assertThat(row.get("isAttachmentsUploadable")).isEqualTo(true); + } + + @Test + void testPopulateUploadableFlags_path1_multipleCompositions_bothFlagsSet() { + CdsEntity target = mock(CdsEntity.class); + CdsModel model = mock(CdsModel.class); + CdsEntity attachmentsEntity = mock(CdsEntity.class); + CdsEntity referencesEntity = mock(CdsEntity.class); + Result attachResult = mock(Result.class); + Result refResult = mock(Result.class); + + when(context.getTarget()).thenReturn(target); + when(context.getModel()).thenReturn(model); + when(target.getQualifiedName()).thenReturn("sap.capire.Books"); + when(target.compositions()) + .thenAnswer( + inv -> + Stream.of( + buildComposition("attachments", "2"), buildComposition("references", "3"))); + when(target.elements()).thenAnswer(inv -> Stream.of(buildKeyElement("ID"))); + when(model.findEntity("sap.capire.Books.attachments")) + .thenReturn(Optional.of(attachmentsEntity)); + when(model.findEntity("sap.capire.Books.references")).thenReturn(Optional.of(referencesEntity)); + when(attachResult.rowCount()).thenReturn(2L); // at limit → false + when(refResult.rowCount()).thenReturn(1L); // below limit → true + when(dbQuery.getAttachmentsForUPID(eq(attachmentsEntity), any(), eq("p1"), eq("up__ID"))) + .thenReturn(attachResult); + when(dbQuery.getAttachmentsForUPID(eq(referencesEntity), any(), eq("p1"), eq("refKey"))) + .thenReturn(refResult); + + Map rowMap = new HashMap<>(); + rowMap.put("ID", "p1"); + CdsData row = CdsData.create(rowMap); + + try (MockedStatic sdmUtils = Mockito.mockStatic(SDMUtils.class)) { + sdmUtils.when(() -> SDMUtils.getUpIdKey(attachmentsEntity)).thenReturn("up__ID"); + sdmUtils.when(() -> SDMUtils.getUpIdKey(referencesEntity)).thenReturn("refKey"); + cut.populateUploadableFlags(context, List.of(row)); + } + + assertThat(row.get("isAttachmentsUploadable")).isEqualTo(false); + assertThat(row.get("isReferencesUploadable")).isEqualTo(true); + } + + @Test + void testPopulateUploadableFlags_path1_multipleRows_dbCalledForEach() { + CdsEntity target = mock(CdsEntity.class); + CdsModel model = mock(CdsModel.class); + CdsEntity attachmentEntity = mock(CdsEntity.class); + Result countResult1 = mock(Result.class); + Result countResult2 = mock(Result.class); + + when(context.getTarget()).thenReturn(target); + when(context.getModel()).thenReturn(model); + when(target.getQualifiedName()).thenReturn("sap.capire.Books"); + when(target.compositions()).thenAnswer(inv -> Stream.of(buildComposition("attachments", "2"))); + when(target.elements()).thenAnswer(inv -> Stream.of(buildKeyElement("ID"))); + when(model.findEntity("sap.capire.Books.attachments")) + .thenReturn(Optional.of(attachmentEntity)); + when(countResult1.rowCount()).thenReturn(1L); + when(countResult2.rowCount()).thenReturn(2L); + when(dbQuery.getAttachmentsForUPID(eq(attachmentEntity), any(), eq("p1"), eq("up__ID"))) + .thenReturn(countResult1); + when(dbQuery.getAttachmentsForUPID(eq(attachmentEntity), any(), eq("p2"), eq("up__ID"))) + .thenReturn(countResult2); + + Map rowMap1 = new HashMap<>(); + rowMap1.put("ID", "p1"); + Map rowMap2 = new HashMap<>(); + rowMap2.put("ID", "p2"); + CdsData row1 = CdsData.create(rowMap1); + CdsData row2 = CdsData.create(rowMap2); + + try (MockedStatic sdmUtils = Mockito.mockStatic(SDMUtils.class)) { + sdmUtils.when(() -> SDMUtils.getUpIdKey(attachmentEntity)).thenReturn("up__ID"); + cut.populateUploadableFlags(context, List.of(row1, row2)); + } + + assertThat(row1.get("isAttachmentsUploadable")).isEqualTo(true); + assertThat(row2.get("isAttachmentsUploadable")).isEqualTo(false); + verify(dbQuery, times(2)).getAttachmentsForUPID(eq(attachmentEntity), any(), any(), any()); + } + + // ── findFacetsWithMaxCount edge cases (tested via routing to Path 2) ────── + + @Test + void testPopulateUploadableFlags_noMaxCountAnnotation_routesToPath2() { + CdsEntity target = mock(CdsEntity.class); + + when(context.getTarget()).thenReturn(target); + // Composition present but no maxCount annotation → empty facet list → Path 2 + CdsElement comp = mock(CdsElement.class); + when(comp.findAnnotation(SDMConstants.ATTACHMENT_MAXCOUNT)).thenReturn(Optional.empty()); + when(target.compositions()).thenAnswer(inv -> Stream.of(comp)); + when(target.getQualifiedName()).thenReturn("sap.capire.Books.attachments"); + + // No "up_" in data → Path 2 returns early + Map rowMap = new HashMap<>(); + CdsData row = CdsData.create(rowMap); + + cut.populateUploadableFlags(context, List.of(row)); + + // Path 1 DB call never made (facets empty), Path 2 returns early (no up_) + verify(dbQuery, never()).getAttachmentsForUPID(any(), any(), any(), any()); + } + + @Test + void testPopulateUploadableFlags_maxCountInvalidNumber_facetSkipped_routesToPath2() { + CdsEntity target = mock(CdsEntity.class); + + when(context.getTarget()).thenReturn(target); + CdsElement comp = mock(CdsElement.class); + CdsAnnotation anno = buildAnnotation("not-a-number"); + when(comp.findAnnotation(SDMConstants.ATTACHMENT_MAXCOUNT)).thenReturn(Optional.of(anno)); + when(target.compositions()).thenAnswer(inv -> Stream.of(comp)); + when(target.getQualifiedName()).thenReturn("sap.capire.Books.attachments"); + + Map rowMap = new HashMap<>(); + CdsData row = CdsData.create(rowMap); + + cut.populateUploadableFlags(context, List.of(row)); + + verify(dbQuery, never()).getAttachmentsForUPID(any(), any(), any(), any()); + } + + @Test + void testPopulateUploadableFlags_maxCountZero_facetSkipped_routesToPath2() { + CdsEntity target = mock(CdsEntity.class); + + when(context.getTarget()).thenReturn(target); + CdsElement comp = mock(CdsElement.class); + CdsAnnotation anno = buildAnnotation("0"); + when(comp.findAnnotation(SDMConstants.ATTACHMENT_MAXCOUNT)).thenReturn(Optional.of(anno)); + when(target.compositions()).thenAnswer(inv -> Stream.of(comp)); + when(target.getQualifiedName()).thenReturn("sap.capire.Books.attachments"); + + Map rowMap = new HashMap<>(); + CdsData row = CdsData.create(rowMap); + + cut.populateUploadableFlags(context, List.of(row)); + + verify(dbQuery, never()).getAttachmentsForUPID(any(), any(), any(), any()); + } + + @Test + void testPopulateUploadableFlags_maxCountNegative_facetSkipped_routesToPath2() { + CdsEntity target = mock(CdsEntity.class); + + when(context.getTarget()).thenReturn(target); + CdsElement comp = mock(CdsElement.class); + CdsAnnotation anno = buildAnnotation("-1"); + when(comp.findAnnotation(SDMConstants.ATTACHMENT_MAXCOUNT)).thenReturn(Optional.of(anno)); + when(target.compositions()).thenAnswer(inv -> Stream.of(comp)); + when(target.getQualifiedName()).thenReturn("sap.capire.Books.attachments"); + + Map rowMap = new HashMap<>(); + CdsData row = CdsData.create(rowMap); + + cut.populateUploadableFlags(context, List.of(row)); + + verify(dbQuery, never()).getAttachmentsForUPID(any(), any(), any(), any()); + } + + // ── Path 2: populateUploadableFlagsViaUp ────────────────────────────────── + + @Test + void testPopulateUploadableFlags_path2_noUpData_returnsEarly() { + CdsEntity target = mock(CdsEntity.class); + + when(context.getTarget()).thenReturn(target); + when(target.compositions()).thenAnswer(inv -> Stream.empty()); + when(target.getQualifiedName()).thenReturn("sap.capire.Books.attachments"); + + // Row has no "up_" key + Map rowMap = new HashMap<>(); + rowMap.put("up__ID", "p1"); + CdsData row = CdsData.create(rowMap); + + cut.populateUploadableFlags(context, List.of(row)); + + verify(dbQuery, never()).getAttachmentsForUPID(any(), any(), any(), any()); + } + + @Test + void testPopulateUploadableFlags_path2_entityNameNoDot_returnsEarly() { + CdsEntity target = mock(CdsEntity.class); + + when(context.getTarget()).thenReturn(target); + when(target.compositions()).thenAnswer(inv -> Stream.empty()); + // No dot in entity name → lastDot < 0 → early return + when(target.getQualifiedName()).thenReturn("attachments"); + + Map rowMap = new HashMap<>(); + rowMap.put("up_", new HashMap<>()); + CdsData row = CdsData.create(rowMap); + + cut.populateUploadableFlags(context, List.of(row)); + + verify(dbQuery, never()).getAttachmentsForUPID(any(), any(), any(), any()); + } + + @Test + void testPopulateUploadableFlags_path2_parentEntityNotFound_returnsEarly() { + CdsEntity target = mock(CdsEntity.class); + CdsModel model = mock(CdsModel.class); + + when(context.getTarget()).thenReturn(target); + when(context.getModel()).thenReturn(model); + when(target.compositions()).thenAnswer(inv -> Stream.empty()); + when(target.getQualifiedName()).thenReturn("sap.capire.Books.attachments"); + when(model.findEntity("sap.capire.Books")).thenReturn(Optional.empty()); + + Map rowMap = new HashMap<>(); + rowMap.put("up_", new HashMap<>()); + CdsData row = CdsData.create(rowMap); + + cut.populateUploadableFlags(context, List.of(row)); + + verify(dbQuery, never()).getAttachmentsForUPID(any(), any(), any(), any()); + } + + @Test + void testPopulateUploadableFlags_path2_noMaxCountOnParentComposition_returnsEarly() { + CdsEntity target = mock(CdsEntity.class); + CdsModel model = mock(CdsModel.class); + CdsEntity parentEntity = mock(CdsEntity.class); + CdsElement comp = mock(CdsElement.class); + + when(context.getTarget()).thenReturn(target); + when(context.getModel()).thenReturn(model); + when(target.compositions()).thenAnswer(inv -> Stream.empty()); + when(target.getQualifiedName()).thenReturn("sap.capire.Books.attachments"); + when(model.findEntity("sap.capire.Books")).thenReturn(Optional.of(parentEntity)); + when(comp.getName()).thenReturn("attachments"); + when(comp.findAnnotation(SDMConstants.ATTACHMENT_MAXCOUNT)).thenReturn(Optional.empty()); + when(parentEntity.compositions()).thenAnswer(inv -> Stream.of(comp)); + + Map rowMap = new HashMap<>(); + rowMap.put("up_", new HashMap<>()); + CdsData row = CdsData.create(rowMap); + + cut.populateUploadableFlags(context, List.of(row)); + + verify(dbQuery, never()).getAttachmentsForUPID(any(), any(), any(), any()); + } + + @Test + void testPopulateUploadableFlags_path2_maxCountInvalid_returnsEarly() { + CdsEntity target = mock(CdsEntity.class); + CdsModel model = mock(CdsModel.class); + CdsEntity parentEntity = mock(CdsEntity.class); + + when(context.getTarget()).thenReturn(target); + when(context.getModel()).thenReturn(model); + when(target.compositions()).thenAnswer(inv -> Stream.empty()); + when(target.getQualifiedName()).thenReturn("sap.capire.Books.attachments"); + when(model.findEntity("sap.capire.Books")).thenReturn(Optional.of(parentEntity)); + setupParentCompositionWithMaxCount(parentEntity, "attachments", "not-a-number"); + + Map rowMap = new HashMap<>(); + rowMap.put("up_", new HashMap<>()); + CdsData row = CdsData.create(rowMap); + + cut.populateUploadableFlags(context, List.of(row)); + + verify(dbQuery, never()).getAttachmentsForUPID(any(), any(), any(), any()); + } + + @Test + void testPopulateUploadableFlags_path2_maxCountZero_returnsEarly() { + CdsEntity target = mock(CdsEntity.class); + CdsModel model = mock(CdsModel.class); + CdsEntity parentEntity = mock(CdsEntity.class); + + when(context.getTarget()).thenReturn(target); + when(context.getModel()).thenReturn(model); + when(target.compositions()).thenAnswer(inv -> Stream.empty()); + when(target.getQualifiedName()).thenReturn("sap.capire.Books.attachments"); + when(model.findEntity("sap.capire.Books")).thenReturn(Optional.of(parentEntity)); + setupParentCompositionWithMaxCount(parentEntity, "attachments", "0"); + + Map rowMap = new HashMap<>(); + rowMap.put("up_", new HashMap<>()); + CdsData row = CdsData.create(rowMap); + + cut.populateUploadableFlags(context, List.of(row)); + + verify(dbQuery, never()).getAttachmentsForUPID(any(), any(), any(), any()); + } + + @Test + void testPopulateUploadableFlags_path2_upIdKeyEmpty_returnsEarly() { + CdsEntity target = mock(CdsEntity.class); + CdsModel model = mock(CdsModel.class); + CdsEntity parentEntity = mock(CdsEntity.class); + + when(context.getTarget()).thenReturn(target); + when(context.getModel()).thenReturn(model); + when(target.compositions()).thenAnswer(inv -> Stream.empty()); + when(target.getQualifiedName()).thenReturn("sap.capire.Books.attachments"); + when(model.findEntity("sap.capire.Books")).thenReturn(Optional.of(parentEntity)); + setupParentCompositionWithMaxCount(parentEntity, "attachments", "2"); + + Map rowMap = new HashMap<>(); + rowMap.put("up_", new HashMap<>()); + CdsData row = CdsData.create(rowMap); + + try (MockedStatic sdmUtils = Mockito.mockStatic(SDMUtils.class)) { + sdmUtils.when(() -> SDMUtils.getUpIdKey(target)).thenReturn(""); + cut.populateUploadableFlags(context, List.of(row)); + } + + verify(dbQuery, never()).getAttachmentsForUPID(any(), any(), any(), any()); + } + + @Test + void testPopulateUploadableFlags_path2_upDataNotMap_rowSkipped() { + CdsEntity target = mock(CdsEntity.class); + CdsModel model = mock(CdsModel.class); + CdsEntity parentEntity = mock(CdsEntity.class); + + when(context.getTarget()).thenReturn(target); + when(context.getModel()).thenReturn(model); + when(target.compositions()).thenAnswer(inv -> Stream.empty()); + when(target.getQualifiedName()).thenReturn("sap.capire.Books.attachments"); + when(model.findEntity("sap.capire.Books")).thenReturn(Optional.of(parentEntity)); + setupParentCompositionWithMaxCount(parentEntity, "attachments", "2"); + + Map rowMap = new HashMap<>(); + rowMap.put("up_", "not-a-map"); // not a Map + rowMap.put("up__ID", "p1"); + CdsData row = CdsData.create(rowMap); + + try (MockedStatic sdmUtils = Mockito.mockStatic(SDMUtils.class)) { + sdmUtils.when(() -> SDMUtils.getUpIdKey(target)).thenReturn("up__ID"); + cut.populateUploadableFlags(context, List.of(row)); + } + + verify(dbQuery, never()).getAttachmentsForUPID(any(), any(), any(), any()); + } + + @Test + void testPopulateUploadableFlags_path2_parentIdNull_rowSkipped() { + CdsEntity target = mock(CdsEntity.class); + CdsModel model = mock(CdsModel.class); + CdsEntity parentEntity = mock(CdsEntity.class); + + when(context.getTarget()).thenReturn(target); + when(context.getModel()).thenReturn(model); + when(target.compositions()).thenAnswer(inv -> Stream.empty()); + when(target.getQualifiedName()).thenReturn("sap.capire.Books.attachments"); + when(model.findEntity("sap.capire.Books")).thenReturn(Optional.of(parentEntity)); + setupParentCompositionWithMaxCount(parentEntity, "attachments", "2"); + + Map upMap = new HashMap<>(); + Map rowMap = new HashMap<>(); + rowMap.put("up_", upMap); + // "up__ID" absent → parentIdObj is null → row skipped + CdsData row = CdsData.create(rowMap); + + try (MockedStatic sdmUtils = Mockito.mockStatic(SDMUtils.class)) { + sdmUtils.when(() -> SDMUtils.getUpIdKey(target)).thenReturn("up__ID"); + cut.populateUploadableFlags(context, List.of(row)); + } + + verify(dbQuery, never()).getAttachmentsForUPID(any(), any(), any(), any()); + assertThat(upMap.get("isAttachmentsUploadable")).isNull(); + } + + @Test + void testPopulateUploadableFlags_path2_belowMaxCount_setsTrueOnUpMap() { + CdsEntity target = mock(CdsEntity.class); + CdsModel model = mock(CdsModel.class); + CdsEntity parentEntity = mock(CdsEntity.class); + Result countResult = mock(Result.class); + + when(context.getTarget()).thenReturn(target); + when(context.getModel()).thenReturn(model); + when(target.compositions()).thenAnswer(inv -> Stream.empty()); + when(target.getQualifiedName()).thenReturn("sap.capire.Books.attachments"); + when(model.findEntity("sap.capire.Books")).thenReturn(Optional.of(parentEntity)); + setupParentCompositionWithMaxCount(parentEntity, "attachments", "2"); + when(countResult.rowCount()).thenReturn(1L); + when(dbQuery.getAttachmentsForUPID(eq(target), eq(persistenceService), eq("p1"), eq("up__ID"))) + .thenReturn(countResult); + + Map upMap = new HashMap<>(); + Map rowMap = new HashMap<>(); + rowMap.put("up_", upMap); + rowMap.put("up__ID", "p1"); + CdsData row = CdsData.create(rowMap); + + try (MockedStatic sdmUtils = Mockito.mockStatic(SDMUtils.class)) { + sdmUtils.when(() -> SDMUtils.getUpIdKey(target)).thenReturn("up__ID"); + cut.populateUploadableFlags(context, List.of(row)); + } + + assertThat(upMap.get("isAttachmentsUploadable")).isEqualTo(true); + } + + @Test + void testPopulateUploadableFlags_path2_atMaxCount_setsFalseOnUpMap() { + CdsEntity target = mock(CdsEntity.class); + CdsModel model = mock(CdsModel.class); + CdsEntity parentEntity = mock(CdsEntity.class); + Result countResult = mock(Result.class); + + when(context.getTarget()).thenReturn(target); + when(context.getModel()).thenReturn(model); + when(target.compositions()).thenAnswer(inv -> Stream.empty()); + when(target.getQualifiedName()).thenReturn("sap.capire.Books.attachments"); + when(model.findEntity("sap.capire.Books")).thenReturn(Optional.of(parentEntity)); + setupParentCompositionWithMaxCount(parentEntity, "attachments", "2"); + when(countResult.rowCount()).thenReturn(2L); + when(dbQuery.getAttachmentsForUPID(eq(target), eq(persistenceService), eq("p1"), eq("up__ID"))) + .thenReturn(countResult); + + Map upMap = new HashMap<>(); + Map rowMap = new HashMap<>(); + rowMap.put("up_", upMap); + rowMap.put("up__ID", "p1"); + CdsData row = CdsData.create(rowMap); + + try (MockedStatic sdmUtils = Mockito.mockStatic(SDMUtils.class)) { + sdmUtils.when(() -> SDMUtils.getUpIdKey(target)).thenReturn("up__ID"); + cut.populateUploadableFlags(context, List.of(row)); + } + + assertThat(upMap.get("isAttachmentsUploadable")).isEqualTo(false); + } + + @Test + void testPopulateUploadableFlags_path2_isDraftEntity_stripsSuffix() { + CdsEntity target = mock(CdsEntity.class); + CdsModel model = mock(CdsModel.class); + CdsEntity parentEntity = mock(CdsEntity.class); + Result countResult = mock(Result.class); + + when(context.getTarget()).thenReturn(target); + when(context.getModel()).thenReturn(model); + when(target.compositions()).thenAnswer(inv -> Stream.empty()); + // Entity name ends with _drafts + when(target.getQualifiedName()).thenReturn("sap.capire.Books.attachments_drafts"); + // After stripping suffix: "sap.capire.Books.attachments" → facet="attachments", + // parent="sap.capire.Books" + when(model.findEntity("sap.capire.Books")).thenReturn(Optional.of(parentEntity)); + setupParentCompositionWithMaxCount(parentEntity, "attachments", "2"); + when(countResult.rowCount()).thenReturn(0L); + when(dbQuery.getAttachmentsForUPID(eq(target), any(), eq("p1"), eq("up__ID"))) + .thenReturn(countResult); + + Map upMap = new HashMap<>(); + Map rowMap = new HashMap<>(); + rowMap.put("up_", upMap); + rowMap.put("up__ID", "p1"); + CdsData row = CdsData.create(rowMap); + + try (MockedStatic sdmUtils = Mockito.mockStatic(SDMUtils.class)) { + sdmUtils.when(() -> SDMUtils.getUpIdKey(target)).thenReturn("up__ID"); + cut.populateUploadableFlags(context, List.of(row)); + } + + assertThat(upMap.get("isAttachmentsUploadable")).isEqualTo(true); + } + + @Test + void testPopulateUploadableFlags_path2_caching_sameParent_dbCalledOnce() { + CdsEntity target = mock(CdsEntity.class); + CdsModel model = mock(CdsModel.class); + CdsEntity parentEntity = mock(CdsEntity.class); + Result countResult = mock(Result.class); + + when(context.getTarget()).thenReturn(target); + when(context.getModel()).thenReturn(model); + when(target.compositions()).thenAnswer(inv -> Stream.empty()); + when(target.getQualifiedName()).thenReturn("sap.capire.Books.attachments"); + when(model.findEntity("sap.capire.Books")).thenReturn(Optional.of(parentEntity)); + setupParentCompositionWithMaxCount(parentEntity, "attachments", "2"); + when(countResult.rowCount()).thenReturn(1L); + when(dbQuery.getAttachmentsForUPID(eq(target), eq(persistenceService), eq("p1"), eq("up__ID"))) + .thenReturn(countResult); + + // Two rows, same parent ID — DB should be called only once + Map upMap1 = new HashMap<>(); + Map rowMap1 = new HashMap<>(); + rowMap1.put("up_", upMap1); + rowMap1.put("up__ID", "p1"); + + Map upMap2 = new HashMap<>(); + Map rowMap2 = new HashMap<>(); + rowMap2.put("up_", upMap2); + rowMap2.put("up__ID", "p1"); + + CdsData row1 = CdsData.create(rowMap1); + CdsData row2 = CdsData.create(rowMap2); + + try (MockedStatic sdmUtils = Mockito.mockStatic(SDMUtils.class)) { + sdmUtils.when(() -> SDMUtils.getUpIdKey(target)).thenReturn("up__ID"); + cut.populateUploadableFlags(context, List.of(row1, row2)); + } + + verify(dbQuery, times(1)).getAttachmentsForUPID(any(), any(), any(), any()); + assertThat(upMap1.get("isAttachmentsUploadable")).isEqualTo(true); + assertThat(upMap2.get("isAttachmentsUploadable")).isEqualTo(true); + } + + @Test + void testPopulateUploadableFlags_path2_caching_differentParents_dbCalledForEach() { + CdsEntity target = mock(CdsEntity.class); + CdsModel model = mock(CdsModel.class); + CdsEntity parentEntity = mock(CdsEntity.class); + Result countResult1 = mock(Result.class); + Result countResult2 = mock(Result.class); + + when(context.getTarget()).thenReturn(target); + when(context.getModel()).thenReturn(model); + when(target.compositions()).thenAnswer(inv -> Stream.empty()); + when(target.getQualifiedName()).thenReturn("sap.capire.Books.attachments"); + when(model.findEntity("sap.capire.Books")).thenReturn(Optional.of(parentEntity)); + setupParentCompositionWithMaxCount(parentEntity, "attachments", "2"); + when(countResult1.rowCount()).thenReturn(1L); + when(countResult2.rowCount()).thenReturn(2L); + when(dbQuery.getAttachmentsForUPID(eq(target), eq(persistenceService), eq("p1"), eq("up__ID"))) + .thenReturn(countResult1); + when(dbQuery.getAttachmentsForUPID(eq(target), eq(persistenceService), eq("p2"), eq("up__ID"))) + .thenReturn(countResult2); + + Map upMap1 = new HashMap<>(); + Map rowMap1 = new HashMap<>(); + rowMap1.put("up_", upMap1); + rowMap1.put("up__ID", "p1"); + + Map upMap2 = new HashMap<>(); + Map rowMap2 = new HashMap<>(); + rowMap2.put("up_", upMap2); + rowMap2.put("up__ID", "p2"); + + CdsData row1 = CdsData.create(rowMap1); + CdsData row2 = CdsData.create(rowMap2); + + try (MockedStatic sdmUtils = Mockito.mockStatic(SDMUtils.class)) { + sdmUtils.when(() -> SDMUtils.getUpIdKey(target)).thenReturn("up__ID"); + cut.populateUploadableFlags(context, List.of(row1, row2)); + } + + verify(dbQuery, times(2)).getAttachmentsForUPID(any(), any(), any(), any()); + assertThat(upMap1.get("isAttachmentsUploadable")).isEqualTo(true); + assertThat(upMap2.get("isAttachmentsUploadable")).isEqualTo(false); + } + + @Test + void testPopulateUploadableFlags_path2_facetNameFootnotes_virtualFieldCorrect() { + CdsEntity target = mock(CdsEntity.class); + CdsModel model = mock(CdsModel.class); + CdsEntity parentEntity = mock(CdsEntity.class); + Result countResult = mock(Result.class); + + when(context.getTarget()).thenReturn(target); + when(context.getModel()).thenReturn(model); + when(target.compositions()).thenAnswer(inv -> Stream.empty()); + // facet = "footnotes" → virtualField = "isFootnotesUploadable" + when(target.getQualifiedName()).thenReturn("sap.capire.Chapters.footnotes"); + when(model.findEntity("sap.capire.Chapters")).thenReturn(Optional.of(parentEntity)); + setupParentCompositionWithMaxCount(parentEntity, "footnotes", "1"); + when(countResult.rowCount()).thenReturn(0L); + when(dbQuery.getAttachmentsForUPID(eq(target), any(), eq("c1"), eq("up__ID"))) + .thenReturn(countResult); + + Map upMap = new HashMap<>(); + Map rowMap = new HashMap<>(); + rowMap.put("up_", upMap); + rowMap.put("up__ID", "c1"); + CdsData row = CdsData.create(rowMap); + + try (MockedStatic sdmUtils = Mockito.mockStatic(SDMUtils.class)) { + sdmUtils.when(() -> SDMUtils.getUpIdKey(target)).thenReturn("up__ID"); + cut.populateUploadableFlags(context, List.of(row)); + } + + assertThat(upMap.get("isFootnotesUploadable")).isEqualTo(true); + assertThat(upMap.get("isAttachmentsUploadable")).isNull(); + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + @SuppressWarnings("unchecked") + private CdsElement buildComposition(String facetName, String maxCountValue) { + CdsElement comp = mock(CdsElement.class); + CdsAnnotation anno = buildAnnotation(maxCountValue); + when(comp.findAnnotation(SDMConstants.ATTACHMENT_MAXCOUNT)).thenReturn(Optional.of(anno)); + when(comp.getName()).thenReturn(facetName); + return comp; + } + + private CdsElement buildKeyElement(String name) { + CdsElement el = mock(CdsElement.class); + when(el.isKey()).thenReturn(true); + when(el.getName()).thenReturn(name); + return el; + } + + @SuppressWarnings("unchecked") + private CdsAnnotation buildAnnotation(String value) { + CdsAnnotation anno = mock(CdsAnnotation.class); + when(anno.getValue()).thenReturn(value); + return anno; + } + + @SuppressWarnings("unchecked") + private void setupParentCompositionWithMaxCount( + CdsEntity parentEntity, String facetName, String maxCountValue) { + CdsElement comp = mock(CdsElement.class); + CdsAnnotation anno = buildAnnotation(maxCountValue); + when(comp.getName()).thenReturn(facetName); + when(comp.findAnnotation(SDMConstants.ATTACHMENT_MAXCOUNT)).thenReturn(Optional.of(anno)); + when(parentEntity.compositions()).thenAnswer(inv -> Stream.of(comp)); + } +} From c1146fdbb76b5debd2f994a9e52a1b11f7e3f7f8 Mon Sep 17 00:00:00 2001 From: "Yashmeet ." Date: Fri, 22 May 2026 10:46:01 +0530 Subject: [PATCH 2/7] Changes for testing --- .github/workflows/cfdeploy.yml | 8 ++++---- pom.xml | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/cfdeploy.yml b/.github/workflows/cfdeploy.yml index d15aedadc..9f78b0758 100644 --- a/.github/workflows/cfdeploy.yml +++ b/.github/workflows/cfdeploy.yml @@ -56,12 +56,12 @@ jobs: - name: Verify and Checkout Deploy Branch 🔄 run: | git fetch origin - echo "📂 Verifying 'local_deploy' branch..." - if git rev-parse --verify origin/local_deploy; then - git checkout local_deploy + echo "📂 Verifying 'appChangesToHideUploadBtn' branch..." + if git rev-parse --verify origin/appChangesToHideUploadBtn; then + git checkout appChangesToHideUploadBtn echo "✅ Branch checked out successfully!" else - echo "❌ Branch 'local_deploy' not found. Please verify the branch name." + echo "❌ Branch 'appChangesToHideUploadBtn' not found. Please verify the branch name." exit 1 fi diff --git a/pom.xml b/pom.xml index 802d1ef8f..a71a54c5b 100644 --- a/pom.xml +++ b/pom.xml @@ -23,7 +23,7 @@ - 1.8.1-SNAPSHOT + 1.0.0-RC1 17 ${java.version} ${java.version} From 802405be2e49132c00f9d3c1540256088477d963 Mon Sep 17 00:00:00 2001 From: "Yashmeet ." Date: Mon, 25 May 2026 09:48:23 +0530 Subject: [PATCH 3/7] Update Java version in deploy job to 21 for testing --- .github/workflows/cfdeploy.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/cfdeploy.yml b/.github/workflows/cfdeploy.yml index 9f78b0758..bc63c7461 100644 --- a/.github/workflows/cfdeploy.yml +++ b/.github/workflows/cfdeploy.yml @@ -41,10 +41,10 @@ jobs: with: ref: ${{ github.event.inputs.deploy_branch }} - - name: Set up Java 17 ☕ + - name: Set up Java 21 ☕ uses: actions/setup-java@v3 with: - java-version: 17 + java-version: 21 distribution: 'temurin' - name: Build and package 🔨 From 723599acabb8061ffe6a1f4ab7c24fad2b4862b9 Mon Sep 17 00:00:00 2001 From: "Yashmeet ." Date: Tue, 26 May 2026 13:23:03 +0530 Subject: [PATCH 4/7] workflow file changes for testing --- .github/workflows/multi tenancy_Integration.yml | 4 ++-- .github/workflows/multiTenancyDeployLocal.yml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/multi tenancy_Integration.yml b/.github/workflows/multi tenancy_Integration.yml index a20e1fac7..17ec2920f 100644 --- a/.github/workflows/multi tenancy_Integration.yml +++ b/.github/workflows/multi tenancy_Integration.yml @@ -34,10 +34,10 @@ jobs: with: ref: ${{ github.event.inputs.branch_name }} - - name: Set up Java 17 ☕ + - name: Set up Java 21 ☕ uses: actions/setup-java@v3 with: - java-version: 17 + java-version: 21 distribution: 'temurin' cache: 'maven' diff --git a/.github/workflows/multiTenancyDeployLocal.yml b/.github/workflows/multiTenancyDeployLocal.yml index 30d936c54..a5932f3c1 100644 --- a/.github/workflows/multiTenancyDeployLocal.yml +++ b/.github/workflows/multiTenancyDeployLocal.yml @@ -60,7 +60,7 @@ jobs: - name: Clone the cloud-cap-samples-java repo 🌐 run: | echo "🔄 Cloning repository..." - git clone --depth 1 --branch local_mtTests https://github.com/vibhutikumar07/cloud-cap-samples-java.git + git clone --depth 1 --branch appChgesToHideUploadBtn https://github.com/vibhutikumar07/cloud-cap-samples-java.git echo "✅ Repository cloned!" - name: Override cds.services.version (runtime only) From 6937dbde2fe1f083991e90ad27a057058f12fa96 Mon Sep 17 00:00:00 2001 From: "Yashmeet ." Date: Tue, 26 May 2026 15:26:21 +0530 Subject: [PATCH 5/7] revert changes done for testing --- .github/workflows/cfdeploy.yml | 8 ++++---- .github/workflows/multiTenancyDeployLocal.yml | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/cfdeploy.yml b/.github/workflows/cfdeploy.yml index bc63c7461..4ad81dff3 100644 --- a/.github/workflows/cfdeploy.yml +++ b/.github/workflows/cfdeploy.yml @@ -56,12 +56,12 @@ jobs: - name: Verify and Checkout Deploy Branch 🔄 run: | git fetch origin - echo "📂 Verifying 'appChangesToHideUploadBtn' branch..." - if git rev-parse --verify origin/appChangesToHideUploadBtn; then - git checkout appChangesToHideUploadBtn + echo "📂 Verifying 'local_deploy' branch..." + if git rev-parse --verify origin/local_deploy; then + git checkout local_deploy echo "✅ Branch checked out successfully!" else - echo "❌ Branch 'appChangesToHideUploadBtn' not found. Please verify the branch name." + echo "❌ Branch 'local_deploy' not found. Please verify the branch name." exit 1 fi diff --git a/.github/workflows/multiTenancyDeployLocal.yml b/.github/workflows/multiTenancyDeployLocal.yml index a5932f3c1..30d936c54 100644 --- a/.github/workflows/multiTenancyDeployLocal.yml +++ b/.github/workflows/multiTenancyDeployLocal.yml @@ -60,7 +60,7 @@ jobs: - name: Clone the cloud-cap-samples-java repo 🌐 run: | echo "🔄 Cloning repository..." - git clone --depth 1 --branch appChgesToHideUploadBtn https://github.com/vibhutikumar07/cloud-cap-samples-java.git + git clone --depth 1 --branch local_mtTests https://github.com/vibhutikumar07/cloud-cap-samples-java.git echo "✅ Repository cloned!" - name: Override cds.services.version (runtime only) From 38d6d0fd27f8df33fa81aaab32565badbbfb721e Mon Sep 17 00:00:00 2001 From: "Yashmeet ." Date: Thu, 28 May 2026 09:31:47 +0530 Subject: [PATCH 6/7] Address review comments --- .../SDMReadAttachmentsHandler.java | 20 +++++++------------ 1 file changed, 7 insertions(+), 13 deletions(-) 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 91d9c7cd8..4188308d6 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 @@ -426,7 +426,6 @@ public void populateUploadableFlags(CdsReadEventContext context, List d logger.info( "populateUploadableFlags: entity={} rows={}", target.getQualifiedName(), data.size()); - // Path 1: target is a parent entity with maxCount compositions — populate virtual fields List facets = findFacetsWithMaxCount(target); if (!facets.isEmpty()) { logger.info( @@ -499,11 +498,6 @@ public void populateUploadableFlags(CdsReadEventContext context, List d logger.info( "populateUploadableFlags Path2: entity={} checking for up_ expansion", target.getQualifiedName()); - // Path 2: target is an attachment entity read with $expand=up_ - // Fiori uses up_.isXxxUploadable (via InsertRestrictions.Insertable) to control the Upload - // button. After an attachment delete, Fiori refreshes the attachment list with $expand=up_ — - // this handler populates the virtual field on the expanded parent so the button re-enables - // immediately without requiring the user to save. populateUploadableFlagsViaUp(context, target, data); } @@ -549,13 +543,13 @@ private void populateUploadableFlagsViaUp( return; } - Optional> maxCountAnno = + Optional> maxCountAnnotation = baseParentEntity .compositions() .filter(c -> facetName.equals(c.getName())) .findFirst() .flatMap(c -> c.findAnnotation(SDMConstants.ATTACHMENT_MAXCOUNT)); - if (!maxCountAnno.isPresent()) { + if (!maxCountAnnotation.isPresent()) { logger.info( "populateUploadableFlagsViaUp: no maxCount for facet={} on entity={}", facetName, @@ -565,11 +559,11 @@ private void populateUploadableFlagsViaUp( long maxCount; try { - maxCount = Long.parseLong(maxCountAnno.get().getValue().toString()); + maxCount = Long.parseLong(String.valueOf(maxCountAnnotation.get().getValue())); } catch (NumberFormatException e) { logger.debug( "populateUploadableFlagsViaUp: invalid maxCount value={} for facet={}", - maxCountAnno.get().getValue(), + maxCountAnnotation.get().getValue(), facetName); return; } @@ -631,9 +625,9 @@ private List findFacetsWithMaxCount(CdsEntity target) { composition -> { String compName = composition.getName(); logger.debug("findFacetsWithMaxCount: checking composition={}", compName); - Optional> maxCountAnno = + Optional> maxCountAnnotation = composition.findAnnotation(SDMConstants.ATTACHMENT_MAXCOUNT); - if (!maxCountAnno.isPresent()) { + if (!maxCountAnnotation.isPresent()) { logger.debug( "findFacetsWithMaxCount: no maxCount annotation for composition={}", compName); return; @@ -641,7 +635,7 @@ private List findFacetsWithMaxCount(CdsEntity target) { long maxCount; try { - maxCount = Long.parseLong(maxCountAnno.get().getValue().toString()); + maxCount = Long.parseLong(maxCountAnnotation.get().getValue().toString()); } catch (NumberFormatException e) { logger.debug( "findFacetsWithMaxCount: invalid maxCount value for composition={}", compName); From 61475a860ceaf591227ed99e26cc5f11afb6d10b Mon Sep 17 00:00:00 2001 From: "Yashmeet ." Date: Thu, 28 May 2026 17:27:08 +0530 Subject: [PATCH 7/7] ReadMe update and Review comments --- README.md | 90 ++++++++++- .../SDMReadAttachmentsHandler.java | 151 ++++++++++-------- 2 files changed, 168 insertions(+), 73 deletions(-) diff --git a/README.md b/README.md index d0fbc1af5..e2bb94059 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ This plugin can be consumed by the CAP application deployed on BTP to store thei - Draft functionality : Provides the capability of working with draft attachments. - Display attachments specific to repository: Lists attachments contained in the repository that is configured with the CAP application. - Custom properties : Provides the capability to define custom properties for attachments. -- Maximum allowed uploads: Provides the capability to define the maximum number of uploads allowed for the user. +- Maximum allowed uploads: Provides the capability to define the maximum number of uploads allowed for the user. Automatically disables the upload button in the UI when the limit is reached. - Maximum file size: Provides the capability to specify the maximum file size for attachments. - Multiple attachment facets: Provides the capability to define multiple attachment facets/sections in the CAP Entity. - Technical user support: Provides the capability to consume the plugin using technical user. @@ -37,6 +37,7 @@ This plugin can be consumed by the CAP application deployed on BTP to store thei - [Support for Multitenancy](#support-for-multitenancy) - [Support for Custom Properties](#support-for-custom-properties) - [Support for Maximum allowed uploads](#support-for-maximum-allowed-uploads) +- [Upload Button Auto-Disable in the UI](#upload-button-auto-disable-in-the-ui) - [Support for Maximum File Size](#support-for-maximum-file-size) - [Support for Multiple attachment facets](#support-for-multiple-attachment-facets) - [Support for Technical user](#support-for-technical-user) @@ -494,9 +495,90 @@ Example for German language in `messages_de.properties`: SDM.maxCountErrorMessage = Maximale Anzahl von Anhängen erreicht ``` -> **Note** -> -> Once the maxCount is configured, it is recommended not to alter it. If the maxCount is altered, the previously uploaded documents will still be visible. +#### Upload Button Auto-Disable in the UI + +When `maxCount` is configured, the plugin automatically computes a virtual boolean field (e.g. `isAttachmentsUploadable`) on the parent entity at read time and sets it to `false` when the limit is reached. To wire this up in your Fiori UI so the **Upload button is automatically disabled**, follow the steps below. + +**1. Declare the virtual field on the parent entity** + +Add a `virtual` boolean field for each `maxCount` annotated composition directly on the parent entity in your CDS schema. The field name must follow the pattern `is` + capitalised composition name + `Uploadable`: + +```cds +entity Books : managed, cuid { + // ... other fields ... + virtual isAttachmentsUploadable : Boolean; + virtual isReferencesUploadable : Boolean; + + attachments : Composition of many Attachments @SDM.Attachments:{maxCount: 4}; + references : Composition of many Attachments @SDM.Attachments:{maxCount: 2}; +} +``` + +- For a composition named `attachments` declare `virtual isAttachmentsUploadable : Boolean`. +- For `references` declare `virtual isReferencesUploadable : Boolean`, and so on. +- Virtual fields are never stored in the database; the plugin populates them at read time. + +**2. Disable the Upload button via `InsertRestrictions`** + +Annotate each attachment entity in your service to bind its insertability to the virtual field on the parent: + +```cds +annotate MyService.Books.attachments with @( + Capabilities: {InsertRestrictions: {Insertable: up_.isAttachmentsUploadable}} +); +``` + +- Replace `MyService.Books.attachments` with your service and entity path. +- Repeat for every composition facet that has a `maxCount`. + +**3. Refresh the parent after attachment changes via `SideEffects`** + +After an upload or deletion, Fiori must re-read the parent entity to pick up the updated virtual field and reflect the new button state. Add a named `Common.SideEffects` annotation on the parent entity: + +```cds +annotate MyService.Books with @( + Common.SideEffects #attachmentsUploadable: { + SourceEntities: ['attachments'], + TargetProperties: [''] + } +); +``` + +- `SourceEntities: ['attachments']` — triggers the refresh when the `attachments` list changes. +- `TargetEntities: ['']` — re-fetches the parent entity (`Books`) so the updated `isAttachmentsUploadable` value is returned to the UI. +- The qualifier (e.g. `#attachmentsUploadable`) can be any unique name; it is only needed to distinguish multiple `SideEffects` annotations on the same entity. + +**Example with multiple facets** + +If an entity has several `maxCount`-annotated compositions, add one `virtual field` declaration, one `InsertRestrictions`, and one `SideEffects` per facet: + + +```cds +annotate MyService.Books.attachments with @( + Capabilities: {InsertRestrictions: {Insertable: up_.isAttachmentsUploadable}} +); + +annotate MyService.Books.references with @( + Capabilities: {InsertRestrictions: {Insertable: up_.isReferencesUploadable}} +); + +annotate MyService.Books with @( + Common.SideEffects #attachmentsUploadable: { + SourceEntities: ['attachments'], + TargetProperties: ['isAttachmentsUploadable'] + }, + Common.SideEffects #referencesUploadable: { + SourceEntities: ['references'], + TargetProperties: ['isAttachmentsUploadable'] + } +); +``` +See this [example](https://github.com/cap-java/sdm/blob/cc537c5c855ad59fa14100a397536ab22f4b1aa7/cap-notebook/demoapp/db/schema.cds#L19) of `virtual field` declaration from a sample Bookshop app. + +See this [example](https://github.com/cap-java/sdm/blob/cc537c5c855ad59fa14100a397536ab22f4b1aa7/cap-notebook/demoapp/srv/admin-service.cds#L46) of `InsertRestriction` annotation from a sample Bookshop app. + +See this [example](https://github.com/cap-java/sdm/blob/cc537c5c855ad59fa14100a397536ab22f4b1aa7/cap-notebook/demoapp/srv/admin-service.cds#L279) of `SideEffects` annotation from a sample Bookshop app. + ## Support for Maximum File Size 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 4188308d6..776b12f92 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 @@ -428,13 +428,10 @@ public void populateUploadableFlags(CdsReadEventContext context, List d List facets = findFacetsWithMaxCount(target); if (!facets.isEmpty()) { - logger.info( + logger.debug( "populateUploadableFlags Path1: entity={} facets={}", target.getQualifiedName(), facets.size()); - boolean isDraft = - data.stream().anyMatch(row -> Boolean.FALSE.equals(row.get("IsActiveEntity"))); - logger.debug("populateUploadableFlags Path1: isDraft={}", isDraft); String keyField = target @@ -444,11 +441,28 @@ public void populateUploadableFlags(CdsReadEventContext context, List d .map(CdsElement::getName) .findFirst() .orElse(null); - logger.debug("populateUploadableFlags Path1: keyField={}", keyField); 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<>(); for (CdsData row : data) { + // Determine draft state per row — a single result set can mix active and draft records. + boolean rowIsDraft = Boolean.FALSE.equals(row.get("IsActiveEntity")); Object keyVal = row.get(keyField); if (keyVal == null) { logger.debug("populateUploadableFlags Path1: skipping row with null keyVal"); @@ -458,12 +472,8 @@ public void populateUploadableFlags(CdsReadEventContext context, List d for (FacetInfo facet : facets) { String attachmentEntityBase = target.getQualifiedName() + "." + facet.facetName; - logger.debug( - "populateUploadableFlags Path1: resolving entity={} isDraft={}", - attachmentEntityBase, - isDraft); CdsEntity attachmentEntity = - resolveAttachmentEntityForCount(model, attachmentEntityBase, isDraft); + resolveAttachmentEntityForCount(model, attachmentEntityBase, rowIsDraft); if (attachmentEntity == null) { logger.debug( "populateUploadableFlags Path1: entity not found, skipping facet={}", @@ -472,22 +482,23 @@ public void populateUploadableFlags(CdsReadEventContext context, List d } String upIdKey = SDMUtils.getUpIdKey(attachmentEntity); - logger.debug( - "populateUploadableFlags Path1: upIdKey={} for facet={}", upIdKey, facet.facetName); if (upIdKey.isEmpty()) continue; - Result countResult = - dbQuery.getAttachmentsForUPID( - attachmentEntity, persistenceService, parentId, upIdKey); - boolean isUploadable = countResult.rowCount() < facet.maxCount; - - logger.info( - "Path1: entity={} parentId={} facet={} count={}/{} uploadable={}", + 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, - countResult.rowCount(), - facet.maxCount, isUploadable); row.put(facet.virtualFieldName, isUploadable); } @@ -517,6 +528,7 @@ private void populateUploadableFlagsViaUp( 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 = @@ -576,8 +588,7 @@ private void populateUploadableFlagsViaUp( } logger.debug("populateUploadableFlagsViaUp: maxCount={} facet={}", maxCount, facetName); - String virtualFieldName = - "is" + Character.toUpperCase(facetName.charAt(0)) + facetName.substring(1) + "Uploadable"; + String virtualFieldName = toVirtualFieldName(facetName); logger.debug("populateUploadableFlagsViaUp: virtualField={}", virtualFieldName); String upIdKey = SDMUtils.getUpIdKey(attachmentEntity); @@ -606,62 +617,57 @@ private void populateUploadableFlagsViaUp( return countResult.rowCount() < maxCount; }); - logger.info( + 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<>(); - target - .compositions() - .forEach( - composition -> { - String compName = composition.getName(); - logger.debug("findFacetsWithMaxCount: checking composition={}", compName); - Optional> maxCountAnnotation = - composition.findAnnotation(SDMConstants.ATTACHMENT_MAXCOUNT); - if (!maxCountAnnotation.isPresent()) { - logger.debug( - "findFacetsWithMaxCount: no maxCount annotation for composition={}", compName); - return; - } - - long maxCount; - try { - maxCount = Long.parseLong(maxCountAnnotation.get().getValue().toString()); - } catch (NumberFormatException e) { - logger.debug( - "findFacetsWithMaxCount: invalid maxCount value for composition={}", compName); - return; - } - if (maxCount <= 0) { - logger.debug( - "findFacetsWithMaxCount: maxCount={} is non-positive for composition={}, skipping", - maxCount, - compName); - return; - } - - String facetName = composition.getName(); - String virtualFieldName = - "is" - + Character.toUpperCase(facetName.charAt(0)) - + facetName.substring(1) - + "Uploadable"; - logger.debug( - "findFacetsWithMaxCount: facet={} virtualField={} maxCount={}", - facetName, - virtualFieldName, - maxCount); - result.add(new FacetInfo(facetName, virtualFieldName, maxCount)); - }); + 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; } @@ -677,8 +683,8 @@ private CdsEntity resolveAttachmentEntityForCount( baseEntityName + "_drafts"); return draftOpt.get(); } - logger.debug( - "resolveAttachmentEntityForCount: draft entity not found, falling back to active entity={}", + logger.warn( + "resolveAttachmentEntityForCount: _drafts entity not found for '{}', falling back to active entity", baseEntityName); } CdsEntity active = model.findEntity(baseEntityName).orElse(null); @@ -689,6 +695,13 @@ private CdsEntity resolveAttachmentEntityForCount( 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;