diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/configuration/Registration.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/configuration/Registration.java index cfca76765..578e748c7 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/configuration/Registration.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/configuration/Registration.java @@ -101,9 +101,9 @@ public void eventHandlers(CdsRuntimeConfigurer configurer) { new DefaultAttachmentMalwareScanner(persistenceService, attachmentService, scanClient); EndTransactionMalwareScanProvider malwareScanEndTransactionListener = - (attachmentEntity, contentId) -> + (attachmentEntity, contentId, inlinePrefix) -> new EndTransactionMalwareScanRunner( - attachmentEntity, contentId, malwareScanner, runtime); + attachmentEntity, contentId, inlinePrefix, malwareScanner, runtime); // register event handlers for attachment service configurer.eventHandler( @@ -129,7 +129,8 @@ public void eventHandlers(CdsRuntimeConfigurer configurer) { eventFactory, attachmentsReader, outboxedAttachmentService, storage, defaultMaxSize)); configurer.eventHandler(new DeleteAttachmentsHandler(attachmentsReader, deleteEvent)); EndTransactionMalwareScanRunner scanRunner = - new EndTransactionMalwareScanRunner(null, null, malwareScanner, runtime); + new EndTransactionMalwareScanRunner( + null, null, Optional.empty(), malwareScanner, runtime); configurer.eventHandler( new ReadAttachmentsHandler( attachmentService, new AttachmentStatusValidator(), scanRunner, persistenceService)); diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/DeleteAttachmentsHandler.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/DeleteAttachmentsHandler.java index a11f3e6da..93af00333 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/DeleteAttachmentsHandler.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/DeleteAttachmentsHandler.java @@ -19,6 +19,7 @@ import com.sap.cds.services.handler.annotations.ServiceName; import java.io.InputStream; import java.util.List; +import java.util.Optional; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -52,9 +53,17 @@ void processBefore(CdsDeleteEventContext context) { context.getModel(), context.getTarget(), context.getCqn()); Converter converter = - (path, element, value) -> - deleteEvent.processEvent( - path, (InputStream) value, Attachments.of(path.target().values()), context); + (path, element, value) -> { + Optional inlinePrefix = + ApplicationHandlerHelper.getInlineAttachmentPrefix( + path.target().entity(), element.getName()); + return deleteEvent.processEvent( + path, + (InputStream) value, + Attachments.of(path.target().values()), + context, + inlinePrefix); + }; CdsDataProcessor.create() .addConverter(ApplicationHandlerHelper.MEDIA_CONTENT_FILTER, converter) diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/ReadAttachmentsHandler.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/ReadAttachmentsHandler.java index 810152ec3..de8fb893d 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/ReadAttachmentsHandler.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/ReadAttachmentsHandler.java @@ -43,6 +43,7 @@ import java.util.Map; import java.util.Map.Entry; import java.util.Objects; +import java.util.Optional; import java.util.function.Supplier; import java.util.stream.Collectors; import org.slf4j.Logger; @@ -99,8 +100,11 @@ void processBefore(CdsReadEventContext context) { CdsModel cdsModel = context.getModel(); List fieldNames = getAttachmentAssociations(cdsModel, context.getTarget(), "", new ArrayList<>()); - if (!fieldNames.isEmpty()) { - CqnSelect resultCqn = CQL.copy(context.getCqn(), new BeforeReadItemsModifier(fieldNames)); + List inlinePrefixes = + ApplicationHandlerHelper.getInlineAttachmentFieldNames(context.getTarget()); + if (!fieldNames.isEmpty() || !inlinePrefixes.isEmpty()) { + CqnSelect resultCqn = + CQL.copy(context.getCqn(), new BeforeReadItemsModifier(fieldNames, inlinePrefixes)); context.setCqn(resultCqn); } } @@ -114,10 +118,21 @@ void processAfter(CdsReadEventContext context, List data) { Converter converter = (path, element, value) -> { - Attachments attachment = Attachments.of(path.target().values()); + Attachments attachment; + // Check if this is an inline attachment field + Optional inlinePrefix = + ApplicationHandlerHelper.getInlineAttachmentPrefix( + path.target().type(), element.getName()); + if (inlinePrefix.isPresent()) { + attachment = + ApplicationHandlerHelper.extractInlineAttachment( + path.target().values(), inlinePrefix.get()); + } else { + attachment = Attachments.of(path.target().values()); + } InputStream content = attachment.getContent(); if (nonNull(attachment.getContentId())) { - verifyStatus(path, attachment); + verifyStatus(path, attachment, inlinePrefix); Supplier supplier = nonNull(content) ? () -> content @@ -137,7 +152,7 @@ void processAfter(CdsReadEventContext context, List data) { private List getAttachmentAssociations( CdsModel model, CdsEntity entity, String associationName, List processedEntities) { List associationNames = new ArrayList<>(); - if (ApplicationHandlerHelper.isMediaEntity(entity)) { + if (ApplicationHandlerHelper.isDirectMediaEntity(entity)) { associationNames.add(associationName); } @@ -167,7 +182,7 @@ private List getAttachmentAssociations( return associationNames; } - private void verifyStatus(Path path, Attachments attachment) { + private void verifyStatus(Path path, Attachments attachment, Optional inlinePrefix) { if (areKeysEmpty(path.target().keys())) { String currentStatus = attachment.getStatus(); logger.debug( @@ -176,13 +191,13 @@ private void verifyStatus(Path path, Attachments attachment) { currentStatus); if (needsScan(currentStatus, attachment.getScannedAt())) { if (StatusCode.CLEAN.equals(currentStatus)) { - transitionToScanning(path.target().entity(), attachment); + transitionToScanning(path.target().entity(), attachment, inlinePrefix); } logger.debug( "Scanning content with ID {} for malware, has current status {}", attachment.getContentId(), currentStatus); - scanExecutor.scanAsync(path.target().entity(), attachment.getContentId()); + scanExecutor.scanAsync(path.target().entity(), attachment.getContentId(), inlinePrefix); } statusValidator.verifyStatus(attachment.getStatus()); } @@ -201,26 +216,34 @@ private boolean isScanStale(Instant scannedAt) { return scannedAt == null || Instant.now().isAfter(scannedAt.plus(RESCAN_THRESHOLD)); } - private void transitionToScanning(CdsEntity entity, Attachments attachment) { + private void transitionToScanning( + CdsEntity entity, Attachments attachment, Optional inlinePrefix) { logger.debug( "Attachment {} has stale scan (scannedAt={}), transitioning to SCANNING for rescan.", attachment.getContentId(), attachment.getScannedAt()); + String contentIdCol = resolveColumn(Attachments.CONTENT_ID, inlinePrefix); + String statusCol = resolveColumn(Attachments.STATUS, inlinePrefix); + Attachments updateData = Attachments.create(); - updateData.setStatus(StatusCode.SCANNING); + updateData.put(statusCol, StatusCode.SCANNING); // Filter by contentId because primary keys are unavailable during content-only reads // (areKeysEmpty returns true). This is consistent with DefaultAttachmentMalwareScanner. CqnUpdate update = Update.entity(entity) .data(updateData) - .where(entry -> entry.get(Attachments.CONTENT_ID).eq(attachment.getContentId())); + .where(entry -> entry.get(contentIdCol).eq(attachment.getContentId())); persistenceService.run(update); attachment.setStatus(StatusCode.SCANNING); } + private static String resolveColumn(String fieldName, Optional inlinePrefix) { + return inlinePrefix.map(p -> p + "_" + fieldName).orElse(fieldName); + } + private boolean areKeysEmpty(Map keys) { return keys.values().stream().allMatch(Objects::isNull); } diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/UpdateAttachmentsHandler.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/UpdateAttachmentsHandler.java index e7166ffc1..c5179962d 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/UpdateAttachmentsHandler.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/UpdateAttachmentsHandler.java @@ -98,12 +98,25 @@ void processBefore(CdsUpdateEventContext context, List data) { } private boolean associationsAreUnchanged(CdsEntity entity, List data) { - // TODO: check if this should be replaced with - // entity.assocations().noneMatch(...) - return entity - .compositions() - .noneMatch( - association -> data.stream().anyMatch(d -> d.containsKey(association.getName()))); + // Check composition associations + boolean compositionsUnchanged = + entity + .compositions() + .noneMatch( + association -> data.stream().anyMatch(d -> d.containsKey(association.getName()))); + + // Also check inline attachment fields + List inlinePrefixes = ApplicationHandlerHelper.getInlineAttachmentFieldNames(entity); + boolean inlineUnchanged = + inlinePrefixes.stream() + .noneMatch( + prefix -> + data.stream() + .anyMatch( + d -> + d.keySet().stream().anyMatch(key -> key.startsWith(prefix + "_")))); + + return compositionsUnchanged && inlineUnchanged; } private void deleteRemovedAttachments( diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/ModifyApplicationHandlerHelper.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/ModifyApplicationHandlerHelper.java index 2c315bbe9..24d095022 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/ModifyApplicationHandlerHelper.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/ModifyApplicationHandlerHelper.java @@ -20,6 +20,7 @@ import java.io.InputStream; import java.util.List; import java.util.Map; +import java.util.Optional; public final class ModifyApplicationHandlerHelper { @@ -51,14 +52,19 @@ public static void handleAttachmentForEntities( ApplicationHandlerHelper.condenseAttachments(existingAttachments, entity); Converter converter = - (path, element, value) -> - handleAttachmentForEntity( - condensedExistingAttachments, - eventFactory, - eventContext, - path, - (InputStream) value, - defaultMaxSize); + (path, element, value) -> { + Optional inlinePrefix = + ApplicationHandlerHelper.getInlineAttachmentPrefix( + path.target().entity(), element.getName()); + return handleAttachmentForEntity( + condensedExistingAttachments, + eventFactory, + eventContext, + path, + (InputStream) value, + defaultMaxSize, + inlinePrefix); + }; CdsDataProcessor.create() .addConverter(ApplicationHandlerHelper.MEDIA_CONTENT_FILTER, converter) @@ -74,6 +80,7 @@ public static void handleAttachmentForEntities( * @param path the {@link Path} of the attachment * @param content the content of the attachment * @param defaultMaxSize the default max size to use when no annotation is present + * @param inlinePrefix the inline attachment field prefix, or empty for composition-based * @return the processed content as an {@link InputStream} */ public static InputStream handleAttachmentForEntity( @@ -82,11 +89,20 @@ public static InputStream handleAttachmentForEntity( EventContext eventContext, Path path, InputStream content, - String defaultMaxSize) { + String defaultMaxSize, + Optional inlinePrefix) { Map keys = ApplicationHandlerHelper.removeDraftKey(path.target().keys()); ReadonlyDataContextEnhancer.restoreReadonlyFields((CdsData) path.target().values()); Attachments attachment = getExistingAttachment(keys, existingAttachments); - String contentId = (String) path.target().values().get(Attachments.CONTENT_ID); + + // For inline attachment fields, extract contentId using the known prefix + String contentId; + if (inlinePrefix.isPresent()) { + contentId = + (String) path.target().values().get(inlinePrefix.get() + "_" + Attachments.CONTENT_ID); + } else { + contentId = (String) path.target().values().get(Attachments.CONTENT_ID); + } String contentLength = eventContext.getParameterInfo().getHeader("Content-Length"); String maxSizeStr = getValMaxValue(path.target().entity(), defaultMaxSize); eventContext.put( @@ -112,7 +128,8 @@ public static InputStream handleAttachmentForEntity( ModifyAttachmentEvent eventToProcess = eventFactory.getEvent(wrappedContent, contentId, attachment); try { - return eventToProcess.processEvent(path, wrappedContent, attachment, eventContext); + return eventToProcess.processEvent( + path, wrappedContent, attachment, eventContext, inlinePrefix); } catch (Exception e) { if (wrappedContent != null && wrappedContent.isLimitExceeded()) { throw tooLargeException; @@ -122,8 +139,20 @@ public static InputStream handleAttachmentForEntity( } private static String getValMaxValue(CdsEntity entity, String defaultMaxSize) { + // Try direct content element first (composition-based) return entity .findElement("content") + .or( + () -> { + // Try inline attachment content elements (e.g. profilePicture_content) + List prefixes = + ApplicationHandlerHelper.getInlineAttachmentFieldNames(entity); + for (String prefix : prefixes) { + var found = entity.findElement(prefix + "_content"); + if (found.isPresent()) return found; + } + return Optional.empty(); + }) .flatMap(e -> e.findAnnotation("Validation.Maximum")) .map(CdsAnnotation::getValue) .filter(v -> !"true".equals(v.toString())) diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/ReadonlyDataContextEnhancer.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/ReadonlyDataContextEnhancer.java index 2df30e3d1..83db2998a 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/ReadonlyDataContextEnhancer.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/ReadonlyDataContextEnhancer.java @@ -11,6 +11,7 @@ import com.sap.cds.reflect.CdsEntity; import java.util.List; import java.util.Objects; +import java.util.Optional; /** * The class {@link ReadonlyDataContextEnhancer} provides methods to backup and restore readonly @@ -35,14 +36,38 @@ public static void preserveReadonlyFields(CdsEntity target, List data, Validator validator = (path, element, value) -> { if (isDraft) { - Attachments values = Attachments.of(path.target().values()); - Attachments attachment = Attachments.create(); - attachment.setContentId(values.getContentId()); - attachment.setStatus(values.getStatus()); - attachment.setScannedAt(values.getScannedAt()); - path.target().values().put(DRAFT_READONLY_CONTEXT, attachment); + // Determine if this is an inline attachment field + Optional inlinePrefix = + ApplicationHandlerHelper.getInlineAttachmentPrefix( + path.target().type(), element.getName()); + if (inlinePrefix.isPresent()) { + // Inline attachment: use prefixed field names + String prefix = inlinePrefix.get() + "_"; + Attachments attachment = Attachments.create(); + attachment.setContentId( + (String) path.target().values().get(prefix + Attachments.CONTENT_ID)); + attachment.setStatus( + (String) path.target().values().get(prefix + Attachments.STATUS)); + attachment.setScannedAt( + (java.time.Instant) path.target().values().get(prefix + Attachments.SCANNED_AT)); + path.target().values().put(prefix + DRAFT_READONLY_CONTEXT, attachment); + } else { + // Composition-based attachment: use direct field names + Attachments values = Attachments.of(path.target().values()); + Attachments attachment = Attachments.create(); + attachment.setContentId(values.getContentId()); + attachment.setStatus(values.getStatus()); + attachment.setScannedAt(values.getScannedAt()); + path.target().values().put(DRAFT_READONLY_CONTEXT, attachment); + } } else { path.target().values().remove(DRAFT_READONLY_CONTEXT); + // Also remove inline prefixed draft readonly contexts + List prefixes = + ApplicationHandlerHelper.getInlineAttachmentFieldNames(path.target().type()); + for (String prefix : prefixes) { + path.target().values().remove(prefix + "_" + DRAFT_READONLY_CONTEXT); + } } }; @@ -53,11 +78,12 @@ public static void preserveReadonlyFields(CdsEntity target, List data, /** * Restores the readonly fields with the backup from the data in the custom field {@value - * #DRAFT_READONLY_CONTEXT}. + * #DRAFT_READONLY_CONTEXT}. Supports both composition-based and inline attachment fields. * * @param data the {@link CdsData data} to restore with readonly fields */ public static void restoreReadonlyFields(CdsData data) { + // Restore composition-based readonly fields CdsData readOnlyData = (CdsData) data.get(DRAFT_READONLY_CONTEXT); if (Objects.nonNull(readOnlyData)) { data.put(Attachments.CONTENT_ID, readOnlyData.get(Attachments.CONTENT_ID)); @@ -65,6 +91,24 @@ public static void restoreReadonlyFields(CdsData data) { data.put(Attachments.SCANNED_AT, readOnlyData.get(Attachments.SCANNED_AT)); data.remove(DRAFT_READONLY_CONTEXT); } + + // Restore inline attachment readonly fields + for (String key : List.copyOf(data.keySet())) { + if (key.endsWith("_" + DRAFT_READONLY_CONTEXT)) { + String prefix = key.substring(0, key.length() - DRAFT_READONLY_CONTEXT.length() - 1); + CdsData inlineReadOnlyData = (CdsData) data.get(key); + if (Objects.nonNull(inlineReadOnlyData)) { + data.put( + prefix + "_" + Attachments.CONTENT_ID, + inlineReadOnlyData.get(Attachments.CONTENT_ID)); + data.put(prefix + "_" + Attachments.STATUS, inlineReadOnlyData.get(Attachments.STATUS)); + data.put( + prefix + "_" + Attachments.SCANNED_AT, + inlineReadOnlyData.get(Attachments.SCANNED_AT)); + data.remove(key); + } + } + } } private ReadonlyDataContextEnhancer() { diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/mimeTypeValidation/AttachmentValidationHelper.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/mimeTypeValidation/AttachmentValidationHelper.java index 72df1920b..9cca79201 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/mimeTypeValidation/AttachmentValidationHelper.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/mimeTypeValidation/AttachmentValidationHelper.java @@ -39,7 +39,7 @@ public static void validateMediaAttachments( CdsModel cdsModel = cdsRuntime.getCdsModel(); List mediaEntityNames = - ApplicationHandlerHelper.isMediaEntity(entity) + ApplicationHandlerHelper.isDirectMediaEntity(entity) ? List.of(entity.getQualifiedName()) : cascader.findMediaEntityNames(cdsModel, entity); diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/mimeTypeValidation/MediaTypeResolver.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/mimeTypeValidation/MediaTypeResolver.java index b96d2e4c9..58e7a823e 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/mimeTypeValidation/MediaTypeResolver.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/mimeTypeValidation/MediaTypeResolver.java @@ -41,7 +41,8 @@ private static Optional> fetchAcceptableMediaTypes(CdsEntity entity public static Optional> getAcceptableMediaTypesAnnotation( CdsEntity entity) { - return Optional.ofNullable(entity.getElement(CONTENT_ELEMENT)) + return entity + .findElement(CONTENT_ELEMENT) .flatMap(element -> element.findAnnotation(ACCEPTABLE_MEDIA_TYPES_ANNOTATION)); } diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/CreateAttachmentEvent.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/CreateAttachmentEvent.java index 6d66f2793..91ae28f3c 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/CreateAttachmentEvent.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/CreateAttachmentEvent.java @@ -43,14 +43,21 @@ public CreateAttachmentEvent( @Override public InputStream processEvent( - Path path, InputStream content, Attachments attachment, EventContext eventContext) { + Path path, + InputStream content, + Attachments attachment, + EventContext eventContext, + Optional inlinePrefix) { logger.debug( "Calling attachment service with create event for entity {}", path.target().entity().getQualifiedName()); Map values = path.target().values(); Map keys = ApplicationHandlerHelper.removeDraftKey(path.target().keys()); - Optional mimeTypeOptional = getFieldValue(MediaData.MIME_TYPE, values, attachment); - Optional fileNameOptional = getFieldValue(MediaData.FILE_NAME, values, attachment); + + Optional mimeTypeOptional = + getFieldValue(MediaData.MIME_TYPE, values, attachment, inlinePrefix); + Optional fileNameOptional = + getFieldValue(MediaData.FILE_NAME, values, attachment, inlinePrefix); CreateAttachmentInput createEventInput = new CreateAttachmentInput( @@ -58,22 +65,39 @@ public InputStream processEvent( path.target().entity(), fileNameOptional.orElse(null), mimeTypeOptional.orElse(null), - content); + content, + inlinePrefix); AttachmentModificationResult result = attachmentService.createAttachment(createEventInput); ChangeSetListener createListener = listenerProvider.provideListener(result.contentId(), eventContext.getCdsRuntime()); eventContext.getChangeSetContext().register(createListener); - path.target().values().put(Attachments.CONTENT_ID, result.contentId()); - path.target().values().put(Attachments.STATUS, result.status()); + // Set contentId and status using correct field names (prefixed for inline) + String contentIdField = + inlinePrefix.map(p -> p + "_" + Attachments.CONTENT_ID).orElse(Attachments.CONTENT_ID); + String statusField = + inlinePrefix.map(p -> p + "_" + Attachments.STATUS).orElse(Attachments.STATUS); + path.target().values().put(contentIdField, result.contentId()); + path.target().values().put(statusField, result.status()); if (nonNull(result.scannedAt())) { - path.target().values().put(Attachments.SCANNED_AT, result.scannedAt()); + String scannedAtField = + inlinePrefix.map(p -> p + "_" + Attachments.SCANNED_AT).orElse(Attachments.SCANNED_AT); + path.target().values().put(scannedAtField, result.scannedAt()); } return result.isInternalStored() ? content : null; } private static Optional getFieldValue( - String fieldName, Map values, Attachments attachment) { + String fieldName, + Map values, + Attachments attachment, + Optional inlinePrefix) { + // Try prefixed field name first (for inline types) + if (inlinePrefix.isPresent()) { + Object prefixedValue = values.get(inlinePrefix.get() + "_" + fieldName); + if (nonNull(prefixedValue)) return Optional.of((String) prefixedValue); + } + // Fall back to direct field name Object annotationValue = values.get(fieldName); Object value = nonNull(annotationValue) ? annotationValue : attachment.get(fieldName); return Optional.ofNullable((String) value); diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/DoNothingAttachmentEvent.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/DoNothingAttachmentEvent.java index b409d274a..4d56753b0 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/DoNothingAttachmentEvent.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/DoNothingAttachmentEvent.java @@ -7,6 +7,7 @@ import com.sap.cds.ql.cqn.Path; import com.sap.cds.services.EventContext; import java.io.InputStream; +import java.util.Optional; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -20,7 +21,11 @@ public class DoNothingAttachmentEvent implements ModifyAttachmentEvent { @Override public InputStream processEvent( - Path path, InputStream content, Attachments attachment, EventContext eventContext) { + Path path, + InputStream content, + Attachments attachment, + EventContext eventContext, + Optional inlinePrefix) { logger.debug("Do nothing event for entity {}", path.target().entity().getQualifiedName()); return content; diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/MarkAsDeletedAttachmentEvent.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/MarkAsDeletedAttachmentEvent.java index c1316ee9f..9085b8e13 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/MarkAsDeletedAttachmentEvent.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/MarkAsDeletedAttachmentEvent.java @@ -7,12 +7,14 @@ import static java.util.Objects.requireNonNull; import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.Attachments; +import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.MediaData; import com.sap.cds.feature.attachments.service.AttachmentService; import com.sap.cds.feature.attachments.service.model.service.MarkAsDeletedInput; import com.sap.cds.ql.cqn.Path; import com.sap.cds.services.EventContext; import com.sap.cds.services.draft.DraftService; import java.io.InputStream; +import java.util.Optional; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -33,7 +35,11 @@ public MarkAsDeletedAttachmentEvent(AttachmentService attachmentService) { @Override public InputStream processEvent( - Path path, InputStream content, Attachments attachment, EventContext eventContext) { + Path path, + InputStream content, + Attachments attachment, + EventContext eventContext, + Optional inlinePrefix) { String qualifiedName = eventContext.getTarget().getQualifiedName(); logger.debug( "Processing the event for calling attachment service with mark as delete event for entity {}", @@ -51,14 +57,26 @@ public InputStream processEvent( qualifiedName); } if (nonNull(path)) { - String newContentId = (String) path.target().values().get(Attachments.CONTENT_ID); + String contentIdField = resolveField(Attachments.CONTENT_ID, inlinePrefix); + String statusField = resolveField(Attachments.STATUS, inlinePrefix); + String scannedAtField = resolveField(Attachments.SCANNED_AT, inlinePrefix); + String mimeTypeField = resolveField(MediaData.MIME_TYPE, inlinePrefix); + String fileNameField = resolveField(MediaData.FILE_NAME, inlinePrefix); + + String newContentId = (String) path.target().values().get(contentIdField); if (nonNull(newContentId) && newContentId.equals(attachment.getContentId()) - || !path.target().values().containsKey(Attachments.CONTENT_ID)) { - path.target().values().put(Attachments.CONTENT_ID, null); - path.target().values().put(Attachments.STATUS, null); - path.target().values().put(Attachments.SCANNED_AT, null); + || !path.target().values().containsKey(contentIdField)) { + path.target().values().put(contentIdField, null); + path.target().values().put(statusField, null); + path.target().values().put(scannedAtField, null); + path.target().values().put(mimeTypeField, null); + path.target().values().put(fileNameField, null); } } return content; } + + private static String resolveField(String fieldName, Optional inlinePrefix) { + return inlinePrefix.map(p -> p + "_" + fieldName).orElse(fieldName); + } } diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/ModifyAttachmentEvent.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/ModifyAttachmentEvent.java index 1a830b5c2..9497ad91d 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/ModifyAttachmentEvent.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/ModifyAttachmentEvent.java @@ -8,6 +8,7 @@ import com.sap.cds.ql.cqn.Path; import com.sap.cds.services.EventContext; import java.io.InputStream; +import java.util.Optional; /** * The interface {@link ModifyAttachmentEvent} provides a method to process an event on the {@link @@ -22,8 +23,14 @@ public interface ModifyAttachmentEvent { * @param content the content of the attachment * @param attachment existing attachment data * @param eventContext the current event context + * @param inlinePrefix the inline attachment field prefix (e.g. "coverImage"), or empty for + * composition-based attachments * @return the processed content */ InputStream processEvent( - Path path, InputStream content, Attachments attachment, EventContext eventContext); + Path path, + InputStream content, + Attachments attachment, + EventContext eventContext, + Optional inlinePrefix); } diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/UpdateAttachmentEvent.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/UpdateAttachmentEvent.java index a178be89d..49acdb96b 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/UpdateAttachmentEvent.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/UpdateAttachmentEvent.java @@ -10,6 +10,7 @@ import com.sap.cds.ql.cqn.Path; import com.sap.cds.services.EventContext; import java.io.InputStream; +import java.util.Optional; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -34,12 +35,16 @@ public UpdateAttachmentEvent( @Override public InputStream processEvent( - Path path, InputStream content, Attachments attachment, EventContext eventContext) { + Path path, + InputStream content, + Attachments attachment, + EventContext eventContext, + Optional inlinePrefix) { logger.debug( "Processing UPDATE event by calling attachment service with create and delete event for entity {}", path.target().entity().getQualifiedName()); - deleteEvent.processEvent(path, content, attachment, eventContext); - return createEvent.processEvent(path, content, attachment, eventContext); + deleteEvent.processEvent(path, content, attachment, eventContext, inlinePrefix); + return createEvent.processEvent(path, content, attachment, eventContext, inlinePrefix); } } diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/readhelper/BeforeReadItemsModifier.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/readhelper/BeforeReadItemsModifier.java index e5b16ab7b..3e46b17d6 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/readhelper/BeforeReadItemsModifier.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/readhelper/BeforeReadItemsModifier.java @@ -10,6 +10,7 @@ import com.sap.cds.ql.cqn.CqnSelectListItem; import com.sap.cds.ql.cqn.Modifier; import java.util.ArrayList; +import java.util.Collections; import java.util.List; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -25,9 +26,16 @@ public class BeforeReadItemsModifier implements Modifier { private static final String ROOT_ASSOCIATION = ""; private final List mediaAssociations; + private final List inlineAttachmentPrefixes; public BeforeReadItemsModifier(List mediaAssociations) { + this(mediaAssociations, Collections.emptyList()); + } + + public BeforeReadItemsModifier( + List mediaAssociations, List inlineAttachmentPrefixes) { this.mediaAssociations = mediaAssociations; + this.inlineAttachmentPrefixes = inlineAttachmentPrefixes; } @Override @@ -79,6 +87,30 @@ private void enhanceWithNewFieldForMediaAssociation( listToEnhance.add(CQL.get(Attachments.STATUS)); listToEnhance.add(CQL.get(Attachments.SCANNED_AT)); } + // Also add inline attachment prefixed fields, but only when the content field + // is explicitly selected (mirroring the composition-based guard above). + // When the items list is empty or contains only a star (SELECT *), all columns + // are already included, so adding explicit columns would break the query by + // replacing SELECT * with a partial column list. + if (ROOT_ASSOCIATION.equals(association)) { + for (String prefix : inlineAttachmentPrefixes) { + String prefixedContent = prefix + "_" + MediaData.CONTENT; + String prefixedContentId = prefix + "_" + Attachments.CONTENT_ID; + String prefixedStatus = prefix + "_" + Attachments.STATUS; + String prefixedScannedAt = prefix + "_" + Attachments.SCANNED_AT; + if (list.stream().anyMatch(item -> isItemRefFieldWithName(item, prefixedContent)) + && list.stream().noneMatch(item -> isItemRefFieldWithName(item, prefixedContentId))) { + logger.debug( + "Adding inline attachment fields: {}, {} and {}", + prefixedContentId, + prefixedStatus, + prefixedScannedAt); + listToEnhance.add(CQL.get(prefixedContentId)); + listToEnhance.add(CQL.get(prefixedStatus)); + listToEnhance.add(CQL.get(prefixedScannedAt)); + } + } + } } private boolean isMediaAssociationAndNeedNewContentIdField( diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/common/ApplicationHandlerHelper.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/common/ApplicationHandlerHelper.java index c8308f513..2b4d70304 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/common/ApplicationHandlerHelper.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/common/ApplicationHandlerHelper.java @@ -10,13 +10,16 @@ import com.sap.cds.CdsDataProcessor.Filter; import com.sap.cds.CdsDataProcessor.Validator; import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.Attachments; +import com.sap.cds.reflect.CdsElement; import com.sap.cds.reflect.CdsEntity; import com.sap.cds.reflect.CdsStructuredType; import com.sap.cds.services.draft.Drafts; import java.util.ArrayList; import java.util.HashMap; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.concurrent.atomic.AtomicBoolean; /** @@ -29,12 +32,19 @@ public final class ApplicationHandlerHelper { /** * A filter for media content fields. The filter checks if the entity is a media entity and if the - * element has the annotation "Core.MediaType". + * element has the annotation "Core.MediaType". Also supports inline attachment type fields where + * the structured type is flattened into the parent entity. */ public static final Filter MEDIA_CONTENT_FILTER = - (path, element, type) -> - isMediaEntity(path.target().type()) - && element.findAnnotation(ANNOTATION_CORE_MEDIA_TYPE).isPresent(); + (path, element, type) -> { + // Case 1: Composition-based attachment entity (existing behavior) + if (path.target().type().getAnnotationValue(ANNOTATION_IS_MEDIA_DATA, false) + && element.findAnnotation(ANNOTATION_CORE_MEDIA_TYPE).isPresent()) { + return true; + } + // Case 2: Inline attachment type field (flattened into parent entity) + return isInlineAttachmentContentField(path.target().type(), element); + }; /** * Checks if the data contains a content field. @@ -53,15 +63,111 @@ public static boolean containsContentField(CdsEntity entity, Listtrue if the entity is a media entity, false otherwise */ public static boolean isMediaEntity(CdsStructuredType baseEntity) { + if (baseEntity.getAnnotationValue(ANNOTATION_IS_MEDIA_DATA, false)) { + return true; + } + return hasInlineAttachmentElements(baseEntity); + } + + /** + * Checks if the entity is directly annotated as a media entity (without considering inline + * elements). Used for composition-based attachment detection. + * + * @param baseEntity The entity to check + * @return true if the entity itself has the annotation + */ + public static boolean isDirectMediaEntity(CdsStructuredType baseEntity) { return baseEntity.getAnnotationValue(ANNOTATION_IS_MEDIA_DATA, false); } + /** + * Checks if the entity has inline attachment elements. In the flattened CDS model, these appear + * as elements with the annotation "_is_media_data" on the element itself, where the entity is not + * directly annotated as a media entity. The flattened element names follow the pattern + * "prefix_content", "prefix_contentId", etc. + * + * @param entity The entity to check + * @return true if inline attachment elements exist + */ + public static boolean hasInlineAttachmentElements(CdsStructuredType entity) { + if (entity.getAnnotationValue(ANNOTATION_IS_MEDIA_DATA, false)) { + return false; // Entity itself is a media entity (composition-based), not inline + } + return !getInlineAttachmentFieldNames(entity).isEmpty(); + } + + /** + * Returns the inline attachment element name prefixes for a given entity. In the flattened CDS + * model, inline attachment fields appear as "prefix_content", "prefix_contentId", etc. with + * element-level "_is_media_data" annotation. This method finds all unique prefixes by looking for + * elements ending with "_content" that have the annotation. + * + * @param entity The entity to inspect + * @return list of inline attachment field name prefixes (e.g. ["profilePicture"]) + */ + public static List getInlineAttachmentFieldNames(CdsStructuredType entity) { + var elements = entity.elements(); + if (elements == null) return List.of(); + String contentSuffix = "_content"; + LinkedHashSet fieldNames = new LinkedHashSet<>(); + elements + .filter(e -> e.getName().endsWith(contentSuffix)) + .filter(e -> e.getAnnotationValue(ANNOTATION_IS_MEDIA_DATA, false)) + .filter(e -> e.findAnnotation(ANNOTATION_CORE_MEDIA_TYPE).isPresent()) + .forEach( + e -> { + String prefix = + e.getName().substring(0, e.getName().length() - contentSuffix.length()); + if (!prefix.isEmpty()) { + fieldNames.add(prefix); + } + }); + return new ArrayList<>(fieldNames); + } + + /** + * Checks if an element is a flattened content field from an inline Attachment type. For example, + * "profilePicture_content" where "profilePicture" is of type Attachment. In the flattened model, + * this is an element that ends with "_content", has the "_is_media_data" annotation, and has the + * "Core.MediaType" annotation. + * + * @param entity The parent entity + * @param element The element to check + * @return true if the element is an inline attachment content field + */ + public static boolean isInlineAttachmentContentField( + CdsStructuredType entity, CdsElement element) { + if (entity.getAnnotationValue(ANNOTATION_IS_MEDIA_DATA, false)) { + return false; // This is a composition-based attachment entity, not inline + } + String elementName = element.getName(); + return elementName.contains("_") + && element.getAnnotationValue(ANNOTATION_IS_MEDIA_DATA, false) + && element.findAnnotation(ANNOTATION_CORE_MEDIA_TYPE).isPresent(); + } + + /** + * Finds the inline attachment prefix for a given flattened element name. For example, given + * "profilePicture_content", returns Optional of "profilePicture". Uses the known inline prefixes + * from the entity to match against the element name. + * + * @param entity The parent entity + * @param elementName The flattened element name + * @return Optional containing the prefix, or empty if not an inline attachment field + */ + public static Optional getInlineAttachmentPrefix( + CdsStructuredType entity, String elementName) { + return getInlineAttachmentFieldNames(entity).stream() + .filter(prefix -> elementName.startsWith(prefix + "_")) + .findFirst(); + } + /** * Extracts key fields from CdsData based on the entity definition. * @@ -86,6 +192,7 @@ public static Map extractKeys(CdsData data, CdsEntity entity) { /** * Condenses the attachments from the given data into a list of {@link Attachments attachments}. + * Supports both composition-based and inline attachment type fields. * * @param data the list of {@link CdsData} to process * @param entity the {@link CdsEntity entity} type of the given data @@ -96,12 +203,55 @@ public static List condenseAttachments( List resultList = new ArrayList<>(); Validator validator = - (path, element, value) -> resultList.add(Attachments.of(path.target().values())); + (path, element, value) -> { + // For composition-based: path.target() is the attachment entity + if (path.target().type().getAnnotationValue(ANNOTATION_IS_MEDIA_DATA, false)) { + resultList.add(Attachments.of(path.target().values())); + } else { + // For inline type: extract prefixed fields from parent entity + Optional prefix = + getInlineAttachmentPrefix(path.target().type(), element.getName()); + if (prefix.isPresent()) { + Attachments attachment = + extractInlineAttachment(path.target().values(), prefix.get()); + // Avoid duplicates (same prefix already processed) + if (resultList.stream() + .noneMatch( + existing -> + nonNull(existing.getContentId()) + && existing.getContentId().equals(attachment.getContentId()))) { + resultList.add(attachment); + } + } + } + }; CdsDataProcessor.create().addValidator(MEDIA_CONTENT_FILTER, validator).process(data, entity); return resultList; } + /** + * Extracts inline attachment data from a parent entity's values by stripping the prefix. For + * example, from "profilePicture_contentId" extracts "contentId". + * + * @param parentValues the parent entity values map + * @param prefix the inline field prefix (e.g. "profilePicture") + * @return an Attachments object with the extracted values + */ + public static Attachments extractInlineAttachment( + Map parentValues, String prefix) { + Attachments attachment = Attachments.create(); + String prefixWithUnderscore = prefix + "_"; + parentValues.forEach( + (key, value) -> { + if (key.startsWith(prefixWithUnderscore)) { + String logicalName = key.substring(prefixWithUnderscore.length()); + attachment.put(logicalName, value); + } + }); + return attachment; + } + public static boolean areKeysInData(Map keys, CdsData data) { return keys.entrySet().stream() .allMatch( diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/common/AssociationCascader.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/common/AssociationCascader.java index 60d16126c..a36ff729b 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/common/AssociationCascader.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/common/AssociationCascader.java @@ -71,7 +71,7 @@ private List> getAttachmentAssociationPath( var localProcessEntities = new ArrayList(); currentList.set(new LinkedList<>()); - var isMediaEntity = ApplicationHandlerHelper.isMediaEntity(entity); + var isMediaEntity = ApplicationHandlerHelper.isDirectMediaEntity(entity); if (isMediaEntity) { var identifier = new AssociationIdentifier(associationName, entity.getQualifiedName()); firstList.addLast(identifier); diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/common/AttachmentsReader.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/common/AttachmentsReader.java index 9eb80d2fc..1b1653860 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/common/AttachmentsReader.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/common/AttachmentsReader.java @@ -12,6 +12,7 @@ import com.sap.cds.ql.Select; import com.sap.cds.ql.StructuredType; import com.sap.cds.ql.cqn.CqnFilterableStatement; +import com.sap.cds.ql.cqn.CqnSelectListItem; import com.sap.cds.reflect.CdsEntity; import com.sap.cds.reflect.CdsModel; import com.sap.cds.services.persistence.PersistenceService; @@ -46,10 +47,22 @@ public List readAttachments( NodeTree nodePath = cascader.findEntityPath(model, entity); List> expandList = buildExpandList(nodePath); - Select select = - !expandList.isEmpty() - ? Select.from(statement.ref()).columns(expandList) - : Select.from(statement.ref()).columns(StructuredType::_all); + // Also include inline attachment fields directly in the select + List inlineFields = ApplicationHandlerHelper.getInlineAttachmentFieldNames(entity); + List inlineColumns = new ArrayList<>(); + for (String fieldName : inlineFields) { + inlineColumns.add(CQL.get(fieldName + "_" + Attachments.CONTENT_ID)); + inlineColumns.add(CQL.get(fieldName + "_" + Attachments.STATUS)); + } + + Select select; + if (!expandList.isEmpty() || !inlineColumns.isEmpty()) { + List allItems = new ArrayList<>(inlineColumns); + allItems.addAll(expandList); + select = Select.from(statement.ref()).columns(allItems); + } else { + select = Select.from(statement.ref()).columns(StructuredType::_all); + } statement.where().ifPresent(select::where); Result result = persistence.run(select); diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/draftservice/DraftCancelAttachmentsHandler.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/draftservice/DraftCancelAttachmentsHandler.java index b71231943..378fa1b05 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/draftservice/DraftCancelAttachmentsHandler.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/draftservice/DraftCancelAttachmentsHandler.java @@ -43,9 +43,21 @@ public class DraftCancelAttachmentsHandler implements EventHandler { private static final Logger logger = LoggerFactory.getLogger(DraftCancelAttachmentsHandler.class); private static final Filter contentIdFilter = - (path, element, type) -> - ApplicationHandlerHelper.isMediaEntity(path.target().type()) - && element.getName().equals(Attachments.CONTENT_ID); + (path, element, type) -> { + // Case 1: Composition-based attachment entity + if (ApplicationHandlerHelper.isDirectMediaEntity(path.target().type()) + && element.getName().equals(Attachments.CONTENT_ID)) { + return true; + } + // Case 2: Inline attachment type — check for prefixed contentId + String elementName = element.getName(); + if (elementName.endsWith("_" + Attachments.CONTENT_ID)) { + return ApplicationHandlerHelper.getInlineAttachmentPrefix( + path.target().type(), elementName) + .isPresent(); + } + return false; + }; private final AttachmentsReader attachmentsReader; private final MarkAsDeletedAttachmentEvent deleteEvent; @@ -88,9 +100,12 @@ void processBeforeDraftCancel(DraftCancelEventContext context) { private Validator buildDeleteContentValidator( DraftCancelEventContext context, List activeCondensedAttachments) { return (path, element, value) -> { + Optional inlinePrefix = + ApplicationHandlerHelper.getInlineAttachmentPrefix( + path.target().entity(), element.getName()); Attachments attachment = Attachments.of(path.target().values()); if (Boolean.FALSE.equals(attachment.get(Drafts.HAS_ACTIVE_ENTITY))) { - deleteEvent.processEvent(path, null, attachment, context); + deleteEvent.processEvent(path, null, attachment, context, inlinePrefix); return; } Map keys = ApplicationHandlerHelper.removeDraftKey(path.target().keys()); @@ -101,7 +116,7 @@ private Validator buildDeleteContentValidator( existingEntry.ifPresent( entry -> { if (!entry.get(Attachments.CONTENT_ID).equals(value)) { - deleteEvent.processEvent(null, null, attachment, context); + deleteEvent.processEvent(null, null, attachment, context, inlinePrefix); } }); }; @@ -118,7 +133,12 @@ private boolean deepSearchForAttachmentsRecursive(CdsEntity entity, HashSet existingAttachments; + Optional inlinePrefix = + ApplicationHandlerHelper.getInlineAttachmentPrefix( + path.target().entity(), element.getName()); + if (inlinePrefix.isPresent()) { + // For inline attachments, the DB result has flattened column names (e.g. + // profileIcon_contentId). + // Extract to unprefixed Attachments and carry over parent entity keys for matching. + Map parentKeys = path.target().keys(); + existingAttachments = + result.listOf(Attachments.class).stream() + .map( + raw -> { + Attachments extracted = + ApplicationHandlerHelper.extractInlineAttachment( + raw, inlinePrefix.get()); + parentKeys.forEach(extracted::putIfAbsent); + return extracted; + }) + .collect(Collectors.toList()); + } else { + existingAttachments = result.listOf(Attachments.class); + } + return ModifyApplicationHandlerHelper.handleAttachmentForEntity( - result.listOf(Attachments.class), + existingAttachments, eventFactory, context, path, (InputStream) value, - defaultMaxSize); + defaultMaxSize, + inlinePrefix); }; CdsDataProcessor.create() diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/service/AttachmentsServiceImpl.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/service/AttachmentsServiceImpl.java index 37bf8469b..9806c4d1f 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/service/AttachmentsServiceImpl.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/service/AttachmentsServiceImpl.java @@ -50,6 +50,7 @@ public AttachmentModificationResult createAttachment(CreateAttachmentInput input mediaData.setMimeType(input.mimeType()); mediaData.setContent(input.content()); createContext.setData(mediaData); + input.inlinePrefix().ifPresent(prefix -> createContext.put("attachment.inlinePrefix", prefix)); emit(createContext); diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/service/handler/DefaultAttachmentsServiceHandler.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/service/handler/DefaultAttachmentsServiceHandler.java index 6d769d4d0..05eb94174 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/service/handler/DefaultAttachmentsServiceHandler.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/service/handler/DefaultAttachmentsServiceHandler.java @@ -18,6 +18,7 @@ import com.sap.cds.services.handler.annotations.On; import com.sap.cds.services.handler.annotations.ServiceName; import java.util.Objects; +import java.util.Optional; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -65,9 +66,11 @@ void createAttachment(AttachmentCreateEventContext context) { */ @After void afterCreateAttachment(AttachmentCreateEventContext context) { + String prefix = (String) context.get("attachment.inlinePrefix"); + Optional inlinePrefix = Optional.ofNullable(prefix); ChangeSetListener listener = malwareScanProvider.getChangeSetListener( - context.getAttachmentEntity(), context.getContentId()); + context.getAttachmentEntity(), context.getContentId(), inlinePrefix); context.getChangeSetContext().register(listener); } diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/service/handler/transaction/EndTransactionMalwareScanProvider.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/service/handler/transaction/EndTransactionMalwareScanProvider.java index da667e0d5..28f110183 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/service/handler/transaction/EndTransactionMalwareScanProvider.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/service/handler/transaction/EndTransactionMalwareScanProvider.java @@ -5,6 +5,7 @@ import com.sap.cds.reflect.CdsEntity; import com.sap.cds.services.changeset.ChangeSetListener; +import java.util.Optional; /** * This interface provides a {@link ChangeSetListener} for the malware scan after the transaction is @@ -17,7 +18,10 @@ public interface EndTransactionMalwareScanProvider { * * @param attachmentEntity The entity containing the attachment to scan * @param contentId The ID of the attachment content + * @param inlinePrefix For inline attachments, the field name prefix; empty for composition-based + * attachments * @return The {@link ChangeSetListener} for the malware scan after the transaction is completed */ - ChangeSetListener getChangeSetListener(CdsEntity attachmentEntity, String contentId); + ChangeSetListener getChangeSetListener( + CdsEntity attachmentEntity, String contentId, Optional inlinePrefix); } diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/service/handler/transaction/EndTransactionMalwareScanRunner.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/service/handler/transaction/EndTransactionMalwareScanRunner.java index a0f380fc6..b48bf95cc 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/service/handler/transaction/EndTransactionMalwareScanRunner.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/service/handler/transaction/EndTransactionMalwareScanRunner.java @@ -10,6 +10,7 @@ import com.sap.cds.services.runtime.CdsRuntime; import com.sap.cds.services.runtime.RequestContextRunner; import java.util.Objects; +import java.util.Optional; import java.util.concurrent.CompletableFuture; import java.util.function.Supplier; import org.slf4j.Logger; @@ -22,12 +23,14 @@ * * @param attachmentEntity The attachment entity to be scanned * @param contentId The content ID of the attachment + * @param inlinePrefix For inline attachments, the field name prefix; empty for composition-based * @param attachmentMalwareScanner The attachment malware scanner to be used for scanning * @param runtime The runtime instance to be used for creating the request context */ public record EndTransactionMalwareScanRunner( CdsEntity attachmentEntity, String contentId, + Optional inlinePrefix, AttachmentMalwareScanner attachmentMalwareScanner, CdsRuntime runtime) implements ChangeSetListener, AsyncMalwareScanExecutor { @@ -38,16 +41,18 @@ public record EndTransactionMalwareScanRunner( @Override public void afterClose(boolean completed) { if (completed) { - startScanning(attachmentEntity, contentId); + startScanning(attachmentEntity, contentId, inlinePrefix); } } @Override - public void scanAsync(CdsEntity attachmentEntity, String contentId) { - startScanning(attachmentEntity, contentId); + public void scanAsync( + CdsEntity attachmentEntity, String contentId, Optional inlinePrefix) { + startScanning(attachmentEntity, contentId, inlinePrefix); } - private void startScanning(CdsEntity attachmentEntityToScan, String contentId) { + private void startScanning( + CdsEntity attachmentEntityToScan, String contentId, Optional prefix) { // get current request context RequestContextRunner runner = runtime.requestContext(); @@ -71,7 +76,7 @@ private void startScanning(CdsEntity attachmentEntityToScan, String contentId) { contentId, attachmentEntityToScan.getQualifiedName()); attachmentMalwareScanner.scanAttachment( - attachmentEntityToScan, contentId); + attachmentEntityToScan, contentId, prefix); }); }); return null; diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/service/malware/AsyncMalwareScanExecutor.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/service/malware/AsyncMalwareScanExecutor.java index fc6413643..0af68ffee 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/service/malware/AsyncMalwareScanExecutor.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/service/malware/AsyncMalwareScanExecutor.java @@ -4,6 +4,7 @@ package com.sap.cds.feature.attachments.service.malware; import com.sap.cds.reflect.CdsEntity; +import java.util.Optional; /** Supports asynchronous malware scanning of attachments. */ public interface AsyncMalwareScanExecutor { @@ -13,6 +14,8 @@ public interface AsyncMalwareScanExecutor { * * @param attachmentEntity The entity containing the attachment to scan * @param contentId The content id of the attachment entity + * @param inlinePrefix For inline attachments, the field name prefix; empty for composition-based + * attachments */ - void scanAsync(CdsEntity attachmentEntity, String contentId); + void scanAsync(CdsEntity attachmentEntity, String contentId, Optional inlinePrefix); } diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/service/malware/AttachmentMalwareScanner.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/service/malware/AttachmentMalwareScanner.java index d64d60cbe..b3c97243a 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/service/malware/AttachmentMalwareScanner.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/service/malware/AttachmentMalwareScanner.java @@ -5,6 +5,7 @@ import com.sap.cds.reflect.CdsEntity; import com.sap.cds.services.ServiceException; +import java.util.Optional; /** * The {@link AttachmentMalwareScanner} is the connection to the malware scan service. It reads the @@ -18,7 +19,9 @@ public interface AttachmentMalwareScanner { * * @param attachmentEntity The entity containing the attachment to scan * @param contentId The content id of the attachment entity + * @param inlinePrefix For inline attachments, the field name prefix (e.g. "profileIcon"); empty + * for composition-based attachments * @throws ServiceException Exception to be thrown in case of errors during scanning the content */ - void scanAttachment(CdsEntity attachmentEntity, String contentId); + void scanAttachment(CdsEntity attachmentEntity, String contentId, Optional inlinePrefix); } diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/service/malware/DefaultAttachmentMalwareScanner.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/service/malware/DefaultAttachmentMalwareScanner.java index 552161aab..3b72185c2 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/service/malware/DefaultAttachmentMalwareScanner.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/service/malware/DefaultAttachmentMalwareScanner.java @@ -25,6 +25,7 @@ import java.util.ArrayList; import java.util.List; import java.util.Objects; +import java.util.Optional; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -67,13 +68,14 @@ public DefaultAttachmentMalwareScanner( } @Override - public void scanAttachment(CdsEntity attachmentEntity, String contentId) { + public void scanAttachment( + CdsEntity attachmentEntity, String contentId, Optional inlinePrefix) { logger.debug( "Started scanning attachment {} of entity {}.", contentId, attachmentEntity.getQualifiedName()); - List selectionResult = selectData(attachmentEntity, contentId); + List selectionResult = selectData(attachmentEntity, contentId, inlinePrefix); selectionResult.forEach( result -> { @@ -82,7 +84,7 @@ public void scanAttachment(CdsEntity attachmentEntity, String contentId) { logger.debug( "No attachments {} found in entity {}, nothing to scan.", contentId, - result.entity.getQualifiedName()); + result.entity().getQualifiedName()); return; } @@ -90,52 +92,71 @@ public void scanAttachment(CdsEntity attachmentEntity, String contentId) { logger.warn( "More than one attachment {} found in entity {}.", contentId, - result.entity.getQualifiedName()); + result.entity().getQualifiedName()); throw new IllegalStateException( "More than one attachment with contentId %s.".formatted(contentId)); } - Attachments attachment = result.result().single(Attachments.class); + Attachments attachment = extractAttachment(result.result(), inlinePrefix); MalwareScanResultStatus status = scanDocument(attachment); - updateData(result.entity, contentId, status); + updateData(result.entity(), contentId, status, inlinePrefix); }); } - private List selectData(CdsEntity attachmentEntity, String contentId) { + private Attachments extractAttachment(Result queryResult, Optional inlinePrefix) { + if (inlinePrefix.isEmpty()) { + return queryResult.single(Attachments.class); + } + String prefix = inlinePrefix.get() + "_"; + var row = queryResult.single(); + Attachments attachment = Attachments.create(); + attachment.setContentId((String) row.get(prefix + Attachments.CONTENT_ID)); + attachment.setContent((InputStream) row.get(prefix + Attachments.CONTENT)); + attachment.setStatus((String) row.get(prefix + Attachments.STATUS)); + return attachment; + } + + @VisibleForTesting + static String resolveColumn(String fieldName, Optional inlinePrefix) { + return inlinePrefix.map(p -> p + "_" + fieldName).orElse(fieldName); + } + + private List selectData( + CdsEntity attachmentEntity, String contentId, Optional inlinePrefix) { List result = new ArrayList<>(); try { CdsEntity entity = (CdsEntity) attachmentEntity.getTargetOf(Drafts.SIBLING_ENTITY); - Result selectionResult = readData(contentId, entity); + Result selectionResult = readData(contentId, entity, inlinePrefix); result.add(new SelectionResult(entity, selectionResult)); } catch (CdsElementNotFoundException ignored) { // no sibling found nothing to select } - Result selectionResult = readData(contentId, attachmentEntity); + Result selectionResult = readData(contentId, attachmentEntity, inlinePrefix); result.add(new SelectionResult(attachmentEntity, selectionResult)); return result; } - private Result readData(String contentId, CdsEntity entity) { + private Result readData(String contentId, CdsEntity entity, Optional inlinePrefix) { + String contentIdCol = resolveColumn(Attachments.CONTENT_ID, inlinePrefix); + String contentCol = resolveColumn(Attachments.CONTENT, inlinePrefix); + String statusCol = resolveColumn(Attachments.STATUS, inlinePrefix); + CqnSelect select = Select.from(entity) - .columns(Attachments.CONTENT_ID, Attachments.CONTENT, Attachments.STATUS) + .columns(contentIdCol, contentCol, statusCol) .where( - e -> - e.get(Attachments.CONTENT_ID) - .eq(contentId) - .and(e.get(Attachments.STATUS).ne(StatusCode.CLEAN))); + e -> e.get(contentIdCol).eq(contentId).and(e.get(statusCol).ne(StatusCode.CLEAN))); Result result = persistenceService.run(select); - result - .streamOf(Attachments.class) + result.stream() .forEach( - attachment -> + row -> logger.debug( "Found attachment {} in entity {} with status {}.", - attachment.getContentId(), + row.get(contentIdCol), entity.getQualifiedName(), - attachment.getStatus())); + row.get(statusCol))); return result; } @@ -157,20 +178,30 @@ private MalwareScanResultStatus scanDocument(Attachments attachment) { } private void updateData( - CdsEntity attachmentEntity, String contentId, MalwareScanResultStatus status) { + CdsEntity attachmentEntity, + String contentId, + MalwareScanResultStatus status, + Optional inlinePrefix) { + String contentIdCol = resolveColumn(Attachments.CONTENT_ID, inlinePrefix); + String statusCol = resolveColumn(Attachments.STATUS, inlinePrefix); + String scannedAtCol = resolveColumn(Attachments.SCANNED_AT, inlinePrefix); + + String mappedStatus = mapStatus(status); + Instant scannedAt = Instant.now(); + Attachments updateData = Attachments.create(); - updateData.setStatus(mapStatus(status)); - updateData.setScannedAt(Instant.now()); + updateData.put(statusCol, mappedStatus); + updateData.put(scannedAtCol, scannedAt); CqnUpdate update = Update.entity(attachmentEntity) .data(updateData) - .where(entry -> entry.get(Attachments.CONTENT_ID).eq(contentId)); + .where(entry -> entry.get(contentIdCol).eq(contentId)); Result result = persistenceService.run(update); logger.debug( "Updated scan status to {} of attachment {} in entity {} -> Row count {}.", - updateData.getStatus(), + mappedStatus, contentId, attachmentEntity.getQualifiedName(), result.rowCount()); diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/service/model/service/CreateAttachmentInput.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/service/model/service/CreateAttachmentInput.java index df4636b3d..baba15aac 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/service/model/service/CreateAttachmentInput.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/service/model/service/CreateAttachmentInput.java @@ -6,6 +6,7 @@ import com.sap.cds.reflect.CdsEntity; import java.io.InputStream; import java.util.Map; +import java.util.Optional; /** * The class {@link CreateAttachmentInput} is used to store the input for creating an attachment. @@ -15,10 +16,12 @@ * @param fileName The file name of the content * @param mimeType The mime type of the content * @param content The input stream of the content + * @param inlinePrefix For inline attachments, the field name prefix; empty for composition-based */ public record CreateAttachmentInput( Map attachmentIds, CdsEntity attachmentEntity, String fileName, String mimeType, - InputStream content) {} + InputStream content, + Optional inlinePrefix) {} diff --git a/cds-feature-attachments/src/main/resources/cds/com.sap.cds/cds-feature-attachments/attachments-annotations.cds b/cds-feature-attachments/src/main/resources/cds/com.sap.cds/cds-feature-attachments/attachments-annotations.cds index 11074d983..3995a269c 100644 --- a/cds-feature-attachments/src/main/resources/cds/com.sap.cds/cds-feature-attachments/attachments-annotations.cds +++ b/cds-feature-attachments/src/main/resources/cds/com.sap.cds/cds-feature-attachments/attachments-annotations.cds @@ -1,8 +1,16 @@ using { + sap.attachments.Attachment, sap.attachments.MediaData, sap.attachments.Attachments } from './attachments'; +// Annotate Attachment type with a static Core.MediaType so that LargeBinary content is exposed as Edm.Stream (enabling Fiori upload widget). +// Using 'mimeType' (path reference) instead of a static value would break inline usage: +// CDS flattening rewrites 'content' to 'prefix_content' but does NOT rewrite the path reference 'mimeType' to 'prefix_mimeType', causing a broken reference to a non-existent field. +annotate Attachment with { + content @Core.MediaType: 'application/octet-stream'; +} + annotate MediaData with @UI.MediaResource: {Stream: content} { content @( title : '{i18n>attachment_content}', diff --git a/cds-feature-attachments/src/main/resources/cds/com.sap.cds/cds-feature-attachments/attachments.cds b/cds-feature-attachments/src/main/resources/cds/com.sap.cds/cds-feature-attachments/attachments.cds index eda765e0e..e5330ad57 100644 --- a/cds-feature-attachments/src/main/resources/cds/com.sap.cds/cds-feature-attachments/attachments.cds +++ b/cds-feature-attachments/src/main/resources/cds/com.sap.cds/cds-feature-attachments/attachments.cds @@ -13,7 +13,7 @@ type StatusCode : String enum { Failed; } -aspect MediaData @(_is_media_data) { +type Attachment @(_is_media_data) { content : LargeBinary; // stored only for db-based services mimeType : String; fileName : String(5000); @@ -22,6 +22,8 @@ aspect MediaData @(_is_media_data) { scannedAt : Timestamp @readonly; } +aspect MediaData : Attachment {} + aspect Attachments : cuid, managed, MediaData { note : String(5000); } diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/CreateAttachmentsHandlerTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/CreateAttachmentsHandlerTest.java index c4c3a690d..9b9f955b9 100644 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/CreateAttachmentsHandlerTest.java +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/CreateAttachmentsHandlerTest.java @@ -226,7 +226,7 @@ void attachmentAccessExceptionCorrectHandledForCreate() { attachment.setFileName("test.txt"); attachment.setContent(null); when(eventFactory.getEvent(any(), any(), any())).thenReturn(event); - when(event.processEvent(any(), any(), any(), any())).thenThrow(new ServiceException("")); + when(event.processEvent(any(), any(), any(), any(), any())).thenThrow(new ServiceException("")); List input = List.of(attachment); assertThrows(ServiceException.class, () -> cut.processBefore(createContext, input)); diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/DeleteAttachmentsHandlerTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/DeleteAttachmentsHandlerTest.java index 29404a393..254821d97 100644 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/DeleteAttachmentsHandlerTest.java +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/DeleteAttachmentsHandlerTest.java @@ -84,7 +84,8 @@ void attachmentDataExistsServiceIsCalled() { cut.processBefore(context); - verify(modifyAttachmentEvent).processEvent(any(), eq(inputStream), eq(data), eq(context)); + verify(modifyAttachmentEvent) + .processEvent(any(), eq(inputStream), eq(data), eq(context), any()); assertThat(data.getContent()).isNull(); } @@ -108,10 +109,10 @@ void attachmentDataExistsAsExpandServiceIsCalled() { verify(modifyAttachmentEvent) .processEvent( - any(Path.class), eq(inputStream), eq(Attachments.of(attachment1)), eq(context)); + any(Path.class), eq(inputStream), eq(Attachments.of(attachment1)), eq(context), any()); verify(modifyAttachmentEvent) .processEvent( - any(Path.class), eq(inputStream), eq(Attachments.of(attachment2)), eq(context)); + any(Path.class), eq(inputStream), eq(Attachments.of(attachment2)), eq(context), any()); assertThat(attachment1.getContent()).isNull(); assertThat(attachment2.getContent()).isNull(); } diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/ReadAttachmentsHandlerTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/ReadAttachmentsHandlerTest.java index 0e70d7380..bc1689a0d 100644 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/ReadAttachmentsHandlerTest.java +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/ReadAttachmentsHandlerTest.java @@ -45,6 +45,7 @@ import java.time.Instant; import java.time.temporal.ChronoUnit; import java.util.List; +import java.util.Optional; import java.util.UUID; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; @@ -232,7 +233,7 @@ void scannerCalledForUnscannedAttachments() { cut.processAfter(readEventContext, List.of(attachment)); verify(asyncMalwareScanExecutor) - .scanAsync(readEventContext.getTarget(), attachment.getContentId()); + .scanAsync(readEventContext.getTarget(), attachment.getContentId(), Optional.empty()); } @Test @@ -246,7 +247,7 @@ void scannerCalledForUnscannedAttachmentsIfNoContentProvided() { cut.processAfter(readEventContext, List.of(attachment)); verify(asyncMalwareScanExecutor) - .scanAsync(readEventContext.getTarget(), attachment.getContentId()); + .scanAsync(readEventContext.getTarget(), attachment.getContentId(), Optional.empty()); } @Test @@ -280,7 +281,7 @@ void scannerCalledForStaleCleanAttachment() { verify(persistenceService).run(any(com.sap.cds.ql.cqn.CqnUpdate.class)); verify(asyncMalwareScanExecutor) - .scanAsync(readEventContext.getTarget(), attachment.getContentId()); + .scanAsync(readEventContext.getTarget(), attachment.getContentId(), Optional.empty()); assertThat(attachment.getStatus()).isEqualTo(StatusCode.SCANNING); } @@ -302,7 +303,7 @@ void scannerCalledForCleanAttachmentWithNullScannedAt() { verify(persistenceService).run(any(com.sap.cds.ql.cqn.CqnUpdate.class)); verify(asyncMalwareScanExecutor) - .scanAsync(readEventContext.getTarget(), attachment.getContentId()); + .scanAsync(readEventContext.getTarget(), attachment.getContentId(), Optional.empty()); assertThat(attachment.getStatus()).isEqualTo(StatusCode.SCANNING); } @@ -349,7 +350,7 @@ void persistenceServiceNotCalledForUnscannedAttachments() { cut.processAfter(readEventContext, List.of(attachment)); verify(asyncMalwareScanExecutor) - .scanAsync(readEventContext.getTarget(), attachment.getContentId()); + .scanAsync(readEventContext.getTarget(), attachment.getContentId(), Optional.empty()); verify(attachmentStatusValidator).verifyStatus(StatusCode.UNSCANNED); verifyNoInteractions(persistenceService); } @@ -429,4 +430,56 @@ private void mockEventContext(String entityName, CqnSelect select) { when(readEventContext.getModel()).thenReturn(runtime.getCdsModel()); when(readEventContext.getCqn()).thenReturn(select); } + + // --- Inline Attachment Tests --- + + @Test + void inlineContentWrappedWithLazyProxyOnRead() { + mockEventContext(RootTable_.CDS_NAME, mock(CqnSelect.class)); + + // Create root data with inline attachment fields + var root = CdsData.create(); + root.put("ID", UUID.randomUUID().toString()); + root.put("profilePicture_content", null); + root.put("profilePicture_contentId", "inline-doc-1"); + root.put("profilePicture_status", StatusCode.CLEAN); + + cut.processAfter(readEventContext, List.of(root)); + + assertThat(root.get("profilePicture_content")).isInstanceOf(LazyProxyInputStream.class); + } + + @Test + void inlineContentWithoutContentIdRemainsNull() { + mockEventContext(RootTable_.CDS_NAME, mock(CqnSelect.class)); + + var root = CdsData.create(); + root.put("ID", UUID.randomUUID().toString()); + root.put("profilePicture_content", null); + // No contentId — should not be wrapped + + cut.processAfter(readEventContext, List.of(root)); + + assertThat(root.get("profilePicture_content")).isNull(); + } + + @Test + void inlineContentWithExistingStreamWrappedWithProxy() throws IOException { + mockEventContext(RootTable_.CDS_NAME, mock(CqnSelect.class)); + var testContent = "inline photo bytes"; + var testStream = new ByteArrayInputStream(testContent.getBytes(StandardCharsets.UTF_8)); + + var root = CdsData.create(); + root.put("ID", UUID.randomUUID().toString()); + root.put("profilePicture_content", testStream); + root.put("profilePicture_contentId", "inline-doc-2"); + root.put("profilePicture_status", StatusCode.CLEAN); + + cut.processAfter(readEventContext, List.of(root)); + + assertThat(root.get("profilePicture_content")).isInstanceOf(LazyProxyInputStream.class); + // The proxy uses the existing stream supplier + byte[] bytes = ((InputStream) root.get("profilePicture_content")).readAllBytes(); + assertThat(bytes).isEqualTo(testContent.getBytes(StandardCharsets.UTF_8)); + } } diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/UpdateAttachmentsHandlerTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/UpdateAttachmentsHandlerTest.java index 1865c4237..197cbc5ed 100644 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/UpdateAttachmentsHandlerTest.java +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/UpdateAttachmentsHandlerTest.java @@ -250,7 +250,7 @@ void attachmentAccessExceptionCorrectHandledForUpdate() { attachment.setFileName("test.txt"); attachment.setContent(null); attachment.setId(id); - when(event.processEvent(any(), any(), any(), any())).thenThrow(new ServiceException("")); + when(event.processEvent(any(), any(), any(), any(), any())).thenThrow(new ServiceException("")); when(attachmentsReader.readAttachments(any(), any(), any(CqnFilterableStatement.class))) .thenReturn(List.of(attachment)); @@ -287,7 +287,11 @@ void existingDataFoundAndUsed() { ArgumentCaptor eventStreamCaptor = ArgumentCaptor.forClass(InputStream.class); verify(event) .processEvent( - any(), eventStreamCaptor.capture(), cdsDataArgumentCaptor.capture(), eq(updateContext)); + any(), + eventStreamCaptor.capture(), + cdsDataArgumentCaptor.capture(), + eq(updateContext), + any()); InputStream eventCaptured = eventStreamCaptor.getValue(); assertThat(eventCaptured).isInstanceOf(CountingInputStream.class); assertThat(((CountingInputStream) eventCaptured).getDelegate()).isSameAs(testStream); @@ -570,4 +574,98 @@ private String getOrCondition(String key1, String key2) { .replace(" ", "") .replace("\n", ""); } + + // --- Inline Attachment Tests --- + + @Test + void inlineContentFieldTriggersProcessing() { + var id = getEntityAndMockContext(RootTable_.CDS_NAME); + var root = CdsData.create(); + root.put("ID", id); + root.put("profilePicture_content", mock(InputStream.class)); + when(attachmentsReader.readAttachments(any(), any(), any(CqnFilterableStatement.class))) + .thenReturn(List.of()); + + cut.processBefore(updateContext, List.of(root)); + + verify(attachmentsReader).readAttachments(any(), any(), any(CqnFilterableStatement.class)); + } + + @Test + void inlineMetadataOnlyFieldTriggersReaderButNotEventFactory() { + // data contains profilePicture_mimeType but NOT profilePicture_content + // associationsAreUnchanged → false (because prefix_ key is present) + // containsContentField → false (mimeType is not content) + var id = getEntityAndMockContext(RootTable_.CDS_NAME); + var root = CdsData.create(); + root.put("ID", id); + root.put("profilePicture_mimeType", "image/png"); + when(attachmentsReader.readAttachments(any(), any(), any(CqnFilterableStatement.class))) + .thenReturn(List.of()); + + cut.processBefore(updateContext, List.of(root)); + + // Reader is called because inline fields changed + verify(attachmentsReader).readAttachments(any(), any(), any(CqnFilterableStatement.class)); + // But eventFactory is not called because no actual content change + verifyNoInteractions(eventFactory); + } + + @Test + void noInlineOrCompositionFieldsSkipsProcessing() { + getEntityAndMockContext(RootTable_.CDS_NAME); + var root = CdsData.create(); + root.put("ID", UUID.randomUUID().toString()); + root.put("title", "Just a title update"); + + cut.processBefore(updateContext, List.of(root)); + + verifyNoInteractions(attachmentsReader); + verifyNoInteractions(eventFactory); + verifyNoInteractions(attachmentService); + } + + @Test + void inlineReadonlyFieldsPreservedForDraftActivation() { + getEntityAndMockContext(RootTable_.CDS_NAME); + + var data = CdsData.create(); + data.put("ID", UUID.randomUUID().toString()); + // Content key must be present for CdsDataProcessor validator to fire + data.put("profilePicture_content", null); + data.put("profilePicture_contentId", "doc-42"); + data.put("profilePicture_status", "Clean"); + when(storageReader.get()).thenReturn(true); + + cut.processBeforeForDraft(updateContext, List.of(data)); + + // ReadonlyDataContextEnhancer preserves inline readonly fields + var readonlyContext = (CdsData) data.get("profilePicture_DRAFT_READONLY_CONTEXT"); + assertThat(readonlyContext).isNotNull(); + assertThat(readonlyContext).containsEntry("contentId", "doc-42"); + assertThat(readonlyContext).containsEntry("status", "Clean"); + } + + @Test + void inlineReadonlyFieldsClearedForNonDraftActivation() { + getEntityAndMockContext(RootTable_.CDS_NAME); + + var readonlyData = CdsData.create(); + readonlyData.put(Attachments.CONTENT_ID, "old-doc"); + readonlyData.put(Attachments.STATUS, "Infected"); + + var data = CdsData.create(); + data.put("ID", UUID.randomUUID().toString()); + data.put("profilePicture_content", null); + data.put("profilePicture_contentId", "doc-42"); + data.put("profilePicture_DRAFT_READONLY_CONTEXT", readonlyData); + when(storageReader.get()).thenReturn(false); + + cut.processBeforeForDraft(updateContext, List.of(data)); + + // Non-draft: readonly context key is removed + assertThat(data.get("profilePicture_DRAFT_READONLY_CONTEXT")).isNull(); + // contentId stays (it was explicitly set) + assertThat(data).containsEntry("profilePicture_contentId", "doc-42"); + } } diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/ModifyApplicationHandlerHelperTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/ModifyApplicationHandlerHelperTest.java index 489017824..b4d8a471f 100644 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/ModifyApplicationHandlerHelperTest.java +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/ModifyApplicationHandlerHelperTest.java @@ -7,7 +7,9 @@ import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.Attachments; @@ -26,6 +28,7 @@ import java.io.InputStream; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.UUID; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; @@ -92,7 +95,8 @@ void serviceExceptionDueToContentLength() { eventContext, path, attachment.getContent(), - ModifyApplicationHandlerHelper.DEFAULT_SIZE_WITH_SCANNER)); + ModifyApplicationHandlerHelper.DEFAULT_SIZE_WITH_SCANNER, + Optional.empty())); assertThat(exception.getErrorStatus()).isEqualTo(ExtendedErrorStatuses.CONTENT_TOO_LARGE); } @@ -119,7 +123,7 @@ void serviceExceptionDueToLimitExceeded() { when(parameterInfo.getHeader("Content-Length")).thenReturn(null); // Make event.processEvent() read from the stream, triggering the limit check - when(event.processEvent(any(), any(), any(), any())) + when(event.processEvent(any(), any(), any(), any(), any())) .thenAnswer( invocation -> { InputStream wrappedContent = invocation.getArgument(1); @@ -146,7 +150,8 @@ void serviceExceptionDueToLimitExceeded() { eventContext, path, content, - ModifyApplicationHandlerHelper.DEFAULT_SIZE_WITH_SCANNER)); + ModifyApplicationHandlerHelper.DEFAULT_SIZE_WITH_SCANNER, + Optional.empty())); assertThat(exception.getErrorStatus()).isEqualTo(ExtendedErrorStatuses.CONTENT_TOO_LARGE); } @@ -178,7 +183,8 @@ void defaultValMaxValueUsed() { eventContext, path, content, - ModifyApplicationHandlerHelper.DEFAULT_SIZE_WITH_SCANNER)); + ModifyApplicationHandlerHelper.DEFAULT_SIZE_WITH_SCANNER, + Optional.empty())); } @Test @@ -210,8 +216,65 @@ void malformedContentLengthHeader() { eventContext, path, content, - ModifyApplicationHandlerHelper.DEFAULT_SIZE_WITH_SCANNER)); + ModifyApplicationHandlerHelper.DEFAULT_SIZE_WITH_SCANNER, + Optional.empty())); assertThat(exception.getErrorStatus()).isEqualTo(ErrorStatuses.BAD_REQUEST); } + + // --- Inline Attachment Tests --- + + @Test + void inlineContentIdResolvedFromPrefixedField() { + // Use real RootTable entity so that inline detection works + CdsEntity realEntity = + runtime.getCdsModel().findEntity("unit.test.TestService.RootTable").orElseThrow(); + when(target.entity()).thenReturn(realEntity); + + var values = com.sap.cds.CdsData.create(); + values.put("ID", UUID.randomUUID().toString()); + values.put("profilePicture_content", mock(InputStream.class)); + values.put("profilePicture_contentId", "existing-doc-77"); + when(target.values()).thenReturn(values); + when(target.keys()).thenReturn(Map.of("ID", values.get("ID"))); + when(parameterInfo.getHeader("Content-Length")).thenReturn(null); + + var existingAttachments = List.of(); + + // contentId should be resolved from profilePicture_contentId + ModifyApplicationHandlerHelper.handleAttachmentForEntity( + existingAttachments, + eventFactory, + eventContext, + path, + (InputStream) values.get("profilePicture_content"), + ModifyApplicationHandlerHelper.DEFAULT_SIZE_WITH_SCANNER, + Optional.of("profilePicture")); + + // Verify eventFactory was called with the resolved contentId + verify(eventFactory).getEvent(any(), eq("existing-doc-77"), any()); + } + + @Test + void handleAttachmentForEntitiesProcessesInlineContent() { + CdsEntity realEntity = + runtime.getCdsModel().findEntity("unit.test.TestService.RootTable").orElseThrow(); + + var content = mock(InputStream.class); + var data = com.sap.cds.CdsData.create(); + data.put("ID", UUID.randomUUID().toString()); + data.put("profilePicture_content", content); + when(parameterInfo.getHeader("Content-Length")).thenReturn(null); + + ModifyApplicationHandlerHelper.handleAttachmentForEntities( + realEntity, + List.of(data), + List.of(), + eventFactory, + eventContext, + ModifyApplicationHandlerHelper.DEFAULT_SIZE_WITH_SCANNER); + + // eventFactory should be called since inline content was found + verify(eventFactory).getEvent(any(), any(), any()); + } } diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/ReadonlyDataContextEnhancerTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/ReadonlyDataContextEnhancerTest.java new file mode 100644 index 000000000..1733aa34b --- /dev/null +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/ReadonlyDataContextEnhancerTest.java @@ -0,0 +1,184 @@ +/* + * © 2024-2025 SAP SE or an SAP affiliate company and cds-feature-attachments contributors. + */ +package com.sap.cds.feature.attachments.handler.applicationservice.helper; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.sap.cds.CdsData; +import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.Attachments; +import com.sap.cds.feature.attachments.generated.test.cds4j.unit.test.testservice.RootTable_; +import com.sap.cds.feature.attachments.handler.helper.RuntimeHelper; +import com.sap.cds.reflect.CdsEntity; +import com.sap.cds.services.runtime.CdsRuntime; +import java.io.ByteArrayInputStream; +import java.time.Instant; +import java.util.List; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +class ReadonlyDataContextEnhancerTest { + + private static CdsRuntime runtime; + private static final String DRAFT_READONLY_CONTEXT = "DRAFT_READONLY_CONTEXT"; + + @BeforeAll + static void classSetup() { + runtime = RuntimeHelper.runtime; + } + + private CdsEntity getRootTableEntity() { + return runtime.getCdsModel().findEntity(RootTable_.CDS_NAME).orElseThrow(); + } + + private CdsEntity getAttachmentEntity() { + return runtime + .getCdsModel() + .findEntity("unit.test.TestService.RootTable.attachments") + .orElseThrow(); + } + + // --- Composition-based preserve/restore tests --- + + @Test + void preserveReadonlyFieldsForDraftComposition() { + CdsEntity entity = getAttachmentEntity(); + CdsData data = CdsData.create(); + data.put(Attachments.CONTENT, new ByteArrayInputStream(new byte[0])); + data.put(Attachments.CONTENT_ID, "cid-123"); + data.put(Attachments.STATUS, "Clean"); + Instant now = Instant.now(); + data.put(Attachments.SCANNED_AT, now); + + ReadonlyDataContextEnhancer.preserveReadonlyFields(entity, List.of(data), true); + + CdsData backup = (CdsData) data.get(DRAFT_READONLY_CONTEXT); + assertThat(backup).isNotNull(); + assertThat(backup.get(Attachments.CONTENT_ID)).isEqualTo("cid-123"); + assertThat(backup.get(Attachments.STATUS)).isEqualTo("Clean"); + assertThat(backup.get(Attachments.SCANNED_AT)).isEqualTo(now); + } + + @Test + void preserveReadonlyFieldsNonDraftRemovesContext() { + CdsEntity entity = getAttachmentEntity(); + CdsData data = CdsData.create(); + data.put(Attachments.CONTENT, new ByteArrayInputStream(new byte[0])); + data.put(Attachments.CONTENT_ID, "cid-123"); + data.put(DRAFT_READONLY_CONTEXT, Attachments.create()); + + ReadonlyDataContextEnhancer.preserveReadonlyFields(entity, List.of(data), false); + + assertThat(data.containsKey(DRAFT_READONLY_CONTEXT)).isFalse(); + } + + @Test + void restoreReadonlyFieldsComposition() { + CdsData data = CdsData.create(); + Attachments backup = Attachments.create(); + backup.setContentId("cid-restored"); + backup.setStatus("Scanning"); + Instant scannedAt = Instant.now(); + backup.setScannedAt(scannedAt); + data.put(DRAFT_READONLY_CONTEXT, backup); + + ReadonlyDataContextEnhancer.restoreReadonlyFields(data); + + assertThat(data.get(Attachments.CONTENT_ID)).isEqualTo("cid-restored"); + assertThat(data.get(Attachments.STATUS)).isEqualTo("Scanning"); + assertThat(data.get(Attachments.SCANNED_AT)).isEqualTo(scannedAt); + assertThat(data.containsKey(DRAFT_READONLY_CONTEXT)).isFalse(); + } + + @Test + void restoreReadonlyFieldsNoBackupDoesNothing() { + CdsData data = CdsData.create(); + data.put("ID", "123"); + + ReadonlyDataContextEnhancer.restoreReadonlyFields(data); + + assertThat(data.get("ID")).isEqualTo("123"); + assertThat(data).hasSize(1); + } + + // --- Inline attachment preserve/restore tests --- + + @Test + void preserveReadonlyFieldsForDraftInline() { + CdsEntity entity = getRootTableEntity(); + CdsData data = CdsData.create(); + data.put("profilePicture_content", new ByteArrayInputStream(new byte[0])); + data.put("profilePicture_contentId", "cid-inline-456"); + data.put("profilePicture_status", "Unscanned"); + Instant now = Instant.now(); + data.put("profilePicture_scannedAt", now); + + ReadonlyDataContextEnhancer.preserveReadonlyFields(entity, List.of(data), true); + + CdsData backup = (CdsData) data.get("profilePicture_" + DRAFT_READONLY_CONTEXT); + assertThat(backup).isNotNull(); + assertThat(backup.get(Attachments.CONTENT_ID)).isEqualTo("cid-inline-456"); + assertThat(backup.get(Attachments.STATUS)).isEqualTo("Unscanned"); + assertThat(backup.get(Attachments.SCANNED_AT)).isEqualTo(now); + } + + @Test + void preserveReadonlyFieldsNonDraftRemovesInlineContext() { + CdsEntity entity = getRootTableEntity(); + CdsData data = CdsData.create(); + data.put("profilePicture_content", new ByteArrayInputStream(new byte[0])); + data.put("profilePicture_" + DRAFT_READONLY_CONTEXT, Attachments.create()); + + ReadonlyDataContextEnhancer.preserveReadonlyFields(entity, List.of(data), false); + + assertThat(data.containsKey("profilePicture_" + DRAFT_READONLY_CONTEXT)).isFalse(); + } + + @Test + void restoreReadonlyFieldsInline() { + CdsData data = CdsData.create(); + data.put("ID", "123"); + Attachments backup = Attachments.create(); + backup.setContentId("cid-inline-restored"); + backup.setStatus("Clean"); + Instant scannedAt = Instant.now(); + backup.setScannedAt(scannedAt); + data.put("profilePicture_" + DRAFT_READONLY_CONTEXT, backup); + + ReadonlyDataContextEnhancer.restoreReadonlyFields(data); + + assertThat(data.get("profilePicture_contentId")).isEqualTo("cid-inline-restored"); + assertThat(data.get("profilePicture_status")).isEqualTo("Clean"); + assertThat(data.get("profilePicture_scannedAt")).isEqualTo(scannedAt); + assertThat(data.containsKey("profilePicture_" + DRAFT_READONLY_CONTEXT)).isFalse(); + } + + @Test + void restoreReadonlyFieldsBothCompositionAndInline() { + CdsData data = CdsData.create(); + + // Composition backup + Attachments compositionBackup = Attachments.create(); + compositionBackup.setContentId("cid-comp"); + compositionBackup.setStatus("Clean"); + data.put(DRAFT_READONLY_CONTEXT, compositionBackup); + + // Inline backup + Attachments inlineBackup = Attachments.create(); + inlineBackup.setContentId("cid-inline"); + inlineBackup.setStatus("Scanning"); + data.put("profilePicture_" + DRAFT_READONLY_CONTEXT, inlineBackup); + + ReadonlyDataContextEnhancer.restoreReadonlyFields(data); + + // Composition restored + assertThat(data.get(Attachments.CONTENT_ID)).isEqualTo("cid-comp"); + assertThat(data.get(Attachments.STATUS)).isEqualTo("Clean"); + // Inline restored + assertThat(data.get("profilePicture_contentId")).isEqualTo("cid-inline"); + assertThat(data.get("profilePicture_status")).isEqualTo("Scanning"); + // Backup keys removed + assertThat(data.containsKey(DRAFT_READONLY_CONTEXT)).isFalse(); + assertThat(data.containsKey("profilePicture_" + DRAFT_READONLY_CONTEXT)).isFalse(); + } +} diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/mimeTypeValidation/AttachmentValidationHelperTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/mimeTypeValidation/AttachmentValidationHelperTest.java index 99e05b6cf..49e40ccd9 100644 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/mimeTypeValidation/AttachmentValidationHelperTest.java +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/mimeTypeValidation/AttachmentValidationHelperTest.java @@ -51,7 +51,7 @@ void doesNothing_whenEntityNotFoundInModel() { try (MockedStatic helper = mockStatic(ApplicationHandlerHelper.class)) { - helper.when(() -> ApplicationHandlerHelper.isMediaEntity(entity)).thenReturn(false); + helper.when(() -> ApplicationHandlerHelper.isDirectMediaEntity(entity)).thenReturn(false); setupMockCascader(entity, model, false); @@ -71,7 +71,7 @@ void doesNothing_whenNoEntityHasAcceptableMediaTypesAnnotation() { MockedStatic extractor = mockStatic(AttachmentDataExtractor.class)) { - helper.when(() -> ApplicationHandlerHelper.isMediaEntity(entity)).thenReturn(true); + helper.when(() -> ApplicationHandlerHelper.isDirectMediaEntity(entity)).thenReturn(true); // MediaTypeResolver returns empty map = no entity has the annotation resolver @@ -101,7 +101,7 @@ void doesNotThrow_whenNoFiles() { MockedStatic extractor = mockStatic(AttachmentDataExtractor.class)) { CdsRuntime runtime = mockRuntime(entity); - helper.when(() -> ApplicationHandlerHelper.isMediaEntity(entity)).thenReturn(true); + helper.when(() -> ApplicationHandlerHelper.isDirectMediaEntity(entity)).thenReturn(true); resolver .when( @@ -122,7 +122,7 @@ void doesNotThrow_whenNoFiles() { @ParameterizedTest @MethodSource("validFileScenarios") - void doesNotThrow_whenFilesAreValid(boolean isMediaEntity) { + void doesNotThrow_whenFilesAreValid(boolean isDirectMediaEntity) { CdsEntity entity = mockEntity("Entity"); CdsRuntime runtime = mockRuntime(entity); @@ -136,8 +136,10 @@ void doesNotThrow_whenFilesAreValid(boolean isMediaEntity) { MockedStatic extractor = mockStatic(AttachmentDataExtractor.class)) { - helper.when(() -> ApplicationHandlerHelper.isMediaEntity(entity)).thenReturn(isMediaEntity); - setupMockCascader(entity, runtime.getCdsModel(), !isMediaEntity); + helper + .when(() -> ApplicationHandlerHelper.isDirectMediaEntity(entity)) + .thenReturn(isDirectMediaEntity); + setupMockCascader(entity, runtime.getCdsModel(), !isDirectMediaEntity); resolver .when( @@ -165,7 +167,7 @@ private static Stream validFileScenarios() { @ParameterizedTest @MethodSource("invalidFileScenarios") - void throwsException_whenFilesAreInvalid(boolean isMediaEntity) { + void throwsException_whenFilesAreInvalid(boolean isDirectMediaEntity) { CdsEntity entity = mockEntity("Entity"); CdsRuntime runtime = mockRuntime(entity); @@ -179,8 +181,10 @@ void throwsException_whenFilesAreInvalid(boolean isMediaEntity) { MockedStatic extractor = mockStatic(AttachmentDataExtractor.class)) { - helper.when(() -> ApplicationHandlerHelper.isMediaEntity(entity)).thenReturn(isMediaEntity); - setupMockCascader(entity, runtime.getCdsModel(), !isMediaEntity); + helper + .when(() -> ApplicationHandlerHelper.isDirectMediaEntity(entity)) + .thenReturn(isDirectMediaEntity); + setupMockCascader(entity, runtime.getCdsModel(), !isDirectMediaEntity); resolver .when( diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/mimeTypeValidation/MediaTypeResolverTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/mimeTypeValidation/MediaTypeResolverTest.java index f0056d06c..eb7bd1f99 100644 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/mimeTypeValidation/MediaTypeResolverTest.java +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/mimeTypeValidation/MediaTypeResolverTest.java @@ -36,7 +36,7 @@ void shouldReturnMediaTypesFromAnnotation() { when(model.getEntity("MediaEntity")).thenReturn(media); - when(media.getElement("content")).thenReturn(element); + when(media.findElement("content")).thenReturn(Optional.of(element)); when(element.findAnnotation("Core.AcceptableMediaTypes")).thenReturn(Optional.of(annotation)); when(annotation.getValue()).thenReturn(List.of("image/png", "image/jpeg")); @@ -52,7 +52,7 @@ void shouldExcludeEntityWithoutAnnotation() { CdsEntity media = mock(CdsEntity.class); when(model.getEntity("MediaEntity")).thenReturn(media); - when(media.getElement(any())).thenReturn(null); + when(media.findElement(any())).thenReturn(Optional.empty()); Map> result = MediaTypeResolver.getAcceptableMediaTypesFromEntity(model, List.of("MediaEntity")); diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/CreateAttachmentEventTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/CreateAttachmentEventTest.java index df10cf9e8..d60374f8b 100644 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/CreateAttachmentEventTest.java +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/CreateAttachmentEventTest.java @@ -11,7 +11,9 @@ import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.Attachments; import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.MediaData; +import com.sap.cds.feature.attachments.generated.test.cds4j.unit.test.testservice.RootTable_; import com.sap.cds.feature.attachments.handler.applicationservice.transaction.ListenerProvider; +import com.sap.cds.feature.attachments.handler.helper.RuntimeHelper; import com.sap.cds.feature.attachments.service.AttachmentService; import com.sap.cds.feature.attachments.service.model.service.AttachmentModificationResult; import com.sap.cds.feature.attachments.service.model.service.CreateAttachmentInput; @@ -26,7 +28,9 @@ import java.io.IOException; import java.io.InputStream; import java.nio.charset.StandardCharsets; +import java.util.HashMap; import java.util.Map; +import java.util.Optional; import java.util.UUID; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -97,7 +101,7 @@ void storageCalledWithAllFieldsFilledFromExistingData() { existingData.setFileName("some file name"); existingData.setMimeType("some mime type"); - cut.processEvent(path, attachment.getContent(), existingData, eventContext); + cut.processEvent(path, attachment.getContent(), existingData, eventContext, Optional.empty()); verify(attachmentService).createAttachment(contextArgumentCaptor.capture()); var createInput = contextArgumentCaptor.getValue(); @@ -120,7 +124,8 @@ void resultFromServiceStoredInPath() { when(attachmentService.createAttachment(any())).thenReturn(attachmentServiceResult); when(target.values()).thenReturn(attachment); - cut.processEvent(path, attachment.getContent(), Attachments.create(), eventContext); + cut.processEvent( + path, attachment.getContent(), Attachments.create(), eventContext, Optional.empty()); assertThat(attachment.getContentId()).isEqualTo(attachmentServiceResult.contentId()); assertThat(attachment.getStatus()).isEqualTo(attachmentServiceResult.status()); @@ -136,7 +141,7 @@ void changesetIstRegistered() { when(attachmentService.createAttachment(any())) .thenReturn(new AttachmentModificationResult(false, contentId, "test", null)); - cut.processEvent(path, null, Attachments.create(), eventContext); + cut.processEvent(path, null, Attachments.create(), eventContext, Optional.empty()); verify(changeSetContext).register(listener); } @@ -157,7 +162,8 @@ void contentIsReturnedIfNotExternalStored(boolean isExternalStored) throws IOExc .thenReturn(new AttachmentModificationResult(isExternalStored, "id", "test", null)); var result = - cut.processEvent(path, attachment.getContent(), Attachments.create(), eventContext); + cut.processEvent( + path, attachment.getContent(), Attachments.create(), eventContext, Optional.empty()); var expectedContent = isExternalStored ? attachment.getContent() : null; assertThat(result).isEqualTo(expectedContent); @@ -176,7 +182,115 @@ private Attachments prepareAndExecuteEventWithData() { when(attachmentService.createAttachment(any())) .thenReturn(new AttachmentModificationResult(false, "id", "test", null)); - cut.processEvent(path, attachment.getContent(), Attachments.create(), eventContext); + cut.processEvent( + path, attachment.getContent(), Attachments.create(), eventContext, Optional.empty()); return attachment; } + + // --- Inline Attachment Tests --- + + @Test + void inlineContentIdAndStatusWrittenWithPrefix() { + // Use real entity from CDS model so that getInlineAttachmentFieldNames returns + // ["profilePicture"] + CdsEntity realEntity = + RuntimeHelper.runtime.getCdsModel().findEntity(RootTable_.CDS_NAME).orElseThrow(); + when(target.entity()).thenReturn(realEntity); + + Map values = new HashMap<>(); + values.put("ID", UUID.randomUUID().toString()); + values.put("profilePicture_mimeType", "image/png"); + values.put("profilePicture_fileName", "photo.png"); + when(target.values()).thenReturn(values); + when(target.keys()).thenReturn(Map.of("ID", values.get("ID"))); + + var content = mock(InputStream.class); + when(attachmentService.createAttachment(any())) + .thenReturn(new AttachmentModificationResult(false, "doc-123", "Clean", null)); + + cut.processEvent( + path, content, Attachments.create(), eventContext, Optional.of("profilePicture")); + + assertThat(values).containsEntry("profilePicture_contentId", "doc-123"); + assertThat(values).containsEntry("profilePicture_status", "Clean"); + } + + @Test + void inlinePrefixedFieldValuesPassedToService() { + CdsEntity realEntity = + RuntimeHelper.runtime.getCdsModel().findEntity(RootTable_.CDS_NAME).orElseThrow(); + when(target.entity()).thenReturn(realEntity); + + Map values = new HashMap<>(); + values.put("ID", UUID.randomUUID().toString()); + values.put("profilePicture_mimeType", "image/jpeg"); + values.put("profilePicture_fileName", "avatar.jpg"); + when(target.values()).thenReturn(values); + when(target.keys()).thenReturn(Map.of("ID", values.get("ID"))); + + var content = mock(InputStream.class); + when(attachmentService.createAttachment(any())) + .thenReturn(new AttachmentModificationResult(false, "id", "ok", null)); + + cut.processEvent( + path, content, Attachments.create(), eventContext, Optional.of("profilePicture")); + + verify(attachmentService).createAttachment(contextArgumentCaptor.capture()); + var input = contextArgumentCaptor.getValue(); + assertThat(input.mimeType()).isEqualTo("image/jpeg"); + assertThat(input.fileName()).isEqualTo("avatar.jpg"); + assertThat(input.content()).isEqualTo(content); + } + + @Test + void inlineFallsBackToAttachmentObjectWhenPrefixedFieldMissing() { + CdsEntity realEntity = + RuntimeHelper.runtime.getCdsModel().findEntity(RootTable_.CDS_NAME).orElseThrow(); + when(target.entity()).thenReturn(realEntity); + + Map values = new HashMap<>(); + values.put("ID", UUID.randomUUID().toString()); + // No prefixed mimeType/fileName in values + when(target.values()).thenReturn(values); + when(target.keys()).thenReturn(Map.of("ID", values.get("ID"))); + + var content = mock(InputStream.class); + when(attachmentService.createAttachment(any())) + .thenReturn(new AttachmentModificationResult(false, "id", "ok", null)); + + var existingData = Attachments.create(); + existingData.setFileName("fallback.txt"); + existingData.setMimeType("text/plain"); + + cut.processEvent(path, content, existingData, eventContext, Optional.of("profilePicture")); + + verify(attachmentService).createAttachment(contextArgumentCaptor.capture()); + var input = contextArgumentCaptor.getValue(); + assertThat(input.mimeType()).isEqualTo("text/plain"); + assertThat(input.fileName()).isEqualTo("fallback.txt"); + } + + @Test + void nonInlineEntityDoesNotUsePrefixedFields() { + // Mock entity that is NOT inline + // Plain mock has no elements, so getInlineAttachmentFieldNames returns empty + when(entity.getQualifiedName()).thenReturn(TEST_FULL_NAME); + + Map values = new HashMap<>(); + values.put("ID", UUID.randomUUID().toString()); + values.put(MediaData.MIME_TYPE, "application/pdf"); + values.put(MediaData.FILE_NAME, "doc.pdf"); + when(target.values()).thenReturn(values); + when(target.keys()).thenReturn(Map.of("ID", values.get("ID"))); + + var content = mock(InputStream.class); + when(attachmentService.createAttachment(any())) + .thenReturn(new AttachmentModificationResult(false, "doc-999", "ok", null)); + + cut.processEvent(path, content, Attachments.create(), eventContext, Optional.empty()); + + // Fields written with unprefixed names + assertThat(values).containsEntry(Attachments.CONTENT_ID, "doc-999"); + assertThat(values).containsEntry(Attachments.STATUS, "ok"); + } } diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/DoNothingAttachmentEventTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/DoNothingAttachmentEventTest.java index 2a0cecb82..349bbac05 100644 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/DoNothingAttachmentEventTest.java +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/DoNothingAttachmentEventTest.java @@ -17,6 +17,7 @@ import java.io.ByteArrayInputStream; import java.nio.charset.StandardCharsets; import java.util.Objects; +import java.util.Optional; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.EmptySource; @@ -49,7 +50,8 @@ void contentReturned(String input) { when(target.entity()).thenReturn(entity); when(entity.getQualifiedName()).thenReturn("some.qualified.name"); - var result = cut.processEvent(path, streamInput, data, mock(EventContext.class)); + var result = + cut.processEvent(path, streamInput, data, mock(EventContext.class), Optional.empty()); assertThat(result).isEqualTo(streamInput); verifyNoInteractions(element, data); diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/MarkAsDeletedAttachmentEventTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/MarkAsDeletedAttachmentEventTest.java index 18e95854c..f73340d2a 100644 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/MarkAsDeletedAttachmentEventTest.java +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/MarkAsDeletedAttachmentEventTest.java @@ -10,6 +10,9 @@ import static org.mockito.Mockito.when; import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.Attachments; +import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.MediaData; +import com.sap.cds.feature.attachments.generated.test.cds4j.unit.test.testservice.RootTable_; +import com.sap.cds.feature.attachments.handler.helper.RuntimeHelper; import com.sap.cds.feature.attachments.service.AttachmentService; import com.sap.cds.feature.attachments.service.model.service.MarkAsDeletedInput; import com.sap.cds.ql.cqn.Path; @@ -22,6 +25,7 @@ import java.nio.charset.StandardCharsets; import java.util.HashMap; import java.util.Map; +import java.util.Optional; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; @@ -31,6 +35,7 @@ class MarkAsDeletedAttachmentEventTest { private MarkAsDeletedAttachmentEvent cut; private AttachmentService attachmentService; private Path path; + private ResolvedSegment target; private Map currentData; private EventContext context; private UserInfo userInfo; @@ -42,9 +47,13 @@ void setup() { context = mock(EventContext.class); path = mock(Path.class); - var target = mock(ResolvedSegment.class); + target = mock(ResolvedSegment.class); currentData = new HashMap<>(); when(path.target()).thenReturn(target); + // Default: non-inline entity (mock with no elements → getInlineAttachmentFieldNames returns + // empty) + var entity = mock(CdsEntity.class); + when(target.entity()).thenReturn(entity); var eventTarget = mock(CdsEntity.class); when(context.getTarget()).thenReturn(eventTarget); when(eventTarget.getQualifiedName()).thenReturn("some.qualified.name"); @@ -60,7 +69,7 @@ void documentIsExternallyDeleted() { var data = Attachments.create(); data.setContentId(contentId); - var expectedValue = cut.processEvent(path, value, data, context); + var expectedValue = cut.processEvent(path, value, data, context, Optional.empty()); assertThat(expectedValue).isEqualTo(value); assertThat(data.getContentId()).isEqualTo(contentId); @@ -71,7 +80,9 @@ void documentIsExternallyDeleted() { assertThat(currentData) .containsEntry(Attachments.CONTENT_ID, null) .containsEntry(Attachments.STATUS, null) - .containsEntry(Attachments.SCANNED_AT, null); + .containsEntry(Attachments.SCANNED_AT, null) + .containsEntry(MediaData.MIME_TYPE, null) + .containsEntry(MediaData.FILE_NAME, null); } @Test @@ -79,12 +90,15 @@ void documentIsNotExternallyDeletedBecauseDoesNotExistBefore() { var value = new ByteArrayInputStream("test".getBytes(StandardCharsets.UTF_8)); var data = Attachments.create(); - var expectedValue = cut.processEvent(path, value, data, context); + var expectedValue = cut.processEvent(path, value, data, context, Optional.empty()); assertThat(expectedValue).isEqualTo(value); assertThat(data.getContentId()).isNull(); verifyNoInteractions(attachmentService); - assertThat(currentData).containsEntry(Attachments.CONTENT_ID, null); + assertThat(currentData) + .containsEntry(Attachments.CONTENT_ID, null) + .containsEntry(MediaData.MIME_TYPE, null) + .containsEntry(MediaData.FILE_NAME, null); } @Test @@ -95,12 +109,15 @@ void documentIsNotExternallyDeletedBecauseItIsDraftChangeEvent() { data.setContentId(contentId); when(context.getEvent()).thenReturn(DraftService.EVENT_DRAFT_PATCH); - var expectedValue = cut.processEvent(path, value, data, context); + var expectedValue = cut.processEvent(path, value, data, context, Optional.empty()); assertThat(expectedValue).isEqualTo(value); assertThat(data.getContentId()).isEqualTo(contentId); verifyNoInteractions(attachmentService); - assertThat(currentData).containsEntry(Attachments.CONTENT_ID, null); + assertThat(currentData) + .containsEntry(Attachments.CONTENT_ID, null) + .containsEntry(MediaData.MIME_TYPE, null) + .containsEntry(MediaData.FILE_NAME, null); } @Test @@ -110,7 +127,7 @@ void processEvent_withNullPath_doesNotModifyPathValues() { var data = Attachments.create(); data.setContentId(contentId); - var expectedValue = cut.processEvent(null, value, data, context); + var expectedValue = cut.processEvent(null, value, data, context, Optional.empty()); assertThat(expectedValue).isEqualTo(value); // Attachment service should still be called to mark as deleted @@ -131,7 +148,7 @@ void processEvent_withDifferentNewContentId_doesNotClearContentId() { // Set a different contentId in the path values currentData.put(Attachments.CONTENT_ID, newContentId); - var expectedValue = cut.processEvent(path, value, data, context); + var expectedValue = cut.processEvent(path, value, data, context, Optional.empty()); assertThat(expectedValue).isEqualTo(value); // Attachment service should be called to mark old content as deleted @@ -141,4 +158,61 @@ void processEvent_withDifferentNewContentId_doesNotClearContentId() { // currentData should NOT be cleared since newContentId differs from attachment.getContentId() assertThat(currentData).containsEntry(Attachments.CONTENT_ID, newContentId); } + + // --- Inline Attachment Tests --- + + @Test + void inlineDelete_clearsPrefixedFields() { + // Use real entity from CDS model so that getInlineAttachmentFieldNames returns + // ["profilePicture"] + CdsEntity realEntity = + RuntimeHelper.runtime.getCdsModel().findEntity(RootTable_.CDS_NAME).orElseThrow(); + when(target.entity()).thenReturn(realEntity); + + Map values = new HashMap<>(); + values.put("ID", "some-id"); + values.put("profilePicture_contentId", "old-content-id"); + values.put("profilePicture_status", "Clean"); + values.put("profilePicture_mimeType", "image/png"); + values.put("profilePicture_fileName", "photo.png"); + when(target.values()).thenReturn(values); + + var data = Attachments.create(); + data.setContentId("old-content-id"); + when(context.getEvent()).thenReturn(DraftService.EVENT_DRAFT_PATCH); + + cut.processEvent(path, null, data, context, Optional.of("profilePicture")); + + // All prefixed fields should be cleared + assertThat(values) + .containsEntry("profilePicture_contentId", null) + .containsEntry("profilePicture_status", null) + .containsEntry("profilePicture_scannedAt", null) + .containsEntry("profilePicture_mimeType", null) + .containsEntry("profilePicture_fileName", null); + // Unprefixed fields should NOT be set + assertThat(values).doesNotContainKey(Attachments.CONTENT_ID); + assertThat(values).doesNotContainKey(Attachments.STATUS); + } + + @Test + void inlineDelete_withDifferentNewContentId_doesNotClearPrefixedFields() { + CdsEntity realEntity = + RuntimeHelper.runtime.getCdsModel().findEntity(RootTable_.CDS_NAME).orElseThrow(); + when(target.entity()).thenReturn(realEntity); + + Map values = new HashMap<>(); + values.put("ID", "some-id"); + values.put("profilePicture_contentId", "different-new-content-id"); + when(target.values()).thenReturn(values); + + var data = Attachments.create(); + data.setContentId("old-content-id"); + + cut.processEvent(path, null, data, context, Optional.of("profilePicture")); + + // contentId differs from attachment's contentId, so fields should NOT be cleared + assertThat(values).containsEntry("profilePicture_contentId", "different-new-content-id"); + assertThat(values).doesNotContainKey("profilePicture_status"); + } } diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/UpdateAttachmentEventTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/UpdateAttachmentEventTest.java index 640a304a3..f18029b91 100644 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/UpdateAttachmentEventTest.java +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/UpdateAttachmentEventTest.java @@ -13,6 +13,7 @@ import com.sap.cds.reflect.CdsEntity; import com.sap.cds.services.EventContext; import java.io.InputStream; +import java.util.Optional; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -44,9 +45,11 @@ void eventsCorrectCalled() { var existingData = Attachments.create(); var eventContext = mock(EventContext.class); - cut.processEvent(path, testContentStream, existingData, eventContext); + cut.processEvent(path, testContentStream, existingData, eventContext, Optional.empty()); - verify(createEvent).processEvent(path, testContentStream, existingData, eventContext); - verify(deleteEvent).processEvent(path, testContentStream, existingData, eventContext); + verify(createEvent) + .processEvent(path, testContentStream, existingData, eventContext, Optional.empty()); + verify(deleteEvent) + .processEvent(path, testContentStream, existingData, eventContext, Optional.empty()); } } diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/readhelper/BeforeReadItemsModifierTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/readhelper/BeforeReadItemsModifierTest.java index 3c2444cc2..45998fb98 100644 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/readhelper/BeforeReadItemsModifierTest.java +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/readhelper/BeforeReadItemsModifierTest.java @@ -198,4 +198,88 @@ private void runTestForDirectSelectScannedAt(CqnSelect select, int expectedField .count(); assertThat(count).isEqualTo(expectedFieldCount); } + + // --- Inline attachment modifier tests --- + + @Test + void inlineAttachmentFieldsAreAdded() { + CqnSelect select = + Select.from(RootTable_.class) + .columns(RootTable_::ID, RootTable_::title, b -> b.get("profilePicture_content")); + + cut = new BeforeReadItemsModifier(List.of(), List.of("profilePicture")); + List resultItems = cut.items(select.items()); + + var contentIdCount = + resultItems.stream() + .filter( + item -> + item.isRef() && item.asRef().displayName().equals("profilePicture_contentId")) + .count(); + var statusCount = + resultItems.stream() + .filter( + item -> item.isRef() && item.asRef().displayName().equals("profilePicture_status")) + .count(); + var scannedAtCount = + resultItems.stream() + .filter( + item -> + item.isRef() && item.asRef().displayName().equals("profilePicture_scannedAt")) + .count(); + assertThat(contentIdCount).isEqualTo(1); + assertThat(statusCount).isEqualTo(1); + assertThat(scannedAtCount).isEqualTo(1); + } + + @Test + void inlineAttachmentFieldsNotDuplicatedIfAlreadyPresent() { + CqnSelect select = + Select.from(RootTable_.class) + .columns(RootTable_::ID, b -> b.get("profilePicture_contentId")); + + cut = new BeforeReadItemsModifier(List.of(), List.of("profilePicture")); + List resultItems = cut.items(select.items()); + + var contentIdCount = + resultItems.stream() + .filter( + item -> + item.isRef() && item.asRef().displayName().equals("profilePicture_contentId")) + .count(); + assertThat(contentIdCount).isEqualTo(1); + } + + @Test + void emptyInlinePrefixesDoNotAddFields() { + CqnSelect select = Select.from(RootTable_.class).columns(RootTable_::ID, RootTable_::title); + + cut = new BeforeReadItemsModifier(List.of(), List.of()); + List resultItems = cut.items(select.items()); + + var inlineFieldCount = + resultItems.stream() + .filter( + item -> item.isRef() && item.asRef().displayName().startsWith("profilePicture_")) + .count(); + assertThat(inlineFieldCount).isEqualTo(0); + } + + @Test + void inlineAttachmentFieldsNotAddedWithoutContentInSelect() { + // When profilePicture_content is NOT in the select (e.g. SELECT ID, title), + // the modifier must NOT add profilePicture_contentId/status. Otherwise it + // would convert a SELECT * into a partial column list, breaking draftPrepare. + CqnSelect select = Select.from(RootTable_.class).columns(RootTable_::ID, RootTable_::title); + + cut = new BeforeReadItemsModifier(List.of(), List.of("profilePicture")); + List resultItems = cut.items(select.items()); + + var inlineFieldCount = + resultItems.stream() + .filter( + item -> item.isRef() && item.asRef().displayName().startsWith("profilePicture_")) + .count(); + assertThat(inlineFieldCount).isEqualTo(0); + } } diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/common/ApplicationHandlerHelperTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/common/ApplicationHandlerHelperTest.java index e7d8cfa33..f110e5cb2 100644 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/common/ApplicationHandlerHelperTest.java +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/common/ApplicationHandlerHelperTest.java @@ -8,11 +8,28 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import com.sap.cds.CdsData; +import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.Attachments; +import com.sap.cds.feature.attachments.generated.test.cds4j.unit.test.testservice.RootTable_; +import com.sap.cds.feature.attachments.handler.helper.RuntimeHelper; +import com.sap.cds.reflect.CdsEntity; +import com.sap.cds.services.runtime.CdsRuntime; +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.util.List; import java.util.Map; +import java.util.Optional; +import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; class ApplicationHandlerHelperTest { + private static CdsRuntime runtime; + + @BeforeAll + static void classSetup() { + runtime = RuntimeHelper.runtime; + } + @Test void keysAreInData() { Map keys = Map.of("key1", "value1", "key2", "value2"); @@ -57,4 +74,209 @@ void removeDraftKey() { assertFalse(result.containsKey("IsActiveEntity")); assertTrue(result.containsKey("key1")); } + + // --- Inline attachment tests --- + + private CdsEntity getRootTableEntity() { + return runtime.getCdsModel().findEntity(RootTable_.CDS_NAME).orElseThrow(); + } + + private CdsEntity getAttachmentEntity() { + return runtime + .getCdsModel() + .findEntity("unit.test.TestService.RootTable.attachments") + .orElseThrow(); + } + + @Test + void hasInlineAttachmentElementsReturnsTrueForEntityWithInlineField() { + var entity = getRootTableEntity(); + assertThat(ApplicationHandlerHelper.hasInlineAttachmentElements(entity)).isTrue(); + } + + @Test + void hasInlineAttachmentElementsReturnsFalseForAttachmentEntity() { + var entity = getAttachmentEntity(); + assertThat(ApplicationHandlerHelper.hasInlineAttachmentElements(entity)).isFalse(); + } + + @Test + void getInlineAttachmentFieldNamesReturnsCorrectPrefixes() { + var entity = getRootTableEntity(); + List prefixes = ApplicationHandlerHelper.getInlineAttachmentFieldNames(entity); + assertThat(prefixes).containsExactly("profilePicture"); + } + + @Test + void getInlineAttachmentFieldNamesReturnsEmptyForAttachmentEntity() { + var entity = getAttachmentEntity(); + List prefixes = ApplicationHandlerHelper.getInlineAttachmentFieldNames(entity); + assertThat(prefixes).isEmpty(); + } + + @Test + void isMediaEntityReturnsTrueForEntityWithInlineAttachment() { + var entity = getRootTableEntity(); + assertThat(ApplicationHandlerHelper.isMediaEntity(entity)).isTrue(); + } + + @Test + void isMediaEntityReturnsTrueForDirectMediaEntity() { + var entity = getAttachmentEntity(); + assertThat(ApplicationHandlerHelper.isMediaEntity(entity)).isTrue(); + } + + @Test + void isDirectMediaEntityReturnsFalseForEntityWithOnlyInlineAttachments() { + var entity = getRootTableEntity(); + assertThat(ApplicationHandlerHelper.isDirectMediaEntity(entity)).isFalse(); + } + + @Test + void isDirectMediaEntityReturnsTrueForAttachmentEntity() { + var entity = getAttachmentEntity(); + assertThat(ApplicationHandlerHelper.isDirectMediaEntity(entity)).isTrue(); + } + + @Test + void isInlineAttachmentContentFieldReturnsTrueForPrefixedContent() { + var entity = getRootTableEntity(); + var contentElement = entity.findElement("profilePicture_content").orElseThrow(); + assertThat(ApplicationHandlerHelper.isInlineAttachmentContentField(entity, contentElement)) + .isTrue(); + } + + @Test + void isInlineAttachmentContentFieldReturnsFalseForNonContentField() { + var entity = getRootTableEntity(); + var mimeTypeElement = entity.findElement("profilePicture_mimeType").orElseThrow(); + assertThat(ApplicationHandlerHelper.isInlineAttachmentContentField(entity, mimeTypeElement)) + .isFalse(); + } + + @Test + void isInlineAttachmentContentFieldReturnsFalseForRegularField() { + var entity = getRootTableEntity(); + var titleElement = entity.findElement("title").orElseThrow(); + assertThat(ApplicationHandlerHelper.isInlineAttachmentContentField(entity, titleElement)) + .isFalse(); + } + + @Test + void getInlineAttachmentPrefixReturnsPrefixForFlattenedField() { + var entity = getRootTableEntity(); + Optional prefix = + ApplicationHandlerHelper.getInlineAttachmentPrefix(entity, "profilePicture_content"); + assertThat(prefix).isPresent().contains("profilePicture"); + } + + @Test + void getInlineAttachmentPrefixReturnsPrefixForContentIdField() { + var entity = getRootTableEntity(); + Optional prefix = + ApplicationHandlerHelper.getInlineAttachmentPrefix(entity, "profilePicture_contentId"); + assertThat(prefix).isPresent().contains("profilePicture"); + } + + @Test + void getInlineAttachmentPrefixReturnsEmptyForRegularField() { + var entity = getRootTableEntity(); + Optional prefix = ApplicationHandlerHelper.getInlineAttachmentPrefix(entity, "title"); + assertThat(prefix).isEmpty(); + } + + @Test + void getInlineAttachmentPrefixReturnsEmptyForUnprefixedContentId() { + var entity = getRootTableEntity(); + Optional prefix = + ApplicationHandlerHelper.getInlineAttachmentPrefix(entity, "contentId"); + assertThat(prefix).isEmpty(); + } + + @Test + void extractInlineAttachmentStripsPrefix() { + Map parentValues = + Map.of( + "ID", "123", + "title", "Test", + "profilePicture_contentId", "cid-abc", + "profilePicture_mimeType", "image/png", + "profilePicture_fileName", "photo.png", + "profilePicture_status", "Clean"); + + Attachments result = + ApplicationHandlerHelper.extractInlineAttachment(parentValues, "profilePicture"); + + assertThat(result.getContentId()).isEqualTo("cid-abc"); + assertThat(result.getMimeType()).isEqualTo("image/png"); + assertThat(result.getFileName()).isEqualTo("photo.png"); + assertThat(result.getStatus()).isEqualTo("Clean"); + // Non-prefixed fields should NOT be included + assertThat(result.get("ID")).isNull(); + assertThat(result.get("title")).isNull(); + } + + @Test + void condenseAttachmentsIncludesInlineAttachments() { + var entity = getRootTableEntity(); + var data = CdsData.create(); + data.put("ID", "123"); + data.put("profilePicture_content", new ByteArrayInputStream(new byte[0])); + data.put("profilePicture_contentId", "cid-inline"); + data.put("profilePicture_mimeType", "image/png"); + data.put("profilePicture_status", "Clean"); + + List result = ApplicationHandlerHelper.condenseAttachments(List.of(data), entity); + + assertThat(result).hasSize(1); + assertThat(result.get(0).getContentId()).isEqualTo("cid-inline"); + assertThat(result.get(0).getMimeType()).isEqualTo("image/png"); + } + + @Test + void condenseAttachmentsAvoidsDuplicateInlineEntries() { + var entity = getRootTableEntity(); + var data = CdsData.create(); + data.put("ID", "123"); + data.put("profilePicture_content", new ByteArrayInputStream(new byte[0])); + data.put("profilePicture_contentId", "cid-inline"); + data.put("profilePicture_status", "Clean"); + + // Same data twice — condenseAttachments should deduplicate by contentId + List result = + ApplicationHandlerHelper.condenseAttachments(List.of(data, data), entity); + + long distinctContentIds = result.stream().map(Attachments::getContentId).distinct().count(); + assertThat(distinctContentIds).isEqualTo(1); + } + + @Test + void containsContentFieldReturnsTrueForInlineContent() { + var entity = getRootTableEntity(); + var data = CdsData.create(); + data.put("profilePicture_content", new ByteArrayInputStream(new byte[0])); + + assertThat(ApplicationHandlerHelper.containsContentField(entity, List.of(data))).isTrue(); + } + + @Test + void containsContentFieldReturnsFalseForNoContent() { + var entity = getRootTableEntity(); + var data = CdsData.create(); + data.put("ID", "123"); + data.put("title", "Test"); + + assertThat(ApplicationHandlerHelper.containsContentField(entity, List.of(data))).isFalse(); + } + + @Test + void mediaContentFilterMatchesInlineContentField() { + var entity = getRootTableEntity(); + var data = CdsData.create(); + data.put("profilePicture_content", (InputStream) new ByteArrayInputStream(new byte[0])); + data.put("profilePicture_contentId", "cid-123"); + + // Use containsContentField which internally uses MEDIA_CONTENT_FILTER + assertThat(ApplicationHandlerHelper.containsContentField(entity, List.of(data))).isTrue(); + } } diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/common/AssociationCascaderTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/common/AssociationCascaderTest.java index dfac40883..cb7e6baa6 100644 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/common/AssociationCascaderTest.java +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/common/AssociationCascaderTest.java @@ -140,4 +140,26 @@ private void verifyItemAttachments( .isEqualTo(itemAttachmentNodeName); assertThat(itemAttachmentNode.getChildren()).isNotNull().isEmpty(); } + + @Test + void rootEntityWithInlineAttachmentDoesNotAddExtraTreeChild() { + // Inline attachment fields on the root entity are NOT represented as NodeTree children. + // They're handled directly by AttachmentsReader via CQL select columns. + var serviceEntity = runtime.getCdsModel().findEntity(RootTable_.CDS_NAME).orElseThrow(); + + assertThat(ApplicationHandlerHelper.hasInlineAttachmentElements(serviceEntity)) + .as("RootTable should have inline attachment elements (profilePicture)") + .isTrue(); + + var rootNode = cut.findEntityPath(runtime.getCdsModel(), serviceEntity); + var rootChildren = rootNode.getChildren(); + + // Inline fields on root do NOT create extra NodeTree children + // only composition associations (attachments, itemTable) appear + assertThat(rootChildren).hasSize(2); + assertThat(rootChildren.get(0).getIdentifier().associationName()) + .isEqualTo(RootTable.ATTACHMENTS); + assertThat(rootChildren.get(1).getIdentifier().associationName()) + .isEqualTo(RootTable.ITEM_TABLE); + } } diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/common/AttachmentsReaderTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/common/AttachmentsReaderTest.java index 4ec146e3e..d86cdda5d 100644 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/common/AttachmentsReaderTest.java +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/common/AttachmentsReaderTest.java @@ -15,6 +15,7 @@ import com.sap.cds.feature.attachments.generated.test.cds4j.unit.test.Attachment_; import com.sap.cds.feature.attachments.generated.test.cds4j.unit.test.testservice.Items_; import com.sap.cds.feature.attachments.generated.test.cds4j.unit.test.testservice.RootTable_; +import com.sap.cds.feature.attachments.handler.helper.RuntimeHelper; import com.sap.cds.feature.attachments.helper.LogObserver; import com.sap.cds.ql.CQL; import com.sap.cds.ql.Delete; @@ -297,4 +298,26 @@ private String getExpectedSelectStatement() { private String removeSpaceInString(String input) { return input.replace("\n", "").replace("\t", "").replace(" ", ""); } + + // --- Inline Attachment Tests --- + + @Test + void selectIncludesInlineColumnsForEntityWithInlineAttachments() { + // Use real RootTable entity so getInlineAttachmentFieldNames returns ["profilePicture"] + CdsEntity realEntity = + RuntimeHelper.runtime.getCdsModel().findEntity(RootTable_.CDS_NAME).orElseThrow(); + var nodeTree = new NodeTree(new AssociationIdentifier("", RootTable_.CDS_NAME)); + when(cascader.findEntityPath(any(), any(CdsEntity.class))).thenReturn(nodeTree); + List data = List.of(Attachments.create()); + when(result.listOf(Attachments.class)).thenReturn(data); + + CqnDelete delete = Delete.from(RootTable_.CDS_NAME); + cut.readAttachments(model, realEntity, delete); + + verify(persistenceService).run(selectArgumentCaptor.capture()); + var selectStr = selectArgumentCaptor.getValue().toString(); + // Inline columns: profilePicture_contentId and profilePicture_status + assertThat(selectStr).contains("profilePicture_contentId"); + assertThat(selectStr).contains("profilePicture_status"); + } } diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/draftservice/DraftCancelAttachmentsHandlerTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/draftservice/DraftCancelAttachmentsHandlerTest.java index 06e4045fa..d8be08638 100644 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/draftservice/DraftCancelAttachmentsHandlerTest.java +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/draftservice/DraftCancelAttachmentsHandlerTest.java @@ -207,7 +207,7 @@ void createdEntityNeedsToBeDeleted() { cut.processBeforeDraftCancel(eventContext); verify(deleteContentAttachmentEvent) - .processEvent(any(), eq(null), dataArgumentCaptor.capture(), eq(eventContext)); + .processEvent(any(), eq(null), dataArgumentCaptor.capture(), eq(eventContext), any()); assertThat(dataArgumentCaptor.getValue()).isEqualTo(attachment); } @@ -226,7 +226,7 @@ void updatedEntityNeedsToBeDeleted() { cut.processBeforeDraftCancel(eventContext); verify(deleteContentAttachmentEvent) - .processEvent(any(), eq(null), dataArgumentCaptor.capture(), eq(eventContext)); + .processEvent(any(), eq(null), dataArgumentCaptor.capture(), eq(eventContext), any()); assertThat(dataArgumentCaptor.getValue()).isEqualTo(draftAttachment); } @@ -316,4 +316,22 @@ private void getEntityAndMockContext(String cdsName) { Optional serviceEntity = runtime.getCdsModel().findEntity(cdsName); when(eventContext.getTarget()).thenReturn(serviceEntity.orElseThrow()); } + + // --- Inline Attachment Tests --- + + @Test + void entityWithInlineAttachmentsIsProcessed() { + // RootTable has profilePicture: Attachment (inline) + // deepSearchForAttachments should detect it via hasInlineAttachmentElements and process + getEntityAndMockContext(RootTable_.CDS_NAME); + CqnDelete delete = Delete.from(RootTable_.class); + when(eventContext.getCqn()).thenReturn(delete); + when(eventContext.getModel()).thenReturn(runtime.getCdsModel()); + when(eventContext.getEvent()).thenReturn("DRAFT_CANCEL"); + + cut.processBeforeDraftCancel(eventContext); + + // Inline attachment on root means attachmentsReader is called + verify(attachmentsReader, atLeastOnce()).readAttachments(any(), any(), any()); + } } diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/draftservice/DraftPatchAttachmentsHandlerTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/draftservice/DraftPatchAttachmentsHandlerTest.java index 035dd766b..b8add72a1 100644 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/draftservice/DraftPatchAttachmentsHandlerTest.java +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/draftservice/DraftPatchAttachmentsHandlerTest.java @@ -10,6 +10,7 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import com.sap.cds.CdsData; import com.sap.cds.Result; import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.Attachments; import com.sap.cds.feature.attachments.generated.test.cds4j.unit.test.Events; @@ -141,7 +142,7 @@ void contentIdUsedForEventFactory() { InputStream captured = streamCaptor.getValue(); assertThat(captured).isInstanceOf(CountingInputStream.class); assertThat(((CountingInputStream) captured).getDelegate()).isSameAs(content); - verify(event).processEvent(any(), eq(captured), eq(attachment), eq(eventContext)); + verify(event).processEvent(any(), eq(captured), eq(attachment), eq(eventContext), any()); } @Test @@ -193,4 +194,65 @@ private void getEntityAndMockContext(String cdsName) { private void mockTargetInUpdateContext(CdsEntity serviceEntity) { when(eventContext.getTarget()).thenReturn(serviceEntity); } + + // --- Inline Attachment Tests --- + + @Test + void inlineContentFieldTriggersConverterViaMEDIA_CONTENT_FILTER() { + // RootTable has profilePicture : Attachment (inline). + // MEDIA_CONTENT_FILTER should match profilePicture_content and the converter + // should call persistence + eventFactory. + getEntityAndMockContext(RootTable_.CDS_NAME); + + var data = CdsData.create(); + data.put("ID", UUID.randomUUID().toString()); + data.put("profilePicture_content", mock(InputStream.class)); + + var result = mock(Result.class); + when(persistence.run(any(CqnSelect.class))).thenReturn(result); + + cut.processBeforeDraftPatch(eventContext, List.of(data)); + + // The converter reads from persistence (draft entity) and calls eventFactory + verify(persistence).run(any(CqnSelect.class)); + verify(eventFactory).getEvent(any(), any(), any()); + } + + @Test + void inlineDeleteExtractsExistingContentIdFromFlattenedDbResult() { + // When the user deletes an inline attachment, the PATCH data has + // profilePicture_content: null. The DB result has flattened column names + // (profilePicture_contentId). The handler must extract the existing contentId + // from the flattened DB result so the event factory can return deleteEvent. + getEntityAndMockContext(RootTable_.CDS_NAME); + + String bookId = UUID.randomUUID().toString(); + String existingContentId = UUID.randomUUID().toString(); + + // Incoming data: user deleting the inline attachment (content = null) + var data = CdsData.create(); + data.put("ID", bookId); + data.put("profilePicture_content", null); + + // DB result: existing draft row with flattened inline attachment fields + var dbRow = Attachments.create(); + dbRow.put("ID", bookId); + dbRow.put("profilePicture_contentId", existingContentId); + dbRow.put("profilePicture_status", "Clean"); + dbRow.put("profilePicture_mimeType", "image/png"); + dbRow.put("profilePicture_fileName", "avatar.png"); + + var result = mock(Result.class); + when(persistence.run(any(CqnSelect.class))).thenReturn(result); + when(result.listOf(Attachments.class)).thenReturn(List.of(dbRow)); + + cut.processBeforeDraftPatch(eventContext, List.of(data)); + + // Verify the event factory receives an Attachments with the correctly extracted + // (unprefixed) contentId from the DB data + ArgumentCaptor attachmentCaptor = ArgumentCaptor.forClass(Attachments.class); + verify(eventFactory).getEvent(any(), any(), attachmentCaptor.capture()); + Attachments captured = attachmentCaptor.getValue(); + assertThat(captured.getContentId()).isEqualTo(existingContentId); + } } diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/service/AttachmentsServiceImplTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/service/AttachmentsServiceImplTest.java index dff314a63..18f66bdad 100644 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/service/AttachmentsServiceImplTest.java +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/service/AttachmentsServiceImplTest.java @@ -26,6 +26,7 @@ import java.io.InputStream; import java.time.Instant; import java.util.Map; +import java.util.Optional; import java.util.concurrent.atomic.AtomicReference; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -97,7 +98,8 @@ void createAttachmentInsertsData(Boolean isExternalCreated) { var stream = mock(InputStream.class); Map ids = Map.of("ID1", "value1", "id2", "Value2"); var input = - new CreateAttachmentInput(ids, mock(CdsEntity.class), "fileName", "mimeType", stream); + new CreateAttachmentInput( + ids, mock(CdsEntity.class), "fileName", "mimeType", stream, Optional.empty()); var result = cut.createAttachment(input); @@ -125,13 +127,76 @@ void createAttachmentExternalCreateNotFilledReturnedFalse() { Map ids = Map.of("ID1", "value1", "id2", "Value2"); var input = new CreateAttachmentInput( - ids, mock(CdsEntity.class), "fileName", "mimeType", mock(InputStream.class)); + ids, + mock(CdsEntity.class), + "fileName", + "mimeType", + mock(InputStream.class), + Optional.empty()); var result = cut.createAttachment(input); assertThat(result.isInternalStored()).isFalse(); } + @Test + void createAttachmentWithInlinePrefixPutsItInContext() { + var contextReference = new AtomicReference(); + doAnswer( + input -> { + var context = (AttachmentCreateEventContext) input.getArgument(0); + contextReference.set(context); + context.setCompleted(); + return null; + }) + .when(handler) + .process(any()); + serviceSpi.on(AttachmentService.EVENT_CREATE_ATTACHMENT, "", handler); + Map ids = Map.of("ID1", "value1"); + var input = + new CreateAttachmentInput( + ids, + mock(CdsEntity.class), + "fileName", + "mimeType", + mock(InputStream.class), + Optional.of("profileIcon")); + + cut.createAttachment(input); + + var createContext = contextReference.get(); + assertThat(createContext.get("attachment.inlinePrefix")).isEqualTo("profileIcon"); + } + + @Test + void createAttachmentWithoutInlinePrefixDoesNotSetContext() { + var contextReference = new AtomicReference(); + doAnswer( + input -> { + var context = (AttachmentCreateEventContext) input.getArgument(0); + contextReference.set(context); + context.setCompleted(); + return null; + }) + .when(handler) + .process(any()); + serviceSpi.on(AttachmentService.EVENT_CREATE_ATTACHMENT, "", handler); + Map ids = Map.of("ID1", "value1"); + var input = + new CreateAttachmentInput( + ids, + mock(CdsEntity.class), + "fileName", + "mimeType", + mock(InputStream.class), + Optional.empty()); + + cut.createAttachment(input); + + var createContext = contextReference.get(); + assertThat(createContext.get("attachment.inlinePrefix")).isNull(); + } + @Test void markAsDeleteAttachmentInsertsData() { var contextReference = new AtomicReference(); diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/service/handler/AttachmentsServiceImplHandlerTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/service/handler/AttachmentsServiceImplHandlerTest.java index cbfd67a5d..c971c2832 100644 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/service/handler/AttachmentsServiceImplHandlerTest.java +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/service/handler/AttachmentsServiceImplHandlerTest.java @@ -25,6 +25,7 @@ import com.sap.cds.services.impl.changeset.ChangeSetContextImpl; import java.util.Map; import java.util.Objects; +import java.util.Optional; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -150,7 +151,8 @@ void readMethodHasCorrectAnnotation() throws NoSuchMethodException { void malwareScannerRegisteredForEndOfTransaction() { var listener = mock(ChangeSetListener.class); var entity = mock(CdsEntity.class); - when(malwareScanProvider.getChangeSetListener(entity, "contentId")).thenReturn(listener); + when(malwareScanProvider.getChangeSetListener(entity, "contentId", Optional.empty())) + .thenReturn(listener); var createContext = AttachmentCreateEventContext.create(); createContext.setAttachmentIds(Map.of(Attachments.ID, "contentId")); createContext.setData(MediaData.create()); @@ -160,7 +162,27 @@ void malwareScannerRegisteredForEndOfTransaction() { cut.createAttachment(createContext); cut.afterCreateAttachment(createContext); - verify(malwareScanProvider).getChangeSetListener(entity, "contentId"); + verify(malwareScanProvider).getChangeSetListener(entity, "contentId", Optional.empty()); + } + + @Test + void malwareScannerRegisteredWithInlinePrefixFromContext() { + var listener = mock(ChangeSetListener.class); + var entity = mock(CdsEntity.class); + when(malwareScanProvider.getChangeSetListener(entity, "contentId", Optional.of("profileIcon"))) + .thenReturn(listener); + var createContext = AttachmentCreateEventContext.create(); + createContext.setAttachmentIds(Map.of(Attachments.ID, "contentId")); + createContext.setData(MediaData.create()); + createContext.setAttachmentEntity(entity); + createContext.put("attachment.inlinePrefix", "profileIcon"); + ChangeSetContextImpl.open(false); + + cut.createAttachment(createContext); + cut.afterCreateAttachment(createContext); + + verify(malwareScanProvider) + .getChangeSetListener(entity, "contentId", Optional.of("profileIcon")); } private void closeChangeSetContext() throws Exception { diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/service/handler/transaction/EndTransactionMalwareScanRunnerTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/service/handler/transaction/EndTransactionMalwareScanRunnerTest.java index 59bc339e8..1c95bfd37 100644 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/service/handler/transaction/EndTransactionMalwareScanRunnerTest.java +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/service/handler/transaction/EndTransactionMalwareScanRunnerTest.java @@ -20,6 +20,7 @@ import com.sap.cds.services.runtime.CdsRuntime; import com.sap.cds.services.runtime.ChangeSetContextRunner; import com.sap.cds.services.runtime.RequestContextRunner; +import java.util.Optional; import java.util.UUID; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; @@ -70,7 +71,7 @@ void setup() { attachmentMalwareScanner = mock(AttachmentMalwareScanner.class); cut = new EndTransactionMalwareScanRunner( - attachmentEntity, contentId, attachmentMalwareScanner, runtime); + attachmentEntity, contentId, Optional.empty(), attachmentMalwareScanner, runtime); observer = LogObserver.create(cut.getClass().getName()); } @@ -88,7 +89,7 @@ void notCompletedTransactionDoNothing() { return null; }) .when(attachmentMalwareScanner) - .scanAttachment(attachmentEntity, contentId); + .scanAttachment(attachmentEntity, contentId, Optional.empty()); cut.afterClose(false); @@ -111,12 +112,12 @@ void completedTransactionScanAttachments() { return null; }) .when(attachmentMalwareScanner) - .scanAttachment(attachmentEntity, contentId); + .scanAttachment(attachmentEntity, contentId, Optional.empty()); cut.afterClose(true); Awaitility.await().until(executionDone::get); - verify(attachmentMalwareScanner).scanAttachment(attachmentEntity, contentId); + verify(attachmentMalwareScanner).scanAttachment(attachmentEntity, contentId, Optional.empty()); assertThat(usedThread.get()).isNotEmpty().isNotEqualTo(Thread.currentThread().getName()); } @@ -127,7 +128,7 @@ void exceptionDuringScanningLogged() { throw new RuntimeException("Some exception"); }) .when(attachmentMalwareScanner) - .scanAttachment(attachmentEntity, contentId); + .scanAttachment(attachmentEntity, contentId, Optional.empty()); observer.start(); cut.afterClose(true); @@ -146,12 +147,12 @@ void directScanCallScanAttachments() { return null; }) .when(attachmentMalwareScanner) - .scanAttachment(attachmentEntity, contentId); + .scanAttachment(attachmentEntity, contentId, Optional.empty()); - cut.scanAsync(attachmentEntity, contentId); + cut.scanAsync(attachmentEntity, contentId, Optional.empty()); Awaitility.await().until(executionDone::get); - verify(attachmentMalwareScanner).scanAttachment(attachmentEntity, contentId); + verify(attachmentMalwareScanner).scanAttachment(attachmentEntity, contentId, Optional.empty()); assertThat(usedThread.get()).isNotEmpty().isNotEqualTo(Thread.currentThread().getName()); } @@ -162,10 +163,10 @@ void exceptionDuringScanningLoggedForDirectScanCall() { throw new RuntimeException("Some exception"); }) .when(attachmentMalwareScanner) - .scanAttachment(attachmentEntity, contentId); + .scanAttachment(attachmentEntity, contentId, Optional.empty()); observer.start(); - cut.scanAsync(attachmentEntity, contentId); + cut.scanAsync(attachmentEntity, contentId, Optional.empty()); verifyLogIsWritten(); } diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/service/malware/DefaultAttachmentMalwareScannerTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/service/malware/DefaultAttachmentMalwareScannerTest.java index 41c68ce0f..63b5659d2 100644 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/service/malware/DefaultAttachmentMalwareScannerTest.java +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/service/malware/DefaultAttachmentMalwareScannerTest.java @@ -27,6 +27,7 @@ import com.sap.cds.services.persistence.PersistenceService; import com.sap.cds.services.runtime.CdsRuntime; import java.io.InputStream; +import java.util.Optional; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -71,7 +72,7 @@ void correctSelectForNonDraftEntity() { var entity = runtime.getCdsModel().findEntity(Attachment_.CDS_NAME); when(persistenceService.run(any(CqnSelect.class))).thenReturn(result); - cut.scanAttachment(entity.orElseThrow(), "ID"); + cut.scanAttachment(entity.orElseThrow(), "ID", Optional.empty()); verify(persistenceService).run(selectCaptor.capture()); var select = selectCaptor.getValue(); @@ -84,7 +85,7 @@ void correctSelectForDraftEntity() { var entity = runtime.getCdsModel().findEntity(getTestServiceAttachmentName()); mockSelectResult(Attachments.create(), MalwareScanResultStatus.CLEAN); - cut.scanAttachment(entity.orElseThrow(), "ID"); + cut.scanAttachment(entity.orElseThrow(), "ID", Optional.empty()); verify(persistenceService, times(2)).run(selectCaptor.capture()); var selects = selectCaptor.getAllValues(); @@ -110,7 +111,7 @@ void fallbackToActiveEntityIfDraftHasNoData() { when(result.single(Attachments.class)).thenReturn(cdsData); when(malwareScanClient.scanContent(any())).thenReturn(MalwareScanResultStatus.CLEAN); - cut.scanAttachment(entity.orElseThrow(), "ID"); + cut.scanAttachment(entity.orElseThrow(), "ID", Optional.empty()); verify(malwareScanClient).scanContent(content); verify(persistenceService, times(2)).run(selectCaptor.capture()); @@ -129,7 +130,8 @@ void exceptionIfTooManyResultsAreSelected() { when(persistenceService.run(any(CqnSelect.class))).thenReturn(result); when(result.rowCount()).thenReturn(2L); - assertThrows(IllegalStateException.class, () -> cut.scanAttachment(entity, "")); + assertThrows( + IllegalStateException.class, () -> cut.scanAttachment(entity, "", Optional.empty())); } @ParameterizedTest @@ -138,7 +140,7 @@ void dataAreUpdatedWithStatus(MalwareScanResultStatus status) { var entity = runtime.getCdsModel().findEntity(getTestServiceAttachmentName()); mockSelectResult(Attachments.create(), status); - cut.scanAttachment(entity.orElseThrow(), "ID"); + cut.scanAttachment(entity.orElseThrow(), "ID", Optional.empty()); verifyPersistenceServiceCalledCorrectlyForReadAndUpdate(status); } @@ -152,7 +154,7 @@ void dataAreUpdatedWithStatusFromFailingScanClient() { when(malwareScanClient.scanContent(any())) .thenThrow(new ServiceException("Error reading attachment")); - cut.scanAttachment(entity.orElseThrow(), "ID"); + cut.scanAttachment(entity.orElseThrow(), "ID", Optional.empty()); verifyPersistenceServiceCalledCorrectlyForReadAndUpdate(MalwareScanResultStatus.FAILED); } @@ -166,7 +168,7 @@ void dataAreUpdatedWithStatusFromFailingAttachmentService() { when(attachmentService.readAttachment(any())) .thenThrow(new ServiceException("Error reading attachment")); - cut.scanAttachment(entity.orElseThrow(), "ID"); + cut.scanAttachment(entity.orElseThrow(), "ID", Optional.empty()); verifyPersistenceServiceCalledCorrectlyForReadAndUpdate(MalwareScanResultStatus.FAILED); } @@ -179,7 +181,7 @@ void contentTakenFromTheDatabaseSelect() { data.put("content", content); mockSelectResult(data, MalwareScanResultStatus.CLEAN); - cut.scanAttachment(entity.orElseThrow(), ""); + cut.scanAttachment(entity.orElseThrow(), "", Optional.empty()); verify(malwareScanClient, times(2)).scanContent(content); verifyNoInteractions(attachmentService); @@ -195,7 +197,7 @@ void contentTakenFromTheAttachmentService() { var content = mock(InputStream.class); when(attachmentService.readAttachment(contentId)).thenReturn(content); - cut.scanAttachment(entity.orElseThrow(), ""); + cut.scanAttachment(entity.orElseThrow(), "", Optional.empty()); verify(attachmentService, times(2)).readAttachment(contentId); verify(malwareScanClient, times(2)).scanContent(content); @@ -211,7 +213,7 @@ void contentTakenFromTheAttachmentServiceForNonDraft() { var content = mock(InputStream.class); when(attachmentService.readAttachment(contentId)).thenReturn(content); - cut.scanAttachment(entity.orElseThrow(), ""); + cut.scanAttachment(entity.orElseThrow(), "", Optional.empty()); verify(attachmentService, times(1)).readAttachment(contentId); verify(malwareScanClient, times(1)).scanContent(content); @@ -230,7 +232,7 @@ void noDataReturnedForUpdateNothingDoneForNonDraftEntity() { .thenReturn(Attachments.create()); when(malwareScanClient.scanContent(any())).thenReturn(MalwareScanResultStatus.CLEAN); - cut.scanAttachment(entity.orElseThrow(), "ID"); + cut.scanAttachment(entity.orElseThrow(), "ID", Optional.empty()); verify(persistenceService).run(updateCaptor.capture()); var update = updateCaptor.getValue(); @@ -248,7 +250,7 @@ void clientNotCalledIfNoInstanceBound() { when(result.rowCount()).thenReturn(1L); when(result.single(Attachments.class)).thenReturn(Attachments.create()); - cut.scanAttachment(entity.orElseThrow(), "ID"); + cut.scanAttachment(entity.orElseThrow(), "ID", Optional.empty()); verifyNoInteractions(malwareScanClient); verify(persistenceService).run(updateCaptor.capture()); diff --git a/cds-feature-attachments/src/test/resources/cds/db-model.cds b/cds-feature-attachments/src/test/resources/cds/db-model.cds index 25d91921d..54b03ebde 100644 --- a/cds-feature-attachments/src/test/resources/cds/db-model.cds +++ b/cds-feature-attachments/src/test/resources/cds/db-model.cds @@ -2,6 +2,7 @@ namespace unit.test; using {cuid} from '@sap/cds/common'; using {sap.attachments.Attachments} from '../../../main/resources/cds/com.sap.cds/cds-feature-attachments'; +using {sap.attachments.Attachment as AttachmentType} from '../../../main/resources/cds/com.sap.cds/cds-feature-attachments'; using from '@sap/cds/srv/outbox'; entity Attachment : Attachments { @@ -12,6 +13,7 @@ entity Roots : cuid { itemTable : Composition of many Items on itemTable.rootId = $self.ID; attachments : Composition of many Attachments; + profilePicture : AttachmentType; } entity Items : cuid { diff --git a/samples/bookshop/app/admin-books/fiori-service.cds b/samples/bookshop/app/admin-books/fiori-service.cds index 36fa09086..66304d85f 100644 --- a/samples/bookshop/app/admin-books/fiori-service.cds +++ b/samples/bookshop/app/admin-books/fiori-service.cds @@ -31,6 +31,22 @@ annotate AdminService.Books with @(UI: { $Type : 'UI.ReferenceFacet', Label : '{i18n>Admin}', Target: '@UI.FieldGroup#Admin' + }, + { + $Type : 'UI.ReferenceFacet', + Label : 'Profile Icon', + Target: '@UI.FieldGroup#ProfileIcon' + }, + { + $Type : 'UI.ReferenceFacet', + Label : 'Cover Image', + Target: '@UI.FieldGroup#CoverImage' + }, + { + $Type : 'UI.ReferenceFacet', + ID : 'AttachmentsFacet', + Label : '{i18n>attachments}', + Target: 'attachments/@UI.LineItem' } ], FieldGroup #General: {Data: [ @@ -48,6 +64,12 @@ annotate AdminService.Books with @(UI: { {Value: createdAt}, {Value: modifiedBy}, {Value: modifiedAt} + ]}, + FieldGroup #ProfileIcon: {Data: [ + {Value: profileIcon_content} + ]}, + FieldGroup #CoverImage: {Data: [ + {Value: coverImage_content} ]} }); diff --git a/samples/bookshop/pom.xml b/samples/bookshop/pom.xml index ce37d9cf3..4ea901222 100644 --- a/samples/bookshop/pom.xml +++ b/samples/bookshop/pom.xml @@ -48,7 +48,7 @@ com.sap.cds cds-feature-attachments - 1.3.3 + 1.4.0-SNAPSHOT diff --git a/samples/bookshop/srv/attachments.cds b/samples/bookshop/srv/attachments.cds index 5a54d43f0..97c165682 100644 --- a/samples/bookshop/srv/attachments.cds +++ b/samples/bookshop/srv/attachments.cds @@ -1,5 +1,6 @@ using {sap.capire.bookshop as my} from '../db/schema'; using {sap.attachments.Attachments} from 'com.sap.cds/cds-feature-attachments'; +using {sap.attachments.Attachment} from 'com.sap.cds/cds-feature-attachments'; // Extend Books entity to support file attachments (images, PDFs, documents) // Each book can have multiple attachments via composition relationship @@ -25,6 +26,12 @@ annotate my.Books.mediaValidatedAttachments with { ]; } +// Extend Books entity with inline single-file attachments +extend my.Books with { + profileIcon : Attachment; + coverImage : Attachment; +} + // Add UI component for attachments table to the Browse Books App using {CatalogService as service} from '../app/services'; @@ -35,15 +42,9 @@ annotate service.Books with @(UI.Facets: [{ Target: 'attachments/@UI.LineItem' }]); -// Adding the UI Component (a table) to the Administrator App -using {AdminService as adminService} from '../app/services'; - -annotate adminService.Books with @(UI.Facets: [{ - $Type : 'UI.ReferenceFacet', - ID : 'AttachmentsFacet', - Label : '{i18n>attachments}', - Target: 'mediaValidatedAttachments/@UI.LineItem' -}]); +// AdminService Facets (including attachments and profileIcon) are defined in +// app/admin-books/fiori-service.cds. Don't re-annotate UI.Facets here, +// as it would override the complete facet list defined there. service nonDraft {