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 e99586ea3..f32d683a1 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
@@ -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;
@@ -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.");
diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/ItemsCountValidationHandler.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/ItemsCountValidationHandler.java
new file mode 100644
index 000000000..ed3e94579
--- /dev/null
+++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/ItemsCountValidationHandler.java
@@ -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}.
+ *
+ *
During draft activation (draft save), violations are reported as warnings. During direct
+ * active CREATE/UPDATE operations, violations are reported as errors.
+ *
+ *
The annotation values can be:
+ *
+ *
+ * An integer literal, e.g. {@code @Validation.MaxItems: 20}
+ * A property reference (string), e.g. {@code @Validation.MaxItems: 'stock'} — the property
+ * value is looked up from the entity data at runtime
+ *
+ *
+ * Error messages can be overridden per entity/property using the i18n key pattern: {@code
+ * Validation_MaxItems//}. 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 data) {
+ validateItemsCount(context, context.getTarget(), data);
+ }
+
+ @Before(event = CqnService.EVENT_UPDATE)
+ @HandlerOrder(HandlerOrder.LATE)
+ void validateOnUpdate(CdsUpdateEventContext context, List data) {
+ validateItemsCount(context, context.getTarget(), data);
+ }
+
+ void validateItemsCount(EventContext context, CdsEntity entity, List data) {
+ boolean isDraftActivation = storageReader.get();
+ Messages messages = context.getMessages();
+
+ entity
+ .elements()
+ .filter(
+ element ->
+ element.getType().isAssociation()
+ && element.getType().as(CdsAssociationType.class).isComposition())
+ .forEach(
+ element -> {
+ Optional maxItemsOpt =
+ element
+ .findAnnotation(ANNOTATION_MAX_ITEMS)
+ .map(CdsAnnotation::getValue)
+ .filter(v -> !"true".equals(v.toString()));
+ Optional 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();
+ }
+ }
+
+ /**
+ * Resolves the annotation value to an integer. Supports:
+ *
+ *
+ * Integer/Number literals — used directly
+ * String values — first tried as integer literal, then as a property reference in the
+ * entity data
+ *
+ *
+ * @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;
+ }
+
+ /**
+ * 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.
+ *
+ * Applications can override the error message for a specific entity/property by defining the
+ * specific key in their {@code messages.properties}:
+ *
+ *
+ * Validation_MaxItems/my.Entity/attachments = Custom message for {0}, max {1}, current {2}
+ *
+ *
+ * @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");
+ bundle.getString(specificKey);
+ return specificKey;
+ } catch (MissingResourceException e) {
+ return baseKey;
+ }
+ }
+}
diff --git a/cds-feature-attachments/src/main/resources/messages.properties b/cds-feature-attachments/src/main/resources/messages.properties
index e9af11c93..42a774be5 100644
--- a/cds-feature-attachments/src/main/resources/messages.properties
+++ b/cds-feature-attachments/src/main/resources/messages.properties
@@ -1 +1,3 @@
-AttachmentSizeExceeded = File size exceeds the limit of {0}.
\ No newline at end of file
+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}.
\ No newline at end of file
diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/configuration/RegistrationTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/configuration/RegistrationTest.java
index b0229cd5b..7eff970f4 100644
--- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/configuration/RegistrationTest.java
+++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/configuration/RegistrationTest.java
@@ -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;
@@ -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);
}
@@ -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);
}
@@ -140,6 +141,7 @@ private void checkHandlers(List 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);
@@ -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);
diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/ItemsCountValidationHandlerTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/ItemsCountValidationHandlerTest.java
new file mode 100644
index 000000000..65d27b7c2
--- /dev/null
+++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/ItemsCountValidationHandlerTest.java
@@ -0,0 +1,541 @@
+/*
+ * © 2024-2026 SAP SE or an SAP affiliate company and cds-feature-attachments contributors.
+ */
+package com.sap.cds.feature.attachments.handler.applicationservice;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.doThrow;
+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.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.CdsElement;
+import com.sap.cds.reflect.CdsEntity;
+import com.sap.cds.reflect.CdsType;
+import com.sap.cds.services.ServiceException;
+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.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.ArrayList;
+import java.util.List;
+import java.util.Optional;
+import java.util.stream.Stream;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+
+class ItemsCountValidationHandlerTest {
+
+ private ItemsCountValidationHandler cut;
+ private ThreadDataStorageReader storageReader;
+ private CdsCreateEventContext createContext;
+ private CdsUpdateEventContext updateContext;
+ private Messages messages;
+ private Message message;
+
+ @BeforeEach
+ void setup() {
+ storageReader = mock(ThreadDataStorageReader.class);
+ cut = new ItemsCountValidationHandler(storageReader);
+
+ createContext = mock(CdsCreateEventContext.class);
+ updateContext = mock(CdsUpdateEventContext.class);
+ messages = mock(Messages.class);
+ message = mock(Message.class);
+
+ when(createContext.getMessages()).thenReturn(messages);
+ when(updateContext.getMessages()).thenReturn(messages);
+ when(messages.error(anyString(), any(Object[].class))).thenReturn(message);
+ when(messages.warn(anyString(), any(Object[].class))).thenReturn(message);
+ when(message.target(anyString(), any())).thenReturn(message);
+ }
+
+ @Test
+ void classHasCorrectAnnotation() {
+ var annotation = cut.getClass().getAnnotation(ServiceName.class);
+
+ assertThat(annotation.type()).containsOnly(ApplicationService.class);
+ assertThat(annotation.value()).containsOnly("*");
+ }
+
+ @Test
+ void createMethodHasCorrectAnnotations() throws NoSuchMethodException {
+ var method =
+ cut.getClass()
+ .getDeclaredMethod("validateOnCreate", CdsCreateEventContext.class, List.class);
+
+ var beforeAnnotation = method.getAnnotation(Before.class);
+ var handlerOrderAnnotation = method.getAnnotation(HandlerOrder.class);
+
+ assertThat(beforeAnnotation.event()).containsExactly(CqnService.EVENT_CREATE);
+ assertThat(handlerOrderAnnotation.value()).isEqualTo(HandlerOrder.LATE);
+ }
+
+ @Test
+ void updateMethodHasCorrectAnnotations() throws NoSuchMethodException {
+ var method =
+ cut.getClass()
+ .getDeclaredMethod("validateOnUpdate", CdsUpdateEventContext.class, List.class);
+
+ var beforeAnnotation = method.getAnnotation(Before.class);
+ var handlerOrderAnnotation = method.getAnnotation(HandlerOrder.class);
+
+ assertThat(beforeAnnotation.event()).containsExactly(CqnService.EVENT_UPDATE);
+ assertThat(handlerOrderAnnotation.value()).isEqualTo(HandlerOrder.LATE);
+ }
+
+ @Test
+ void constructorRejectsNull() {
+ assertThrows(NullPointerException.class, () -> new ItemsCountValidationHandler(null));
+ }
+
+ @Nested
+ class MaxItemsValidation {
+
+ @Test
+ void maxItemsViolationAddsError() {
+ CdsEntity entity = mockEntityWithAnnotation("attachments", "Validation.MaxItems", 5, null);
+ when(createContext.getTarget()).thenReturn(entity);
+
+ CdsData root = CdsData.create();
+ List items = createItems(6);
+ root.put("attachments", items);
+
+ cut.validateOnCreate(createContext, List.of(root));
+
+ verify(messages).error(anyString(), eq("attachments"), eq(5), eq(6));
+ verify(message).target(eq("in"), any());
+ }
+
+ @Test
+ void maxItemsNotViolatedNoMessage() {
+ CdsEntity entity = mockEntityWithAnnotation("attachments", "Validation.MaxItems", 5, null);
+ when(createContext.getTarget()).thenReturn(entity);
+
+ CdsData root = CdsData.create();
+ List items = createItems(3);
+ root.put("attachments", items);
+
+ cut.validateOnCreate(createContext, List.of(root));
+
+ verify(messages, never()).error(anyString(), any(Object[].class));
+ verify(messages, never()).warn(anyString(), any(Object[].class));
+ }
+
+ @Test
+ void maxItemsExactBoundaryNoViolation() {
+ CdsEntity entity = mockEntityWithAnnotation("attachments", "Validation.MaxItems", 5, null);
+ when(createContext.getTarget()).thenReturn(entity);
+
+ CdsData root = CdsData.create();
+ List items = createItems(5);
+ root.put("attachments", items);
+
+ cut.validateOnCreate(createContext, List.of(root));
+
+ verify(messages, never()).error(anyString(), any(Object[].class));
+ }
+
+ @Test
+ void maxItemsViolationDuringDraftActivationAddsWarning() {
+ when(storageReader.get()).thenReturn(true);
+ CdsEntity entity = mockEntityWithAnnotation("attachments", "Validation.MaxItems", 3, null);
+ when(createContext.getTarget()).thenReturn(entity);
+
+ CdsData root = CdsData.create();
+ root.put("attachments", createItems(5));
+
+ cut.validateOnCreate(createContext, List.of(root));
+
+ verify(messages).warn(anyString(), eq("attachments"), eq(3), eq(5));
+ verify(messages, never()).error(anyString(), any(Object[].class));
+ }
+ }
+
+ @Nested
+ class MinItemsValidation {
+
+ @Test
+ void minItemsViolationAddsError() {
+ CdsEntity entity = mockEntityWithAnnotation("attachments", null, null, 2);
+ when(createContext.getTarget()).thenReturn(entity);
+
+ CdsData root = CdsData.create();
+ root.put("attachments", createItems(1));
+
+ cut.validateOnCreate(createContext, List.of(root));
+
+ verify(messages).error(anyString(), eq("attachments"), eq(2), eq(1));
+ verify(message).target(eq("in"), any());
+ }
+
+ @Test
+ void minItemsNotViolatedNoMessage() {
+ CdsEntity entity = mockEntityWithAnnotation("attachments", null, null, 2);
+ when(createContext.getTarget()).thenReturn(entity);
+
+ CdsData root = CdsData.create();
+ root.put("attachments", createItems(3));
+
+ cut.validateOnCreate(createContext, List.of(root));
+
+ verify(messages, never()).error(anyString(), any(Object[].class));
+ verify(messages, never()).warn(anyString(), any(Object[].class));
+ }
+
+ @Test
+ void minItemsExactBoundaryNoViolation() {
+ CdsEntity entity = mockEntityWithAnnotation("attachments", null, null, 2);
+ when(createContext.getTarget()).thenReturn(entity);
+
+ CdsData root = CdsData.create();
+ root.put("attachments", createItems(2));
+
+ cut.validateOnCreate(createContext, List.of(root));
+
+ verify(messages, never()).error(anyString(), any(Object[].class));
+ }
+
+ @Test
+ void minItemsViolationDuringDraftActivationAddsWarning() {
+ when(storageReader.get()).thenReturn(true);
+ CdsEntity entity = mockEntityWithAnnotation("attachments", null, null, 3);
+ when(createContext.getTarget()).thenReturn(entity);
+
+ CdsData root = CdsData.create();
+ root.put("attachments", createItems(1));
+
+ cut.validateOnCreate(createContext, List.of(root));
+
+ verify(messages).warn(anyString(), eq("attachments"), eq(3), eq(1));
+ verify(messages, never()).error(anyString(), any(Object[].class));
+ }
+ }
+
+ @Nested
+ class CombinedValidation {
+
+ @Test
+ void bothMaxAndMinItemsViolatedAddsErrors() {
+ // Max=5, Min=2, count=0 -> min violation
+ CdsEntity entity = mockEntityWithAnnotation("attachments", "Validation.MaxItems", 5, 2);
+ when(createContext.getTarget()).thenReturn(entity);
+
+ CdsData root = CdsData.create();
+ root.put("attachments", createItems(0));
+
+ cut.validateOnCreate(createContext, List.of(root));
+
+ verify(messages).error(anyString(), eq("attachments"), eq(2), eq(0));
+ verify(messages, never()).error(anyString(), eq("attachments"), eq(5), eq(0));
+ }
+
+ @Test
+ void maxViolatedButNotMinAddsOnlyMaxError() {
+ CdsEntity entity = mockEntityWithAnnotation("attachments", "Validation.MaxItems", 3, 1);
+ when(createContext.getTarget()).thenReturn(entity);
+
+ CdsData root = CdsData.create();
+ root.put("attachments", createItems(5));
+
+ cut.validateOnCreate(createContext, List.of(root));
+
+ verify(messages).error(anyString(), eq("attachments"), eq(3), eq(5));
+ }
+ }
+
+ @Nested
+ class CompositionNotInPayload {
+
+ @Test
+ void compositionNotPresentInDataSkipsValidation() {
+ CdsEntity entity = mockEntityWithAnnotation("attachments", "Validation.MaxItems", 2, null);
+ when(createContext.getTarget()).thenReturn(entity);
+
+ CdsData root = CdsData.create();
+ root.put("title", "some title");
+ // no "attachments" key in data
+
+ cut.validateOnCreate(createContext, List.of(root));
+
+ verify(messages, never()).error(anyString(), any(Object[].class));
+ verify(messages, never()).warn(anyString(), any(Object[].class));
+ }
+
+ @Test
+ void nonListCompositionDataSkipsValidation() {
+ CdsEntity entity = mockEntityWithAnnotation("attachments", "Validation.MaxItems", 2, null);
+ when(createContext.getTarget()).thenReturn(entity);
+
+ CdsData root = CdsData.create();
+ root.put("attachments", "not a list");
+
+ cut.validateOnCreate(createContext, List.of(root));
+
+ verify(messages, never()).error(anyString(), any(Object[].class));
+ }
+ }
+
+ @Nested
+ class NoAnnotations {
+
+ @Test
+ void entityWithoutAnnotationsSkipsValidation() {
+ CdsEntity entity = mockEntityWithAnnotation("attachments", null, null, null);
+ when(createContext.getTarget()).thenReturn(entity);
+
+ CdsData root = CdsData.create();
+ root.put("attachments", createItems(100));
+
+ cut.validateOnCreate(createContext, List.of(root));
+
+ verify(messages, never()).error(anyString(), any(Object[].class));
+ verify(messages, never()).warn(anyString(), any(Object[].class));
+ }
+
+ @Test
+ void annotationWithTrueValueIsIgnored() {
+ CdsEntity entity = mockEntityWithTrueAnnotation("attachments");
+ when(createContext.getTarget()).thenReturn(entity);
+
+ CdsData root = CdsData.create();
+ root.put("attachments", createItems(100));
+
+ cut.validateOnCreate(createContext, List.of(root));
+
+ verify(messages, never()).error(anyString(), any(Object[].class));
+ }
+ }
+
+ @Nested
+ class UpdateEvent {
+
+ @Test
+ void updateWithMaxItemsViolation() {
+ CdsEntity entity = mockEntityWithAnnotation("attachments", "Validation.MaxItems", 3, null);
+ when(updateContext.getTarget()).thenReturn(entity);
+
+ CdsData root = CdsData.create();
+ root.put("attachments", createItems(5));
+
+ cut.validateOnUpdate(updateContext, List.of(root));
+
+ verify(messages).error(anyString(), eq("attachments"), eq(3), eq(5));
+ }
+ }
+
+ @Nested
+ class AnnotationValueResolution {
+
+ @Test
+ void integerAnnotationValue() {
+ CdsData data = CdsData.create();
+ int result = ItemsCountValidationHandler.resolveAnnotationValue(42, data);
+ assertThat(result).isEqualTo(42);
+ }
+
+ @Test
+ void stringIntegerAnnotationValue() {
+ CdsData data = CdsData.create();
+ int result = ItemsCountValidationHandler.resolveAnnotationValue("10", data);
+ assertThat(result).isEqualTo(10);
+ }
+
+ @Test
+ void propertyReferenceAnnotationValue() {
+ CdsData data = CdsData.create();
+ data.put("stock", 15);
+ int result = ItemsCountValidationHandler.resolveAnnotationValue("stock", data);
+ assertThat(result).isEqualTo(15);
+ }
+
+ @Test
+ void propertyReferenceNotFoundReturnsNegative() {
+ CdsData data = CdsData.create();
+ int result = ItemsCountValidationHandler.resolveAnnotationValue("nonExistent", data);
+ assertThat(result).isEqualTo(-1);
+ }
+
+ @Test
+ void propertyReferenceNonNumericReturnsNegative() {
+ CdsData data = CdsData.create();
+ data.put("name", "text");
+ int result = ItemsCountValidationHandler.resolveAnnotationValue("name", data);
+ assertThat(result).isEqualTo(-1);
+ }
+
+ @Test
+ void unknownTypeReturnsNegative() {
+ CdsData data = CdsData.create();
+ int result = ItemsCountValidationHandler.resolveAnnotationValue(new Object(), data);
+ assertThat(result).isEqualTo(-1);
+ }
+ }
+
+ @Nested
+ class MessageKeyResolution {
+
+ @Test
+ void baseKeyUsedWhenSpecificNotDefined() {
+ String key =
+ ItemsCountValidationHandler.resolveMessageKey(
+ "Validation_MaxItems", "my.Entity", "attachments");
+ // When no specific key in bundle, falls back to base key
+ assertThat(key).isEqualTo("Validation_MaxItems");
+ }
+ }
+
+ @Nested
+ class ThrowIfError {
+
+ @Test
+ void throwIfErrorCalledForActiveOperation() {
+ CdsEntity entity = mockEntityWithAnnotation("attachments", "Validation.MaxItems", 2, null);
+ when(createContext.getTarget()).thenReturn(entity);
+ when(storageReader.get()).thenReturn(false);
+
+ CdsData root = CdsData.create();
+ root.put("attachments", createItems(5));
+
+ cut.validateOnCreate(createContext, List.of(root));
+
+ verify(messages).throwIfError();
+ }
+
+ @Test
+ void throwIfErrorNotCalledForDraftActivation() {
+ CdsEntity entity = mockEntityWithAnnotation("attachments", "Validation.MaxItems", 2, null);
+ when(createContext.getTarget()).thenReturn(entity);
+ when(storageReader.get()).thenReturn(true);
+
+ CdsData root = CdsData.create();
+ root.put("attachments", createItems(5));
+
+ cut.validateOnCreate(createContext, List.of(root));
+
+ verify(messages, never()).throwIfError();
+ }
+
+ @Test
+ void throwIfErrorCausesExceptionOnViolation() {
+ CdsEntity entity = mockEntityWithAnnotation("attachments", "Validation.MaxItems", 2, null);
+ when(createContext.getTarget()).thenReturn(entity);
+ doThrow(new ServiceException("validation error")).when(messages).throwIfError();
+
+ CdsData root = CdsData.create();
+ root.put("attachments", createItems(5));
+
+ assertThrows(
+ ServiceException.class, () -> cut.validateOnCreate(createContext, List.of(root)));
+ }
+ }
+
+ @Nested
+ class NonCompositionElements {
+
+ @Test
+ void nonCompositionElementsAreSkipped() {
+ CdsEntity entity = mock(CdsEntity.class);
+ when(entity.getQualifiedName()).thenReturn("test.Entity");
+
+ CdsElement element = mock(CdsElement.class);
+ CdsType type = mock(CdsType.class);
+ when(type.isAssociation()).thenReturn(false);
+ when(element.getType()).thenReturn(type);
+ when(entity.elements()).thenReturn(Stream.of(element));
+
+ when(createContext.getTarget()).thenReturn(entity);
+
+ CdsData root = CdsData.create();
+ cut.validateOnCreate(createContext, List.of(root));
+
+ verify(messages, never()).error(anyString(), any(Object[].class));
+ }
+ }
+
+ // --- Helper methods ---
+
+ private List createItems(int count) {
+ List items = new ArrayList<>();
+ for (int i = 0; i < count; i++) {
+ items.add(CdsData.create());
+ }
+ return items;
+ }
+
+ @SuppressWarnings("unchecked")
+ private CdsEntity mockEntityWithAnnotation(
+ String compositionName, String maxAnnotation, Integer maxValue, Integer minValue) {
+ CdsEntity entity = mock(CdsEntity.class);
+ when(entity.getQualifiedName()).thenReturn("test.Entity");
+
+ CdsElement element = mock(CdsElement.class);
+ when(element.getName()).thenReturn(compositionName);
+
+ CdsType type = mock(CdsType.class);
+ when(type.isAssociation()).thenReturn(true);
+
+ CdsAssociationType assocType = mock(CdsAssociationType.class);
+ when(assocType.isComposition()).thenReturn(true);
+ when(type.as(CdsAssociationType.class)).thenReturn(assocType);
+ when(element.getType()).thenReturn(type);
+
+ if (maxValue != null) {
+ CdsAnnotation maxAnnot = mock(CdsAnnotation.class);
+ when(maxAnnot.getValue()).thenReturn(maxValue);
+ when(element.findAnnotation("Validation.MaxItems")).thenReturn(Optional.of(maxAnnot));
+ } else {
+ when(element.findAnnotation("Validation.MaxItems")).thenReturn(Optional.empty());
+ }
+
+ if (minValue != null) {
+ CdsAnnotation minAnnot = mock(CdsAnnotation.class);
+ when(minAnnot.getValue()).thenReturn(minValue);
+ when(element.findAnnotation("Validation.MinItems")).thenReturn(Optional.of(minAnnot));
+ } else {
+ when(element.findAnnotation("Validation.MinItems")).thenReturn(Optional.empty());
+ }
+
+ when(entity.elements()).thenReturn(Stream.of(element));
+ return entity;
+ }
+
+ @SuppressWarnings("unchecked")
+ private CdsEntity mockEntityWithTrueAnnotation(String compositionName) {
+ CdsEntity entity = mock(CdsEntity.class);
+ when(entity.getQualifiedName()).thenReturn("test.Entity");
+
+ CdsElement element = mock(CdsElement.class);
+ when(element.getName()).thenReturn(compositionName);
+
+ CdsType type = mock(CdsType.class);
+ when(type.isAssociation()).thenReturn(true);
+ CdsAssociationType assocType = mock(CdsAssociationType.class);
+ when(assocType.isComposition()).thenReturn(true);
+ when(type.as(CdsAssociationType.class)).thenReturn(assocType);
+ when(element.getType()).thenReturn(type);
+
+ CdsAnnotation maxAnnot = mock(CdsAnnotation.class);
+ when(maxAnnot.getValue()).thenReturn("true");
+ when(element.findAnnotation("Validation.MaxItems")).thenReturn(Optional.of(maxAnnot));
+ when(element.findAnnotation("Validation.MinItems")).thenReturn(Optional.empty());
+
+ when(entity.elements()).thenReturn(Stream.of(element));
+ return entity;
+ }
+}
diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/common/AssociationCascaderTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/common/AssociationCascaderTest.java
index bd12583db..157473d2a 100644
--- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/common/AssociationCascaderTest.java
+++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/common/AssociationCascaderTest.java
@@ -41,15 +41,22 @@ void pathCorrectFoundForRoot() {
assertThat(rootNode.getIdentifier().associationName()).isEmpty();
assertThat(rootNode.getIdentifier().fullEntityName()).isEqualTo(RootTable_.CDS_NAME);
var rootChildren = rootNode.getChildren();
- assertThat(rootChildren).hasSize(2);
- var rootAttachmentNode = rootChildren.get(0);
+ assertThat(rootChildren).hasSize(3);
+ var rootLimitedAttachmentNode = rootChildren.get(0);
+ assertThat(rootLimitedAttachmentNode.getChildren()).isNotNull().isEmpty();
+ assertThat(rootLimitedAttachmentNode.getIdentifier().associationName())
+ .isEqualTo("limitedAttachments");
+ assertThat(rootLimitedAttachmentNode.getIdentifier().fullEntityName())
+ .isEqualTo("unit.test.TestService.RootTable.limitedAttachments");
+
+ var rootAttachmentNode = rootChildren.get(1);
assertThat(rootAttachmentNode.getChildren()).isNotNull().isEmpty();
assertThat(rootAttachmentNode.getIdentifier().associationName())
.isEqualTo(RootTable.ATTACHMENTS);
assertThat(rootAttachmentNode.getIdentifier().fullEntityName())
.isEqualTo("unit.test.TestService.RootTable.attachments");
- var itemNode = rootChildren.get(1);
+ var itemNode = rootChildren.get(2);
assertThat(itemNode.getIdentifier().associationName()).isEqualTo(RootTable.ITEM_TABLE);
assertThat(itemNode.getIdentifier().fullEntityName()).isEqualTo(Items_.CDS_NAME);
assertThat(itemNode.getChildren()).hasSize(3);
@@ -78,14 +85,20 @@ void pathCorrectFoundForDatabaseRoots() {
assertThat(databaseRootNode.getIdentifier().associationName()).isEmpty();
assertThat(databaseRootNode.getIdentifier().fullEntityName()).isEqualTo(Roots_.CDS_NAME);
- assertThat(databaseRootNode.getChildren()).hasSize(2);
- var databaseRootAttachmentNode = databaseRootNode.getChildren().get(0);
+ assertThat(databaseRootNode.getChildren()).hasSize(3);
+ var databaseRootLimitedAttachmentNode = databaseRootNode.getChildren().get(0);
+ assertThat(databaseRootLimitedAttachmentNode.getIdentifier().associationName())
+ .isEqualTo("limitedAttachments");
+ assertThat(databaseRootLimitedAttachmentNode.getIdentifier().fullEntityName())
+ .isEqualTo("unit.test.Roots.limitedAttachments");
+
+ var databaseRootAttachmentNode = databaseRootNode.getChildren().get(1);
assertThat(databaseRootAttachmentNode.getIdentifier().associationName())
.isEqualTo("attachments");
assertThat(databaseRootAttachmentNode.getIdentifier().fullEntityName())
.isEqualTo("unit.test.Roots.attachments");
- var databaseRootItemNode = databaseRootNode.getChildren().get(1);
+ var databaseRootItemNode = databaseRootNode.getChildren().get(2);
assertThat(databaseRootItemNode.getIdentifier().associationName()).isEqualTo("itemTable");
assertThat(databaseRootItemNode.getIdentifier().fullEntityName()).isEqualTo("unit.test.Items");
verifyItemAttachments(
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..48fd96138 100644
--- a/cds-feature-attachments/src/test/resources/cds/db-model.cds
+++ b/cds-feature-attachments/src/test/resources/cds/db-model.cds
@@ -9,9 +9,13 @@ entity Attachment : Attachments {
entity Roots : cuid {
title : String;
+ stock : Integer;
itemTable : Composition of many Items
on itemTable.rootId = $self.ID;
attachments : Composition of many Attachments;
+ @Validation.MaxItems : 5
+ @Validation.MinItems : 1
+ limitedAttachments : Composition of many Attachments;
}
entity Items : cuid {
diff --git a/integration-tests/db/data-model.cds b/integration-tests/db/data-model.cds
index f6c35e191..17ae447c4 100644
--- a/integration-tests/db/data-model.cds
+++ b/integration-tests/db/data-model.cds
@@ -14,6 +14,9 @@ entity Roots : cuid {
items : Composition of many Items
on items.parentID = $self.ID;
sizeLimitedAttachments : Composition of many Attachments;
+ @Validation.MaxItems : 5
+ @Validation.MinItems : 1
+ itemsLimitedAttachments : Composition of many Attachments;
}
entity Items : cuid {
diff --git a/samples/bookshop/srv/attachments.cds b/samples/bookshop/srv/attachments.cds
index 04f4d5549..95e075dc1 100644
--- a/samples/bookshop/srv/attachments.cds
+++ b/samples/bookshop/srv/attachments.cds
@@ -4,6 +4,8 @@ using {sap.attachments.Attachments} 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
extend my.Books with {
+ @Validation.MaxItems : 20
+ @Validation.MinItems : 2
attachments : Composition of many Attachments;
@UI.Hidden
sizeLimitedAttachments : Composition of many Attachments;