Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
51e61ae
implement max file size
Schmarvinius Dec 27, 2025
d8dde3c
improvements
Schmarvinius Dec 28, 2025
ca2d3a8
Apply suggestions from code review
Schmarvinius Jan 12, 2026
9649f8d
update exception handling
Schmarvinius Jan 12, 2026
09997be
wip
Schmarvinius Jan 12, 2026
d71cae3
status quo
Schmarvinius Jan 13, 2026
8b40080
Refactor attachment validation and size handling; add message propert…
Schmarvinius Jan 13, 2026
9d58374
Merge branch 'main' into validation-maximum
Schmarvinius Jan 13, 2026
149aa2b
formatting and stuff
Schmarvinius Jan 13, 2026
3a2165f
fix tests
Schmarvinius Jan 13, 2026
ff5a414
tests
Schmarvinius Jan 13, 2026
96a83fb
wip
Schmarvinius Jan 14, 2026
9df040a
Merge branch 'main' into validation-maximum
Schmarvinius Jan 15, 2026
0e26d1a
Apply suggestions from code review
Schmarvinius Jan 15, 2026
29dd214
improvements
Schmarvinius Jan 15, 2026
eeb811b
updates
Schmarvinius Jan 15, 2026
1909f5c
add stream wrapper
Schmarvinius Jan 16, 2026
4a22def
fix error message
Schmarvinius Jan 20, 2026
941e3dc
add tests
Schmarvinius Jan 20, 2026
31051d4
add unit tests for attachment handling and validation limits
Schmarvinius Jan 21, 2026
c0741e5
Merge branch 'main' into validation-maximum
Schmarvinius Jan 21, 2026
85c2a80
Apply suggestions from code review
Schmarvinius Jan 21, 2026
57fc125
minor updates
Schmarvinius Jan 21, 2026
1f29957
remove formatting
Schmarvinius Jan 21, 2026
ad28673
feat(attachments): add configurable maximum file size validation
Schmarvinius Jan 21, 2026
ceaca2a
pipeline changes
Schmarvinius Jan 21, 2026
3739967
remove unused logic
Schmarvinius Jan 22, 2026
574d1ba
license stuff
Schmarvinius Jan 22, 2026
0ed298c
remove logging setting
Schmarvinius Jan 22, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,14 @@

import static java.util.Objects.requireNonNull;

import java.io.IOException;

