Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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,6 +5,7 @@

import com.sap.cds.feature.attachments.handler.applicationservice.CreateAttachmentsHandler;
import com.sap.cds.feature.attachments.handler.applicationservice.DeleteAttachmentsHandler;
import com.sap.cds.feature.attachments.handler.applicationservice.ItemsCountValidationHandler;
import com.sap.cds.feature.attachments.handler.applicationservice.ReadAttachmentsHandler;
import com.sap.cds.feature.attachments.handler.applicationservice.UpdateAttachmentsHandler;
import com.sap.cds.feature.attachments.handler.applicationservice.helper.ModifyApplicationHandlerHelper;
Expand Down Expand Up @@ -133,6 +134,7 @@ public void eventHandlers(CdsRuntimeConfigurer configurer) {
configurer.eventHandler(
new ReadAttachmentsHandler(
attachmentService, new AttachmentStatusValidator(), scanRunner));
configurer.eventHandler(new ItemsCountValidationHandler(storage));
} else {
logger.debug(
"No application service is available. Application service event handlers will not be registered.");
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
/*
* © 2024-2026 SAP SE or an SAP affiliate company and cds-feature-attachments contributors.
*/
package com.sap.cds.feature.attachments.handler.applicationservice;

import static java.util.Objects.requireNonNull;

import com.sap.cds.CdsData;
import com.sap.cds.feature.attachments.handler.applicationservice.helper.ThreadDataStorageReader;
import com.sap.cds.reflect.CdsAnnotation;
import com.sap.cds.reflect.CdsAssociationType;
import com.sap.cds.reflect.CdsEntity;
import com.sap.cds.services.EventContext;
import com.sap.cds.services.cds.ApplicationService;
import com.sap.cds.services.cds.CdsCreateEventContext;
import com.sap.cds.services.cds.CdsUpdateEventContext;
import com.sap.cds.services.cds.CqnService;
import com.sap.cds.services.handler.EventHandler;
import com.sap.cds.services.handler.annotations.Before;
import com.sap.cds.services.handler.annotations.HandlerOrder;
import com.sap.cds.services.handler.annotations.ServiceName;
import com.sap.cds.services.messages.Message;
import com.sap.cds.services.messages.Messages;
import java.util.List;
import java.util.MissingResourceException;
import java.util.Optional;
import java.util.ResourceBundle;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
* The class {@link ItemsCountValidationHandler} is an event handler that validates the number of
* items in compositions annotated with {@code @Validation.MaxItems} or
* {@code @Validation.MinItems}.
*
* <p>During draft activation (draft save), violations are reported as warnings. During direct
* active CREATE/UPDATE operations, violations are reported as errors.
*
* <p>The annotation values can be:
*
* <ul>
* <li>An integer literal, e.g. {@code @Validation.MaxItems: 20}
* <li>A property reference (string), e.g. {@code @Validation.MaxItems: 'stock'} — the property
* value is looked up from the entity data at runtime
* </ul>
*
* <p>Error messages can be overridden per entity/property using the i18n key pattern: {@code
* Validation_MaxItems/<EntityName>/<PropertyName>}. If no specific key is found, the base key
* {@code Validation_MaxItems} is used.
*/
@ServiceName(value = "*", type = ApplicationService.class)
public class ItemsCountValidationHandler implements EventHandler {

private static final Logger logger = LoggerFactory.getLogger(ItemsCountValidationHandler.class);

static final String ANNOTATION_MAX_ITEMS = "Validation.MaxItems";
static final String ANNOTATION_MIN_ITEMS = "Validation.MinItems";
static final String MESSAGE_KEY_MAX_ITEMS = "Validation_MaxItems";
static final String MESSAGE_KEY_MIN_ITEMS = "Validation_MinItems";

private final ThreadDataStorageReader storageReader;

public ItemsCountValidationHandler(ThreadDataStorageReader storageReader) {
this.storageReader = requireNonNull(storageReader, "storageReader must not be null");
}

@Before(event = CqnService.EVENT_CREATE)
@HandlerOrder(HandlerOrder.LATE)
void validateOnCreate(CdsCreateEventContext context, List<CdsData> data) {
validateItemsCount(context, context.getTarget(), data);
}

@Before(event = CqnService.EVENT_UPDATE)
@HandlerOrder(HandlerOrder.LATE)
void validateOnUpdate(CdsUpdateEventContext context, List<CdsData> data) {
validateItemsCount(context, context.getTarget(), data);
}

void validateItemsCount(EventContext context, CdsEntity entity, List<CdsData> data) {
boolean isDraftActivation = storageReader.get();
Messages messages = context.getMessages();

entity
.elements()
Copy link
Contributor

Choose a reason for hiding this comment

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

Bug: entity.elements() is consumed by the Stream.forEach inside a loop over data, but a Stream can only be iterated once

entity.elements() returns a Stream. At line 83 it is chained with .filter(...).forEach(element -> { for (CdsData d : data) { ... } }). The outer loop is over elements() (via forEach) and the inner loop is over data — this is correct and not a double-consumption issue here. However, if entity.elements() is ever called more than once (e.g. for re-validation), callers must be aware the stream is fresh each invocation. This is fine as long as CdsEntity.elements() returns a new stream each call, which is the typical CAP SDK contract but is worth confirming. No action required if confirmed; flag for awareness.


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

.filter(
element ->
element.getType().isAssociation()
&& element.getType().as(CdsAssociationType.class).isComposition())
.forEach(
element -> {
Optional<Object> maxItemsOpt =
element
.findAnnotation(ANNOTATION_MAX_ITEMS)
.map(CdsAnnotation::getValue)
.filter(v -> !"true".equals(v.toString()));
Optional<Object> minItemsOpt =
element
.findAnnotation(ANNOTATION_MIN_ITEMS)
.map(CdsAnnotation::getValue)
.filter(v -> !"true".equals(v.toString()));

if (maxItemsOpt.isEmpty() && minItemsOpt.isEmpty()) {
return;
}

String compositionName = element.getName();

for (CdsData d : data) {
Object compositionData = d.get(compositionName);
if (compositionData instanceof List<?> items) {
int count = items.size();

if (maxItemsOpt.isPresent()) {
int maxItems = resolveAnnotationValue(maxItemsOpt.get(), d);
if (maxItems >= 0 && count > maxItems) {
String messageKey =
resolveMessageKey(
MESSAGE_KEY_MAX_ITEMS, entity.getQualifiedName(), compositionName);
logger.debug(
"MaxItems violation on {}.{}: count={}, max={}",
entity.getQualifiedName(),
compositionName,
count,
maxItems);
Message message =
isDraftActivation
? messages.warn(messageKey, compositionName, maxItems, count)
: messages.error(messageKey, compositionName, maxItems, count);
message.target("in", b -> b.to(compositionName));
}
}

if (minItemsOpt.isPresent()) {
int minItems = resolveAnnotationValue(minItemsOpt.get(), d);
if (minItems >= 0 && count < minItems) {
String messageKey =
resolveMessageKey(
MESSAGE_KEY_MIN_ITEMS, entity.getQualifiedName(), compositionName);
logger.debug(
"MinItems violation on {}.{}: count={}, min={}",
entity.getQualifiedName(),
compositionName,
count,
minItems);
Message message =
isDraftActivation
? messages.warn(messageKey, compositionName, minItems, count)
: messages.error(messageKey, compositionName, minItems, count);
message.target("in", b -> b.to(compositionName));
}
}
}
}
});

if (!isDraftActivation) {
messages.throwIfError();
Copy link
Contributor

Choose a reason for hiding this comment

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

Logic Error: throwIfError() is called unconditionally after all elements are processed, even when no compositions with annotations were present

When isDraftActivation is false, messages.throwIfError() is always called at line 157, regardless of whether any annotated compositions were found or whether any error was added. While harmless if throwIfError is a no-op with no errors, the intent per the Javadoc is that this is only meaningful after violations were detected. More importantly, if no compositions have constraints, the loop short-circuits at line 103, but throwIfError is still invoked unconditionally outside the loop. This is wasteful and may cause unintended side-effects depending on the Messages implementation.

Consider only calling throwIfError() when at least one error was actually emitted, or move the call inside the loop after the violation check block.


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

}
}

/**
* Resolves the annotation value to an integer. Supports:
*
* <ul>
* <li>Integer/Number literals — used directly
* <li>String values — first tried as integer literal, then as a property reference in the
* entity data
* </ul>
*
* @param annotationValue the raw annotation value
* @param data the entity data for property reference resolution
* @return the resolved integer value, or -1 if resolution failed
*/
static int resolveAnnotationValue(Object annotationValue, CdsData data) {
if (annotationValue instanceof Number number) {
return number.intValue();
}
if (annotationValue instanceof String stringValue) {
try {
return Integer.parseInt(stringValue);
} catch (NumberFormatException e) {
// Treat as property reference
Object propertyValue = data.get(stringValue);
if (propertyValue instanceof Number number) {
return number.intValue();
}
}
}
return -1;
Copy link
Contributor

Choose a reason for hiding this comment

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

Logic Error: Silent fallback to -1 masks unresolvable annotation values

When resolveAnnotationValue cannot resolve the annotation value (unsupported type, non-existent property reference, non-numeric property value), it returns -1. The caller then skips validation with the >= 0 guard (if (maxItems >= 0 && ...) / if (minItems >= 0 && ...)). This means a misconfigured annotation like @Validation.MaxItems: 'nonExistentProp' silently produces no validation, offering no feedback to the developer. At minimum a logger.warn should be emitted when resolution fails to make misconfiguration diagnosable.

Suggested change
return -1;
logger.warn(
"Could not resolve annotation value '{}' to an integer. Skipping validation.",
annotationValue);
return -1;
}

Double-check suggestion before committing. Edit this comment for amendments.


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

}

/**
* Resolves the i18n message key following the Fiori elements approach. First tries a specific key
* in the format {@code baseKey/entityName/propertyName}. If the specific key is defined in the
* application's {@code messages.properties}, it is used. Otherwise, falls back to the base key.
*
* <p>Applications can override the error message for a specific entity/property by defining the
* specific key in their {@code messages.properties}:
*
* <pre>
* Validation_MaxItems/my.Entity/attachments = Custom message for {0}, max {1}, current {2}
* </pre>
*
* @param baseKey the base message key (e.g. {@code Validation_MaxItems})
* @param entityName the fully qualified entity name
* @param propertyName the composition property name
* @return the specific key if defined in the message bundle, otherwise the base key
*/
static String resolveMessageKey(String baseKey, String entityName, String propertyName) {
String specificKey = baseKey + "/" + entityName + "/" + propertyName;
try {
ResourceBundle bundle = ResourceBundle.getBundle("messages");
Copy link
Contributor

Choose a reason for hiding this comment

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

Bug: resolveMessageKey loads a new ResourceBundle on every call, with no caching

ResourceBundle.getBundle("messages") is called for every composition element that has a constraint, on every CREATE/UPDATE event. ResourceBundle.getBundle involves class-loading, I/O and synchronisation overhead. In practice, the static bundle is re-loaded thousands of times per request in a busy application.

Consider caching the bundle (or the lookup result) in a static final field, or wrapping getBundle in a try-catch at class initialisation time and storing the result:

Suggested change
ResourceBundle bundle = ResourceBundle.getBundle("messages");
static String resolveMessageKey(String baseKey, String entityName, String propertyName) {
String specificKey = baseKey + "/" + entityName + "/" + propertyName;
try {
ResourceBundle bundle = ResourceBundle.getBundle("messages");
bundle.getString(specificKey);
return specificKey;
} catch (MissingResourceException e) {
return baseKey;
}
}

Double-check suggestion before committing. Edit this comment for amendments.


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

bundle.getString(specificKey);
return specificKey;
} catch (MissingResourceException e) {
return baseKey;
}
}
}
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
AttachmentSizeExceeded = File size exceeds the limit of {0}.
AttachmentSizeExceeded = File size exceeds the limit of {0}.
Validation_MaxItems = The number of items for ''{0}'' exceeds the maximum of {1}. Current count: {2}.
Validation_MinItems = The number of items for ''{0}'' is below the minimum of {1}. Current count: {2}.
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

