diff --git a/.github/actions/build/action.yml b/.github/actions/build/action.yml
index a6fd34568..f1960655b 100644
--- a/.github/actions/build/action.yml
+++ b/.github/actions/build/action.yml
@@ -8,10 +8,6 @@ inputs:
maven-version:
description: The Maven version the build will run with.
required: true
- mutation-testing:
- description: Whether to run mutation testing or not.
- default: 'true'
- required: false
runs:
using: composite
@@ -33,8 +29,3 @@ runs:
with:
step-name: mavenBuild
docker-image: ''
-
- - name: Mutation Testing
- if: ${{ inputs.mutation-testing == 'true' }}
- run: mvn org.pitest:pitest-maven:mutationCoverage -f cds-feature-attachments/pom.xml -ntp -B
- shell: bash
diff --git a/CLAUDE.md b/CLAUDE.md
index 49cf9e133..22734dc62 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -122,7 +122,6 @@ Defined in `cds-feature-attachments/src/main/resources/cds/com.sap.cds/cds-featu
All enforced in CI:
- **JaCoCo:** 95% minimum (instruction, branch, complexity), 0 missed classes
-- **Mutation testing (Pitest):** 90% aggregated threshold on `handler.*` and `service.*`
- **SpotBugs:** max effort, includes tests
- **PMD:** SAP Cloud SDK rules, excludes generated code and tests
- **Spotless:** Google Java Format check
diff --git a/README.md b/README.md
index 7d5b38f42..8cc76885f 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,5 @@
-[](https://github.com/cap-java/cds-feature-attachments/actions/workflows/main.yml)
-[](https://github.com/cap-java/cds-feature-attachments/actions/workflows/release.yml)
+[](https://github.com/cap-java/cds-feature-attachments/actions/workflows/main.yml)
+[](https://github.com/cap-java/cds-feature-attachments/actions/workflows/release.yml)
[](https://api.reuse.software/info/github.com/cap-java/cds-feature-attachments)
# Attachments Plugin for SAP Cloud Application Programming Model (CAP)
@@ -14,30 +14,31 @@ It supports the [AWS, Azure, and Google object stores](storage-targets/cds-featu
-* [Quick Start](#quick-start)
-* [Usage](#usage)
- * [MVN Setup](#mvn-setup)
- * [Changes in the CDS Models and for the UI](#changes-in-the-cds-models-and-for-the-UI)
- * [Try the Bookshop Sample](#try-the-bookshop-sample)
- * [Storage Targets](#storage-targets)
- * [Malware Scanner](#malware-scanner)
- * [Specify the maximum file size](#specify-the-maximum-file-size)
- * [Restrict allowed MIME types](#restrict-allowed-mime-types)
- * [Outbox](#outbox)
- * [Restore Endpoint](#restore-endpoint)
- * [Motivation](#motivation)
- * [HTTP Endpoint](#http-endpoint)
- * [Security](#security)
-* [Releases: Maven Central and Artifactory](#releases-maven-central-and-artifactory)
-* [Minimum UI5 and CAP Java Version](#minimum-ui5-and-cap-java-version)
-* [Architecture Overview](#architecture-overview)
- * [Design](#design)
- * [Multitenancy](#multitenancy)
- * [Object Stores](#object-stores)
- * [Model Texts](#model-texts)
-* [Monitoring \& Logging](#monitoring--logging)
-* [Support, Feedback, Contributing](#support-feedback-contributing)
-* [References \& Links](#references--links)
+- [Quick Start](#quick-start)
+- [Usage](#usage)
+ - [MVN Setup](#mvn-setup)
+ - [Changes in the CDS Models and for the UI](#changes-in-the-cds-models-and-for-the-UI)
+ - [Single (Inline) Attachments](#single-inline-attachments)
+ - [Try the Bookshop Sample](#try-the-bookshop-sample)
+ - [Storage Targets](#storage-targets)
+ - [Malware Scanner](#malware-scanner)
+ - [Specify the maximum file size](#specify-the-maximum-file-size)
+ - [Restrict allowed MIME types](#restrict-allowed-mime-types)
+ - [Outbox](#outbox)
+ - [Restore Endpoint](#restore-endpoint)
+ - [Motivation](#motivation)
+ - [HTTP Endpoint](#http-endpoint)
+ - [Security](#security)
+- [Releases: Maven Central and Artifactory](#releases-maven-central-and-artifactory)
+- [Minimum UI5 and CAP Java Version](#minimum-ui5-and-cap-java-version)
+- [Architecture Overview](#architecture-overview)
+ - [Design](#design)
+ - [Multitenancy](#multitenancy)
+ - [Object Stores](#object-stores)
+ - [Model Texts](#model-texts)
+- [Monitoring \& Logging](#monitoring--logging)
+- [Support, Feedback, Contributing](#support-feedback-contributing)
+- [References \& Links](#references--links)
## Quick Start
@@ -95,7 +96,7 @@ To use this file with the [incidents app](https://github.com/cap-java/incidents-
```cds
using { sap.capire.incidents as my } from '../db/schema';
-using { sap.attachments.Attachments } from 'com.sap.cds/cds-feature-attachments';
+using { Attachments } from 'com.sap.cds/cds-feature-attachments';
extend my.Incidents with {
attachments: Composition of many Attachments;
}
@@ -115,19 +116,56 @@ annotate service.Incidents with @(
The UI Facet can also be added directly after other UI Facets in a `cds` file in the `app` folder.
-### Try the Bookshop Sample
+### Single (Inline) Attachments
-The easiest way to get started is with the included [bookshop sample](samples/bookshop/):
+> [!Important]
+> Inline attachments require **cds-services 4.9.0** or higher and are available from **cds-feature-attachments 1.6.0**.
-```bash
-cd samples/bookshop
-mvn compile
-mvn spring-boot:run
+In addition to the composition-based `Attachments` aspect (which supports multiple files), `cds-feature-attachments` provides the `Attachment` type for **single-file** attachment fields directly on an entity. This is useful when an entity needs exactly one file, for example a profile icon or a cover image.
+
+```cds
+using { Attachment } from 'com.sap.cds/cds-feature-attachments';
+
+entity Books {
+ key ID : UUID;
+ title : String;
+ profileIcon : Attachment;
+ coverImage : Attachment;
+}
```
-Then browse to http://localhost:8080/browse/index.html to see attachments in action.
+CDS flattens inline attachment fields onto the parent entity. For example, `profileIcon : Attachment` generates the following columns on the `Books` table:
+
+- `profileIcon_content` (LargeBinary)
+- `profileIcon_mimeType` (String)
+- `profileIcon_fileName` (String)
+- `profileIcon_contentId` (String)
+- `profileIcon_status` (StatusCode)
+- `profileIcon_scannedAt` (Timestamp)
+- `profileIcon_note` (String)
+
+All plugin features: malware scanning, storage targets, maximum file size, and MIME type validation work the same way for inline attachments as for composition-based attachments.
+
+#### UI Annotations for Inline Attachments
-For detailed setup instructions and implementation details, see the [bookshop sample README](samples/bookshop/README.md).
+To display inline attachments in a Fiori Elements UI, use a `FieldGroup` referencing the flattened field names:
+
+```cds
+annotate AdminService.Books with @(UI: {
+ Facets: [
+ // ... other facets ...
+ {
+ $Type : 'UI.ReferenceFacet',
+ Label : 'Profile Icon',
+ Target: '@UI.FieldGroup#ProfileIcon'
+ }
+ ],
+ FieldGroup #ProfileIcon: {Data: [
+ {Value: profileIcon_content},
+ {Value: profileIcon_status}
+ ]}
+});
+```
### Storage Targets
@@ -144,8 +182,7 @@ When using a dedicated storage target, the attachment is not stored in the under
### Malware Scanner
-This plugin checks for a binding to
-the [SAP Malware Scanning Service](https://help.sap.com/docs/malware-scanning-servce), which needs to have the label `malware-scanner`. The entry in the [mta-file](https://cap.cloud.sap/docs/guides/deployment/to-cf#add-mta-yaml) may look like:
+This plugin checks for a binding to the [SAP Malware Scanning Service](https://help.sap.com/docs/malware-scanning-servce), which needs to have the label `malware-scanner`. The entry in the [mta-file](https://cap.cloud.sap/docs/guides/deployment/to-cf#add-mta-yaml) may look like:
```
_schema-version: '0.1'
@@ -212,6 +249,7 @@ annotate Books.attachments with {
```
The @Validation.Maximum value is a size string consisting of a number followed by a unit. The following units are supported:
+
- B (bytes)
- KB, MB, GB, TB (decimal units)
- KiB, MiB, GiB, TiB (binary units)
@@ -249,7 +287,6 @@ annotate Books.attachments with {
}
```
-
### Outbox
In this plugin the [persistent outbox](https://cap.cloud.sap/docs/java/outbox#persistent) is used to mark attachments as
@@ -343,7 +380,7 @@ In the Spring Boot context the `AttachmentService` can be autowired in the handl
To secure the endpoint, security annotations can be used. For example:
```cds
-using {sap.attachments.Attachments} from `com.sap.cds/cds-feature-attachments`;
+using {Attachments} from `com.sap.cds/cds-feature-attachments`;
entity Items : cuid {
...
diff --git a/cds-feature-attachments/pom.xml b/cds-feature-attachments/pom.xml
index 44bec84e4..dda78a2d7 100644
--- a/cds-feature-attachments/pom.xml
+++ b/cds-feature-attachments/pom.xml
@@ -92,40 +92,6 @@
${project.artifactId}
-
- org.pitest
- pitest-maven
-
-
- com.sap.cds.feature.attachments.handler.*
- com.sap.cds.feature.attachments.service.*
-
-
- CONSTRUCTOR_CALLS
- VOID_METHOD_CALLS
- NON_VOID_METHOD_CALLS
- REMOVE_CONDITIONALS_ORDER_ELSE
- CONDITIONALS_BOUNDARY
- EMPTY_RETURNS
- NEGATE_CONDITIONALS
- REMOVE_CONDITIONALS_EQUAL_IF
- REMOVE_CONDITIONALS_EQUAL_ELSE
- REMOVE_CONDITIONALS_ORDER_IF
- REMOVE_CONDITIONALS_ORDER_ELSE
-
- 95
- 90
-
-
-
-
- org.pitest
- pitest-junit5-plugin
- 1.2.3
-
-
-
-
maven-clean-plugin
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 92c477234..3aeabf7a7 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
@@ -132,9 +132,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(
@@ -163,7 +163,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,
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..8d944a3f5 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,21 @@ 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());
+ // For inline attachments, extract the prefixed fields to get proper contentId
+ Attachments attachment;
+ if (inlinePrefix.isPresent()) {
+ attachment =
+ ApplicationHandlerHelper.extractInlineAttachment(
+ path.target().values(), inlinePrefix.get());
+ } else {
+ attachment = Attachments.of(path.target().values());
+ }
+ return deleteEvent.processEvent(path, (InputStream) value, attachment, context);
+ };
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 253e57d20..c5601e2c2 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
@@ -39,6 +39,7 @@
import java.util.List;
import java.util.Map;
import java.util.Objects;
+import java.util.Optional;
import java.util.function.Supplier;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -99,8 +100,11 @@ void processBefore(CdsReadEventContext context) {
CdsModel cdsModel = context.getModel();
List fieldNames = cascader.findMediaAssociationNames(cdsModel, context.getTarget());
- 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,7 +118,18 @@ 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);
@@ -135,7 +150,9 @@ void processAfter(CdsReadEventContext context, List data) {
}
private void verifyStatus(Path path, Attachments attachment) {
- if (areKeysEmpty(path.target().keys())) {
+ Optional inlinePrefix =
+ Optional.ofNullable((String) attachment.get(ApplicationHandlerHelper.INLINE_PREFIX_MARKER));
+ if (areKeysEmpty(path.target().keys()) || inlinePrefix.isPresent()) {
String currentStatus = attachment.getStatus();
logger.debug(
"In verify status for content id {} and status {}",
@@ -143,13 +160,13 @@ private void verifyStatus(Path path, Attachments attachment) {
currentStatus);
if (scannerAvailable && 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());
}
@@ -168,21 +185,26 @@ 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 =
+ ApplicationHandlerHelper.resolveFieldName(Attachments.CONTENT_ID, inlinePrefix);
+ String statusCol = ApplicationHandlerHelper.resolveFieldName(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);
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 0747b36c2..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,10 +98,25 @@ void processBefore(CdsUpdateEventContext context, List data) {
}
private boolean associationsAreUnchanged(CdsEntity entity, List data) {
- 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..d719e49d5 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,13 +89,23 @@ 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);
+ Attachments attachment = getExistingAttachment(keys, existingAttachments, inlinePrefix);
+
+ // 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);
+ String maxSizeStr = getValMaxValue(path.target().entity(), defaultMaxSize, inlinePrefix);
eventContext.put(
"attachment.MaxSize",
maxSizeStr); // make max size available in context for error handling later
@@ -112,6 +129,11 @@ public static InputStream handleAttachmentForEntity(
ModifyAttachmentEvent eventToProcess =
eventFactory.getEvent(wrappedContent, contentId, attachment);
try {
+ // Ensure the attachment carries the inline prefix marker for processEvent implementations
+ if (inlinePrefix.isPresent()
+ && attachment.get(ApplicationHandlerHelper.INLINE_PREFIX_MARKER) == null) {
+ attachment.put(ApplicationHandlerHelper.INLINE_PREFIX_MARKER, inlinePrefix.get());
+ }
return eventToProcess.processEvent(path, wrappedContent, attachment, eventContext);
} catch (Exception e) {
if (wrappedContent != null && wrappedContent.isLimitExceeded()) {
@@ -121,9 +143,23 @@ public static InputStream handleAttachmentForEntity(
}
}
- private static String getValMaxValue(CdsEntity entity, String defaultMaxSize) {
+ private static String getValMaxValue(
+ CdsEntity entity, String defaultMaxSize, Optional inlinePrefix) {
return entity
.findElement("content")
+ .or(
+ () -> {
+ if (inlinePrefix.isPresent()) {
+ return entity.findElement(inlinePrefix.get() + "_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()))
@@ -132,9 +168,21 @@ private static String getValMaxValue(CdsEntity entity, String defaultMaxSize) {
}
private static Attachments getExistingAttachment(
- Map keys, List existingAttachments) {
+ Map keys,
+ List existingAttachments,
+ Optional inlinePrefix) {
return existingAttachments.stream()
- .filter(existingData -> ApplicationHandlerHelper.areKeysInData(keys, existingData))
+ .filter(
+ existingData -> {
+ // For inline attachments, match by the prefix marker
+ if (inlinePrefix.isPresent()) {
+ String existingPrefix =
+ (String) existingData.get(ApplicationHandlerHelper.INLINE_PREFIX_MARKER);
+ return inlinePrefix.get().equals(existingPrefix);
+ }
+ // For composition-based attachments, match by keys
+ return ApplicationHandlerHelper.areKeysInData(keys, existingData);
+ })
.findAny()
.orElse(Attachments.create());
}
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..343adb9d5 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
@@ -7,10 +7,12 @@
import com.sap.cds.CdsDataProcessor;
import com.sap.cds.CdsDataProcessor.Validator;
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.handler.common.ApplicationHandlerHelper;
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 +37,41 @@ 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));
+ attachment.setFileName(
+ (String) path.target().values().get(prefix + MediaData.FILE_NAME));
+ 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());
+ attachment.setFileName(values.getFileName());
+ 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,18 +82,45 @@ 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));
data.put(Attachments.STATUS, readOnlyData.get(Attachments.STATUS));
data.put(Attachments.SCANNED_AT, readOnlyData.get(Attachments.SCANNED_AT));
+ // Only restore fileName if it was preserved (avoid overwriting framework-provided value)
+ if (readOnlyData.get(MediaData.FILE_NAME) != null) {
+ data.put(MediaData.FILE_NAME, readOnlyData.get(MediaData.FILE_NAME));
+ }
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));
+ if (inlineReadOnlyData.get(MediaData.FILE_NAME) != null) {
+ data.put(
+ prefix + "_" + MediaData.FILE_NAME, inlineReadOnlyData.get(MediaData.FILE_NAME));
+ }
+ 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 284ffa127..85435bf92 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
@@ -52,23 +52,34 @@ public CreateAttachmentEvent(
@Override
public InputStream processEvent(
Path path, InputStream content, Attachments attachment, EventContext eventContext) {
+ Optional inlinePrefix =
+ Optional.ofNullable((String) attachment.get(ApplicationHandlerHelper.INLINE_PREFIX_MARKER));
+
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);
+
+ String fileNameField =
+ ApplicationHandlerHelper.resolveFieldName(MediaData.FILE_NAME, inlinePrefix);
+ String mimeTypeField =
+ ApplicationHandlerHelper.resolveFieldName(MediaData.MIME_TYPE, inlinePrefix);
+
+ Optional fileNameOptional =
+ getFieldValue(MediaData.FILE_NAME, values, attachment, inlinePrefix);
+ Optional mimeTypeOptional =
+ getFieldValue(MediaData.MIME_TYPE, values, attachment, inlinePrefix);
// Fall back to HTTP headers when values are not set in payload
if (eventContext.getParameterInfo() != null) {
if (fileNameOptional.isEmpty()) {
fileNameOptional = extractFileNameFromHeader(eventContext);
- fileNameOptional.ifPresent(fn -> values.put(MediaData.FILE_NAME, fn));
+ fileNameOptional.ifPresent(fn -> values.put(fileNameField, fn));
}
if (mimeTypeOptional.isEmpty()) {
mimeTypeOptional = extractMimeTypeFromHeader(eventContext);
- mimeTypeOptional.ifPresent(mt -> values.put(MediaData.MIME_TYPE, mt));
+ mimeTypeOptional.ifPresent(mt -> values.put(mimeTypeField, mt));
}
}
@@ -78,22 +89,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());
+ String contentIdField =
+ ApplicationHandlerHelper.resolveFieldName(Attachments.CONTENT_ID, inlinePrefix);
+ String statusField =
+ ApplicationHandlerHelper.resolveFieldName(Attachments.STATUS, inlinePrefix);
+ 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());
+ path.target()
+ .values()
+ .put(
+ ApplicationHandlerHelper.resolveFieldName(Attachments.SCANNED_AT, inlinePrefix),
+ 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) {
+ if (inlinePrefix.isPresent()) {
+ Object prefixedValue =
+ values.get(ApplicationHandlerHelper.resolveFieldName(fieldName, inlinePrefix));
+ if (nonNull(prefixedValue)) return Optional.of((String) prefixedValue);
+ }
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/MarkAsDeletedAttachmentEvent.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/modifyevents/MarkAsDeletedAttachmentEvent.java
index c1316ee9f..2e8a4c2ab 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,15 @@
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.handler.common.ApplicationHandlerHelper;
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;
@@ -34,6 +37,9 @@ public MarkAsDeletedAttachmentEvent(AttachmentService attachmentService) {
@Override
public InputStream processEvent(
Path path, InputStream content, Attachments attachment, EventContext eventContext) {
+ Optional inlinePrefix =
+ Optional.ofNullable((String) attachment.get(ApplicationHandlerHelper.INLINE_PREFIX_MARKER));
+
String qualifiedName = eventContext.getTarget().getQualifiedName();
logger.debug(
"Processing the event for calling attachment service with mark as delete event for entity {}",
@@ -51,12 +57,29 @@ public InputStream processEvent(
qualifiedName);
}
if (nonNull(path)) {
- String newContentId = (String) path.target().values().get(Attachments.CONTENT_ID);
- 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);
+ String contentIdField =
+ ApplicationHandlerHelper.resolveFieldName(Attachments.CONTENT_ID, inlinePrefix);
+ String statusField =
+ ApplicationHandlerHelper.resolveFieldName(Attachments.STATUS, inlinePrefix);
+ String scannedAtField =
+ ApplicationHandlerHelper.resolveFieldName(Attachments.SCANNED_AT, inlinePrefix);
+ String mimeTypeField =
+ ApplicationHandlerHelper.resolveFieldName(MediaData.MIME_TYPE, inlinePrefix);
+ String fileNameField =
+ ApplicationHandlerHelper.resolveFieldName(MediaData.FILE_NAME, inlinePrefix);
+
+ String newContentId = (String) path.target().values().get(contentIdField);
+ boolean replacedBySameContent =
+ nonNull(newContentId) && newContentId.equals(attachment.getContentId());
+ boolean noNewContentSupplied = !path.target().values().containsKey(contentIdField);
+ if (replacedBySameContent || noNewContentSupplied) {
+ path.target().values().put(contentIdField, null);
+ path.target().values().put(statusField, null);
+ path.target().values().put(scannedAtField, null);
+ if (inlinePrefix.isPresent()) {
+ path.target().values().put(mimeTypeField, null);
+ path.target().values().put(fileNameField, null);
+ }
}
}
return content;
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..2ba79a2c6 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;
/**
@@ -27,14 +30,28 @@ public final class ApplicationHandlerHelper {
private static final String ANNOTATION_IS_MEDIA_DATA = "_is_media_data";
private static final String ANNOTATION_CORE_MEDIA_TYPE = "Core.MediaType";
+ /**
+ * Internal marker key used to store the inline attachment prefix in extracted attachment data.
+ * This enables correct matching of inline attachments when operating on multiple inline
+ * attachments on the same entity.
+ */
+ public static final String INLINE_PREFIX_MARKER = "_inlinePrefix";
+
/**
* 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 +70,111 @@ public static boolean containsContentField(CdsEntity entity, List extends CdsD
/**
* Checks if the entity is a media entity. A media entity is an entity that is annotated with the
- * annotation "_is_media_data".
+ * annotation "_is_media_data", or has inline structured type elements with that annotation.
*
* @param baseEntity The entity to check
* @return true 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.endsWith("_content")
+ && 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 +199,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 +210,57 @@ 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);
+ }
+ });
+ // Store the inline prefix so we can match later
+ attachment.put(INLINE_PREFIX_MARKER, prefix);
+ return attachment;
+ }
+
public static boolean areKeysInData(Map keys, CdsData data) {
return keys.entrySet().stream()
.allMatch(
@@ -123,6 +282,10 @@ public static Map removeDraftKey(Map keys) {
return keyMap;
}
+ public static String resolveFieldName(String fieldName, Optional inlinePrefix) {
+ return inlinePrefix.map(p -> p + "_" + fieldName).orElse(fieldName);
+ }
+
private ApplicationHandlerHelper() {
// avoid instantiation
}
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 43fdcb39d..5dedb4acb 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
@@ -33,7 +33,7 @@ public List findMediaEntityNames(CdsModel model, CdsEntity entity) {
public List findMediaAssociationNames(CdsModel model, CdsEntity entity) {
List result = new ArrayList<>();
- if (ApplicationHandlerHelper.isMediaEntity(entity)) {
+ if (ApplicationHandlerHelper.isDirectMediaEntity(entity)) {
result.add("");
}
NodeTree tree = findEntityPath(model, entity);
@@ -93,17 +93,27 @@ private List> getAttachmentAssociationPath(
var currentList = new LinkedList();
var localProcessEntities = new ArrayList();
- var isMediaEntity = ApplicationHandlerHelper.isMediaEntity(entity);
- if (isMediaEntity) {
+ var isDirectMediaEntity = ApplicationHandlerHelper.isDirectMediaEntity(entity);
+ var hasInlineAttachments = ApplicationHandlerHelper.hasInlineAttachmentElements(entity);
+
+ // Direct media entities (e.g. Attachments) are always leaf nodes
+ // No need to traverse compositions
+ if (isDirectMediaEntity) {
var identifier = new AssociationIdentifier(associationName, entity.getQualifiedName());
firstList.addLast(identifier);
- }
-
- if (isMediaEntity) {
internalResultList.add(firstList);
return internalResultList;
}
+ // Entities with inline attachment fields (e.g. Items with receipt : Attachment)
+ // are treated as media entities, but may also have compositions that need traversal.
+ // Record this entity as a path AND continue to discover child compositions.
+ if (hasInlineAttachments) {
+ var inlinePath = new LinkedList<>(firstList);
+ inlinePath.addLast(new AssociationIdentifier(associationName, entity.getQualifiedName()));
+ internalResultList.add(inlinePath);
+ }
+
Map associations =
entity
.elements()
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 136d32c7d..2a1a06594 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,8 +12,10 @@
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.draft.Drafts;
import com.sap.cds.services.persistence.PersistenceService;
import java.util.ArrayList;
import java.util.List;
@@ -44,17 +46,25 @@ public List readAttachments(
logger.debug("Start reading attachments for entity {}", entity.getQualifiedName());
NodeTree nodePath = cascader.findEntityPath(model, entity);
- List> expandList = buildExpandList(nodePath);
+ List> expandList = buildExpandList(model, nodePath);
- if (expandList.isEmpty() && !ApplicationHandlerHelper.isMediaEntity(entity)) {
+ List inlineColumns = buildInlineAttachmentColumns(entity);
+
+ if (expandList.isEmpty()
+ && inlineColumns.isEmpty()
+ && !ApplicationHandlerHelper.isMediaEntity(entity)) {
logResultData(entity, List.of());
return List.of();
}
- Select> select =
- !expandList.isEmpty()
- ? Select.from(statement.ref()).columns(expandList)
- : Select.from(statement.ref()).columns(StructuredType::_all);
+ 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);
@@ -63,18 +73,52 @@ public List readAttachments(
return attachments;
}
- private List> buildExpandList(NodeTree root) {
+ private List buildInlineAttachmentColumns(CdsEntity entity) {
+ List inlineFields = ApplicationHandlerHelper.getInlineAttachmentFieldNames(entity);
+ List columns = new ArrayList<>();
+ for (String fieldName : inlineFields) {
+ // Include the content field so CdsDataProcessor's MEDIA_CONTENT_FILTER can match it
+ columns.add(CQL.get(fieldName + "_content"));
+ columns.add(CQL.get(fieldName + "_" + Attachments.CONTENT_ID));
+ columns.add(CQL.get(fieldName + "_" + Attachments.STATUS));
+ }
+ if (!columns.isEmpty()) {
+ entity.keyElements().forEach(keyElement -> columns.add(CQL.get(keyElement.getName())));
+ if (entity.findElement(Drafts.HAS_ACTIVE_ENTITY).isPresent()) {
+ columns.add(CQL.get(Drafts.HAS_ACTIVE_ENTITY));
+ }
+ }
+ return columns;
+ }
+
+ private List> buildExpandList(CdsModel model, NodeTree root) {
List> expandResultList = new ArrayList<>();
- root.getChildren().forEach(child -> expandResultList.add(buildExpandFromTree(child)));
+ root.getChildren().forEach(child -> expandResultList.add(buildExpandFromTree(model, child)));
return expandResultList;
}
- private Expand> buildExpandFromTree(NodeTree node) {
- return node.getChildren().isEmpty()
+ private Expand> buildExpandFromTree(CdsModel model, NodeTree node) {
+ // Look up the entity for this node to check for inline attachments
+ CdsEntity nodeEntity = model.findEntity(node.getIdentifier().fullEntityName()).orElse(null);
+
+ // Build inline attachment columns for this child entity if it has any
+ List inlineColumns =
+ nodeEntity != null ? buildInlineAttachmentColumns(nodeEntity) : List.of();
+
+ // Build child expands recursively
+ List childExpands = new ArrayList<>();
+ for (NodeTree child : node.getChildren()) {
+ childExpands.add(buildExpandFromTree(model, child));
+ }
+
+ // Combine inline columns and child expands
+ List expandItems = new ArrayList<>(inlineColumns);
+ expandItems.addAll(childExpands);
+
+ return expandItems.isEmpty()
? CQL.to(node.getIdentifier().associationName()).expand()
- : CQL.to(node.getIdentifier().associationName())
- .expand(node.getChildren().stream().map(this::buildExpandFromTree).toList());
+ : CQL.to(node.getIdentifier().associationName()).expand(expandItems);
}
private static void logResultData(CdsEntity entity, List attachments) {
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 82978dcf0..bedcd3b5d 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
@@ -15,6 +15,8 @@
import com.sap.cds.feature.attachments.handler.common.AttachmentsReader;
import com.sap.cds.ql.CQL;
import com.sap.cds.ql.cqn.CqnDelete;
+import com.sap.cds.ql.cqn.Path;
+import com.sap.cds.reflect.CdsElement;
import com.sap.cds.reflect.CdsEntity;
import com.sap.cds.reflect.CdsStructuredType;
import com.sap.cds.services.draft.DraftCancelEventContext;
@@ -26,6 +28,7 @@
import com.sap.cds.services.handler.annotations.ServiceName;
import java.util.List;
import java.util.Map;
+import java.util.Objects;
import java.util.Optional;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -41,9 +44,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,25 +103,63 @@ void processBeforeDraftCancel(DraftCancelEventContext context) {
private Validator buildDeleteContentValidator(
DraftCancelEventContext context, List extends CdsData> activeCondensedAttachments) {
return (path, element, value) -> {
- Attachments attachment = Attachments.of(path.target().values());
+ Attachments attachment = extractAttachmentFromPath(path, element);
+
if (Boolean.FALSE.equals(attachment.get(Drafts.HAS_ACTIVE_ENTITY))) {
deleteEvent.processEvent(path, null, attachment, context);
return;
}
+
+ Optional inlinePrefix =
+ ApplicationHandlerHelper.getInlineAttachmentPrefix(
+ path.target().entity(), element.getName());
Map keys = ApplicationHandlerHelper.removeDraftKey(path.target().keys());
Optional extends CdsData> existingEntry =
activeCondensedAttachments.stream()
- .filter(updatedData -> ApplicationHandlerHelper.areKeysInData(keys, updatedData))
+ .filter(
+ updatedData -> {
+ if (inlinePrefix.isPresent()) {
+ return inlinePrefix
+ .get()
+ .equals(updatedData.get(ApplicationHandlerHelper.INLINE_PREFIX_MARKER));
+ }
+ return ApplicationHandlerHelper.areKeysInData(keys, updatedData);
+ })
.findAny();
- existingEntry.ifPresent(
- entry -> {
- if (!entry.get(Attachments.CONTENT_ID).equals(value)) {
- deleteEvent.processEvent(null, null, attachment, context);
- }
- });
+
+ if (existingEntry.isPresent()) {
+ Object existingContentId = existingEntry.get().get(Attachments.CONTENT_ID);
+ if (!Objects.equals(existingContentId, attachment.getContentId())) {
+ deleteEvent.processEvent(null, null, attachment, context);
+ }
+ } else if (attachment.getContentId() != null) {
+ logger.warn(
+ "Draft attachment with contentId {} has no matching active entry. Deleting to prevent orphan.",
+ attachment.getContentId());
+ deleteEvent.processEvent(null, null, attachment, context);
+ }
};
}
+ private Attachments extractAttachmentFromPath(Path path, CdsElement element) {
+ Optional inlinePrefix =
+ ApplicationHandlerHelper.getInlineAttachmentPrefix(
+ path.target().entity(), element.getName());
+ Attachments attachment;
+ if (inlinePrefix.isPresent()) {
+ attachment =
+ ApplicationHandlerHelper.extractInlineAttachment(
+ path.target().values(), inlinePrefix.get());
+ Object hasActiveEntity = path.target().values().get(Drafts.HAS_ACTIVE_ENTITY);
+ if (hasActiveEntity != null) {
+ attachment.put(Drafts.HAS_ACTIVE_ENTITY, hasActiveEntity);
+ }
+ } else {
+ attachment = Attachments.of(path.target().values());
+ }
+ return attachment;
+ }
+
private List readAttachments(
DraftCancelEventContext context, CdsStructuredType entity, boolean isActiveEntity) {
logger.debug(
diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/draftservice/DraftPatchAttachmentsHandler.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/draftservice/DraftPatchAttachmentsHandler.java
index 5d66dbc96..addc213d6 100644
--- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/draftservice/DraftPatchAttachmentsHandler.java
+++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/draftservice/DraftPatchAttachmentsHandler.java
@@ -10,11 +10,17 @@
import com.sap.cds.CdsDataProcessor.Converter;
import com.sap.cds.Result;
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.handler.applicationservice.helper.ModifyApplicationHandlerHelper;
import com.sap.cds.feature.attachments.handler.applicationservice.modifyevents.ModifyAttachmentEventFactory;
import com.sap.cds.feature.attachments.handler.common.ApplicationHandlerHelper;
+import com.sap.cds.ql.CQL;
import com.sap.cds.ql.Select;
+import com.sap.cds.ql.Update;
+import com.sap.cds.ql.cqn.CqnPredicate;
import com.sap.cds.ql.cqn.CqnSelect;
+import com.sap.cds.ql.cqn.CqnUpdate;
+import com.sap.cds.reflect.CdsElement;
import com.sap.cds.reflect.CdsEntity;
import com.sap.cds.services.draft.DraftPatchEventContext;
import com.sap.cds.services.draft.DraftService;
@@ -24,7 +30,11 @@
import com.sap.cds.services.handler.annotations.ServiceName;
import com.sap.cds.services.persistence.PersistenceService;
import java.io.InputStream;
+import java.util.HashMap;
import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.stream.Collectors;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -63,17 +73,95 @@ void processBeforeDraftPatch(DraftPatchEventContext context, List extends CdsD
CqnSelect select = Select.from(draftEntity).matching(path.target().keys());
Result result = persistence.run(select);
- return ModifyApplicationHandlerHelper.handleAttachmentForEntity(
- result.listOf(Attachments.class),
- eventFactory,
- context,
- path,
- (InputStream) value,
- defaultMaxSize);
+ List 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);
+ }
+
+ InputStream processedContent =
+ ModifyApplicationHandlerHelper.handleAttachmentForEntity(
+ existingAttachments,
+ eventFactory,
+ context,
+ path,
+ (InputStream) value,
+ defaultMaxSize,
+ inlinePrefix);
+
+ return processedContent;
};
CdsDataProcessor.create()
.addConverter(ApplicationHandlerHelper.MEDIA_CONTENT_FILTER, converter)
.process(data, context.getTarget());
+
+ // The framework's DRAFT_PATCH ON handler only persists readonly fields added by
+ // @Before handlers, so mimeType and fileName (non-readonly) set by CreateAttachmentEvent
+ // are dropped. Persist them directly via the PersistenceService.
+ persistInlineAttachmentMetadata(context.getTarget(), data);
+ }
+
+ private void persistInlineAttachmentMetadata(CdsEntity target, List extends CdsData> data) {
+ List inlinePrefixes = ApplicationHandlerHelper.getInlineAttachmentFieldNames(target);
+ if (inlinePrefixes.isEmpty()) {
+ return;
+ }
+
+ CdsEntity draftEntity = DraftUtils.getDraftEntity(target);
+ for (CdsData d : data) {
+ for (String prefix : inlinePrefixes) {
+ String mimeTypeField = prefix + "_" + MediaData.MIME_TYPE;
+ String fileNameField = prefix + "_" + MediaData.FILE_NAME;
+ String contentIdField = prefix + "_" + Attachments.CONTENT_ID;
+
+ // Only update if the attachment was actually processed (contentId present)
+ Object contentId = d.get(contentIdField);
+ if (contentId == null) {
+ continue;
+ }
+
+ Map updateData = new HashMap<>();
+ Object mimeType = d.get(mimeTypeField);
+ Object fileName = d.get(fileNameField);
+ if (mimeType != null) {
+ updateData.put(mimeTypeField, mimeType);
+ }
+ if (fileName != null) {
+ updateData.put(fileNameField, fileName);
+ }
+ if (updateData.isEmpty()) {
+ continue;
+ }
+
+ CqnPredicate predicate = CQL.get(contentIdField).eq(contentId);
+ for (CdsElement key : target.keyElements().toList()) {
+ Object keyValue = d.get(key.getName());
+ if (keyValue != null) {
+ predicate = CQL.and(predicate, CQL.get(key.getName()).eq(keyValue));
+ }
+ }
+ CqnUpdate update = Update.entity(draftEntity).data(updateData).where(predicate);
+ persistence.run(update);
+ }
+ }
}
}
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 a33660351..f6082d879 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 c14217499..be93e6d24 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;
@@ -66,9 +67,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 8f22ad570..ba4848aac 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
@@ -9,6 +9,7 @@
import com.sap.cds.Result;
import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.Attachments;
import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.StatusCode;
+import com.sap.cds.feature.attachments.handler.common.ApplicationHandlerHelper;
import com.sap.cds.feature.attachments.service.AttachmentService;
import com.sap.cds.feature.attachments.service.malware.client.MalwareScanClient;
import com.sap.cds.feature.attachments.service.malware.client.MalwareScanResultStatus;
@@ -23,8 +24,11 @@
import java.io.InputStream;
import java.time.Instant;
import java.util.ArrayList;
+import java.util.HashMap;
import java.util.List;
+import java.util.Map;
import java.util.Objects;
+import java.util.Optional;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -67,15 +71,17 @@ 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 selectionResults = selectData(attachmentEntity, contentId);
+ List selectionResults = selectData(attachmentEntity, contentId, inlinePrefix);
- MalwareScanResultStatus status = findAndScanAttachments(selectionResults, contentId);
+ MalwareScanResultStatus status =
+ findAndScanAttachments(selectionResults, contentId, inlinePrefix);
if (status == null) {
logger.debug("Attachment {} not found in any entity, skipping update.", contentId);
@@ -84,17 +90,20 @@ public void scanAttachment(CdsEntity attachmentEntity, String contentId) {
// Update ALL candidate entities. This ensures the scan result is persisted
// even if the attachment moved between draft and active tables during the scan.
- for (SelectionResult result : selectionResults) {
- updateData(result.entity, contentId, status);
+ for (SelectionResult selectionResult : selectionResults) {
+ Map keys = extractKeys(selectionResult.result(), selectionResult.entity());
+ updateData(selectionResult.entity(), contentId, status, inlinePrefix, keys);
}
}
private MalwareScanResultStatus findAndScanAttachments(
- List selectionResults, String contentId) {
+ List selectionResults, String contentId, Optional inlinePrefix) {
return selectionResults.stream()
.filter(result -> validateAndFilter(result, contentId))
.findFirst()
- .map(result -> scanDocument(result.result().single(Attachments.class), result.entity))
+ .map(
+ result ->
+ scanDocument(extractAttachment(result.result(), inlinePrefix), result.entity()))
.orElse(null);
}
@@ -104,7 +113,7 @@ private boolean validateAndFilter(SelectionResult result, String contentId) {
logger.debug(
"No attachments {} found in entity {}, nothing to scan.",
contentId,
- result.entity.getQualifiedName());
+ result.entity().getQualifiedName());
return false;
}
@@ -112,7 +121,7 @@ private boolean validateAndFilter(SelectionResult result, 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));
}
@@ -120,41 +129,82 @@ private boolean validateAndFilter(SelectionResult result, String contentId) {
return true;
}
- 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;
+ }
+
+ 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 Map extractKeys(Result result, CdsEntity entity) {
+ if (result.rowCount() != 1) {
+ return Map.of();
+ }
+ var row = result.single();
+ Map keys = new HashMap<>();
+ entity
+ .keyElements()
+ .forEach(
+ keyElement -> {
+ String keyName = keyElement.getName();
+ Object value = row.get(keyName);
+ if (value != null) {
+ keys.put(keyName, value);
+ }
+ });
+ return keys;
+ }
+
+ private Result readData(String contentId, CdsEntity entity, Optional inlinePrefix) {
+ String contentIdCol =
+ ApplicationHandlerHelper.resolveFieldName(Attachments.CONTENT_ID, inlinePrefix);
+ String contentCol =
+ ApplicationHandlerHelper.resolveFieldName(Attachments.CONTENT, inlinePrefix);
+ String statusCol = ApplicationHandlerHelper.resolveFieldName(Attachments.STATUS, inlinePrefix);
+
+ List columns = new ArrayList<>();
+ columns.add(contentIdCol);
+ columns.add(contentCol);
+ columns.add(statusCol);
+ entity.keyElements().forEach(keyElement -> columns.add(keyElement.getName()));
+
CqnSelect select =
Select.from(entity)
- .columns(Attachments.CONTENT_ID, Attachments.CONTENT, Attachments.STATUS)
+ .columns(columns.toArray(String[]::new))
.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;
}
@@ -180,20 +230,38 @@ private MalwareScanResultStatus scanDocument(Attachments attachment, CdsEntity a
}
private void updateData(
- CdsEntity attachmentEntity, String contentId, MalwareScanResultStatus status) {
+ CdsEntity attachmentEntity,
+ String contentId,
+ MalwareScanResultStatus status,
+ Optional inlinePrefix,
+ Map entityKeys) {
+ String contentIdCol =
+ ApplicationHandlerHelper.resolveFieldName(Attachments.CONTENT_ID, inlinePrefix);
+ String statusCol = ApplicationHandlerHelper.resolveFieldName(Attachments.STATUS, inlinePrefix);
+ String scannedAtCol =
+ ApplicationHandlerHelper.resolveFieldName(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));
+ CqnUpdate update;
+ if (entityKeys.isEmpty()) {
+ update =
+ Update.entity(attachmentEntity)
+ .data(updateData)
+ .where(entry -> entry.get(contentIdCol).eq(contentId));
+ } else {
+ update = Update.entity(attachmentEntity).data(updateData).matching(entityKeys);
+ }
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 85ed597a2..3525f653c 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,12 +1,15 @@
using {
+ sap.attachments.Attachment,
sap.attachments.MediaData,
sap.attachments.Attachments
} from './attachments';
annotate sap.attachments.MediaData with @UI.MediaResource: {Stream: content} {
content @(
- title : '{i18n>attachment_content}',
- Core.MediaType: mimeType,
+ title : '{i18n>attachment_content}',
+ Core.ContentDisposition.Type : 'inline',
+ Core.MediaType : (mimeType),
+ Core.ContentDisposition.Filename: (fileName),
);
mimeType @(
title: '{i18n>attachment_mimeType}',
@@ -92,4 +95,8 @@ annotate sap.attachments.Attachments with
@Common : {SideEffects #ContentChanged: {
SourceProperties: [content],
TargetProperties: ['status']
-}}
+}};
+
+annotate sap.attachments.Attachment with {
+ content @Core.ContentDisposition.Filename: (fileName);
+}
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 7fba49eec..a1fd61e2d 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
@@ -8,6 +8,8 @@ using {
// using { Attachments } from 'com.sap.cds/cds-feature-attachments'
aspect Attachments : sap.attachments.Attachments {}
+type Attachment : sap.attachments.Attachment;
+
context sap.attachments {
type StatusCode : String(32) enum {
@@ -24,7 +26,7 @@ context sap.attachments {
criticality : Integer @UI.Hidden;
}
- aspect MediaData @(_is_media_data) : managed {
+ aspect MediaData @(_is_media_data) {
content : LargeBinary; // stored only for db-based services
mimeType : String;
fileName : String(5000);
@@ -34,7 +36,9 @@ context sap.attachments {
note : String(5000);
}
- aspect Attachments : cuid, MediaData {
+ type Attachment : MediaData {}
+
+ aspect Attachments : cuid, MediaData, managed {
statusNav : Association to one ScanStates
on statusNav.code = status;
}
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 a0974bd0e..87fd6a142 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
@@ -19,6 +19,7 @@
import com.sap.cds.feature.attachments.generated.test.cds4j.unit.test.EventItems;
import com.sap.cds.feature.attachments.generated.test.cds4j.unit.test.EventItems_;
import com.sap.cds.feature.attachments.generated.test.cds4j.unit.test.testservice.Attachment_;
+import com.sap.cds.feature.attachments.generated.test.cds4j.unit.test.testservice.InlineOnly_;
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.generated.test.cds4j.unit.test.testservice.RootTable_;
@@ -41,6 +42,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;
@@ -230,7 +232,7 @@ void scannerCalledForUnscannedAttachments() {
cut.processAfter(readEventContext, List.of(attachment));
verify(asyncMalwareScanExecutor)
- .scanAsync(readEventContext.getTarget(), attachment.getContentId());
+ .scanAsync(readEventContext.getTarget(), attachment.getContentId(), Optional.empty());
}
@Test
@@ -244,7 +246,7 @@ void scannerCalledForUnscannedAttachmentsIfNoContentProvided() {
cut.processAfter(readEventContext, List.of(attachment));
verify(asyncMalwareScanExecutor)
- .scanAsync(readEventContext.getTarget(), attachment.getContentId());
+ .scanAsync(readEventContext.getTarget(), attachment.getContentId(), Optional.empty());
}
@Test
@@ -278,7 +280,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);
}
@@ -300,7 +302,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);
}
@@ -347,7 +349,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);
}
@@ -466,6 +468,48 @@ void scannerNotAvailable_unscannedAttachmentStillFailsValidation() {
verifyNoInteractions(persistenceService);
}
+ @Test
+ void processBeforeWithInlineOnlyEntity() {
+ var select = Select.from(InlineOnly_.class).columns(InlineOnly_::ID);
+ mockEventContext(InlineOnly_.CDS_NAME, select);
+
+ cut.processBefore(readEventContext);
+ }
+
+ @Test
+ void processBeforeWithBothCompositionAndInlineEntity() {
+ var select = Select.from(RootTable_.class).columns(RootTable_::ID);
+ mockEventContext(RootTable_.CDS_NAME, select);
+
+ cut.processBefore(readEventContext);
+ }
+
+ @Test
+ void processAfterWithInlineAttachmentData() {
+ mockEventContext(RootTable_.CDS_NAME, mock(CqnSelect.class));
+ var root = RootTable.create();
+ root.setProfilePictureContentId("inline-cid");
+ root.setProfilePictureContent(mock(InputStream.class));
+ root.setProfilePictureStatus(StatusCode.CLEAN);
+ root.setProfilePictureScannedAt(Instant.now());
+
+ cut.processAfter(readEventContext, List.of(root));
+
+ assertThat(root.getProfilePictureContent()).isInstanceOf(LazyProxyInputStream.class);
+ }
+
+ @Test
+ void processAfterWithInlineAttachmentWithoutContentIdReturnsOriginalValue() {
+ mockEventContext(RootTable_.CDS_NAME, mock(CqnSelect.class));
+ var root = RootTable.create();
+ var originalStream = mock(InputStream.class);
+ root.setProfilePictureContent(originalStream);
+
+ cut.processAfter(readEventContext, List.of(root));
+
+ assertThat(root.getProfilePictureContent()).isSameAs(originalStream);
+ }
+
private void mockEventContext(String entityName, CqnSelect select) {
var serviceEntity = runtime.getCdsModel().findEntity(entityName);
when(readEventContext.getTarget()).thenReturn(serviceEntity.orElseThrow());
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..71ec481e6 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
@@ -26,6 +26,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 +93,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);
}
@@ -146,7 +148,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 +181,8 @@ void defaultValMaxValueUsed() {
eventContext,
path,
content,
- ModifyApplicationHandlerHelper.DEFAULT_SIZE_WITH_SCANNER));
+ ModifyApplicationHandlerHelper.DEFAULT_SIZE_WITH_SCANNER,
+ Optional.empty()));
}
@Test
@@ -210,8 +214,120 @@ void malformedContentLengthHeader() {
eventContext,
path,
content,
- ModifyApplicationHandlerHelper.DEFAULT_SIZE_WITH_SCANNER));
+ ModifyApplicationHandlerHelper.DEFAULT_SIZE_WITH_SCANNER,
+ Optional.empty()));
assertThat(exception.getErrorStatus()).isEqualTo(ErrorStatuses.BAD_REQUEST);
}
+
+ @Test
+ void inlineAttachmentSizeLimitFromAnnotation() {
+ String inlineOnlyEntityName = "unit.test.TestService.InlineOnly";
+ CdsEntity entity = runtime.getCdsModel().findEntity(inlineOnlyEntityName).orElseThrow();
+
+ var data = Attachments.create();
+ data.setId(UUID.randomUUID().toString());
+ data.put("avatar_contentId", "inline-cid");
+ data.put("avatar_content", mock(InputStream.class));
+
+ when(target.entity()).thenReturn(entity);
+ when(target.values()).thenReturn(data);
+ when(target.keys()).thenReturn(Map.of("ID", data.getId()));
+
+ when(parameterInfo.getHeader("Content-Length")).thenReturn("20000");
+
+ var existingAttachments = List.of();
+
+ var exception =
+ assertThrows(
+ ServiceException.class,
+ () ->
+ ModifyApplicationHandlerHelper.handleAttachmentForEntity(
+ existingAttachments,
+ eventFactory,
+ eventContext,
+ path,
+ (InputStream) data.get("avatar_content"),
+ ModifyApplicationHandlerHelper.DEFAULT_SIZE_WITH_SCANNER,
+ Optional.of("avatar")));
+
+ assertThat(exception.getErrorStatus()).isEqualTo(ExtendedErrorStatuses.CONTENT_TOO_LARGE);
+ }
+
+ @Test
+ void inlineAttachmentWithinSizeLimitSucceeds() {
+ String inlineOnlyEntityName = "unit.test.TestService.InlineOnly";
+ CdsEntity entity = runtime.getCdsModel().findEntity(inlineOnlyEntityName).orElseThrow();
+
+ var data = Attachments.create();
+ data.setId(UUID.randomUUID().toString());
+ data.put("avatar_contentId", "inline-cid");
+ var content = mock(InputStream.class);
+ data.put("avatar_content", content);
+
+ when(target.entity()).thenReturn(entity);
+ when(target.values()).thenReturn(data);
+ when(target.keys()).thenReturn(Map.of("ID", data.getId()));
+
+ when(parameterInfo.getHeader("Content-Length")).thenReturn("5000");
+
+ var existingAttachments = List.of();
+
+ assertDoesNotThrow(
+ () ->
+ ModifyApplicationHandlerHelper.handleAttachmentForEntity(
+ existingAttachments,
+ eventFactory,
+ eventContext,
+ path,
+ content,
+ ModifyApplicationHandlerHelper.DEFAULT_SIZE_WITH_SCANNER,
+ Optional.of("avatar")));
+ }
+
+ @Test
+ void streamingLimitExceededOnInlineAttachment() {
+ String inlineOnlyEntityName = "unit.test.TestService.InlineOnly";
+ CdsEntity entity = runtime.getCdsModel().findEntity(inlineOnlyEntityName).orElseThrow();
+
+ var data = Attachments.create();
+ data.setId(UUID.randomUUID().toString());
+ data.put("avatar_contentId", "inline-cid");
+ byte[] largeContent = new byte[15000]; // 15KB > 10KB limit
+ var content = new ByteArrayInputStream(largeContent);
+ data.put("avatar_content", content);
+
+ when(target.entity()).thenReturn(entity);
+ when(target.values()).thenReturn(data);
+ when(target.keys()).thenReturn(Map.of("ID", data.getId()));
+ when(parameterInfo.getHeader("Content-Length")).thenReturn(null);
+
+ when(event.processEvent(any(), any(), any(), any()))
+ .thenAnswer(
+ invocation -> {
+ InputStream wrappedContent = invocation.getArgument(1);
+ if (wrappedContent != null) {
+ byte[] buffer = new byte[1024];
+ while (wrappedContent.read(buffer) != -1) {}
+ }
+ return null;
+ });
+
+ var existingAttachments = List.of();
+
+ var exception =
+ assertThrows(
+ ServiceException.class,
+ () ->
+ ModifyApplicationHandlerHelper.handleAttachmentForEntity(
+ existingAttachments,
+ eventFactory,
+ eventContext,
+ path,
+ content,
+ ModifyApplicationHandlerHelper.DEFAULT_SIZE_WITH_SCANNER,
+ Optional.of("avatar")));
+
+ assertThat(exception.getErrorStatus()).isEqualTo(ExtendedErrorStatuses.CONTENT_TOO_LARGE);
+ }
}
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
index eb20bd79b..e8c452862 100644
--- 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
@@ -7,11 +7,14 @@
import com.sap.cds.CdsData;
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.Events_;
import com.sap.cds.feature.attachments.generated.test.cds4j.unit.test.testservice.Attachment_;
+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;
@@ -28,88 +31,92 @@ static void classSetup() {
runtime = RuntimeHelper.runtime;
}
- @Test
- void preserveReadonlyFields_isDraft_backupCreated() {
- CdsEntity entity = runtime.getCdsModel().findEntity(Attachment_.CDS_NAME).orElseThrow();
-
- var attachment = Attachments.create();
- attachment.setContentId("doc-123");
- attachment.setStatus("Clean");
- Instant scannedAt = Instant.parse("2024-06-01T12:00:00Z");
- attachment.setScannedAt(scannedAt);
- attachment.setContent(null);
-
- ReadonlyDataContextEnhancer.preserveReadonlyFields(entity, List.of(attachment), true);
+ private CdsEntity getRootTableEntity() {
+ return runtime.getCdsModel().findEntity(RootTable_.CDS_NAME).orElseThrow();
+ }
- assertThat(attachment.get(DRAFT_READONLY_CONTEXT)).isNotNull();
- var backup = (CdsData) attachment.get(DRAFT_READONLY_CONTEXT);
- assertThat(backup)
- .containsEntry(Attachments.CONTENT_ID, "doc-123")
- .containsEntry(Attachments.STATUS, "Clean")
- .containsEntry(Attachments.SCANNED_AT, scannedAt)
- .doesNotContainKey(Attachments.CONTENT);
+ private CdsEntity getAttachmentEntity() {
+ return runtime
+ .getCdsModel()
+ .findEntity("unit.test.TestService.RootTable.attachments")
+ .orElseThrow();
}
- @Test
- void preserveReadonlyFields_isNotDraft_backupRemoved() {
- CdsEntity entity = runtime.getCdsModel().findEntity(Attachment_.CDS_NAME).orElseThrow();
+ // --- Composition-based preserve/restore tests ---
- var attachment = Attachments.create();
- attachment.setContentId("doc-456");
- attachment.setContent(null);
- var existingBackup = CdsData.create();
- existingBackup.put(Attachments.CONTENT_ID, "old-id");
- existingBackup.put(Attachments.STATUS, "old-status");
- existingBackup.put(Attachments.SCANNED_AT, Instant.EPOCH);
- attachment.put(DRAFT_READONLY_CONTEXT, existingBackup);
+ @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(attachment), false);
+ ReadonlyDataContextEnhancer.preserveReadonlyFields(entity, List.of(data), true);
- assertThat(attachment.get(DRAFT_READONLY_CONTEXT)).isNull();
+ 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 preserveReadonlyFields_isDraft_noAttachmentEntity_nothingHappens() {
- CdsEntity entity = runtime.getCdsModel().findEntity(Events_.CDS_NAME).orElseThrow();
-
- var data = CdsData.create();
- data.put("content", "some text");
+ 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), true);
+ ReadonlyDataContextEnhancer.preserveReadonlyFields(entity, List.of(data), false);
- assertThat(data.get(DRAFT_READONLY_CONTEXT)).isNull();
+ assertThat(data.containsKey(DRAFT_READONLY_CONTEXT)).isFalse();
}
@Test
- void restoreReadonlyFields_withBackup_fieldsRestoredAndBackupRemoved() {
- var data = CdsData.create();
- var backup = CdsData.create();
- backup.put(Attachments.CONTENT_ID, "restored-id");
- backup.put(Attachments.STATUS, "Infected");
- Instant scannedAt = Instant.parse("2025-01-15T08:30:00Z");
- backup.put(Attachments.SCANNED_AT, scannedAt);
+ 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("restored-id");
- assertThat(data.get(Attachments.STATUS)).isEqualTo("Infected");
+ 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.get(DRAFT_READONLY_CONTEXT)).isNull();
+ assertThat(data.containsKey(DRAFT_READONLY_CONTEXT)).isFalse();
}
@Test
- void restoreReadonlyFields_withoutBackup_noOp() {
- var data = CdsData.create();
- data.put("someKey", "someValue");
+ void restoreReadonlyFieldsNoBackupDoesNothing() {
+ CdsData data = CdsData.create();
+ data.put("ID", "123");
ReadonlyDataContextEnhancer.restoreReadonlyFields(data);
- assertThat(data).containsEntry("someKey", "someValue");
- assertThat(data).doesNotContainKey(DRAFT_READONLY_CONTEXT);
- assertThat(data).doesNotContainKey(Attachments.CONTENT_ID);
- assertThat(data).doesNotContainKey(Attachments.STATUS);
- assertThat(data).doesNotContainKey(Attachments.SCANNED_AT);
+ assertThat(data.get("ID")).isEqualTo("123");
+ assertThat(data).hasSize(1);
+ }
+
+ // --- Edge-case tests from main ---
+
+ @Test
+ void preserveReadonlyFields_isDraft_noAttachmentEntity_nothingHappens() {
+ CdsEntity entity = runtime.getCdsModel().findEntity(Events_.CDS_NAME).orElseThrow();
+
+ var data = CdsData.create();
+ data.put("content", "some text");
+
+ ReadonlyDataContextEnhancer.preserveReadonlyFields(entity, List.of(data), true);
+
+ assertThat(data.get(DRAFT_READONLY_CONTEXT)).isNull();
}
@Test
@@ -139,4 +146,73 @@ void preserveReadonlyFields_isNotDraft_noExistingBackup_nothingHappens() {
assertThat(attachment.get(DRAFT_READONLY_CONTEXT)).isNull();
}
+
+ // --- Inline attachment preserve/restore tests ---
+
+ @Test
+ void preserveReadonlyFieldsForDraftInlineAttachment() {
+ CdsEntity entity = runtime.getCdsModel().findEntity(RootTable_.CDS_NAME).orElseThrow();
+ CdsData data = CdsData.create();
+ data.put("profilePicture_content", new ByteArrayInputStream(new byte[0]));
+ data.put("profilePicture_contentId", "inline-cid");
+ data.put("profilePicture_status", "Clean");
+ Instant now = Instant.now();
+ data.put("profilePicture_scannedAt", now);
+ data.put("profilePicture_fileName", "photo.jpg");
+
+ 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("inline-cid");
+ assertThat(backup.get(Attachments.STATUS)).isEqualTo("Clean");
+ assertThat(backup.get(Attachments.SCANNED_AT)).isEqualTo(now);
+ assertThat(backup.get(MediaData.FILE_NAME)).isEqualTo("photo.jpg");
+ }
+
+ @Test
+ void restoreReadonlyFieldsForInlineAttachment() {
+ CdsData data = CdsData.create();
+ Attachments backup = Attachments.create();
+ backup.setContentId("inline-restored-cid");
+ backup.setStatus("Scanning");
+ Instant scannedAt = Instant.now();
+ backup.setScannedAt(scannedAt);
+ data.put("profilePicture_" + DRAFT_READONLY_CONTEXT, backup);
+
+ ReadonlyDataContextEnhancer.restoreReadonlyFields(data);
+
+ assertThat(data.get("profilePicture_contentId")).isEqualTo("inline-restored-cid");
+ assertThat(data.get("profilePicture_status")).isEqualTo("Scanning");
+ assertThat(data.get("profilePicture_scannedAt")).isEqualTo(scannedAt);
+ assertThat(data.containsKey("profilePicture_" + DRAFT_READONLY_CONTEXT)).isFalse();
+ }
+
+ @Test
+ void restoreReadonlyFieldsForInlineAttachmentWithFileName() {
+ CdsData data = CdsData.create();
+ Attachments backup = Attachments.create();
+ backup.setContentId("inline-cid");
+ backup.setStatus("Clean");
+ backup.setFileName("preserved-file.pdf");
+ data.put("profilePicture_" + DRAFT_READONLY_CONTEXT, backup);
+
+ ReadonlyDataContextEnhancer.restoreReadonlyFields(data);
+
+ assertThat(data.get("profilePicture_contentId")).isEqualTo("inline-cid");
+ assertThat(data.get("profilePicture_fileName")).isEqualTo("preserved-file.pdf");
+ assertThat(data.containsKey("profilePicture_" + DRAFT_READONLY_CONTEXT)).isFalse();
+ }
+
+ @Test
+ void preserveReadonlyFieldsNonDraftRemovesInlinePrefixedContext() {
+ CdsEntity entity = runtime.getCdsModel().findEntity(RootTable_.CDS_NAME).orElseThrow();
+ 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();
+ }
}
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 8a4e3a99a..126246203 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
@@ -6,12 +6,14 @@
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
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.handler.applicationservice.transaction.ListenerProvider;
+import com.sap.cds.feature.attachments.handler.common.ApplicationHandlerHelper;
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;
@@ -27,6 +29,7 @@
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
+import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
import org.junit.jupiter.api.BeforeEach;
@@ -210,7 +213,6 @@ void fileNameFromRfc5987HeaderWithTrailingParams() {
when(target.keys()).thenReturn(Map.of("ID", attachment.getId()));
when(attachmentService.createAttachment(any()))
.thenReturn(new AttachmentModificationResult(false, "id", "test", null));
- // Header with trailing parameters after the filename - should stop at semicolon
when(parameterInfo.getHeader("Content-Disposition"))
.thenReturn("attachment; filename*=UTF-8''my%20file.pdf; size=1234");
@@ -332,7 +334,6 @@ void fileNameIgnoredForInvalidHeader() {
when(target.keys()).thenReturn(Map.of("ID", attachment.getId()));
when(attachmentService.createAttachment(any()))
.thenReturn(new AttachmentModificationResult(false, "id", "test", null));
- // Header exists but has no valid filename pattern
when(parameterInfo.getHeader("Content-Disposition")).thenReturn("inline");
when(parameterInfo.getHeader("slug")).thenReturn(null);
@@ -374,4 +375,264 @@ void headersSkippedWhenParameterInfoIsNull() {
assertThat(contextArgumentCaptor.getValue().fileName()).isNull();
assertThat(contextArgumentCaptor.getValue().mimeType()).isNull();
}
+
+ // --- Inline Attachment Tests ---
+
+ @Test
+ void inlineContentIdAndStatusWrittenWithPrefix() {
+ when(entity.getQualifiedName()).thenReturn(TEST_FULL_NAME);
+
+ 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, inlineAttachment("profilePicture"), eventContext);
+
+ assertThat(values).containsEntry("profilePicture_contentId", "doc-123");
+ assertThat(values).containsEntry("profilePicture_status", "Clean");
+ }
+
+ @Test
+ void inlinePrefixedFieldValuesPassedToService() {
+ when(entity.getQualifiedName()).thenReturn(TEST_FULL_NAME);
+
+ 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, inlineAttachment("profilePicture"), eventContext);
+
+ 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() {
+ when(entity.getQualifiedName()).thenReturn(TEST_FULL_NAME);
+
+ Map values = new HashMap<>();
+ values.put("ID", UUID.randomUUID().toString());
+ 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");
+ existingData.put(ApplicationHandlerHelper.INLINE_PREFIX_MARKER, "profilePicture");
+
+ cut.processEvent(path, content, existingData, eventContext);
+
+ verify(attachmentService).createAttachment(contextArgumentCaptor.capture());
+ var input = contextArgumentCaptor.getValue();
+ assertThat(input.mimeType()).isEqualTo("text/plain");
+ assertThat(input.fileName()).isEqualTo("fallback.txt");
+ }
+
+ @Test
+ void nonInlineEntityDoesNotUsePrefixedFields() {
+ 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);
+
+ assertThat(values).containsEntry(Attachments.CONTENT_ID, "doc-999");
+ assertThat(values).containsEntry(Attachments.STATUS, "ok");
+ }
+
+ @Test
+ void processEventWritesScannedAtWhenNonNull() {
+ when(entity.getQualifiedName()).thenReturn(TEST_FULL_NAME);
+
+ 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 scannedAt = java.time.Instant.now();
+ var content = mock(InputStream.class);
+ when(attachmentService.createAttachment(any()))
+ .thenReturn(new AttachmentModificationResult(false, "doc-scan", "Clean", scannedAt));
+
+ cut.processEvent(path, content, inlineAttachment("profilePicture"), eventContext);
+
+ assertThat(values).containsEntry("profilePicture_contentId", "doc-scan");
+ assertThat(values).containsEntry("profilePicture_status", "Clean");
+ assertThat(values).containsEntry("profilePicture_scannedAt", scannedAt);
+ }
+
+ // --- Inline Header Extraction Tests ---
+
+ private Map prepareInlineValuesWithoutMetadata() {
+ when(entity.getQualifiedName()).thenReturn(TEST_FULL_NAME);
+
+ Map values = new HashMap<>();
+ values.put("ID", UUID.randomUUID().toString());
+ when(target.values()).thenReturn(values);
+ when(target.keys()).thenReturn(Map.of("ID", values.get("ID")));
+ when(attachmentService.createAttachment(any()))
+ .thenReturn(new AttachmentModificationResult(false, "id", "ok", null));
+ return values;
+ }
+
+ @Test
+ void inlineExtractsFileNameFromRfc5987Header() {
+ Map values = prepareInlineValuesWithoutMetadata();
+ when(parameterInfo.getHeader("Content-Disposition"))
+ .thenReturn("attachment; filename*=UTF-8''my%20file.txt");
+
+ cut.processEvent(
+ path, mock(InputStream.class), inlineAttachment("profilePicture"), eventContext);
+
+ assertThat(values).containsEntry("profilePicture_fileName", "my file.txt");
+ assertThat(values).doesNotContainKey(MediaData.FILE_NAME);
+ verify(attachmentService).createAttachment(contextArgumentCaptor.capture());
+ assertThat(contextArgumentCaptor.getValue().fileName()).isEqualTo("my file.txt");
+ }
+
+ @Test
+ void inlineExtractsFileNameFromPlainHeader() {
+ Map values = prepareInlineValuesWithoutMetadata();
+ when(parameterInfo.getHeader("Content-Disposition"))
+ .thenReturn("attachment; filename=\"report.pdf\"");
+
+ cut.processEvent(
+ path, mock(InputStream.class), inlineAttachment("profilePicture"), eventContext);
+
+ assertThat(values).containsEntry("profilePicture_fileName", "report.pdf");
+ verify(attachmentService).createAttachment(contextArgumentCaptor.capture());
+ assertThat(contextArgumentCaptor.getValue().fileName()).isEqualTo("report.pdf");
+ }
+
+ @Test
+ void inlineExtractsFileNameFromSlugHeader() {
+ Map values = prepareInlineValuesWithoutMetadata();
+ when(parameterInfo.getHeader("Content-Disposition")).thenReturn(null);
+ when(parameterInfo.getHeader("slug")).thenReturn("slug-file.png");
+
+ cut.processEvent(
+ path, mock(InputStream.class), inlineAttachment("profilePicture"), eventContext);
+
+ assertThat(values).containsEntry("profilePicture_fileName", "slug-file.png");
+ verify(attachmentService).createAttachment(contextArgumentCaptor.capture());
+ assertThat(contextArgumentCaptor.getValue().fileName()).isEqualTo("slug-file.png");
+ }
+
+ @Test
+ void inlineBothHeadersNullReturnsEmptyFileName() {
+ Map values = prepareInlineValuesWithoutMetadata();
+ when(parameterInfo.getHeader("Content-Disposition")).thenReturn(null);
+ when(parameterInfo.getHeader("slug")).thenReturn(null);
+
+ cut.processEvent(
+ path, mock(InputStream.class), inlineAttachment("profilePicture"), eventContext);
+
+ assertThat(values).doesNotContainKey("profilePicture_fileName");
+ verify(attachmentService).createAttachment(contextArgumentCaptor.capture());
+ assertThat(contextArgumentCaptor.getValue().fileName()).isNull();
+ }
+
+ @Test
+ void inlineExtractsMimeTypeFromContentTypeHeader() {
+ Map values = prepareInlineValuesWithoutMetadata();
+ when(parameterInfo.getHeader("Content-Type")).thenReturn("image/jpeg; charset=utf-8");
+
+ cut.processEvent(
+ path, mock(InputStream.class), inlineAttachment("profilePicture"), eventContext);
+
+ assertThat(values).containsEntry("profilePicture_mimeType", "image/jpeg");
+ assertThat(values).doesNotContainKey(MediaData.MIME_TYPE);
+ verify(attachmentService).createAttachment(contextArgumentCaptor.capture());
+ assertThat(contextArgumentCaptor.getValue().mimeType()).isEqualTo("image/jpeg");
+ }
+
+ @Test
+ void inlineMimeTypeOctetStreamKeptWhenExplicitlySet() {
+ Map values = prepareInlineValuesWithoutMetadata();
+ values.put("profilePicture_mimeType", "application/octet-stream");
+ when(parameterInfo.getHeader("Content-Type")).thenReturn("image/png");
+
+ cut.processEvent(
+ path, mock(InputStream.class), inlineAttachment("profilePicture"), eventContext);
+
+ assertThat(values).containsEntry("profilePicture_mimeType", "application/octet-stream");
+ verify(attachmentService).createAttachment(contextArgumentCaptor.capture());
+ assertThat(contextArgumentCaptor.getValue().mimeType()).isEqualTo("application/octet-stream");
+ }
+
+ @Test
+ void inlineMimeTypeNullContentTypeReturnsEmpty() {
+ Map values = prepareInlineValuesWithoutMetadata();
+ when(parameterInfo.getHeader("Content-Type")).thenReturn(null);
+
+ cut.processEvent(
+ path, mock(InputStream.class), inlineAttachment("profilePicture"), eventContext);
+
+ assertThat(values).doesNotContainKey("profilePicture_mimeType");
+ verify(attachmentService).createAttachment(contextArgumentCaptor.capture());
+ assertThat(contextArgumentCaptor.getValue().mimeType()).isNull();
+ }
+
+ @Test
+ void inlineMimeTypeOctetStreamFromContentTypeHeaderIsUsed() {
+ Map values = prepareInlineValuesWithoutMetadata();
+ when(parameterInfo.getHeader("Content-Type")).thenReturn("application/octet-stream");
+
+ cut.processEvent(
+ path, mock(InputStream.class), inlineAttachment("profilePicture"), eventContext);
+
+ assertThat(values).containsEntry("profilePicture_mimeType", "application/octet-stream");
+ verify(attachmentService).createAttachment(contextArgumentCaptor.capture());
+ assertThat(contextArgumentCaptor.getValue().mimeType()).isEqualTo("application/octet-stream");
+ }
+
+ @Test
+ void inlineFileNameAlreadyPresentSkipsHeaderExtraction() {
+ Map values = prepareInlineValuesWithoutMetadata();
+ values.put("profilePicture_fileName", "already-set.pdf");
+
+ cut.processEvent(
+ path, mock(InputStream.class), inlineAttachment("profilePicture"), eventContext);
+
+ verify(parameterInfo, never()).getHeader("Content-Disposition");
+ assertThat(values).containsEntry("profilePicture_fileName", "already-set.pdf");
+ }
+
+ private static Attachments inlineAttachment(String prefix) {
+ Attachments attachment = Attachments.create();
+ attachment.put(ApplicationHandlerHelper.INLINE_PREFIX_MARKER, prefix);
+ return attachment;
+ }
}
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..e701d967f 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,10 @@
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.common.ApplicationHandlerHelper;
+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;
@@ -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");
@@ -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)
+ .doesNotContainKey(MediaData.MIME_TYPE)
+ .doesNotContainKey(MediaData.FILE_NAME);
}
@Test
@@ -84,7 +95,10 @@ void documentIsNotExternallyDeletedBecauseDoesNotExistBefore() {
assertThat(expectedValue).isEqualTo(value);
assertThat(data.getContentId()).isNull();
verifyNoInteractions(attachmentService);
- assertThat(currentData).containsEntry(Attachments.CONTENT_ID, null);
+ assertThat(currentData)
+ .containsEntry(Attachments.CONTENT_ID, null)
+ .doesNotContainKey(MediaData.MIME_TYPE)
+ .doesNotContainKey(MediaData.FILE_NAME);
}
@Test
@@ -100,7 +114,10 @@ void documentIsNotExternallyDeletedBecauseItIsDraftChangeEvent() {
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)
+ .doesNotContainKey(MediaData.MIME_TYPE)
+ .doesNotContainKey(MediaData.FILE_NAME);
}
@Test
@@ -141,4 +158,63 @@ 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");
+ data.put(ApplicationHandlerHelper.INLINE_PREFIX_MARKER, "profilePicture");
+ when(context.getEvent()).thenReturn(DraftService.EVENT_DRAFT_PATCH);
+
+ cut.processEvent(path, null, data, context);
+
+ // 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");
+ data.put(ApplicationHandlerHelper.INLINE_PREFIX_MARKER, "profilePicture");
+
+ cut.processEvent(path, null, data, context);
+
+ // 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/readhelper/BeforeReadItemsModifierTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/readhelper/BeforeReadItemsModifierTest.java
index 3c2444cc2..136ef5dd2 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,119 @@ 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);
+ }
+
+ @Test
+ void inlineFieldsNotAddedWhenContentIdAlreadySelected() {
+ // Both profilePicture_content AND profilePicture_contentId are explicitly selected.
+ // The modifier should NOT add duplicate contentId/status/scannedAt fields.
+ CqnSelect select =
+ Select.from(RootTable_.class)
+ .columns(
+ RootTable_::ID,
+ b -> b.get("profilePicture_content"),
+ b -> b.get("profilePicture_contentId"));
+
+ cut = new BeforeReadItemsModifier(List.of(), List.of("profilePicture"));
+ List resultItems = cut.items(select.items());
+
+ // contentId already in select, so it should appear exactly once (no duplicate added)
+ var contentIdCount =
+ resultItems.stream()
+ .filter(
+ item ->
+ item.isRef() && item.asRef().displayName().equals("profilePicture_contentId"))
+ .count();
+ assertThat(contentIdCount).isEqualTo(1);
+ // status and scannedAt should NOT be added either since the guard prevents it
+ var statusCount =
+ resultItems.stream()
+ .filter(
+ item -> item.isRef() && item.asRef().displayName().equals("profilePicture_status"))
+ .count();
+ assertThat(statusCount).isEqualTo(0);
+ }
}
diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/readhelper/CountingInputStreamTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/readhelper/CountingInputStreamTest.java
index e1245d79b..d031eaac0 100644
--- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/readhelper/CountingInputStreamTest.java
+++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/readhelper/CountingInputStreamTest.java
@@ -309,4 +309,14 @@ void constructor_fractionalValue_throwsServiceException() {
assertThat(exception.getMessage()).contains("Error parsing max size annotation value");
assertThat(exception.getCause()).isInstanceOf(ArithmeticException.class);
}
+
+ @Test
+ void close_withNullDelegate_doesNotThrow() {
+ // CountingInputStream.close() guards against null delegate
+ assertDoesNotThrow(
+ () -> {
+ var cut = new CountingInputStream(null, "100");
+ cut.close();
+ });
+ }
}
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..a0de90824 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,29 @@
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.EventItems_;
+import com.sap.cds.feature.attachments.generated.test.cds4j.unit.test.testservice.InlineOnly_;
+import com.sap.cds.feature.attachments.generated.test.cds4j.unit.test.testservice.RootTable;
+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.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 +75,83 @@ void removeDraftKey() {
assertFalse(result.containsKey("IsActiveEntity"));
assertTrue(result.containsKey("key1"));
}
+
+ @Test
+ void getInlineAttachmentFieldNamesReturnsPrefix() {
+ CdsEntity entity = runtime.getCdsModel().findEntity(RootTable_.CDS_NAME).orElseThrow();
+ List fieldNames = ApplicationHandlerHelper.getInlineAttachmentFieldNames(entity);
+
+ assertThat(fieldNames).contains("profilePicture");
+ }
+
+ @Test
+ void getInlineAttachmentFieldNamesReturnsEmptyForNonInlineEntity() {
+ CdsEntity entity = runtime.getCdsModel().findEntity(EventItems_.CDS_NAME).orElseThrow();
+ List fieldNames = ApplicationHandlerHelper.getInlineAttachmentFieldNames(entity);
+
+ assertThat(fieldNames).isEmpty();
+ }
+
+ @Test
+ void getInlineAttachmentPrefixReturnsMatchingPrefix() {
+ CdsEntity entity = runtime.getCdsModel().findEntity(RootTable_.CDS_NAME).orElseThrow();
+ Optional prefix =
+ ApplicationHandlerHelper.getInlineAttachmentPrefix(entity, "profilePicture_content");
+
+ assertThat(prefix).isPresent().hasValue("profilePicture");
+ }
+
+ @Test
+ void getInlineAttachmentPrefixReturnsEmptyForNonMatchingElement() {
+ CdsEntity entity = runtime.getCdsModel().findEntity(RootTable_.CDS_NAME).orElseThrow();
+ Optional prefix = ApplicationHandlerHelper.getInlineAttachmentPrefix(entity, "title");
+
+ assertThat(prefix).isEmpty();
+ }
+
+ @Test
+ void condenseAttachmentsExtractsInlineAttachments() {
+ CdsEntity entity = runtime.getCdsModel().findEntity(RootTable_.CDS_NAME).orElseThrow();
+ var data = CdsData.create();
+ data.put(RootTable.PROFILE_PICTURE_CONTENT_ID, "inline-cid-1");
+ data.put(RootTable.PROFILE_PICTURE_STATUS, "Clean");
+ data.put(RootTable.PROFILE_PICTURE_CONTENT, null);
+
+ List result = ApplicationHandlerHelper.condenseAttachments(List.of(data), entity);
+
+ assertThat(result).hasSize(1);
+ assertThat(result.get(0).getContentId()).isEqualTo("inline-cid-1");
+ assertThat(result.get(0).get(ApplicationHandlerHelper.INLINE_PREFIX_MARKER))
+ .isEqualTo("profilePicture");
+ }
+
+ @Test
+ void condenseAttachmentsDedupsByContentId() {
+ CdsEntity entity = runtime.getCdsModel().findEntity(InlineOnly_.CDS_NAME).orElseThrow();
+ var data = CdsData.create();
+ data.put("avatar_contentId", "same-cid");
+ data.put("avatar_content", null);
+
+ List result = ApplicationHandlerHelper.condenseAttachments(List.of(data), entity);
+
+ assertThat(result).hasSize(1);
+ }
+
+ @Test
+ void extractInlineAttachmentExtractsPrefixedFields() {
+ Map parentValues =
+ Map.of(
+ "profilePicture_contentId", "cid-123",
+ "profilePicture_status", "Clean",
+ "title", "test root");
+
+ Attachments attachment =
+ ApplicationHandlerHelper.extractInlineAttachment(parentValues, "profilePicture");
+
+ assertThat(attachment.getContentId()).isEqualTo("cid-123");
+ assertThat(attachment.getStatus()).isEqualTo("Clean");
+ assertThat(attachment.get(ApplicationHandlerHelper.INLINE_PREFIX_MARKER))
+ .isEqualTo("profilePicture");
+ assertThat(attachment.containsKey("title")).isFalse();
+ }
}
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 3a23f8547..22c4bc159 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
@@ -6,13 +6,17 @@
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.*;
+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.Attachment;
import com.sap.cds.feature.attachments.generated.test.cds4j.unit.test.testservice.Attachment_;
+import com.sap.cds.feature.attachments.generated.test.cds4j.unit.test.testservice.RootTable;
import com.sap.cds.feature.attachments.generated.test.cds4j.unit.test.testservice.RootTable_;
import com.sap.cds.feature.attachments.handler.applicationservice.modifyevents.MarkAsDeletedAttachmentEvent;
+import com.sap.cds.feature.attachments.handler.common.ApplicationHandlerHelper;
import com.sap.cds.feature.attachments.handler.common.AttachmentsReader;
import com.sap.cds.feature.attachments.handler.helper.RuntimeHelper;
import com.sap.cds.ql.Delete;
@@ -268,7 +272,96 @@ void noMatchingActiveEntryForDraftAttachment() {
cut.processBeforeDraftCancel(eventContext);
- // Should not call deleteEvent since keys don't match
+ // Orphan prevention: draft has contentId but no matching active entry, so delete it
+ verify(deleteContentAttachmentEvent).processEvent(isNull(), isNull(), any(), eq(eventContext));
+ }
+
+ @Test
+ void inlineAttachmentWithoutActiveEntityDeletesContent() {
+ 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");
+
+ var id = UUID.randomUUID().toString();
+
+ CdsData draftRoot = CdsData.create();
+ draftRoot.put(RootTable.ID, id);
+ draftRoot.put(RootTable.PROFILE_PICTURE_CONTENT_ID, "new-content-id");
+ draftRoot.put(Drafts.HAS_ACTIVE_ENTITY, false);
+
+ when(attachmentsReader.readAttachments(any(), any(), any()))
+ .thenReturn(List.of(Attachments.of(draftRoot)))
+ .thenReturn(List.of());
+
+ cut.processBeforeDraftCancel(eventContext);
+
+ verify(deleteContentAttachmentEvent)
+ .processEvent(any(), eq(null), dataArgumentCaptor.capture(), eq(eventContext));
+ assertThat(dataArgumentCaptor.getValue().getContentId()).isEqualTo("new-content-id");
+ assertThat(dataArgumentCaptor.getValue().get(ApplicationHandlerHelper.INLINE_PREFIX_MARKER))
+ .isEqualTo("profilePicture");
+ }
+
+ @Test
+ void inlineAttachmentWithActiveEntityAndChangedContentIdDeletesContent() {
+ 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");
+
+ var id = UUID.randomUUID().toString();
+
+ CdsData draftRoot = CdsData.create();
+ draftRoot.put(RootTable.ID, id);
+ draftRoot.put(RootTable.PROFILE_PICTURE_CONTENT_ID, "new-content-id");
+ draftRoot.put(Drafts.HAS_ACTIVE_ENTITY, true);
+
+ CdsData activeRoot = CdsData.create();
+ activeRoot.put(RootTable.ID, id);
+ activeRoot.put(RootTable.PROFILE_PICTURE_CONTENT_ID, "old-content-id");
+ activeRoot.put(RootTable.PROFILE_PICTURE_CONTENT, null);
+
+ when(attachmentsReader.readAttachments(any(), any(), any()))
+ .thenReturn(List.of(Attachments.of(draftRoot)))
+ .thenReturn(List.of(Attachments.of(activeRoot)));
+
+ cut.processBeforeDraftCancel(eventContext);
+
+ verify(deleteContentAttachmentEvent)
+ .processEvent(any(), eq(null), dataArgumentCaptor.capture(), eq(eventContext));
+ assertThat(dataArgumentCaptor.getValue().getContentId()).isEqualTo("new-content-id");
+ }
+
+ @Test
+ void inlineAttachmentWithActiveEntityAndSameContentIdDoesNotDelete() {
+ 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");
+
+ var id = UUID.randomUUID().toString();
+ var contentId = UUID.randomUUID().toString();
+
+ CdsData draftRoot = CdsData.create();
+ draftRoot.put(RootTable.ID, id);
+ draftRoot.put(RootTable.PROFILE_PICTURE_CONTENT_ID, contentId);
+ draftRoot.put(Drafts.HAS_ACTIVE_ENTITY, true);
+
+ CdsData activeRoot = CdsData.create();
+ activeRoot.put(RootTable.ID, id);
+ activeRoot.put(RootTable.PROFILE_PICTURE_CONTENT_ID, contentId);
+ activeRoot.put(RootTable.PROFILE_PICTURE_CONTENT, null);
+
+ when(attachmentsReader.readAttachments(any(), any(), any()))
+ .thenReturn(List.of(Attachments.of(draftRoot)))
+ .thenReturn(List.of(Attachments.of(activeRoot)));
+
+ cut.processBeforeDraftCancel(eventContext);
+
verifyNoInteractions(deleteContentAttachmentEvent);
}
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..81f9dd42c 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,11 +10,15 @@
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.Struct;
import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.Attachments;
import com.sap.cds.feature.attachments.generated.test.cds4j.unit.test.Events;
import com.sap.cds.feature.attachments.generated.test.cds4j.unit.test.Events_;
import com.sap.cds.feature.attachments.generated.test.cds4j.unit.test.testservice.Attachment_;
+import com.sap.cds.feature.attachments.generated.test.cds4j.unit.test.testservice.InlineOnly;
+import com.sap.cds.feature.attachments.generated.test.cds4j.unit.test.testservice.InlineOnly_;
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.generated.test.cds4j.unit.test.testservice.RootTable_;
@@ -24,6 +28,7 @@
import com.sap.cds.feature.attachments.handler.applicationservice.readhelper.CountingInputStream;
import com.sap.cds.feature.attachments.handler.helper.RuntimeHelper;
import com.sap.cds.ql.cqn.CqnSelect;
+import com.sap.cds.ql.cqn.CqnUpdate;
import com.sap.cds.reflect.CdsEntity;
import com.sap.cds.services.draft.DraftPatchEventContext;
import com.sap.cds.services.draft.DraftService;
@@ -34,7 +39,9 @@
import com.sap.cds.services.request.ParameterInfo;
import com.sap.cds.services.runtime.CdsRuntime;
import java.io.InputStream;
+import java.util.HashMap;
import java.util.List;
+import java.util.Map;
import java.util.UUID;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
@@ -155,6 +162,34 @@ void contentIdIsNotSetForNonMediaEntity() {
assertThat(events).doesNotContainKey(Attachments.CONTENT_ID);
}
+ @Test
+ void inlineMetadataUpdateIncludesEntityKeysInWhereClause() {
+ getEntityAndMockContext(InlineOnly_.CDS_NAME);
+ var entityId = UUID.randomUUID().toString();
+ var contentId = UUID.randomUUID().toString();
+
+ // Build data simulating post-converter state where contentId and mimeType have been set
+ Map data = new HashMap<>();
+ data.put(InlineOnly.ID, entityId);
+ data.put(InlineOnly.AVATAR_CONTENT_ID, contentId);
+ data.put(InlineOnly.AVATAR_MIME_TYPE, "image/png");
+
+ when(persistence.run(any(CqnSelect.class))).thenReturn(mock(Result.class));
+ when(persistence.run(any(CqnUpdate.class))).thenReturn(mock(Result.class));
+
+ cut.processBeforeDraftPatch(eventContext, List.of(Struct.access(data).as(CdsData.class)));
+
+ ArgumentCaptor updateCaptor = ArgumentCaptor.forClass(CqnUpdate.class);
+ verify(persistence).run(updateCaptor.capture());
+ var update = updateCaptor.getValue();
+ assertThat(update.where()).isPresent();
+ var where = update.where().get().toString();
+ assertThat(where).contains("avatar_contentId");
+ assertThat(where).contains(contentId);
+ assertThat(where).contains("ID");
+ assertThat(where).contains(entityId);
+ }
+
@Test
void classHasCorrectAnnotations() {
var serviceAnnotation = cut.getClass().getAnnotation(ServiceName.class);
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 c1f569f35..4e852cf8b 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,7 +127,12 @@ 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);
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 6c9f120a6..ce8194c92 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
@@ -24,6 +24,7 @@
import java.util.Collections;
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;
@@ -93,7 +94,8 @@ void readAttachmentSetData() {
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());
@@ -103,7 +105,7 @@ void malwareScannerRegisteredForEndOfTransaction() {
cut.createAttachment(createContext);
cut.afterCreateAttachment(createContext);
- verify(malwareScanProvider).getChangeSetListener(entity, "contentId");
+ verify(malwareScanProvider).getChangeSetListener(entity, "contentId", Optional.empty());
}
@Test
@@ -125,7 +127,7 @@ void createAttachment_emptyAttachmentIds_handlesGracefully() {
@Test
void afterCreateAttachment_noChangeSetContext_throws() {
var entity = mock(CdsEntity.class);
- when(malwareScanProvider.getChangeSetListener(any(), any()))
+ when(malwareScanProvider.getChangeSetListener(any(), any(), any()))
.thenReturn(mock(ChangeSetListener.class));
var createContext = AttachmentCreateEventContext.create();
createContext.setAttachmentIds(Map.of(Attachments.ID, "some-id"));
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 343cd6b15..43ed758e9 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
@@ -14,6 +14,7 @@
import static org.mockito.Mockito.when;
import com.sap.cds.Result;
+import com.sap.cds.Row;
import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.Attachments;
import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.StatusCode;
import com.sap.cds.feature.attachments.generated.test.cds4j.unit.test.Attachment_;
@@ -27,6 +28,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 +73,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 +86,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();
@@ -107,10 +109,14 @@ void fallbackToActiveEntityIfDraftHasNoData() {
var content = mock(InputStream.class);
var cdsData = Attachments.create();
cdsData.setContent(content);
+ cdsData.setId("test-key-id");
when(result.single(Attachments.class)).thenReturn(cdsData);
+ var row = mock(Row.class);
+ when(row.get("ID")).thenReturn("test-key-id");
+ when(result.single()).thenReturn(row);
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 +135,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 +145,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);
}
@@ -148,11 +155,16 @@ void dataAreUpdatedWithStatusFromFailingScanClient() {
var entity = runtime.getCdsModel().findEntity(getTestServiceAttachmentName());
when(persistenceService.run(any(CqnSelect.class))).thenReturn(result);
when(result.rowCount()).thenReturn(1L);
- when(result.single(Attachments.class)).thenReturn(Attachments.create());
+ var data = Attachments.create();
+ data.setId("test-key-id");
+ when(result.single(Attachments.class)).thenReturn(data);
+ var row = mock(Row.class);
+ when(row.get("ID")).thenReturn("test-key-id");
+ when(result.single()).thenReturn(row);
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);
}
@@ -162,11 +174,16 @@ void dataAreUpdatedWithStatusFromFailingAttachmentService() {
var entity = runtime.getCdsModel().findEntity(getTestServiceAttachmentName());
when(persistenceService.run(any(CqnSelect.class))).thenReturn(result);
when(result.rowCount()).thenReturn(1L);
- when(result.single(Attachments.class)).thenReturn(Attachments.create());
+ var data = Attachments.create();
+ data.setId("test-key-id");
+ when(result.single(Attachments.class)).thenReturn(data);
+ var row = mock(Row.class);
+ when(row.get("ID")).thenReturn("test-key-id");
+ when(result.single()).thenReturn(row);
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 +196,7 @@ void contentTakenFromTheDatabaseSelect() {
data.put("content", content);
mockSelectResult(data, MalwareScanResultStatus.CLEAN);
- cut.scanAttachment(entity.orElseThrow(), "");
+ cut.scanAttachment(entity.orElseThrow(), "", Optional.empty());
verify(malwareScanClient, times(1)).scanContent(content);
verifyNoInteractions(attachmentService);
@@ -195,7 +212,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(1)).readAttachment(contentId);
verify(malwareScanClient, times(1)).scanContent(content);
@@ -211,7 +228,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);
@@ -225,12 +242,16 @@ void updateAttemptedForAllEntitiesEvenWhenActiveHasNoData() {
var originSelectionData = Attachments.create();
originSelectionData.setContentId("first contentId");
originSelectionData.setContent(mock(InputStream.class));
+ originSelectionData.setId("test-key-id");
when(result.single(Attachments.class))
.thenReturn(originSelectionData)
.thenReturn(Attachments.create());
+ var row = mock(Row.class);
+ when(row.get("ID")).thenReturn("test-key-id");
+ when(result.single()).thenReturn(row);
when(malwareScanClient.scanContent(any())).thenReturn(MalwareScanResultStatus.CLEAN);
- cut.scanAttachment(entity.orElseThrow(), "ID");
+ cut.scanAttachment(entity.orElseThrow(), "ID", Optional.empty());
verify(persistenceService, times(2)).run(updateCaptor.capture());
var updateList = updateCaptor.getAllValues();
@@ -244,12 +265,16 @@ void clientNotCalledIfNoInstanceBound() {
var entity = runtime.getCdsModel().findEntity(getTestServiceAttachmentName());
var secondResult = mock(Result.class);
when(secondResult.rowCount()).thenReturn(0L);
- when(secondResult.single(Attachments.class)).thenReturn(Attachments.create());
when(persistenceService.run(any(CqnSelect.class))).thenReturn(result).thenReturn(secondResult);
when(result.rowCount()).thenReturn(1L);
- when(result.single(Attachments.class)).thenReturn(Attachments.create());
+ var data = Attachments.create();
+ data.setId("test-key-id");
+ when(result.single(Attachments.class)).thenReturn(data);
+ var row = mock(Row.class);
+ when(row.get("ID")).thenReturn("test-key-id");
+ when(result.single()).thenReturn(row);
- cut.scanAttachment(entity.orElseThrow(), "ID");
+ cut.scanAttachment(entity.orElseThrow(), "ID", Optional.empty());
verifyNoInteractions(malwareScanClient);
verify(persistenceService, times(2)).run(updateCaptor.capture());
@@ -270,10 +295,14 @@ void scanResultWrittenToAllEntitiesEvenIfDraftDeletedDuringScanning() {
var content = mock(InputStream.class);
var draftData = Attachments.create();
draftData.setContent(content);
+ draftData.setId("test-key-id");
// Phase 1: draft has the row, active does not
when(persistenceService.run(any(CqnSelect.class))).thenReturn(result);
when(result.rowCount()).thenReturn(1L).thenReturn(0L);
when(result.single(Attachments.class)).thenReturn(draftData);
+ var row = mock(Row.class);
+ when(row.get("ID")).thenReturn("test-key-id");
+ when(result.single()).thenReturn(row);
when(malwareScanClient.scanContent(any())).thenReturn(MalwareScanResultStatus.CLEAN);
// Phase 2: simulate draft deleted (0 rows updated), active now has the row (1 row updated)
var draftUpdateResult = mock(Result.class);
@@ -284,7 +313,7 @@ void scanResultWrittenToAllEntitiesEvenIfDraftDeletedDuringScanning() {
.thenReturn(draftUpdateResult)
.thenReturn(activeUpdateResult);
- cut.scanAttachment(entity.orElseThrow(), "ID");
+ cut.scanAttachment(entity.orElseThrow(), "ID", Optional.empty());
// Scan should happen once from draft content
verify(malwareScanClient).scanContent(content);
@@ -306,12 +335,45 @@ void noScanOrUpdateWhenAttachmentNotFoundInAnyEntity() {
when(emptyResult.rowCount()).thenReturn(0L);
when(persistenceService.run(any(CqnSelect.class))).thenReturn(emptyResult);
- cut.scanAttachment(entity.orElseThrow(), "ID");
+ cut.scanAttachment(entity.orElseThrow(), "ID", Optional.empty());
verifyNoInteractions(malwareScanClient);
verify(persistenceService, times(0)).run(any(CqnUpdate.class));
}
+ @Test
+ void scanAttachmentWithInlinePrefixExtractsFromPrefixedColumns() {
+ var entity =
+ runtime
+ .getCdsModel()
+ .findEntity(
+ com.sap.cds.feature.attachments.generated.test.cds4j.unit.test.testservice
+ .InlineOnly_.CDS_NAME)
+ .orElseThrow();
+ var content = mock(InputStream.class);
+ var row = mock(Row.class);
+ when(row.get("avatar_contentId")).thenReturn("inline-cid");
+ when(row.get("avatar_content")).thenReturn(content);
+ when(row.get("avatar_status")).thenReturn(StatusCode.UNSCANNED);
+ when(persistenceService.run(any(CqnSelect.class))).thenReturn(result);
+ when(result.rowCount()).thenReturn(1L);
+ when(result.single()).thenReturn(row);
+ when(malwareScanClient.scanContent(any())).thenReturn(MalwareScanResultStatus.CLEAN);
+
+ cut.scanAttachment(entity, "inline-cid", Optional.of("avatar"));
+
+ verify(malwareScanClient).scanContent(content);
+ verify(persistenceService, times(2)).run(updateCaptor.capture());
+ var updates = updateCaptor.getAllValues();
+ assertThat(updates).hasSize(2);
+ updates.forEach(
+ update -> {
+ assertThat(update.entries()).hasSize(1);
+ assertThat(update.entries().get(0)).containsEntry("avatar_status", StatusCode.CLEAN);
+ assertThat(update.entries().get(0)).containsKey("avatar_scannedAt");
+ });
+ }
+
@Test
void mapStatus() {
assertEquals(
@@ -352,9 +414,13 @@ private String getTestServiceAttachmentName() {
}
private void mockSelectResult(Attachments cdsData, MalwareScanResultStatus status) {
+ cdsData.setId("test-key-id");
when(persistenceService.run(any(CqnSelect.class))).thenReturn(result);
when(result.rowCount()).thenReturn(1L);
when(result.single(Attachments.class)).thenReturn(cdsData);
+ var row = mock(Row.class);
+ when(row.get("ID")).thenReturn("test-key-id");
+ when(result.single()).thenReturn(row);
when(malwareScanClient.scanContent(any())).thenReturn(status);
}
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..0c7e5934b 100644
--- a/cds-feature-attachments/src/test/resources/cds/db-model.cds
+++ b/cds-feature-attachments/src/test/resources/cds/db-model.cds
@@ -1,17 +1,20 @@
namespace unit.test;
using {cuid} from '@sap/cds/common';
-using {sap.attachments.Attachments} from '../../../main/resources/cds/com.sap.cds/cds-feature-attachments';
+using {
+ Attachments,
+ Attachment as AttachmentType
+} from '../../../main/resources/cds/com.sap.cds/cds-feature-attachments';
using from '@sap/cds/srv/outbox';
-entity Attachment : Attachments {
-}
+entity Attachment : Attachments {}
entity Roots : cuid {
- title : String;
- itemTable : Composition of many Items
- on itemTable.rootId = $self.ID;
- attachments : Composition of many Attachments;
+ title : String;
+ itemTable : Composition of many Items
+ on itemTable.rootId = $self.ID;
+ attachments : Composition of many Attachments;
+ profilePicture : AttachmentType;
}
entity Items : cuid {
@@ -19,7 +22,8 @@ entity Items : cuid {
note : String;
events : Composition of many Events
on events.id1 = $self.ID;
- attachments : Composition of many Attachment on attachments.ID = $self.ID;
+ attachments : Composition of many Attachment
+ on attachments.ID = $self.ID;
itemAttachments : Composition of many Attachments;
}
@@ -36,12 +40,18 @@ entity Events {
}
entity EventItems {
- key id1 : UUID;
- note : String;
- sizeLimitedAttachments : Composition of many Attachments;
+ key id1 : UUID;
+ note : String;
+ sizeLimitedAttachments : Composition of many Attachments;
defaultSizeLimitedAttachments : Composition of many Attachments;
}
+// Entity with only inline attachment (no composition)
+entity InlineOnly : cuid {
+ title : String;
+ avatar : AttachmentType;
+}
+
annotate EventItems.sizeLimitedAttachments with {
content @Validation.Maximum: '10KB';
};
@@ -50,3 +60,6 @@ annotate EventItems.defaultSizeLimitedAttachments with {
content @Validation.Maximum;
};
+annotate InlineOnly : avatar with {
+ content @Validation.Maximum: '10KB';
+};
diff --git a/cds-feature-attachments/src/test/resources/cds/service.cds b/cds-feature-attachments/src/test/resources/cds/service.cds
index 5541ac775..331c6173f 100644
--- a/cds-feature-attachments/src/test/resources/cds/service.cds
+++ b/cds-feature-attachments/src/test/resources/cds/service.cds
@@ -5,4 +5,6 @@ using unit.test as db from './db-model';
service TestService {
@odata.draft.enabled
entity RootTable as projection on db.Roots;
+ @odata.draft.enabled
+ entity InlineOnly as projection on db.InlineOnly;
}
diff --git a/doc/CHANGELOG.md b/doc/CHANGELOG.md
index 48e2afcf9..5e69d1202 100644
--- a/doc/CHANGELOG.md
+++ b/doc/CHANGELOG.md
@@ -8,9 +8,13 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/).
## Version 1.6.0 - not yet released
-### Changed
+### Added
+- Added support for single (inline) attachments via the `Attachment` type. Requires cds-services 4.9.0 or higher. (#768)
- Added top-level `Attachments` aspect to allow usage without `sap.attachments` namespace (#806), i.e., `using {Attachments} from 'com.sap.cds/cds-feature-attachments'`.
+
+### Changed
+
- Extract `fileName` and `mimeType` from HTTP headers (`Content-Disposition`, `Content-Type`, `slug`) when not provided in the request payload (#804)
## Version 1.5.0 - 2026-04-10
diff --git a/doc/Design.md b/doc/Design.md
index 1945024c5..afc970ae0 100644
--- a/doc/Design.md
+++ b/doc/Design.md
@@ -51,7 +51,6 @@
- [Texts](#texts)
- [Tests](#tests)
- [Unit Tests](#unit-tests)
- - [Mutation Tests](#mutation-tests)
- [Integration Tests](#integration-tests)
- [Quality Tools](#quality-tools)
@@ -93,21 +92,19 @@ In folder `.github/workflows` are the GitHub Actions defined. The following tabl
| File Name | Description |
| -------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `pr.yml` | Builds and tests pull requests for Java 17 and 21. Requires approval for external forks. Each pull request needs green runs from this workflow to be merged. |
-| `main.yml` | Builds, tests, and deploys snapshots when commits are merged to main. Runs unit tests, integration tests, and mutation tests for Java 17 and 21. |
+| `main.yml` | Builds, tests, and deploys snapshots when commits are merged to main. Runs unit tests and integration tests for Java 17 and 21. |
| `release.yml` | Triggered on GitHub releases. Updates version, runs BlackDuck scan, builds, tests, and deploys to Maven Central. See also [Build and Deploy](#build-and-deploy). |
| `pipeline.yml` | Reusable workflow containing shared build, test, integration test, SonarQube scan, CodeQL analysis, and snapshot deployment logic. Called by `pr.yml` and `main.yml`. |
### Build Action
The build step is implemented in action `.github/actions/build/action.yml` which is used in the workflows via `pipeline.yml`.
-As the build action does not only run a build of the project, but also the mutation tests, this action is used in all
-the mentioned workflows.
Additional reusable actions are defined in `.github/actions/`:
| Action | Description |
| --------------------- | ----------------------------------------------------------- |
-| `build` | Builds the project and runs unit/mutation tests |
+| `build` | Builds the project and runs unit tests |
| `integration-tests` | Runs integration tests (build-version, latest-version, oss) |
| `deploy-release` | Deploys release artifacts to Maven Central |
| `newrelease` | Updates version in pom.xml for new releases |
@@ -142,7 +139,7 @@ The following steps are executed in the workflow:
1. Update the version in the `pom.xml` files. The tag used in the release is read and git commands are used to update
the property `revision` in the parent `pom.xml` file.
-2. Build the project and run all unit, integration and mutation tests. Here a reuse action is used which is also
+2. Build the project and run all unit and integration tests. Here a reuse action is used which is also
executed in the main and pull request build.
3. Deploy the project to maven or artifactory. The deployment is done with the maven command `mvn deploy`. The
deployment is done to the repository defined in the `pom.xml` file. So only project parts which have defined the
@@ -660,18 +657,6 @@ The following settings are used for this plugin:
| Complexity Coverage | 95% |
| Class Missed Count | 0 |
-#### Mutation Tests
-
-In addition to this plugin, also mutation tests are executed during the build of the project in the GitHub Actions.
-To run the mutation tests the plugin `pitest-maven` is included in the same pom.
-
-Several mutators are maintained in the plugin and the following settings are used:
-
-| Setting | Value |
-| ----------------------------- | ----- |
-| Coverage Threshold | 95% |
-| Aggregated Mutation Threshold | 90% |
-
### Integration Tests
Spring Boot tests are implemented in the `integration-tests` folder.
diff --git a/integration-tests/db/data-model.cds b/integration-tests/db/data-model.cds
index b0cc7aa76..900618b97 100644
--- a/integration-tests/db/data-model.cds
+++ b/integration-tests/db/data-model.cds
@@ -1,7 +1,10 @@
namespace test.data.model;
using {cuid} from '@sap/cds/common';
-using {sap.attachments.Attachments} from 'com.sap.cds/cds-feature-attachments';
+using {
+ sap.attachments.Attachments,
+ sap.attachments.Attachment
+} from 'com.sap.cds/cds-feature-attachments';
entity AttachmentEntity : Attachments {
parentKey : UUID;
@@ -9,6 +12,8 @@ entity AttachmentEntity : Attachments {
entity Roots : cuid {
title : String;
+ avatar : Attachment;
+ coverImage : Attachment;
attachments : Composition of many AttachmentEntity
on attachments.parentKey = $self.ID;
items : Composition of many Items
@@ -28,6 +33,7 @@ entity Contributors : cuid {
entity Items : cuid {
parentID : UUID;
title : String;
+ icon : Attachment;
events : Association to many Events
on events.itemId = $self.ID;
attachments : Composition of many Attachments;
diff --git a/integration-tests/generic/src/test/java/com/sap/cds/feature/attachments/integrationtests/draftservice/SingleAttachmentDraftTest.java b/integration-tests/generic/src/test/java/com/sap/cds/feature/attachments/integrationtests/draftservice/SingleAttachmentDraftTest.java
new file mode 100644
index 000000000..e1f24c618
--- /dev/null
+++ b/integration-tests/generic/src/test/java/com/sap/cds/feature/attachments/integrationtests/draftservice/SingleAttachmentDraftTest.java
@@ -0,0 +1,497 @@
+/*
+ * © 2024-2026 SAP SE or an SAP affiliate company and cds-feature-attachments contributors.
+ */
+package com.sap.cds.feature.attachments.integrationtests.draftservice;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+import com.sap.cds.Struct;
+import com.sap.cds.feature.attachments.generated.integration.test.cds4j.testdraftservice.DraftRoots;
+import com.sap.cds.feature.attachments.generated.integration.test.cds4j.testdraftservice.DraftRoots_;
+import com.sap.cds.feature.attachments.integrationtests.common.MockHttpRequestHelper;
+import com.sap.cds.feature.attachments.integrationtests.common.TableDataDeleter;
+import com.sap.cds.feature.attachments.integrationtests.constants.Profiles;
+import com.sap.cds.feature.attachments.integrationtests.testhandler.TestPersistenceHandler;
+import com.sap.cds.feature.attachments.integrationtests.testhandler.TestPluginAttachmentsServiceHandler;
+import com.sap.cds.feature.attachments.service.AttachmentService;
+import com.sap.cds.feature.attachments.service.model.servicehandler.AttachmentCreateEventContext;
+import com.sap.cds.feature.attachments.service.model.servicehandler.AttachmentMarkAsDeletedEventContext;
+import com.sap.cds.feature.attachments.service.model.servicehandler.AttachmentReadEventContext;
+import com.sap.cds.ql.Select;
+import com.sap.cds.ql.StructuredType;
+import com.sap.cds.services.persistence.PersistenceService;
+import java.nio.charset.StandardCharsets;
+import java.util.Arrays;
+import java.util.concurrent.TimeUnit;
+import org.awaitility.Awaitility;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.http.MediaType;
+import org.springframework.test.context.ActiveProfiles;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
+
+@SpringBootTest
+@AutoConfigureMockMvc
+@ActiveProfiles(Profiles.TEST_HANDLER_ENABLED)
+class SingleAttachmentDraftTest {
+
+ private static final Logger logger = LoggerFactory.getLogger(SingleAttachmentDraftTest.class);
+ private static final String BASE_URL = MockHttpRequestHelper.ODATA_BASE_URL + "TestDraftService/";
+
+ @Autowired private TestPluginAttachmentsServiceHandler serviceHandler;
+ @Autowired private MockHttpRequestHelper requestHelper;
+ @Autowired private PersistenceService persistenceService;
+ @Autowired private TableDataDeleter dataDeleter;
+ @Autowired private TestPersistenceHandler testPersistenceHandler;
+ @Autowired private MockMvc mvc;
+
+ @AfterEach
+ void teardown() {
+ dataDeleter.deleteData(
+ DraftRoots_.CDS_NAME, DraftRoots_.CDS_NAME + "_drafts", "cds.outbox.Messages");
+ serviceHandler.clearEventContext();
+ serviceHandler.clearDocuments();
+ requestHelper.resetHelper();
+ testPersistenceHandler.reset();
+ }
+
+ @Test
+ void createInlineAttachmentInDraftAndActivate() throws Exception {
+ var draft = createNewDraft();
+ var draftRootUrl = getDraftRootUrl(draft.getId());
+
+ requestHelper.executePatchWithODataResponseAndAssertStatusOk(
+ draftRootUrl, "{\"title\":\"some title\"}");
+
+ var content = putInlineAttachmentContent(draftRootUrl, "avatarContent");
+ prepareAndActivateDraft(draftRootUrl);
+
+ var activeRoot = selectActiveRoot(draft.getId());
+ assertThat(activeRoot.getAvatarContentId()).isNotEmpty();
+ assertThat(activeRoot.getAvatarStatus()).isNotEmpty();
+ verifySingleCreateEvent(activeRoot.getAvatarContentId(), content);
+ }
+
+ @Test
+ void createInlineAttachmentInDraftAndCancel() throws Exception {
+ var draft = createNewDraft();
+ var draftRootUrl = getDraftRootUrl(draft.getId());
+
+ var content = putInlineAttachmentContent(draftRootUrl, "avatarContent");
+ cancelDraft(draftRootUrl);
+
+ waitTillExpectedHandlerMessageSize(2);
+ var createEvents =
+ serviceHandler.getEventContextForEvent(AttachmentService.EVENT_CREATE_ATTACHMENT);
+ assertThat(createEvents).hasSize(1);
+ var createContext = (AttachmentCreateEventContext) createEvents.get(0).context();
+ assertThat(createContext.getData().getContent().readAllBytes())
+ .isEqualTo(content.getBytes(StandardCharsets.UTF_8));
+
+ var deleteEvents =
+ serviceHandler.getEventContextForEvent(AttachmentService.EVENT_MARK_ATTACHMENT_AS_DELETED);
+ assertThat(deleteEvents).hasSize(1);
+ var deleteContext = (AttachmentMarkAsDeletedEventContext) deleteEvents.get(0).context();
+ assertThat(deleteContext.getContentId()).isEqualTo(createContext.getContentId());
+ }
+
+ @Test
+ void updateInlineAttachmentInDraftAndActivate() throws Exception {
+ var draft = createNewDraft();
+ var draftRootUrl = getDraftRootUrl(draft.getId());
+ putInlineAttachmentContent(draftRootUrl, "originalContent");
+ prepareAndActivateDraft(draftRootUrl);
+ var activeRootAfterFirstActivation = selectActiveRoot(draft.getId());
+ var originalContentId = activeRootAfterFirstActivation.getAvatarContentId();
+ serviceHandler.clearEventContext();
+
+ editExistingRoot(draft.getId());
+ var newDraftRootUrl = getDraftRootUrl(draft.getId());
+ var newContent = putInlineAttachmentContent(newDraftRootUrl, "updatedContent");
+ prepareAndActivateDraft(newDraftRootUrl);
+
+ var activeRootAfterUpdate = selectActiveRoot(draft.getId());
+ assertThat(activeRootAfterUpdate.getAvatarContentId()).isNotEmpty();
+ assertThat(activeRootAfterUpdate.getAvatarContentId()).isNotEqualTo(originalContentId);
+
+ waitTillExpectedHandlerMessageSize(2);
+ var createEvents =
+ serviceHandler.getEventContextForEvent(AttachmentService.EVENT_CREATE_ATTACHMENT);
+ assertThat(createEvents).hasSize(1);
+ var createContext = (AttachmentCreateEventContext) createEvents.get(0).context();
+ assertThat(createContext.getContentId()).isEqualTo(activeRootAfterUpdate.getAvatarContentId());
+ assertThat(createContext.getData().getContent().readAllBytes())
+ .isEqualTo(newContent.getBytes(StandardCharsets.UTF_8));
+
+ var deleteEvents =
+ serviceHandler.getEventContextForEvent(AttachmentService.EVENT_MARK_ATTACHMENT_AS_DELETED);
+ assertThat(deleteEvents).hasSize(1);
+ var deleteContext = (AttachmentMarkAsDeletedEventContext) deleteEvents.get(0).context();
+ assertThat(deleteContext.getContentId()).isEqualTo(originalContentId);
+ }
+
+ @Test
+ void deleteInlineAttachmentInDraftAndActivate() throws Exception {
+ var draft = createNewDraft();
+ var draftRootUrl = getDraftRootUrl(draft.getId());
+ putInlineAttachmentContent(draftRootUrl, "contentToDelete");
+ prepareAndActivateDraft(draftRootUrl);
+ var activeRootAfterFirstActivation = selectActiveRoot(draft.getId());
+ var originalContentId = activeRootAfterFirstActivation.getAvatarContentId();
+ serviceHandler.clearEventContext();
+
+ editExistingRoot(draft.getId());
+ var newDraftRootUrl = getDraftRootUrl(draft.getId());
+ requestHelper.executeDeleteWithMatcher(
+ newDraftRootUrl + "/avatar_content", status().isNoContent());
+ prepareAndActivateDraft(newDraftRootUrl);
+
+ var activeRootAfterDelete = selectActiveRoot(draft.getId());
+ assertThat(activeRootAfterDelete.getAvatarContentId()).isNull();
+ verifySingleDeletionEvent(originalContentId);
+ }
+
+ @Test
+ void deleteInlineAttachmentInDraftAndCancel() throws Exception {
+ var draft = createNewDraft();
+ var draftRootUrl = getDraftRootUrl(draft.getId());
+ putInlineAttachmentContent(draftRootUrl, "contentToKeep");
+ prepareAndActivateDraft(draftRootUrl);
+ var activeRootAfterFirstActivation = selectActiveRoot(draft.getId());
+ assertThat(activeRootAfterFirstActivation.getAvatarContentId()).isNotEmpty();
+ serviceHandler.clearEventContext();
+
+ editExistingRoot(draft.getId());
+ var newDraftRootUrl = getDraftRootUrl(draft.getId());
+ requestHelper.executeDeleteWithMatcher(
+ newDraftRootUrl + "/avatar_content", status().isNoContent());
+ cancelDraft(newDraftRootUrl);
+
+ verifyNoAttachmentEventsCalled();
+ var activeRootAfterCancel = selectActiveRoot(draft.getId());
+ assertThat(activeRootAfterCancel.getAvatarContentId()).isNotEmpty();
+ }
+
+ @Test
+ void contentReadableFromDraftBeforeActivation() throws Exception {
+ var draft = createNewDraft();
+ var draftRootUrl = getDraftRootUrl(draft.getId());
+ var content = putInlineAttachmentContent(draftRootUrl, "readableContent");
+ serviceHandler.clearEventContext();
+
+ var contentUrl = draftRootUrl + "/avatar_content";
+ Awaitility.await()
+ .atMost(60, TimeUnit.SECONDS)
+ .pollDelay(1, TimeUnit.SECONDS)
+ .pollInterval(2, TimeUnit.SECONDS)
+ .until(
+ () -> {
+ var response = requestHelper.executeGet(contentUrl);
+ var responseContent = response.getResponse().getContentAsString();
+ var matches = responseContent.equals(content);
+ if (!matches) {
+ logger.info(
+ "Waiting for draft content to be readable. Response: '{}', Expected: '{}'",
+ responseContent,
+ content);
+ }
+ return matches;
+ });
+ serviceHandler.clearEventContext();
+
+ var response = requestHelper.executeGet(contentUrl);
+ assertThat(response.getResponse().getContentAsString()).isEqualTo(content);
+ verifySingleReadEvent(null);
+ }
+
+ @Test
+ void noChangesOnInlineAttachmentStillAvailableAfterActivate() throws Exception {
+ var draft = createNewDraft();
+ var draftRootUrl = getDraftRootUrl(draft.getId());
+ var content = putInlineAttachmentContent(draftRootUrl, "stableContent");
+ prepareAndActivateDraft(draftRootUrl);
+ serviceHandler.clearEventContext();
+
+ editExistingRoot(draft.getId());
+ var newDraftRootUrl = getDraftRootUrl(draft.getId());
+ requestHelper.executePatchWithODataResponseAndAssertStatusOk(
+ newDraftRootUrl, "{\"title\":\"changed title\"}");
+ prepareAndActivateDraft(newDraftRootUrl);
+ verifyNoAttachmentEventsCalled();
+
+ var activeContentUrl = getActiveRootUrl(draft.getId()) + "/avatar_content";
+ Awaitility.await()
+ .atMost(60, TimeUnit.SECONDS)
+ .pollDelay(1, TimeUnit.SECONDS)
+ .pollInterval(2, TimeUnit.SECONDS)
+ .until(
+ () -> {
+ var response = requestHelper.executeGet(activeContentUrl);
+ return response.getResponse().getContentAsString().equals(content);
+ });
+ serviceHandler.clearEventContext();
+
+ var response = requestHelper.executeGet(activeContentUrl);
+ assertThat(response.getResponse().getContentAsString()).isEqualTo(content);
+ }
+
+ @Test
+ void errorInTransactionAfterCreateCallsDelete() throws Exception {
+ var draft = createNewDraft();
+ var draftRootUrl = getDraftRootUrl(draft.getId());
+
+ testPersistenceHandler.setThrowExceptionOnUpdate(true);
+ var contentUrl = draftRootUrl + "/avatar_content";
+ requestHelper.setContentType(MediaType.APPLICATION_OCTET_STREAM);
+ requestHelper.executePutWithMatcher(
+ contentUrl, "errorContent".getBytes(StandardCharsets.UTF_8), status().is5xxServerError());
+ requestHelper.resetHelper();
+
+ waitTillExpectedHandlerMessageSize(2);
+ var createEvents =
+ serviceHandler.getEventContextForEvent(AttachmentService.EVENT_CREATE_ATTACHMENT);
+ assertThat(createEvents).hasSize(1);
+ var deleteEvents =
+ serviceHandler.getEventContextForEvent(AttachmentService.EVENT_MARK_ATTACHMENT_AS_DELETED);
+ assertThat(deleteEvents).hasSize(1);
+ var createContext = (AttachmentCreateEventContext) createEvents.get(0).context();
+ var deleteContext = (AttachmentMarkAsDeletedEventContext) deleteEvents.get(0).context();
+ assertThat(deleteContext.getContentId()).isEqualTo(createContext.getContentId());
+ }
+
+ @Test
+ void uploadWithContentDispositionHeaderInDraftPersistsFileName() throws Exception {
+ var draft = createNewDraft();
+ var draftRootUrl = getDraftRootUrl(draft.getId());
+
+ var contentUrl = draftRootUrl + "/avatar_content";
+ mvc.perform(
+ MockMvcRequestBuilders.put(contentUrl)
+ .contentType(MediaType.APPLICATION_OCTET_STREAM)
+ .header("Content-Disposition", "attachment; filename=\"draft-file.png\"")
+ .content("draft-content".getBytes(StandardCharsets.UTF_8)))
+ .andExpect(status().isNoContent());
+
+ prepareAndActivateDraft(draftRootUrl);
+
+ var activeRoot = selectActiveRoot(draft.getId());
+ assertThat(activeRoot.getAvatarContentId()).isNotEmpty();
+ assertThat(activeRoot.getAvatarFileName()).isEqualTo("draft-file.png");
+ }
+
+ @Test
+ void multiEntityIsolation_activatingOneEntityDoesNotAffectOther() throws Exception {
+ var draftA = createNewDraft();
+ var draftAUrl = getDraftRootUrl(draftA.getId());
+ putInlineAttachmentContent(draftAUrl, "contentA");
+
+ var draftB = createNewDraft();
+ var draftBUrl = getDraftRootUrl(draftB.getId());
+ putInlineAttachmentContent(draftBUrl, "contentB");
+
+ prepareAndActivateDraft(draftAUrl);
+
+ var activeRootA = selectActiveRoot(draftA.getId());
+ assertThat(activeRootA.getAvatarContentId()).isNotEmpty();
+
+ prepareAndActivateDraft(draftBUrl);
+
+ var activeRootB = selectActiveRoot(draftB.getId());
+ assertThat(activeRootB.getAvatarContentId()).isNotEmpty();
+ assertThat(activeRootB.getAvatarContentId()).isNotEqualTo(activeRootA.getAvatarContentId());
+
+ var contentBUrl = getActiveRootUrl(draftB.getId()) + "/avatar_content";
+ Awaitility.await()
+ .atMost(60, TimeUnit.SECONDS)
+ .pollDelay(1, TimeUnit.SECONDS)
+ .pollInterval(2, TimeUnit.SECONDS)
+ .until(
+ () -> {
+ var response = requestHelper.executeGet(contentBUrl);
+ return response.getResponse().getContentAsString().equals("contentB");
+ });
+
+ var response = requestHelper.executeGet(contentBUrl);
+ assertThat(response.getResponse().getContentAsString()).isEqualTo("contentB");
+ }
+
+ @Test
+ void putOversizedContentToCoverImageInDraftReturnsError() throws Exception {
+ var draft = createNewDraft();
+ var draftRootUrl = getDraftRootUrl(draft.getId());
+
+ var url = draftRootUrl + "/coverImage_content";
+ byte[] oversizedContent = new byte[6 * 1024 * 1024]; // 6MB > 5MB limit
+ requestHelper.setContentType(MediaType.APPLICATION_OCTET_STREAM);
+ requestHelper.executePutWithMatcher(url, oversizedContent, status().is4xxClientError());
+ }
+
+ @Test
+ void updateInlineAttachmentInDraftAndCancelDeletesNewContent() throws Exception {
+ var draft = createNewDraft();
+ var draftRootUrl = getDraftRootUrl(draft.getId());
+ putInlineAttachmentContent(draftRootUrl, "originalContent");
+ prepareAndActivateDraft(draftRootUrl);
+ var activeRootAfterFirstActivation = selectActiveRoot(draft.getId());
+ var originalContentId = activeRootAfterFirstActivation.getAvatarContentId();
+ assertThat(originalContentId).isNotEmpty();
+ serviceHandler.clearEventContext();
+
+ editExistingRoot(draft.getId());
+ var newDraftRootUrl = getDraftRootUrl(draft.getId());
+ putInlineAttachmentContent(newDraftRootUrl, "updatedContent");
+ cancelDraft(newDraftRootUrl);
+
+ waitTillExpectedHandlerMessageSize(2);
+ var createEvents =
+ serviceHandler.getEventContextForEvent(AttachmentService.EVENT_CREATE_ATTACHMENT);
+ assertThat(createEvents).hasSize(1);
+ var deleteEvents =
+ serviceHandler.getEventContextForEvent(AttachmentService.EVENT_MARK_ATTACHMENT_AS_DELETED);
+ assertThat(deleteEvents).hasSize(1);
+ var createContext = (AttachmentCreateEventContext) createEvents.get(0).context();
+ var deleteContext = (AttachmentMarkAsDeletedEventContext) deleteEvents.get(0).context();
+ assertThat(deleteContext.getContentId()).isEqualTo(createContext.getContentId());
+
+ var activeRootAfterCancel = selectActiveRoot(draft.getId());
+ assertThat(activeRootAfterCancel.getAvatarContentId()).isEqualTo(originalContentId);
+ }
+
+ // Helper methods
+
+ private DraftRoots createNewDraft() throws Exception {
+ var responseData =
+ requestHelper.executePostWithODataResponseAndAssertStatusCreated(
+ BASE_URL + "DraftRoots", "{}");
+ return Struct.access(responseData).as(DraftRoots.class);
+ }
+
+ private String getDraftRootUrl(String rootId) {
+ return BASE_URL + "DraftRoots(ID=" + rootId + ",IsActiveEntity=false)";
+ }
+
+ private String getActiveRootUrl(String rootId) {
+ return BASE_URL + "DraftRoots(ID=" + rootId + ",IsActiveEntity=true)";
+ }
+
+ private void prepareAndActivateDraft(String draftRootUrl) throws Exception {
+ var draftPrepareUrl = draftRootUrl + "/TestDraftService.draftPrepare";
+ var draftActivateUrl = draftRootUrl + "/TestDraftService.draftActivate";
+ requestHelper.executePostWithMatcher(
+ draftPrepareUrl, "{\"SideEffectsQualifier\":\"\"}", status().isOk());
+ requestHelper.executePostWithMatcher(draftActivateUrl, "{}", status().isOk());
+ }
+
+ private void editExistingRoot(String rootId) throws Exception {
+ var url = getActiveRootUrl(rootId) + "/TestDraftService.draftEdit";
+ requestHelper.executePostWithMatcher(url, "{\"PreserveChanges\":true}", status().isOk());
+ }
+
+ private void cancelDraft(String draftRootUrl) throws Exception {
+ requestHelper.executeDeleteWithMatcher(draftRootUrl, status().isNoContent());
+ }
+
+ private String putInlineAttachmentContent(String draftRootUrl, String content) throws Exception {
+ var contentUrl = draftRootUrl + "/avatar_content";
+ requestHelper.setContentType(MediaType.APPLICATION_OCTET_STREAM);
+ requestHelper.executePutWithMatcher(
+ contentUrl, content.getBytes(StandardCharsets.UTF_8), status().isNoContent());
+ requestHelper.resetHelper();
+ return content;
+ }
+
+ private DraftRoots selectActiveRoot(String rootId) {
+ var select =
+ Select.from(DraftRoots_.CDS_NAME)
+ .where(root -> root.get(DraftRoots.ID).eq(rootId))
+ .columns(StructuredType::_all);
+ return persistenceService.run(select).single(DraftRoots.class);
+ }
+
+ private void verifySingleCreateEvent(String contentId, String content) {
+ verifyEventContextEmptyForEvent(
+ AttachmentService.EVENT_READ_ATTACHMENT,
+ AttachmentService.EVENT_MARK_ATTACHMENT_AS_DELETED);
+ var createEvents =
+ serviceHandler.getEventContextForEvent(AttachmentService.EVENT_CREATE_ATTACHMENT);
+ assertThat(createEvents)
+ .hasSize(1)
+ .first()
+ .satisfies(
+ event -> {
+ assertThat(event.context()).isInstanceOf(AttachmentCreateEventContext.class);
+ var createContext = (AttachmentCreateEventContext) event.context();
+ assertThat(createContext.getContentId()).isEqualTo(contentId);
+ assertThat(createContext.getData().getContent().readAllBytes())
+ .isEqualTo(content.getBytes(StandardCharsets.UTF_8));
+ });
+ }
+
+ private void verifySingleDeletionEvent(String contentId) {
+ waitTillExpectedHandlerMessageSize(1);
+ verifyEventContextEmptyForEvent(
+ AttachmentService.EVENT_CREATE_ATTACHMENT, AttachmentService.EVENT_READ_ATTACHMENT);
+ var deleteEvents =
+ serviceHandler.getEventContextForEvent(AttachmentService.EVENT_MARK_ATTACHMENT_AS_DELETED);
+ assertThat(deleteEvents)
+ .hasSize(1)
+ .first()
+ .satisfies(
+ event -> {
+ assertThat(event.context()).isInstanceOf(AttachmentMarkAsDeletedEventContext.class);
+ var deleteContext = (AttachmentMarkAsDeletedEventContext) event.context();
+ assertThat(deleteContext.getContentId()).isEqualTo(contentId);
+ assertThat(deleteContext.getDeletionUserInfo().getName()).isEqualTo("anonymous");
+ assertThat(deleteContext.getDeletionUserInfo().getIsSystemUser()).isFalse();
+ });
+ }
+
+ private void verifySingleReadEvent(String contentId) {
+ verifyEventContextEmptyForEvent(
+ AttachmentService.EVENT_CREATE_ATTACHMENT,
+ AttachmentService.EVENT_MARK_ATTACHMENT_AS_DELETED);
+ var readContext = serviceHandler.getEventContext();
+ assertThat(readContext)
+ .hasSize(1)
+ .first()
+ .satisfies(
+ event -> {
+ assertThat(event.event()).isEqualTo(AttachmentService.EVENT_READ_ATTACHMENT);
+ if (contentId != null) {
+ assertThat(((AttachmentReadEventContext) event.context()).getContentId())
+ .isEqualTo(contentId);
+ }
+ });
+ }
+
+ private void verifyNoAttachmentEventsCalled() {
+ assertThat(serviceHandler.getEventContext()).isEmpty();
+ }
+
+ private void verifyEventContextEmptyForEvent(String... events) {
+ Arrays.stream(events)
+ .forEach(event -> assertThat(serviceHandler.getEventContextForEvent(event)).isEmpty());
+ }
+
+ private void waitTillExpectedHandlerMessageSize(int expectedSize) {
+ Awaitility.await()
+ .atMost(30, TimeUnit.SECONDS)
+ .pollDelay(1, TimeUnit.SECONDS)
+ .until(
+ () -> {
+ var eventCalls = serviceHandler.getEventContext().size();
+ logger.debug(
+ "Waiting for expected size '{}' in handler context, was '{}'",
+ expectedSize,
+ eventCalls);
+ return eventCalls >= expectedSize;
+ });
+ }
+}
diff --git a/integration-tests/generic/src/test/java/com/sap/cds/feature/attachments/integrationtests/nondraftservice/SingleAttachmentNonDraftTest.java b/integration-tests/generic/src/test/java/com/sap/cds/feature/attachments/integrationtests/nondraftservice/SingleAttachmentNonDraftTest.java
new file mode 100644
index 000000000..fcfc4408d
--- /dev/null
+++ b/integration-tests/generic/src/test/java/com/sap/cds/feature/attachments/integrationtests/nondraftservice/SingleAttachmentNonDraftTest.java
@@ -0,0 +1,909 @@
+/*
+ * © 2024-2026 SAP SE or an SAP affiliate company and cds-feature-attachments contributors.
+ */
+package com.sap.cds.feature.attachments.integrationtests.nondraftservice;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+import com.sap.cds.feature.attachments.generated.integration.test.cds4j.testservice.Items;
+import com.sap.cds.feature.attachments.generated.integration.test.cds4j.testservice.Roots;
+import com.sap.cds.feature.attachments.generated.integration.test.cds4j.testservice.Roots_;
+import com.sap.cds.feature.attachments.integrationtests.common.MockHttpRequestHelper;
+import com.sap.cds.feature.attachments.integrationtests.common.TableDataDeleter;
+import com.sap.cds.feature.attachments.integrationtests.constants.Profiles;
+import com.sap.cds.feature.attachments.integrationtests.testhandler.EventContextHolder;
+import com.sap.cds.feature.attachments.integrationtests.testhandler.TestPersistenceHandler;
+import com.sap.cds.feature.attachments.integrationtests.testhandler.TestPluginAttachmentsServiceHandler;
+import com.sap.cds.feature.attachments.service.AttachmentService;
+import com.sap.cds.feature.attachments.service.model.servicehandler.AttachmentCreateEventContext;
+import com.sap.cds.feature.attachments.service.model.servicehandler.AttachmentMarkAsDeletedEventContext;
+import com.sap.cds.feature.attachments.service.model.servicehandler.AttachmentReadEventContext;
+import com.sap.cds.ql.Select;
+import com.sap.cds.ql.StructuredType;
+import com.sap.cds.services.persistence.PersistenceService;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.concurrent.TimeUnit;
+import org.awaitility.Awaitility;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
+import org.springframework.test.context.ActiveProfiles;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.ResultMatcher;
+import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
+
+@SpringBootTest
+@AutoConfigureMockMvc
+@ActiveProfiles(Profiles.TEST_HANDLER_ENABLED)
+class SingleAttachmentNonDraftTest {
+
+ @Autowired private TestPluginAttachmentsServiceHandler serviceHandler;
+ @Autowired private MockHttpRequestHelper requestHelper;
+ @Autowired private PersistenceService persistenceService;
+ @Autowired private TableDataDeleter dataDeleter;
+ @Autowired private TestPersistenceHandler testPersistenceHandler;
+ @Autowired private MockMvc mvc;
+
+ @AfterEach
+ void teardown() {
+ dataDeleter.deleteData(Roots_.CDS_NAME);
+ serviceHandler.clearEventContext();
+ serviceHandler.clearDocuments();
+ requestHelper.resetHelper();
+ testPersistenceHandler.reset();
+ }
+
+ @Test
+ void createRootWithoutInlineAttachmentWorks() throws Exception {
+ var root = buildRootWithoutContent();
+ postServiceRoot(root);
+
+ var selectedRoot = selectStoredRoot();
+ assertThat(selectedRoot.getId()).isNotEmpty();
+ assertThat(selectedRoot.getTitle()).isEqualTo(root.getTitle());
+ assertThat(selectedRoot.getAvatarContent()).isNull();
+ assertThat(selectedRoot.getAvatarContentId()).isNull();
+ assertThat(selectedRoot.getAvatarFileName()).isNull();
+ verifyNoAttachmentEventsCalled();
+ }
+
+ @Test
+ void putContentToInlineAttachmentOnRootWorks() throws Exception {
+ var root = buildRootWithoutContent();
+ postServiceRoot(root);
+ var selectedRoot = selectStoredRoot();
+
+ var content = putInlineAttachmentContentOnRoot(selectedRoot.getId());
+ var rootAfterPut = selectStoredRoot();
+
+ assertThat(rootAfterPut.getAvatarContentId()).isNotEmpty();
+ assertThat(rootAfterPut.getAvatarStatus()).isNotEmpty();
+ verifySingleCreateEvent(rootAfterPut.getAvatarContentId(), content);
+ }
+
+ @Test
+ void readInlineAttachmentContentOnRootReturnsContent() throws Exception {
+ var root = buildRootWithoutContent();
+ postServiceRoot(root);
+ var selectedRoot = selectStoredRoot();
+
+ var content = putInlineAttachmentContentOnRoot(selectedRoot.getId());
+ serviceHandler.clearEventContext();
+ var rootAfterPut = selectStoredRoot();
+
+ var url = buildRootUrl(rootAfterPut.getId()) + "/avatar_content";
+ var response = requestHelper.executeGet(url);
+
+ assertThat(response.getResponse().getContentAsString()).isEqualTo(content);
+ verifySingleReadEvent(rootAfterPut.getAvatarContentId());
+ }
+
+ @Test
+ void readInlineAttachmentContentReturnsCorrectContentTypeHeader() throws Exception {
+ var root = buildRootWithoutContent();
+ postServiceRoot(root);
+ var selectedRoot = selectStoredRoot();
+
+ var content = putInlineAttachmentContentOnRoot(selectedRoot.getId());
+ serviceHandler.clearEventContext();
+ var rootAfterPut = selectStoredRoot();
+
+ var url = buildRootUrl(rootAfterPut.getId()) + "/avatar_content";
+ var response = requestHelper.executeGet(url);
+
+ assertThat(response.getResponse().getContentAsString()).isEqualTo(content);
+ assertThat(response.getResponse().getContentType()).startsWith("application/octet-stream");
+ }
+
+ @Test
+ void readInlineAttachmentContentReturnsContentDispositionHeader() throws Exception {
+ var root = buildRootWithoutContent();
+ postServiceRoot(root);
+ var selectedRoot = selectStoredRoot();
+
+ putInlineAttachmentContentOnRoot(selectedRoot.getId());
+ serviceHandler.clearEventContext();
+ var rootAfterPut = selectStoredRoot();
+
+ var url = buildRootUrl(rootAfterPut.getId()) + "/avatar_content";
+ var response = requestHelper.executeGet(url);
+
+ var contentDisposition = response.getResponse().getHeader("Content-Disposition");
+ assertThat(contentDisposition).isNotNull();
+ assertThat(contentDisposition).startsWith("inline");
+ }
+
+ @Test
+ void readInlineAttachmentContentReturnsFilenameInContentDisposition() throws Exception {
+ var root = buildRootWithoutContent();
+ postServiceRoot(root);
+ var selectedRoot = selectStoredRoot();
+
+ putInlineAttachmentContentOnRoot(selectedRoot.getId());
+ serviceHandler.clearEventContext();
+ requestHelper.resetHelper(); // Reset after PUT to use JSON for PATCH
+ var rootAfterPut = selectStoredRoot();
+
+ var patchUrl = buildRootUrl(rootAfterPut.getId());
+ requestHelper.executePatchWithODataResponseAndAssertStatusOk(
+ patchUrl, "{\"avatar_fileName\": \"test-file.bin\"}");
+
+ var url = buildRootUrl(rootAfterPut.getId()) + "/avatar_content";
+ var response = requestHelper.executeGet(url);
+
+ var contentDisposition = response.getResponse().getHeader("Content-Disposition");
+ assertThat(contentDisposition).isNotNull();
+ assertThat(contentDisposition).contains("filename=\"test-file.bin\"");
+ }
+
+ @Test
+ void readInlineAttachmentContentWithCustomMimeTypeReturnsCorrectContentType() throws Exception {
+ var root = buildRootWithoutContent();
+ postServiceRoot(root);
+ var selectedRoot = selectStoredRoot();
+
+ var url = buildRootUrl(selectedRoot.getId()) + "/avatar_content";
+ requestHelper.setContentType(MediaType.IMAGE_PNG);
+ requestHelper.executePutWithMatcher(
+ url, "fake-image-content".getBytes(StandardCharsets.UTF_8), status().isNoContent());
+ requestHelper.resetHelper();
+
+ serviceHandler.clearEventContext();
+ var rootAfterPut = selectStoredRoot();
+
+ var readUrl = buildRootUrl(rootAfterPut.getId()) + "/avatar_content";
+ var response = requestHelper.executeGet(readUrl);
+
+ assertThat(response.getResponse().getContentType()).startsWith("image/png");
+ }
+
+ @Test
+ void selectInlineAttachmentIncludesMediaContentTypeAnnotation() throws Exception {
+ var root = buildRootWithoutContent();
+ postServiceRoot(root);
+ var selectedRoot = selectStoredRoot();
+
+ var url = buildRootUrl(selectedRoot.getId()) + "/avatar_content";
+ requestHelper.setContentType(MediaType.TEXT_PLAIN);
+ requestHelper.executePutWithMatcher(
+ url, "test-content".getBytes(StandardCharsets.UTF_8), status().isNoContent());
+ requestHelper.resetHelper();
+
+ serviceHandler.clearEventContext();
+
+ var selectUrl =
+ MockHttpRequestHelper.ODATA_BASE_URL
+ + "TestService/Roots("
+ + selectedRoot.getId()
+ + ")?$select=avatar_content,avatar_mimeType";
+ var response =
+ requestHelper.executeGetWithSingleODataResponseAndAssertStatus(selectUrl, HttpStatus.OK);
+
+ assertThat(response).contains("avatar_content@mediaContentType");
+ assertThat(response).contains("text/plain");
+ }
+
+ @Test
+ void deleteInlineAttachmentContentOnRootClearsContent() throws Exception {
+ var root = buildRootWithoutContent();
+ postServiceRoot(root);
+ var selectedRoot = selectStoredRoot();
+
+ putInlineAttachmentContentOnRoot(selectedRoot.getId());
+ serviceHandler.clearEventContext();
+ var rootAfterPut = selectStoredRoot();
+ var contentIdBeforeDelete = rootAfterPut.getAvatarContentId();
+
+ var url = buildRootUrl(rootAfterPut.getId()) + "/avatar_content";
+ requestHelper.executeDelete(url);
+
+ var rootAfterDelete = selectStoredRoot();
+ assertThat(rootAfterDelete.getAvatarContentId()).isNull();
+ assertThat(rootAfterDelete.getAvatarContent()).isNull();
+ verifySingleDeletionEvent(contentIdBeforeDelete);
+ }
+
+ @Test
+ void updateInlineAttachmentContentOnRootWorks() throws Exception {
+ var root = buildRootWithoutContent();
+ postServiceRoot(root);
+ var selectedRoot = selectStoredRoot();
+
+ putInlineAttachmentContentOnRoot(selectedRoot.getId());
+ serviceHandler.clearEventContext();
+ var rootAfterFirstPut = selectStoredRoot();
+ var firstContentId = rootAfterFirstPut.getAvatarContentId();
+
+ var newContent = putInlineAttachmentContentOnRoot(rootAfterFirstPut.getId(), "newContent");
+ var rootAfterSecondPut = selectStoredRoot();
+
+ assertThat(rootAfterSecondPut.getAvatarContentId()).isNotEmpty();
+ assertThat(rootAfterSecondPut.getAvatarContentId()).isNotEqualTo(firstContentId);
+ verifySingleCreateAndDeleteEvent(
+ rootAfterSecondPut.getAvatarContentId(), firstContentId, newContent);
+ }
+
+ @Test
+ void deleteRootDeletesInlineAttachmentContent() throws Exception {
+ var root = buildRootWithoutContent();
+ postServiceRoot(root);
+ var selectedRoot = selectStoredRoot();
+
+ putInlineAttachmentContentOnRoot(selectedRoot.getId());
+ serviceHandler.clearEventContext();
+ var rootAfterPut = selectStoredRoot();
+ var contentId = rootAfterPut.getAvatarContentId();
+
+ var url = buildRootUrl(rootAfterPut.getId());
+ requestHelper.executeDeleteWithMatcher(url, status().isNoContent());
+
+ verifySingleDeletionEvent(contentId);
+ }
+
+ @Test
+ void inlineAttachmentReadViaExpandHasNoFilledContent() throws Exception {
+ var root = buildRootWithoutContent();
+ postServiceRoot(root);
+ var selectedRoot = selectStoredRoot();
+ putInlineAttachmentContentOnRoot(selectedRoot.getId());
+ serviceHandler.clearEventContext();
+
+ var url = MockHttpRequestHelper.ODATA_BASE_URL + "TestService/Roots?$select=ID,avatar_content";
+ var response =
+ requestHelper.executeGetWithSingleODataResponseAndAssertStatus(
+ url, Roots.class, HttpStatus.OK);
+
+ assertThat(response.getAvatarContent()).isNull();
+ verifyNoAttachmentEventsCalled();
+ }
+
+ @Test
+ void createRootWithItemWithoutInlineAttachmentWorks() throws Exception {
+ var root = buildRootWithItem();
+ postServiceRoot(root);
+
+ var selectedRoot = selectStoredRootWithItems();
+ assertThat(selectedRoot.getItems()).hasSize(1);
+ var item = selectedRoot.getItems().get(0);
+ assertThat(item.getIconContent()).isNull();
+ assertThat(item.getIconContentId()).isNull();
+ verifyNoAttachmentEventsCalled();
+ }
+
+ @Test
+ void putContentToInlineAttachmentOnItemWorks() throws Exception {
+ var root = buildRootWithItem();
+ postServiceRoot(root);
+ var selectedRoot = selectStoredRootWithItems();
+ var item = selectedRoot.getItems().get(0);
+
+ var content = putInlineAttachmentContentOnItem(selectedRoot.getId(), item.getId());
+ var rootAfterPut = selectStoredRootWithItems();
+ var itemAfterPut = rootAfterPut.getItems().get(0);
+
+ assertThat(itemAfterPut.getIconContentId()).isNotEmpty();
+ assertThat(itemAfterPut.getIconStatus()).isNotEmpty();
+ verifySingleCreateEvent(itemAfterPut.getIconContentId(), content);
+ }
+
+ @Test
+ void readInlineAttachmentContentOnItemReturnsContent() throws Exception {
+ var root = buildRootWithItem();
+ postServiceRoot(root);
+ var selectedRoot = selectStoredRootWithItems();
+ var item = selectedRoot.getItems().get(0);
+
+ var content = putInlineAttachmentContentOnItem(selectedRoot.getId(), item.getId());
+ serviceHandler.clearEventContext();
+ var rootAfterPut = selectStoredRootWithItems();
+ var itemAfterPut = rootAfterPut.getItems().get(0);
+
+ var url = buildItemUrl(selectedRoot.getId(), item.getId()) + "/icon_content";
+ var response = requestHelper.executeGet(url);
+
+ assertThat(response.getResponse().getContentAsString()).isEqualTo(content);
+ verifySingleReadEvent(itemAfterPut.getIconContentId());
+ }
+
+ @Test
+ void deleteInlineAttachmentContentOnItemClearsContent() throws Exception {
+ var root = buildRootWithItem();
+ postServiceRoot(root);
+ var selectedRoot = selectStoredRootWithItems();
+ var item = selectedRoot.getItems().get(0);
+
+ putInlineAttachmentContentOnItem(selectedRoot.getId(), item.getId());
+ serviceHandler.clearEventContext();
+ var rootAfterPut = selectStoredRootWithItems();
+ var itemAfterPut = rootAfterPut.getItems().get(0);
+ var contentIdBeforeDelete = itemAfterPut.getIconContentId();
+
+ var url = buildItemUrl(selectedRoot.getId(), item.getId()) + "/icon_content";
+ requestHelper.executeDelete(url);
+
+ var rootAfterDelete = selectStoredRootWithItems();
+ var itemAfterDelete = rootAfterDelete.getItems().get(0);
+ assertThat(itemAfterDelete.getIconContentId()).isNull();
+ assertThat(itemAfterDelete.getIconContent()).isNull();
+ verifySingleDeletionEvent(contentIdBeforeDelete);
+ }
+
+ @Test
+ void updateInlineAttachmentContentOnItemWorks() throws Exception {
+ var root = buildRootWithItem();
+ postServiceRoot(root);
+ var selectedRoot = selectStoredRootWithItems();
+ var item = selectedRoot.getItems().get(0);
+
+ putInlineAttachmentContentOnItem(selectedRoot.getId(), item.getId());
+ serviceHandler.clearEventContext();
+ var rootAfterFirstPut = selectStoredRootWithItems();
+ var itemAfterFirstPut = rootAfterFirstPut.getItems().get(0);
+ var firstContentId = itemAfterFirstPut.getIconContentId();
+
+ var newContent =
+ putInlineAttachmentContentOnItem(
+ rootAfterFirstPut.getId(), itemAfterFirstPut.getId(), "newContent");
+ var rootAfterSecondPut = selectStoredRootWithItems();
+ var itemAfterSecondPut = rootAfterSecondPut.getItems().get(0);
+
+ assertThat(itemAfterSecondPut.getIconContentId()).isNotEmpty();
+ assertThat(itemAfterSecondPut.getIconContentId()).isNotEqualTo(firstContentId);
+ verifySingleCreateAndDeleteEvent(
+ itemAfterSecondPut.getIconContentId(), firstContentId, newContent);
+ }
+
+ @Test
+ void deleteItemDeletesInlineAttachmentContent() throws Exception {
+ var root = buildRootWithItem();
+ postServiceRoot(root);
+ var selectedRoot = selectStoredRootWithItems();
+ var item = selectedRoot.getItems().get(0);
+
+ putInlineAttachmentContentOnItem(selectedRoot.getId(), item.getId());
+ serviceHandler.clearEventContext();
+ var rootAfterPut = selectStoredRootWithItems();
+ var itemAfterPut = rootAfterPut.getItems().get(0);
+ var contentId = itemAfterPut.getIconContentId();
+
+ var url = buildItemUrl(selectedRoot.getId(), item.getId());
+ requestHelper.executeDeleteWithMatcher(url, status().isNoContent());
+
+ verifySingleDeletionEvent(contentId);
+ }
+
+ @Test
+ void deleteRootDeletesInlineAttachmentOnItemContent() throws Exception {
+ var root = buildRootWithItem();
+ postServiceRoot(root);
+ var selectedRoot = selectStoredRootWithItems();
+ var item = selectedRoot.getItems().get(0);
+
+ putInlineAttachmentContentOnItem(selectedRoot.getId(), item.getId());
+ serviceHandler.clearEventContext();
+ var rootAfterPut = selectStoredRootWithItems();
+ var itemAfterPut = rootAfterPut.getItems().get(0);
+ var contentId = itemAfterPut.getIconContentId();
+
+ var url = buildRootUrl(rootAfterPut.getId());
+ requestHelper.executeDeleteWithMatcher(url, status().isNoContent());
+
+ verifySingleDeletionEvent(contentId);
+ }
+
+ @Test
+ void deleteRootDeletesBothRootAndItemInlineAttachments() throws Exception {
+ var root = buildRootWithItem();
+ postServiceRoot(root);
+ var selectedRoot = selectStoredRootWithItems();
+ var item = selectedRoot.getItems().get(0);
+
+ putInlineAttachmentContentOnRoot(selectedRoot.getId());
+ putInlineAttachmentContentOnItem(selectedRoot.getId(), item.getId());
+ serviceHandler.clearEventContext();
+ var rootAfterPut = selectStoredRootWithItems();
+ var itemAfterPut = rootAfterPut.getItems().get(0);
+ var rootContentId = rootAfterPut.getAvatarContentId();
+ var itemContentId = itemAfterPut.getIconContentId();
+
+ var url = buildRootUrl(rootAfterPut.getId());
+ requestHelper.executeDeleteWithMatcher(url, status().isNoContent());
+
+ verifyTwoDeletionEvents(rootContentId, itemContentId);
+ }
+
+ @Test
+ void twoInlineAttachmentsOnSameEntityDoNotCollide() throws Exception {
+ var root = buildRootWithoutContent();
+ postServiceRoot(root);
+ var selectedRoot = selectStoredRoot();
+
+ putInlineAttachmentContentOnRoot(selectedRoot.getId(), "avatarData");
+ serviceHandler.clearEventContext();
+ var coverImageContent = putCoverImageContentOnRoot(selectedRoot.getId(), "coverImageData");
+
+ var rootAfterPut = selectStoredRoot();
+
+ assertThat(rootAfterPut.getAvatarContentId()).isNotEmpty();
+ assertThat(rootAfterPut.getCoverImageContentId()).isNotEmpty();
+ assertThat(rootAfterPut.getAvatarContentId())
+ .isNotEqualTo(rootAfterPut.getCoverImageContentId());
+
+ verifySingleCreateEvent(rootAfterPut.getCoverImageContentId(), coverImageContent);
+ }
+
+ @Test
+ void readingOneInlineAttachmentDoesNotAffectOther() throws Exception {
+ var root = buildRootWithoutContent();
+ postServiceRoot(root);
+ var selectedRoot = selectStoredRoot();
+
+ var avatarContent = putInlineAttachmentContentOnRoot(selectedRoot.getId(), "avatarData");
+ var coverImageContent = putCoverImageContentOnRoot(selectedRoot.getId(), "coverImageData");
+ serviceHandler.clearEventContext();
+
+ var rootAfterPut = selectStoredRoot();
+
+ var avatarUrl = buildRootUrl(rootAfterPut.getId()) + "/avatar_content";
+ var avatarResponse = requestHelper.executeGet(avatarUrl);
+ assertThat(avatarResponse.getResponse().getContentAsString()).isEqualTo(avatarContent);
+
+ verifySingleReadEvent(rootAfterPut.getAvatarContentId());
+ serviceHandler.clearEventContext();
+
+ var coverImageUrl = buildRootUrl(rootAfterPut.getId()) + "/coverImage_content";
+ var coverImageResponse = requestHelper.executeGet(coverImageUrl);
+ assertThat(coverImageResponse.getResponse().getContentAsString()).isEqualTo(coverImageContent);
+
+ verifySingleReadEvent(rootAfterPut.getCoverImageContentId());
+ }
+
+ @Test
+ void deletingOneInlineAttachmentDoesNotAffectOther() throws Exception {
+ var root = buildRootWithoutContent();
+ postServiceRoot(root);
+ var selectedRoot = selectStoredRoot();
+
+ putInlineAttachmentContentOnRoot(selectedRoot.getId(), "avatarData");
+ putCoverImageContentOnRoot(selectedRoot.getId(), "coverImageData");
+ serviceHandler.clearEventContext();
+
+ var rootAfterPut = selectStoredRoot();
+ var avatarContentId = rootAfterPut.getAvatarContentId();
+ var coverImageContentId = rootAfterPut.getCoverImageContentId();
+
+ var avatarUrl = buildRootUrl(rootAfterPut.getId()) + "/avatar_content";
+ requestHelper.executeDelete(avatarUrl);
+
+ var rootAfterDelete = selectStoredRoot();
+
+ assertThat(rootAfterDelete.getAvatarContentId()).isNull();
+ assertThat(rootAfterDelete.getAvatarContent()).isNull();
+
+ assertThat(rootAfterDelete.getCoverImageContentId()).isEqualTo(coverImageContentId);
+
+ verifySingleDeletionEvent(avatarContentId);
+ }
+
+ @Test
+ void updatingOneInlineAttachmentDoesNotAffectOther() throws Exception {
+ var root = buildRootWithoutContent();
+ postServiceRoot(root);
+ var selectedRoot = selectStoredRoot();
+
+ putInlineAttachmentContentOnRoot(selectedRoot.getId(), "avatarData");
+ putCoverImageContentOnRoot(selectedRoot.getId(), "coverImageData");
+ serviceHandler.clearEventContext();
+
+ var rootAfterFirstPut = selectStoredRoot();
+ var originalAvatarContentId = rootAfterFirstPut.getAvatarContentId();
+ var originalCoverImageContentId = rootAfterFirstPut.getCoverImageContentId();
+
+ var newAvatarContent =
+ putInlineAttachmentContentOnRoot(rootAfterFirstPut.getId(), "newAvatarData");
+
+ var rootAfterUpdate = selectStoredRoot();
+
+ assertThat(rootAfterUpdate.getAvatarContentId()).isNotEmpty();
+ assertThat(rootAfterUpdate.getAvatarContentId()).isNotEqualTo(originalAvatarContentId);
+
+ assertThat(rootAfterUpdate.getCoverImageContentId()).isEqualTo(originalCoverImageContentId);
+
+ verifySingleCreateAndDeleteEvent(
+ rootAfterUpdate.getAvatarContentId(), originalAvatarContentId, newAvatarContent);
+ }
+
+ @Test
+ void deleteRootDeletesBothInlineAttachments() throws Exception {
+ var root = buildRootWithoutContent();
+ postServiceRoot(root);
+ var selectedRoot = selectStoredRoot();
+
+ putInlineAttachmentContentOnRoot(selectedRoot.getId(), "avatarData");
+ putCoverImageContentOnRoot(selectedRoot.getId(), "coverImageData");
+ serviceHandler.clearEventContext();
+
+ var rootAfterPut = selectStoredRoot();
+ var avatarContentId = rootAfterPut.getAvatarContentId();
+ var coverImageContentId = rootAfterPut.getCoverImageContentId();
+
+ var url = buildRootUrl(rootAfterPut.getId());
+ requestHelper.executeDeleteWithMatcher(url, status().isNoContent());
+
+ verifyTwoDeletionEvents(avatarContentId, coverImageContentId);
+ }
+
+ @Test
+ void doubleDeleteInlineAttachmentContentHandledCorrectly() throws Exception {
+ var root = buildRootWithoutContent();
+ postServiceRoot(root);
+ var selectedRoot = selectStoredRoot();
+
+ putInlineAttachmentContentOnRoot(selectedRoot.getId());
+ serviceHandler.clearEventContext();
+ var rootAfterPut = selectStoredRoot();
+ var contentId = rootAfterPut.getAvatarContentId();
+
+ var url = buildRootUrl(rootAfterPut.getId()) + "/avatar_content";
+ requestHelper.executeDelete(url);
+ verifySingleDeletionEvent(contentId);
+ serviceHandler.clearEventContext();
+
+ var secondDeleteResult = requestHelper.executeDelete(url);
+ assertThat(secondDeleteResult.getResponse().getStatus())
+ .isIn(HttpStatus.NO_CONTENT.value(), HttpStatus.OK.value());
+ verifyNoAttachmentEventsCalled();
+ }
+
+ @ParameterizedTest
+ @CsvSource({"avatar_status,INFECTED", "avatar_contentId,TEST"})
+ void readOnlyFieldsCannotBeUpdatedViaPatchOnRoot(String field, String value) throws Exception {
+ var root = buildRootWithoutContent();
+ postServiceRoot(root);
+ var selectedRoot = selectStoredRoot();
+
+ putInlineAttachmentContentOnRoot(selectedRoot.getId());
+ serviceHandler.clearEventContext();
+ requestHelper.resetHelper();
+
+ var url = buildRootUrl(selectedRoot.getId());
+ requestHelper.executePatchWithODataResponseAndAssertStatus(
+ url, "{\"" + field + "\":\"" + value + "\"}", HttpStatus.OK);
+
+ var rootAfterPatch = selectStoredRoot();
+ assertThat(rootAfterPatch.get(field)).isNotNull().isNotEqualTo(value);
+ }
+
+ @Test
+ void errorInTransactionAfterCreateRollsBackContent() throws Exception {
+ var root = buildRootWithoutContent();
+ postServiceRoot(root);
+ var selectedRoot = selectStoredRoot();
+
+ testPersistenceHandler.setThrowExceptionOnUpdate(true);
+ putInlineAttachmentContentOnRoot(
+ selectedRoot.getId(), "failContent", status().is5xxServerError());
+
+ var rootAfterError = selectStoredRoot();
+ assertThat(rootAfterError.getAvatarContentId()).isNull();
+ assertThat(rootAfterError.getAvatarContent()).isNull();
+ }
+
+ @Test
+ void uploadWithContentDispositionHeaderExtractsFileName() throws Exception {
+ var root = buildRootWithoutContent();
+ postServiceRoot(root);
+ var selectedRoot = selectStoredRoot();
+
+ var url = buildRootUrl(selectedRoot.getId()) + "/avatar_content";
+ mvc.perform(
+ MockMvcRequestBuilders.put(url)
+ .contentType(MediaType.APPLICATION_OCTET_STREAM)
+ .header("If-Match", "*")
+ .header("Content-Disposition", "attachment; filename=\"uploaded-avatar.png\"")
+ .content("avatar-data".getBytes(StandardCharsets.UTF_8)))
+ .andExpect(status().isNoContent());
+
+ var rootAfterPut = selectStoredRoot();
+ assertThat(rootAfterPut.getAvatarFileName()).isEqualTo("uploaded-avatar.png");
+ assertThat(rootAfterPut.getAvatarContentId()).isNotEmpty();
+ }
+
+ @Test
+ void uploadWithSlugHeaderExtractsFileName() throws Exception {
+ var root = buildRootWithoutContent();
+ postServiceRoot(root);
+ var selectedRoot = selectStoredRoot();
+
+ var url = buildRootUrl(selectedRoot.getId()) + "/avatar_content";
+ mvc.perform(
+ MockMvcRequestBuilders.put(url)
+ .contentType(MediaType.APPLICATION_OCTET_STREAM)
+ .header("If-Match", "*")
+ .header("slug", "uploaded-slug-file.txt")
+ .content("slug-data".getBytes(StandardCharsets.UTF_8)))
+ .andExpect(status().isNoContent());
+
+ var rootAfterPut = selectStoredRoot();
+ assertThat(rootAfterPut.getAvatarFileName()).isEqualTo("uploaded-slug-file.txt");
+ assertThat(rootAfterPut.getAvatarContentId()).isNotEmpty();
+ }
+
+ @Test
+ void uploadWithSpecificContentTypeStoresMimeType() throws Exception {
+ var root = buildRootWithoutContent();
+ postServiceRoot(root);
+ var selectedRoot = selectStoredRoot();
+
+ var url = buildRootUrl(selectedRoot.getId()) + "/avatar_content";
+ requestHelper.setContentType(MediaType.IMAGE_JPEG);
+ requestHelper.executePutWithMatcher(
+ url, "jpeg-data".getBytes(StandardCharsets.UTF_8), status().isNoContent());
+
+ var rootAfterPut = selectStoredRoot();
+ assertThat(rootAfterPut.getAvatarMimeType()).startsWith("image/jpeg");
+ assertThat(rootAfterPut.getAvatarContentId()).isNotEmpty();
+ }
+
+ @Test
+ void malwareScanStatusIsCleanAfterUpload() throws Exception {
+ var root = buildRootWithoutContent();
+ postServiceRoot(root);
+ var selectedRoot = selectStoredRoot();
+
+ putInlineAttachmentContentOnRoot(selectedRoot.getId());
+
+ var rootAfterPut = selectStoredRoot();
+ assertThat(rootAfterPut.getAvatarStatus()).isEqualTo("Clean");
+ assertThat(rootAfterPut.getAvatarScannedAt()).isNotNull();
+ }
+
+ @Test
+ void putOversizedContentToCoverImageReturnsError() throws Exception {
+ var root = buildRootWithoutContent();
+ postServiceRoot(root);
+ var selectedRoot = selectStoredRoot();
+
+ var url = buildRootUrl(selectedRoot.getId()) + "/coverImage_content";
+ byte[] oversizedContent = new byte[6 * 1024 * 1024]; // 6MB > 5MB limit
+ requestHelper.setContentType(MediaType.APPLICATION_OCTET_STREAM);
+ requestHelper.executePutWithMatcher(url, oversizedContent, status().is4xxClientError());
+ }
+
+ private Roots buildRootWithoutContent() {
+ var root = Roots.create();
+ root.setTitle("root with inline attachment");
+ return root;
+ }
+
+ private Roots buildRootWithItem() {
+ var root = Roots.create();
+ root.setTitle("root with item");
+ var items = new ArrayList();
+ var item = Items.create();
+ item.setTitle("item with inline attachment");
+ items.add(item);
+ root.setItems(items);
+ return root;
+ }
+
+ private void postServiceRoot(Roots root) throws Exception {
+ var url = MockHttpRequestHelper.ODATA_BASE_URL + "TestService/Roots";
+ requestHelper.executePostWithMatcher(url, root.toJson(), status().isCreated());
+ }
+
+ private Roots selectStoredRoot() {
+ var select = Select.from(Roots_.class).columns(StructuredType::_all);
+ return persistenceService.run(select).single(Roots.class);
+ }
+
+ private Roots selectStoredRootWithItems() {
+ var select =
+ Select.from(Roots_.class)
+ .columns(StructuredType::_all, root -> root.items().expand(StructuredType::_all));
+ return persistenceService.run(select).single(Roots.class);
+ }
+
+ private String buildRootUrl(String rootId) {
+ return MockHttpRequestHelper.ODATA_BASE_URL + "TestService/Roots(" + rootId + ")";
+ }
+
+ private String buildItemUrl(String rootId, String itemId) {
+ return MockHttpRequestHelper.ODATA_BASE_URL
+ + "TestService/Roots("
+ + rootId
+ + ")/items("
+ + itemId
+ + ")";
+ }
+
+ private String putInlineAttachmentContentOnRoot(String rootId) throws Exception {
+ return putInlineAttachmentContentOnRoot(rootId, "avatarContent");
+ }
+
+ private String putInlineAttachmentContentOnRoot(String rootId, String content) throws Exception {
+ return putInlineAttachmentContentOnRoot(rootId, content, status().isNoContent());
+ }
+
+ private String putInlineAttachmentContentOnRoot(
+ String rootId, String content, ResultMatcher matcher) throws Exception {
+ var url = buildRootUrl(rootId) + "/avatar_content";
+ requestHelper.setContentType(MediaType.APPLICATION_OCTET_STREAM);
+ requestHelper.executePutWithMatcher(url, content.getBytes(StandardCharsets.UTF_8), matcher);
+ return content;
+ }
+
+ private String putCoverImageContentOnRoot(String rootId, String content) throws Exception {
+ var url = buildRootUrl(rootId) + "/coverImage_content";
+ requestHelper.setContentType(MediaType.APPLICATION_OCTET_STREAM);
+ requestHelper.executePutWithMatcher(
+ url, content.getBytes(StandardCharsets.UTF_8), status().isNoContent());
+ return content;
+ }
+
+ private String putInlineAttachmentContentOnItem(String rootId, String itemId) throws Exception {
+ return putInlineAttachmentContentOnItem(rootId, itemId, "iconContent");
+ }
+
+ private String putInlineAttachmentContentOnItem(String rootId, String itemId, String content)
+ throws Exception {
+ var url = buildItemUrl(rootId, itemId) + "/icon_content";
+ requestHelper.setContentType(MediaType.APPLICATION_OCTET_STREAM);
+ requestHelper.executePutWithMatcher(
+ url, content.getBytes(StandardCharsets.UTF_8), status().isNoContent());
+ return content;
+ }
+
+ private void verifySingleCreateEvent(String contentId, String content) {
+ verifyEventContextEmptyForEvent(
+ AttachmentService.EVENT_READ_ATTACHMENT,
+ AttachmentService.EVENT_MARK_ATTACHMENT_AS_DELETED);
+ var createEvent =
+ serviceHandler.getEventContextForEvent(AttachmentService.EVENT_CREATE_ATTACHMENT);
+ assertThat(createEvent)
+ .hasSize(1)
+ .first()
+ .satisfies(
+ event -> {
+ assertThat(event.context()).isInstanceOf(AttachmentCreateEventContext.class);
+ var createContext = (AttachmentCreateEventContext) event.context();
+ assertThat(createContext.getContentId()).isEqualTo(contentId);
+ assertThat(createContext.getData().getContent().readAllBytes())
+ .isEqualTo(content.getBytes(StandardCharsets.UTF_8));
+ });
+ }
+
+ private void verifySingleReadEvent(String contentId) {
+ verifyEventContextEmptyForEvent(
+ AttachmentService.EVENT_CREATE_ATTACHMENT,
+ AttachmentService.EVENT_MARK_ATTACHMENT_AS_DELETED);
+ var readContext = serviceHandler.getEventContext();
+ assertThat(readContext)
+ .hasSize(1)
+ .first()
+ .satisfies(
+ event -> {
+ assertThat(event.event()).isEqualTo(AttachmentService.EVENT_READ_ATTACHMENT);
+ assertThat(((AttachmentReadEventContext) event.context()).getContentId())
+ .isEqualTo(contentId);
+ });
+ }
+
+ private void verifySingleDeletionEvent(String contentId) {
+ waitTillExpectedHandlerMessageSize(1);
+ verifyEventContextEmptyForEvent(
+ AttachmentService.EVENT_CREATE_ATTACHMENT, AttachmentService.EVENT_READ_ATTACHMENT);
+ var deleteEvents =
+ serviceHandler.getEventContextForEvent(AttachmentService.EVENT_MARK_ATTACHMENT_AS_DELETED);
+ assertThat(deleteEvents)
+ .hasSize(1)
+ .first()
+ .satisfies(
+ event -> {
+ assertThat(event.context()).isInstanceOf(AttachmentMarkAsDeletedEventContext.class);
+ var deleteContext = (AttachmentMarkAsDeletedEventContext) event.context();
+ assertThat(deleteContext.getContentId()).isEqualTo(contentId);
+ assertThat(deleteContext.getDeletionUserInfo().getName()).isEqualTo("anonymous");
+ assertThat(deleteContext.getDeletionUserInfo().getIsSystemUser()).isFalse();
+ });
+ }
+
+ private void verifySingleCreateAndDeleteEvent(
+ String newContentId, String deletedContentId, String content) {
+ waitTillExpectedHandlerMessageSize(2);
+ verifyEventContextEmptyForEvent(AttachmentService.EVENT_READ_ATTACHMENT);
+ var createEvents =
+ serviceHandler.getEventContextForEvent(AttachmentService.EVENT_CREATE_ATTACHMENT);
+ assertThat(createEvents).hasSize(1);
+ assertThat(createEvents)
+ .first()
+ .satisfies(
+ event -> {
+ var createContext = (AttachmentCreateEventContext) event.context();
+ assertThat(createContext.getContentId()).isEqualTo(newContentId);
+ assertThat(createContext.getData().getContent().readAllBytes())
+ .isEqualTo(content.getBytes(StandardCharsets.UTF_8));
+ });
+
+ var deleteEvents =
+ serviceHandler.getEventContextForEvent(AttachmentService.EVENT_MARK_ATTACHMENT_AS_DELETED);
+ assertThat(deleteEvents)
+ .hasSize(1)
+ .first()
+ .satisfies(
+ event -> {
+ var deleteContext = (AttachmentMarkAsDeletedEventContext) event.context();
+ assertThat(deleteContext.getContentId()).isEqualTo(deletedContentId);
+ });
+ }
+
+ private void verifyTwoDeletionEvents(String contentId1, String contentId2) {
+ waitTillExpectedHandlerMessageSize(2);
+ verifyEventContextEmptyForEvent(
+ AttachmentService.EVENT_CREATE_ATTACHMENT, AttachmentService.EVENT_READ_ATTACHMENT);
+ var deleteEvents =
+ serviceHandler.getEventContextForEvent(AttachmentService.EVENT_MARK_ATTACHMENT_AS_DELETED);
+ assertThat(deleteEvents).hasSize(2);
+ assertThat(
+ deleteEvents.stream()
+ .anyMatch(event -> verifyDeleteEventContentIdAndUserInfo(event, contentId1)))
+ .isTrue();
+ assertThat(
+ deleteEvents.stream()
+ .anyMatch(event -> verifyDeleteEventContentIdAndUserInfo(event, contentId2)))
+ .isTrue();
+ }
+
+ private boolean verifyDeleteEventContentIdAndUserInfo(
+ EventContextHolder event, String contentId) {
+ var ctx = (AttachmentMarkAsDeletedEventContext) event.context();
+ return ctx.getContentId().equals(contentId)
+ && "anonymous".equals(ctx.getDeletionUserInfo().getName())
+ && Boolean.FALSE.equals(ctx.getDeletionUserInfo().getIsSystemUser());
+ }
+
+ private void verifyNoAttachmentEventsCalled() {
+ assertThat(serviceHandler.getEventContext()).isEmpty();
+ }
+
+ private void verifyEventContextEmptyForEvent(String... events) {
+ for (var event : events) {
+ assertThat(serviceHandler.getEventContextForEvent(event)).isEmpty();
+ }
+ }
+
+ private void waitTillExpectedHandlerMessageSize(int expectedSize) {
+ Awaitility.await()
+ .atMost(30, TimeUnit.SECONDS)
+ .pollDelay(1, TimeUnit.SECONDS)
+ .until(() -> serviceHandler.getEventContext().size() >= expectedSize);
+ }
+}
diff --git a/integration-tests/generic/test-service.cds b/integration-tests/generic/test-service.cds
index ff68a31ff..163ac0f4d 100644
--- a/integration-tests/generic/test-service.cds
+++ b/integration-tests/generic/test-service.cds
@@ -16,6 +16,10 @@ annotate db.Roots.mimeValidatedAttachments with {
content @(Core.AcceptableMediaTypes: ['application/pdf']);
}
+annotate db.Roots:coverImage with {
+ content @Validation.Maximum: '5MB';
+};
+
service TestService {
entity Roots as projection on db.Roots;
entity AttachmentEntity as projection on db.AttachmentEntity;
diff --git a/integration-tests/mtx-local/db/schema.cds b/integration-tests/mtx-local/db/schema.cds
index 9edbfde50..eb2e45d95 100644
--- a/integration-tests/mtx-local/db/schema.cds
+++ b/integration-tests/mtx-local/db/schema.cds
@@ -1,7 +1,7 @@
namespace mt.test.data;
-using { cuid } from '@sap/cds/common';
-using { sap.attachments.Attachments } from 'com.sap.cds/cds-feature-attachments';
+using {cuid} from '@sap/cds/common';
+using {sap.attachments.Attachments} from 'com.sap.cds/cds-feature-attachments';
entity Documents : cuid {
title : String;
diff --git a/pom.xml b/pom.xml
index 2e775ac3b..f25015323 100644
--- a/pom.xml
+++ b/pom.xml
@@ -71,13 +71,13 @@
- 4.6.1
+ 4.9.0
- 9.6.1
+ 9.9.0
- 4.8.0
- 9.7.2
+ 4.9.0
+ 9.9.0
true
@@ -238,11 +238,6 @@
jacoco-maven-plugin
0.8.14
-
- org.pitest
- pitest-maven
- 1.23.0
-
com.github.spotbugs
spotbugs-maven-plugin
diff --git a/samples/bookshop/README.md b/samples/bookshop/README.md
index 7fe10868d..023c7d593 100644
--- a/samples/bookshop/README.md
+++ b/samples/bookshop/README.md
@@ -19,16 +19,19 @@ This sample demonstrates how to use the `cds-feature-attachments` plugin in a CA
## Getting Started
1. **Clone and navigate to the sample**:
+
```bash
cd samples/bookshop
```
2. **Install dependencies**:
+
```bash
mvn clean compile
```
3. **Run the application**:
+
```bash
mvn spring-boot:run
```
@@ -87,7 +90,7 @@ The `srv/attachments.cds` file extends the Books entity with attachments:
```cds
using { sap.capire.bookshop as my } from '../db/schema';
-using { sap.attachments.Attachments } from 'com.sap.cds/cds-feature-attachments';
+using { Attachments } from 'com.sap.cds/cds-feature-attachments';
extend my.Books with {
attachments: Composition of many Attachments;
diff --git a/samples/bookshop/pom.xml b/samples/bookshop/pom.xml
index a49e3c384..069719e4a 100644
--- a/samples/bookshop/pom.xml
+++ b/samples/bookshop/pom.xml
@@ -12,9 +12,10 @@
bookshop parent
+ 1.5.0
17
- 4.6.1
+ 4.9.0
3.5.7
UTF-8
@@ -48,7 +49,7 @@
com.sap.cds
cds-feature-attachments
- 1.5.0
+ ${cds.feature.attachments.version}
diff --git a/samples/bookshop/srv/attachments.cds b/samples/bookshop/srv/attachments.cds
index 0528bf1bc..917c43a87 100644
--- a/samples/bookshop/srv/attachments.cds
+++ b/samples/bookshop/srv/attachments.cds
@@ -1,5 +1,8 @@
using {sap.capire.bookshop as my} from '../db/schema';
-using {sap.attachments.Attachments} from 'com.sap.cds/cds-feature-attachments';
+using {
+ 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
@@ -23,6 +26,16 @@ annotate my.Books.mediaValidatedAttachments with {
];
}
+// Extend Books entity with inline single-file attachments
+extend my.Books with {
+ profileIcon : Attachment;
+ coverImage : Attachment;
+}
+
+annotate my.Books : profileIcon with {
+ content @Validation.Maximum: '1MB' @Core.AcceptableMediaTypes: ['image/*'];
+}
+
// Add UI component for attachments table to the Browse Books App
using {CatalogService as service} from '../app/services';
@@ -36,14 +49,39 @@ annotate service.Books with @(UI.Facets: [{
// 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: 'attachments/@UI.LineItem'
-}]);
-
+annotate adminService.Books with @(UI.Facets: [
+ {
+ $Type : 'UI.ReferenceFacet',
+ ID : 'AttachmentsFacet',
+ Label : '{i18n>attachments}',
+ Target: 'attachments/@UI.LineItem'
+ },
+ {
+ $Type : 'UI.ReferenceFacet',
+ Label : 'Profile Icon',
+ Target: '@UI.FieldGroup#ProfileIcon'
+ },
+ {
+ $Type : 'UI.ReferenceFacet',
+ Label : 'Cover Image',
+ Target: '@UI.FieldGroup#CoverImage'
+ }
+]);
-service nonDraft {
- entity Books as projection on my.Books;
-}
+annotate adminService.Books with @(UI: {
+ FieldGroup #ProfileIcon: {Data: [
+ {
+ Value: profileIcon_content,
+ Label: 'Download'
+ },
+ {Value: profileIcon_fileName},
+ {Value: profileIcon_status},
+ {Value: profileIcon_note}
+ ]},
+ FieldGroup #CoverImage : {Data: [
+ {Value: coverImage_content},
+ {Value: coverImage_fileName},
+ {Value: coverImage_status},
+ {Value: coverImage_note}
+ ]}
+});