import com.sap.cds.CdsData;
import com.sap.cds.CdsDataProcessor;
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.feature.attachments.handler.applicationservice.helper.FileSizeUtils;
import com.sap.cds.feature.attachments.handler.applicationservice.helper.ModifyApplicationHandlerHelper;
import com.sap.cds.feature.attachments.handler.applicationservice.helper.ReadonlyDataContextEnhancer;
import com.sap.cds.feature.attachments.handler.applicationservice.helper.ThreadDataStorageReader;
Expand All @@ -30,18 +34,26 @@
import com.sap.cds.services.utils.model.CqnUtils;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
* The class {@link UpdateAttachmentsHandler} is an event handler that is called before an update
* event is executed. As updates in draft entities or non-draft entities can also be create-events,
* update-events or delete-events the handler needs to distinguish between the different cases.
* The class {@link UpdateAttachmentsHandler} is an event handler that is called
* before an update
* event is executed. As updates in draft entities or non-draft entities can
* also be create-events,
* update-events or delete-events the handler needs to distinguish between the
* different cases.
*/
@ServiceName(value = "*", type = ApplicationService.class)
public class UpdateAttachmentsHandler implements EventHandler {

private static final Logger logger = LoggerFactory.getLogger(UpdateAttachmentsHandler.class);
public static final Filter VALMAX_FILTER = (path, element, type) -> element.getName().contentEquals("content") && element.findAnnotation("Validation.Maximum")
.isPresent();

private final ModifyAttachmentEventFactory eventFactory;
private final AttachmentsReader attachmentsReader;
Expand All @@ -54,17 +66,16 @@ public UpdateAttachmentsHandler(
AttachmentService attachmentService,
ThreadDataStorageReader storageReader) {
this.eventFactory = requireNonNull(eventFactory, "eventFactory must not be null");
this.attachmentsReader =
requireNonNull(attachmentsReader, "attachmentsReader must not be null");
this.attachmentService =
requireNonNull(attachmentService, "attachmentService must not be null");
this.attachmentsReader = requireNonNull(attachmentsReader, "attachmentsReader must not be null");
this.attachmentService = requireNonNull(attachmentService, "attachmentService must not be null");
this.storageReader = requireNonNull(storageReader, "storageReader must not be null");
}

@Before
@HandlerOrder(OrderConstants.Before.CHECK_CAPABILITIES)
void processBeforeForDraft(CdsUpdateEventContext context, List<CdsData> data) {
// before the attachment's readonly fields are removed by the runtime, preserve them in a custom
// before the attachment's readonly fields are removed by the runtime, preserve
// them in a custom
// field in data
ReadonlyDataContextEnhancer.preserveReadonlyFields(
context.getTarget(), data, storageReader.get());
Expand All @@ -77,14 +88,29 @@ void processBefore(CdsUpdateEventContext context, List<CdsData> data) {
boolean associationsAreUnchanged = associationsAreUnchanged(target, data);

if (ApplicationHandlerHelper.containsContentField(target, data) || !associationsAreUnchanged) {
// Check here for size of new attachments
if (containsValMaxAnnotation(target, data)) {
List<Attachments> attachments = ApplicationHandlerHelper.condenseAttachments(data, target);
long maxSizeValue = FileSizeUtils.convertValMaxToInt(getValMaxValue(target, data));
attachments.forEach(attachment -> {
try {
int size = attachment.getContent().available();
if (size > maxSizeValue) {
throw new IllegalArgumentException("Attachment " + attachment.getFileName() + " exceeds the maximum allowed size of " + maxSizeValue + " bytes.");
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Message format uses raw byte count which is not user-friendly

The error message displays the maximum size as raw bytes (e.g., "20000000 bytes" for 20MB), which is difficult for users to understand. The original annotation value (e.g., "20MB") would be more meaningful in error messages.

Consider preserving the original annotation value string or formatting the byte count back to a human-readable format (MB, GB, etc.) for the error message.


Please provide feedback on the review comment by checking the appropriate box:

  • 🌟 Awesome comment, a human might have missed that.
  • ✅ Helpful comment
  • 🤷 Neutral
  • ❌ This comment is not helpful

}
} catch (IOException e) {
throw new RuntimeException("Failed to read attachment content size", e);
}
});
logger.debug("Validation.Maximum annotation found with value: {}", maxSizeValue);
}

logger.debug("Processing before {} event for entity {}", context.getEvent(), target);

CqnSelect select = CqnUtils.toSelect(context.getCqn(), context.getTarget());
List<Attachments> attachments =
attachmentsReader.readAttachments(context.getModel(), target, select);
List<Attachments> attachments = attachmentsReader.readAttachments(context.getModel(), target, select);

List<Attachments> condensedAttachments =
ApplicationHandlerHelper.condenseAttachments(attachments, target);
List<Attachments> condensedAttachments = ApplicationHandlerHelper.condenseAttachments(attachments, target);
ModifyApplicationHandlerHelper.handleAttachmentForEntities(
target, data, condensedAttachments, eventFactory, context);

Expand All @@ -94,8 +120,28 @@ void processBefore(CdsUpdateEventContext context, List<CdsData> data) {
}
}

private String getValMaxValue(CdsEntity entity, List<? extends CdsData> data) {
AtomicReference<String> annotationValue = new AtomicReference<>();
CdsDataProcessor.create()
.addValidator(VALMAX_FILTER, (path, element, value) -> {
element.findAnnotation("Validation.Maximum")
.ifPresent(annotation -> annotationValue.set(annotation.getValue().toString()));
})
.process(data, entity);
return annotationValue.get();
}

private boolean containsValMaxAnnotation(CdsEntity entity, List<? extends CdsData> data) {
AtomicBoolean isIncluded = new AtomicBoolean();
CdsDataProcessor.create()
.addValidator(VALMAX_FILTER, (path, element, value) -> isIncluded.set(true))
.process(data, entity);
return isIncluded.get();
}

private boolean associationsAreUnchanged(CdsEntity entity, List<CdsData> data) {
// TODO: check if this should be replaced with entity.assocations().noneMatch(...)
// TODO: check if this should be replaced with
// entity.assocations().noneMatch(...)
return entity
.compositions()
.noneMatch(
Expand All @@ -107,21 +153,18 @@ private void deleteRemovedAttachments(
List<CdsData> data,
CdsEntity entity,
UserInfo userInfo) {
List<Attachments> condensedAttachments =
ApplicationHandlerHelper.condenseAttachments(data, entity);

Validator validator =
(path, element, value) -> {
Map<String, Object> keys = ApplicationHandlerHelper.removeDraftKey(path.target().keys());
boolean entryExists =
condensedAttachments.stream()
.anyMatch(
updatedData -> ApplicationHandlerHelper.areKeysInData(keys, updatedData));
if (!entryExists) {
String contentId = (String) path.target().values().get(Attachments.CONTENT_ID);
attachmentService.markAttachmentAsDeleted(new MarkAsDeletedInput(contentId, userInfo));
}
};
List<Attachments> condensedAttachments = ApplicationHandlerHelper.condenseAttachments(data, entity);

Validator validator = (path, element, value) -> {
Map<String, Object> keys = ApplicationHandlerHelper.removeDraftKey(path.target().keys());
boolean entryExists = condensedAttachments.stream()
.anyMatch(
updatedData -> ApplicationHandlerHelper.areKeysInData(keys, updatedData));
if (!entryExists) {
String contentId = (String) path.target().values().get(Attachments.CONTENT_ID);
attachmentService.markAttachmentAsDeleted(new MarkAsDeletedInput(contentId, userInfo));
}
};
CdsDataProcessor.create()
.addValidator(ApplicationHandlerHelper.MEDIA_CONTENT_FILTER, validator)
.process(existingAttachments, entity);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package com.sap.cds.feature.attachments.handler.applicationservice.helper;

import java.math.BigDecimal;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

public class FileSizeUtils {
private static final Pattern SIZE = Pattern.compile("^\\s*([0-9]+(?:\\.[0-9]+)?)\\s*([a-zA-Z]*)\\s*$");
private static final Map<String, Long> MULTIPLIER = Map.ofEntries(
Map.entry("", 1L),
Map.entry("B", 1L),

// Decimal
Map.entry("KB", 1000L),
Map.entry("MB", 1000L * 1000),
Map.entry("GB", 1000L * 1000 * 1000),
Map.entry("TB", 1000L * 1000 * 1000 * 1000),

// Binary
Map.entry("KIB", 1024L),
Map.entry("MIB", 1024L * 1024),
Map.entry("GIB", 1024L * 1024 * 1024),
Map.entry("TIB", 1024L * 1024 * 1024 * 1024));

private FileSizeUtils() {}

public static long convertValMaxToInt(String input) {
// First validate string
if (input == null)
throw new IllegalArgumentException("Value for Max File Size is null");

Matcher m = SIZE.matcher(input);
if (!m.matches()) {
throw new IllegalArgumentException("Invalid size: " + input);
}
BigDecimal value = new BigDecimal(m.group(1));
String unitRaw = m.group(2) == null ? "" : m.group(2);
String unit = unitRaw.toUpperCase();

// if (unit.length() == 1) unit = unit + "B"; // for people using K instead of KB
Long mul = MULTIPLIER.get(unit);
if (mul == null) {
throw new IllegalArgumentException("Unkown Unit: " + unitRaw);
}
BigDecimal bytes = value.multiply(BigDecimal.valueOf(mul));
return bytes.longValueExact();
}
}
2 changes: 1 addition & 1 deletion pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@
</distributionManagement>

<properties>
<revision>1.2.4</revision>
<revision>1.2.5-SNAPSHOT</revision>
<java.version>17</java.version>
<maven.compiler.release>${java.version}</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
Expand Down
3 changes: 3 additions & 0 deletions samples/bookshop/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,6 @@ hs_err*
.vscode
.idea
.reloadtrigger

# added by cds
.cdsrc-private.json
2 changes: 1 addition & 1 deletion samples/bookshop/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@
<dependency>
<groupId>com.sap.cds</groupId>
<artifactId>cds-feature-attachments</artifactId>
<version>1.2.4-SNAPSHOT</version>
<version>1.2.5-SNAPSHOT</version>
</dependency>
</dependencies>
</dependencyManagement>
Expand Down
5 changes: 5 additions & 0 deletions samples/bookshop/srv/attachments.cds
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ extend my.Books with {
attachments : Composition of many Attachments;
}

annotate my.Books.attachments with {
content @Validation.Maximum: '20MB';
}


// Add UI component for attachments table to the Browse Books App
using {CatalogService as service} from '../app/services';

Expand Down