import com.sap.cds.feature.attachments.handler.applicationservice.CreateAttachmentsHandler;
import com.sap.cds.feature.attachments.handler.applicationservice.DeleteAttachmentsHandler;
import com.sap.cds.feature.attachments.handler.applicationservice.ItemsCountValidationHandler;
import com.sap.cds.feature.attachments.handler.applicationservice.ReadAttachmentsHandler;
import com.sap.cds.feature.attachments.handler.applicationservice.UpdateAttachmentsHandler;
import com.sap.cds.feature.attachments.handler.draftservice.DraftActiveAttachmentsHandler;
Expand Down Expand Up @@ -108,7 +109,7 @@ void handlersAreRegistered() {

cut.eventHandlers(configurer);

var handlerSize = 8;
var handlerSize = 9;
verify(configurer, times(handlerSize)).eventHandler(handlerArgumentCaptor.capture());
checkHandlers(handlerArgumentCaptor.getAllValues(), handlerSize);
}
Expand All @@ -128,7 +129,7 @@ void handlersAreRegisteredWithoutOutboxService() {

cut.eventHandlers(configurer);

var handlerSize = 8;
var handlerSize = 9;
verify(configurer, times(handlerSize)).eventHandler(handlerArgumentCaptor.capture());
checkHandlers(handlerArgumentCaptor.getAllValues(), handlerSize);
}
Expand All @@ -140,6 +141,7 @@ private void checkHandlers(List<EventHandler> handlers, int handlerSize) {
isHandlerForClassIncluded(handlers, UpdateAttachmentsHandler.class);
isHandlerForClassIncluded(handlers, DeleteAttachmentsHandler.class);
isHandlerForClassIncluded(handlers, ReadAttachmentsHandler.class);
isHandlerForClassIncluded(handlers, ItemsCountValidationHandler.class);
isHandlerForClassIncluded(handlers, DraftPatchAttachmentsHandler.class);
isHandlerForClassIncluded(handlers, DraftCancelAttachmentsHandler.class);
isHandlerForClassIncluded(handlers, DraftActiveAttachmentsHandler.class);
Expand All @@ -166,6 +168,7 @@ void lessHandlersAreRegistered() {
isHandlerForClassMissing(handlers, UpdateAttachmentsHandler.class);
isHandlerForClassMissing(handlers, DeleteAttachmentsHandler.class);
isHandlerForClassMissing(handlers, ReadAttachmentsHandler.class);
isHandlerForClassMissing(handlers, ItemsCountValidationHandler.class);
// event handlers for draft services are not registered
isHandlerForClassMissing(handlers, DraftPatchAttachmentsHandler.class);
isHandlerForClassMissing(handlers, DraftCancelAttachmentsHandler.class);
Expand Down
Loading
Loading