violations = ItemsCountValidator.validate(target, data, existingData);
+
+ if (!violations.isEmpty()) {
+ boolean isDraft = Boolean.TRUE.equals(storageReader.get());
+ reportViolations(violations, context.getMessages(), isDraft);
+ }
+ }
+
+ static boolean hasCompositionsWithItemCountAnnotations(CdsEntity entity) {
+ return entity
+ .elements()
+ .filter(
+ e ->
+ e.getType().isAssociation()
+ && e.getType().as(CdsAssociationType.class).isComposition())
+ .anyMatch(
+ e ->
+ ApplicationHandlerHelper.isMediaEntity(
+ e.getType().as(CdsAssociationType.class).getTarget())
+ && (e.findAnnotation(ItemsCountValidator.ANNOTATION_MAX_ITEMS).isPresent()
+ || e.findAnnotation(ItemsCountValidator.ANNOTATION_MIN_ITEMS).isPresent()));
+ }
+
+ /**
+ * Reports validation violations as messages on the event context.
+ *
+ * For active entity operations, violations are reported as errors via {@link
+ * ServiceException}, causing the request to fail. For draft operations, violations are reported
+ * as warnings via the {@link Messages} API, allowing the draft to be saved in an invalid state.
+ *
+ *
The message key follows the Fiori elements i18n override convention: {@code
+ * __}. Applications can override messages by defining the
+ * specific key in their own {@code messages.properties}. The base keys are always provided as
+ * fallback.
+ *
+ * Message parameters:
+ *
+ *
+ * {0} = the configured limit
+ * {1} = the actual number of items
+ *
+ */
+ static void reportViolations(
+ List violations, Messages messages, boolean isDraft) {
+ for (ItemsCountViolation violation : violations) {
+ String messageKey = violation.getBaseMessageKey();
+ String target = violation.compositionName();
+
+ if (isDraft) {
+ messages.warn(messageKey, violation.limit(), violation.actualCount()).target(target);
+ } else {
+ throw new ServiceException(
+ ErrorStatuses.CONFLICT, messageKey, violation.limit(), violation.actualCount());
+ }
+ }
+ }
+}
diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/ItemsCountValidator.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/ItemsCountValidator.java
new file mode 100644
index 000000000..aa2605653
--- /dev/null
+++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/ItemsCountValidator.java
@@ -0,0 +1,256 @@
+/*
+ * © 2026 SAP SE or an SAP affiliate company and cds-feature-attachments contributors.
+ */
+package com.sap.cds.feature.attachments.handler.applicationservice.helper;
+
+import com.sap.cds.CdsData;
+import com.sap.cds.feature.attachments.handler.common.ApplicationHandlerHelper;
+import com.sap.cds.reflect.CdsAnnotation;
+import com.sap.cds.reflect.CdsAssociationType;
+import com.sap.cds.reflect.CdsElementDefinition;
+import com.sap.cds.reflect.CdsEntity;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Validates the number of items in composition associations against {@code @Validation.MaxItems}
+ * and {@code @Validation.MinItems} annotations.
+ *
+ * Annotation values can be:
+ *
+ *
+ * An integer literal, e.g. {@code @Validation.MaxItems: 20}
+ * A property name from the parent entity, e.g. {@code @Validation.MaxItems: 'maxCount'}
+ *
+ */
+public final class ItemsCountValidator {
+
+ private static final Logger logger = LoggerFactory.getLogger(ItemsCountValidator.class);
+
+ public static final String ANNOTATION_MAX_ITEMS = "Validation.MaxItems";
+ public static final String ANNOTATION_MIN_ITEMS = "Validation.MinItems";
+
+ /**
+ * Validates all composition associations of the given entity that have
+ * {@code @Validation.MaxItems} or {@code @Validation.MinItems} annotations against the items
+ * count in the provided data.
+ *
+ * @param entity the entity definition
+ * @param data the request payload data
+ * @param existingData the existing data from the database (for UPDATE); may be empty for CREATE
+ * @return a list of validation violations found
+ */
+ public static List validate(
+ CdsEntity entity, List extends CdsData> data, List extends CdsData> existingData) {
+ List violations = new ArrayList<>();
+
+ entity
+ .elements()
+ .filter(
+ element ->
+ element.getType().isAssociation()
+ && element.getType().as(CdsAssociationType.class).isComposition())
+ .filter(
+ element ->
+ isMediaEntity(element)
+ && (hasAnnotation(element, ANNOTATION_MAX_ITEMS)
+ || hasAnnotation(element, ANNOTATION_MIN_ITEMS)))
+ .forEach(
+ element -> {
+ String compositionName = element.getName();
+ validateComposition(entity, element, compositionName, data, existingData, violations);
+ });
+
+ return violations;
+ }
+
+ private static boolean isMediaEntity(CdsElementDefinition element) {
+ CdsEntity target = element.getType().as(CdsAssociationType.class).getTarget();
+ return ApplicationHandlerHelper.isMediaEntity(target);
+ }
+
+ private static boolean hasAnnotation(CdsElementDefinition element, String annotationName) {
+ return element.findAnnotation(annotationName).isPresent();
+ }
+
+ private static void validateComposition(
+ CdsEntity entity,
+ CdsElementDefinition element,
+ String compositionName,
+ List extends CdsData> dataList,
+ List extends CdsData> existingDataList,
+ List violations) {
+
+ for (int i = 0; i < dataList.size(); i++) {
+ CdsData data = dataList.get(i);
+ CdsData existingData =
+ (existingDataList != null && i < existingDataList.size())
+ ? existingDataList.get(i)
+ : null;
+
+ int itemCount = countItems(data, compositionName);
+ if (itemCount < 0) {
+ // composition not present in payload, skip validation
+ continue;
+ }
+
+ Map parentData = mergeData(existingData, data);
+
+ validateMaxItems(entity, element, compositionName, itemCount, parentData, violations);
+ validateMinItems(entity, element, compositionName, itemCount, parentData, violations);
+ }
+ }
+
+ /**
+ * Counts items for a composition. If the composition is present in the request data, use that
+ * count. Otherwise, return -1 to indicate the composition is not being modified.
+ */
+ private static int countItems(CdsData data, String compositionName) {
+ Object compositionData = data.get(compositionName);
+ if (compositionData == null) {
+ // composition not in payload, not being modified
+ return -1;
+ }
+ if (compositionData instanceof Collection> collection) {
+ return collection.size();
+ }
+ return -1;
+ }
+
+ private static Map mergeData(CdsData existingData, CdsData requestData) {
+ if (existingData == null) {
+ return requestData;
+ }
+ CdsData merged = CdsData.create();
+ merged.putAll(existingData);
+ merged.putAll(requestData);
+ return merged;
+ }
+
+ private static void validateMaxItems(
+ CdsEntity entity,
+ CdsElementDefinition element,
+ String compositionName,
+ int itemCount,
+ Map parentData,
+ List violations) {
+ Optional maxItems = resolveAnnotationValue(element, ANNOTATION_MAX_ITEMS, parentData);
+ if (maxItems.isPresent() && itemCount > maxItems.get()) {
+ String entityName = getSimpleEntityName(entity);
+ violations.add(
+ new ItemsCountViolation(
+ ItemsCountViolation.Type.MAX_ITEMS,
+ compositionName,
+ entityName,
+ itemCount,
+ maxItems.get()));
+ }
+ }
+
+ private static void validateMinItems(
+ CdsEntity entity,
+ CdsElementDefinition element,
+ String compositionName,
+ int itemCount,
+ Map parentData,
+ List violations) {
+ Optional minItems = resolveAnnotationValue(element, ANNOTATION_MIN_ITEMS, parentData);
+ if (minItems.isPresent() && itemCount < minItems.get()) {
+ String entityName = getSimpleEntityName(entity);
+ violations.add(
+ new ItemsCountViolation(
+ ItemsCountViolation.Type.MIN_ITEMS,
+ compositionName,
+ entityName,
+ itemCount,
+ minItems.get()));
+ }
+ }
+
+ /**
+ * Resolves the annotation value to an integer. Supports:
+ *
+ *
+ * Integer literal: {@code @Validation.MaxItems: 20} → 20
+ * Property reference as string: {@code @Validation.MaxItems: 'maxCount'} → reads value of
+ * 'maxCount' from parent entity data
+ * Bare annotation (value = true): ignored, returns empty
+ *
+ */
+ static Optional resolveAnnotationValue(
+ CdsElementDefinition element, String annotationName, Map parentData) {
+ return element
+ .findAnnotation(annotationName)
+ .map(CdsAnnotation::getValue)
+ .flatMap(value -> resolveValue(value, parentData, annotationName));
+ }
+
+ private static Optional resolveValue(
+ Object value, Map parentData, String annotationName) {
+ if ("true".equals(value.toString())) {
+ // bare annotation without value, ignore
+ return Optional.empty();
+ }
+
+ // try direct integer
+ if (value instanceof Number number) {
+ return Optional.of(number.intValue());
+ }
+
+ String strValue = value.toString().trim();
+
+ // try parsing as integer literal
+ try {
+ return Optional.of(Integer.parseInt(strValue));
+ } catch (NumberFormatException e) {
+ // not a plain integer, try as property reference
+ logger.debug(
+ "Annotation value '{}' is not an integer, trying as property reference", strValue);
+ }
+
+ // try as property reference from parent entity data
+ if (parentData != null) {
+ Object propertyValue = parentData.get(strValue);
+ if (propertyValue instanceof Number number) {
+ return Optional.of(number.intValue());
+ }
+ if (propertyValue != null) {
+ try {
+ return Optional.of(Integer.parseInt(propertyValue.toString()));
+ } catch (NumberFormatException e) {
+ logger.warn(
+ "Cannot resolve {} annotation value '{}' to an integer from property value '{}'",
+ annotationName,
+ strValue,
+ propertyValue);
+ }
+ } else {
+ logger.debug(
+ "Property '{}' referenced by {} annotation not found in entity data",
+ strValue,
+ annotationName);
+ }
+ }
+
+ logger.warn("Cannot resolve {} annotation value '{}' to an integer", annotationName, strValue);
+ return Optional.empty();
+ }
+
+ /**
+ * Returns the simple (unqualified) entity name, e.g. "Incidents" from
+ * "my.namespace.service.Incidents".
+ */
+ private static String getSimpleEntityName(CdsEntity entity) {
+ String qualifiedName = entity.getQualifiedName();
+ return qualifiedName.substring(qualifiedName.lastIndexOf('.') + 1);
+ }
+
+ private ItemsCountValidator() {
+ // avoid instantiation
+ }
+}
diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/ItemsCountViolation.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/ItemsCountViolation.java
new file mode 100644
index 000000000..9a42c2c66
--- /dev/null
+++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/ItemsCountViolation.java
@@ -0,0 +1,54 @@
+/*
+ * © 2026 SAP SE or an SAP affiliate company and cds-feature-attachments contributors.
+ */
+package com.sap.cds.feature.attachments.handler.applicationservice.helper;
+
+/**
+ * Represents a validation violation for items count constraints on a composition.
+ *
+ * @param type the type of violation (MAX_ITEMS or MIN_ITEMS)
+ * @param compositionName the name of the composition association that was violated
+ * @param entityName the simple name of the parent entity
+ * @param actualCount the actual number of items found
+ * @param limit the configured limit that was violated
+ */
+public record ItemsCountViolation(
+ Type type, String compositionName, String entityName, int actualCount, int limit) {
+
+ /** The type of items count violation. */
+ public enum Type {
+ MAX_ITEMS,
+ MIN_ITEMS
+ }
+
+ /**
+ * Returns the i18n message key for this violation. The key follows the Fiori elements convention
+ * of appending entity name and property name for overriding.
+ *
+ * Base keys:
+ *
+ *
+ * {@code AttachmentMaxItemsExceeded} for MAX_ITEMS violations
+ * {@code AttachmentMinItemsNotReached} for MIN_ITEMS violations
+ *
+ *
+ * Override keys (checked first):
+ *
+ *
+ * {@code AttachmentMaxItemsExceeded__}
+ * {@code AttachmentMinItemsNotReached__}
+ *
+ */
+ public String getBaseMessageKey() {
+ return type == Type.MAX_ITEMS ? "AttachmentMaxItemsExceeded" : "AttachmentMinItemsNotReached";
+ }
+
+ /**
+ * Returns the entity/property-specific override message key.
+ *
+ * @return the override key in the form {@code __}
+ */
+ public String getOverrideMessageKey() {
+ return getBaseMessageKey() + "_" + entityName + "_" + compositionName;
+ }
+}
diff --git a/cds-feature-attachments/src/main/resources/messages.properties b/cds-feature-attachments/src/main/resources/messages.properties
index e9af11c93..b113a5394 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}.
+AttachmentMaxItemsExceeded = Number of attachments exceeds the maximum of {0}. Found {1} items.
+AttachmentMinItemsNotReached = Number of attachments is below the minimum of {0}. Found {1} items.
\ 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..ab3786d9f
--- /dev/null
+++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/ItemsCountValidationHandlerTest.java
@@ -0,0 +1,357 @@
+/*
+ * © 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.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+
+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.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_;
+import com.sap.cds.feature.attachments.handler.applicationservice.helper.ItemsCountViolation;
+import com.sap.cds.feature.attachments.handler.applicationservice.helper.ThreadDataStorageReader;
+import com.sap.cds.feature.attachments.handler.common.AttachmentsReader;
+import com.sap.cds.feature.attachments.handler.helper.RuntimeHelper;
+import com.sap.cds.ql.Update;
+import com.sap.cds.ql.cqn.CqnFilterableStatement;
+import com.sap.cds.ql.cqn.CqnUpdate;
+import com.sap.cds.reflect.CdsEntity;
+import com.sap.cds.services.ErrorStatuses;
+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.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.Messages;
+import com.sap.cds.services.runtime.CdsRuntime;
+import com.sap.cds.services.utils.OrderConstants;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.UUID;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
+
+class ItemsCountValidationHandlerTest {
+
+ private static CdsRuntime runtime;
+
+ private ItemsCountValidationHandler cut;
+ private AttachmentsReader attachmentsReader;
+ private ThreadDataStorageReader storageReader;
+ private CdsCreateEventContext createContext;
+ private CdsUpdateEventContext updateContext;
+ private Messages messages;
+
+ @BeforeAll
+ static void classSetup() {
+ runtime = RuntimeHelper.runtime;
+ }
+
+ @BeforeEach
+ void setup() {
+ attachmentsReader = mock(AttachmentsReader.class);
+ storageReader = mock(ThreadDataStorageReader.class);
+ cut = new ItemsCountValidationHandler(attachmentsReader, storageReader);
+
+ createContext = mock(CdsCreateEventContext.class);
+ updateContext = mock(CdsUpdateEventContext.class);
+ messages = mock(Messages.class, Mockito.RETURNS_DEEP_STUBS);
+
+ when(createContext.getMessages()).thenReturn(messages);
+ when(updateContext.getMessages()).thenReturn(messages);
+ }
+
+ @Test
+ void classHasCorrectAnnotation() {
+ var annotation = cut.getClass().getAnnotation(ServiceName.class);
+
+ assertThat(annotation.type()).containsOnly(ApplicationService.class);
+ assertThat(annotation.value()).containsOnly("*");
+ }
+
+ @Test
+ void validateOnCreateMethodHasCorrectAnnotations() 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).isNotNull();
+ assertThat(handlerOrderAnnotation.value()).isEqualTo(OrderConstants.Before.CHECK_CAPABILITIES);
+ }
+
+ @Test
+ void validateOnUpdateMethodHasCorrectAnnotations() 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).isNotNull();
+ assertThat(handlerOrderAnnotation.value()).isEqualTo(OrderConstants.Before.CHECK_CAPABILITIES);
+ }
+
+ @Test
+ void createWithNoAnnotatedCompositionsDoesNothing() {
+ // Attachment entity has no compositions with MaxItems/MinItems
+ getEntityAndMockCreateContext(Attachment_.CDS_NAME);
+ var attachment = Attachments.create();
+
+ cut.validateOnCreate(createContext, List.of(attachment));
+
+ verifyNoInteractions(storageReader);
+ verifyNoInteractions(attachmentsReader);
+ }
+
+ @Test
+ void createWithinLimitsNoViolation() {
+ getEntityAndMockCreateContext(RootTable_.CDS_NAME);
+ var root = RootTable.create();
+ root.setAttachments(createAttachments(5));
+ when(storageReader.get()).thenReturn(false);
+
+ assertDoesNotThrow(() -> cut.validateOnCreate(createContext, List.of(root)));
+ }
+
+ @Test
+ void createExceedsMaxItemsThrowsError() {
+ getEntityAndMockCreateContext(RootTable_.CDS_NAME);
+ var root = RootTable.create();
+ root.setAttachments(createAttachments(25));
+ when(storageReader.get()).thenReturn(false);
+
+ var exception =
+ assertThrows(
+ ServiceException.class, () -> cut.validateOnCreate(createContext, List.of(root)));
+
+ assertThat(exception.getErrorStatus()).isEqualTo(ErrorStatuses.CONFLICT);
+ }
+
+ @Test
+ void createBelowMinItemsThrowsError() {
+ getEntityAndMockCreateContext(RootTable_.CDS_NAME);
+ var root = RootTable.create();
+ root.setAttachments(createAttachments(1));
+ when(storageReader.get()).thenReturn(false);
+
+ var exception =
+ assertThrows(
+ ServiceException.class, () -> cut.validateOnCreate(createContext, List.of(root)));
+
+ assertThat(exception.getErrorStatus()).isEqualTo(ErrorStatuses.CONFLICT);
+ }
+
+ @Test
+ void createInDraftModeAddsWarningInsteadOfError() {
+ getEntityAndMockCreateContext(RootTable_.CDS_NAME);
+ var root = RootTable.create();
+ root.setAttachments(createAttachments(25));
+ when(storageReader.get()).thenReturn(true);
+
+ assertDoesNotThrow(() -> cut.validateOnCreate(createContext, List.of(root)));
+
+ verify(messages).warn("AttachmentMaxItemsExceeded", 20, 25);
+ }
+
+ @Test
+ void createWithNoCompositionInPayloadSkipsValidation() {
+ getEntityAndMockCreateContext(RootTable_.CDS_NAME);
+ var root = RootTable.create();
+ root.setTitle("test");
+ // attachments not in payload
+
+ assertDoesNotThrow(() -> cut.validateOnCreate(createContext, List.of(root)));
+ verifyNoInteractions(storageReader);
+ }
+
+ @Test
+ void updateWithinLimitsNoViolation() {
+ var id = getEntityAndMockUpdateContext(RootTable_.CDS_NAME);
+ var root = RootTable.create();
+ root.setId(id);
+ root.setAttachments(createAttachments(10));
+ when(attachmentsReader.readAttachments(any(), any(), any(CqnFilterableStatement.class)))
+ .thenReturn(Collections.emptyList());
+ when(storageReader.get()).thenReturn(false);
+
+ assertDoesNotThrow(() -> cut.validateOnUpdate(updateContext, List.of(root)));
+ }
+
+ @Test
+ void updateExceedsMaxItemsThrowsError() {
+ var id = getEntityAndMockUpdateContext(RootTable_.CDS_NAME);
+ var root = RootTable.create();
+ root.setId(id);
+ root.setAttachments(createAttachments(25));
+ when(attachmentsReader.readAttachments(any(), any(), any(CqnFilterableStatement.class)))
+ .thenReturn(Collections.emptyList());
+ when(storageReader.get()).thenReturn(false);
+
+ var exception =
+ assertThrows(
+ ServiceException.class, () -> cut.validateOnUpdate(updateContext, List.of(root)));
+
+ assertThat(exception.getErrorStatus()).isEqualTo(ErrorStatuses.CONFLICT);
+ }
+
+ @Test
+ void updateInDraftModeAddsWarning() {
+ var id = getEntityAndMockUpdateContext(RootTable_.CDS_NAME);
+ var root = RootTable.create();
+ root.setId(id);
+ root.setAttachments(createAttachments(25));
+ when(attachmentsReader.readAttachments(any(), any(), any(CqnFilterableStatement.class)))
+ .thenReturn(Collections.emptyList());
+ when(storageReader.get()).thenReturn(true);
+
+ assertDoesNotThrow(() -> cut.validateOnUpdate(updateContext, List.of(root)));
+
+ verify(messages).warn("AttachmentMaxItemsExceeded", 20, 25);
+ }
+
+ @Test
+ void reportViolationsThrowsErrorWhenNotDraft() {
+ var violations =
+ List.of(
+ new ItemsCountViolation(
+ ItemsCountViolation.Type.MAX_ITEMS, "attachments", "RootTable", 25, 20));
+
+ var exception =
+ assertThrows(
+ ServiceException.class,
+ () -> ItemsCountValidationHandler.reportViolations(violations, messages, false));
+
+ assertThat(exception.getErrorStatus()).isEqualTo(ErrorStatuses.CONFLICT);
+ }
+
+ @Test
+ void reportViolationsAddsWarningWhenDraft() {
+ var violations =
+ List.of(
+ new ItemsCountViolation(
+ ItemsCountViolation.Type.MAX_ITEMS, "attachments", "RootTable", 25, 20));
+
+ assertDoesNotThrow(
+ () -> ItemsCountValidationHandler.reportViolations(violations, messages, true));
+
+ verify(messages).warn("AttachmentMaxItemsExceeded", 20, 25);
+ }
+
+ @Test
+ void hasCompositionsWithItemCountAnnotationsReturnsTrueForAnnotatedEntity() {
+ CdsEntity entity = runtime.getCdsModel().findEntity(RootTable_.CDS_NAME).orElseThrow();
+ assertThat(ItemsCountValidationHandler.hasCompositionsWithItemCountAnnotations(entity))
+ .isTrue();
+ }
+
+ @Test
+ void hasCompositionsWithItemCountAnnotationsReturnsFalseForNonAnnotatedEntity() {
+ CdsEntity entity = runtime.getCdsModel().findEntity(Attachment_.CDS_NAME).orElseThrow();
+ assertThat(ItemsCountValidationHandler.hasCompositionsWithItemCountAnnotations(entity))
+ .isFalse();
+ }
+
+ @Test
+ void hasCompositionsWithItemCountAnnotationsReturnsTrueForMinItemsOnlyEntity() {
+ CdsEntity entity = runtime.getCdsModel().findEntity(Items_.CDS_NAME).orElseThrow();
+ assertThat(ItemsCountValidationHandler.hasCompositionsWithItemCountAnnotations(entity))
+ .isTrue();
+ }
+
+ @Test
+ void updateWithNoAnnotatedCompositionsDoesNothing() {
+ // Attachment entity has no compositions with MaxItems/MinItems
+ var serviceEntity = runtime.getCdsModel().findEntity(Attachment_.CDS_NAME).orElseThrow();
+ when(updateContext.getTarget()).thenReturn(serviceEntity);
+ var attachment = Attachments.create();
+
+ cut.validateOnUpdate(updateContext, List.of(attachment));
+
+ verifyNoInteractions(storageReader);
+ verifyNoInteractions(attachmentsReader);
+ }
+
+ @Test
+ void createWithEmptyViolationsListDoesNothing() {
+ getEntityAndMockCreateContext(RootTable_.CDS_NAME);
+ // No composition in payload means no violations
+ var root = RootTable.create();
+ root.setTitle("test");
+
+ cut.validateOnCreate(createContext, List.of(root));
+
+ verifyNoInteractions(storageReader);
+ }
+
+ @Test
+ void reportViolationsMinItemsAddsWarningWhenDraft() {
+ var violations =
+ List.of(
+ new ItemsCountViolation(
+ ItemsCountViolation.Type.MIN_ITEMS, "attachments", "RootTable", 1, 2));
+
+ assertDoesNotThrow(
+ () -> ItemsCountValidationHandler.reportViolations(violations, messages, true));
+
+ verify(messages).warn("AttachmentMinItemsNotReached", 2, 1);
+ }
+
+ @Test
+ void reportViolationsMinItemsThrowsErrorWhenNotDraft() {
+ var violations =
+ List.of(
+ new ItemsCountViolation(
+ ItemsCountViolation.Type.MIN_ITEMS, "attachments", "RootTable", 1, 2));
+
+ var exception =
+ assertThrows(
+ ServiceException.class,
+ () -> ItemsCountValidationHandler.reportViolations(violations, messages, false));
+
+ assertThat(exception.getErrorStatus()).isEqualTo(ErrorStatuses.CONFLICT);
+ }
+
+ private void getEntityAndMockCreateContext(String cdsName) {
+ var serviceEntity = runtime.getCdsModel().findEntity(cdsName).orElseThrow();
+ when(createContext.getTarget()).thenReturn(serviceEntity);
+ }
+
+ private String getEntityAndMockUpdateContext(String cdsName) {
+ var serviceEntity = runtime.getCdsModel().findEntity(cdsName).orElseThrow();
+ var id = UUID.randomUUID().toString();
+ CqnUpdate update =
+ Update.entity(serviceEntity.getQualifiedName()).where(entity -> entity.get("ID").eq(id));
+ when(updateContext.getTarget()).thenReturn(serviceEntity);
+ when(updateContext.getModel()).thenReturn(runtime.getCdsModel());
+ when(updateContext.getCqn()).thenReturn(update);
+ return id;
+ }
+
+ private List createAttachments(int count) {
+ List attachments = new ArrayList<>();
+ for (int i = 0; i < count; i++) {
+ var attachment = Attachments.create();
+ attachment.setFileName("file" + i + ".txt");
+ attachments.add(attachment);
+ }
+ return attachments;
+ }
+}
diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/ItemsCountValidatorTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/ItemsCountValidatorTest.java
new file mode 100644
index 000000000..c693fb052
--- /dev/null
+++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/ItemsCountValidatorTest.java
@@ -0,0 +1,441 @@
+/*
+ * © 2026 SAP SE or an SAP affiliate company and cds-feature-attachments contributors.
+ */
+package com.sap.cds.feature.attachments.handler.applicationservice.helper;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+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.Items;
+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_;
+import com.sap.cds.feature.attachments.handler.helper.RuntimeHelper;
+import com.sap.cds.reflect.CdsAnnotation;
+import com.sap.cds.reflect.CdsElementDefinition;
+import com.sap.cds.reflect.CdsEntity;
+import com.sap.cds.services.runtime.CdsRuntime;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+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 ItemsCountValidatorTest {
+
+ private static CdsRuntime runtime;
+
+ @BeforeAll
+ static void classSetup() {
+ runtime = RuntimeHelper.runtime;
+ }
+
+ @Test
+ void noViolationsWhenCompositionNotInPayload() {
+ CdsEntity entity = runtime.getCdsModel().findEntity(RootTable_.CDS_NAME).orElseThrow();
+ var root = RootTable.create();
+ root.setTitle("test");
+ // attachments not in payload
+
+ List violations =
+ ItemsCountValidator.validate(entity, List.of(root), new ArrayList<>());
+
+ assertThat(violations).isEmpty();
+ }
+
+ @Test
+ void noViolationsWhenWithinLimits() {
+ CdsEntity entity = runtime.getCdsModel().findEntity(RootTable_.CDS_NAME).orElseThrow();
+ var root = RootTable.create();
+ // Create 5 attachments (within 2-20 range)
+ List attachments = createAttachments(5);
+ root.setAttachments(attachments);
+
+ List violations =
+ ItemsCountValidator.validate(entity, List.of(root), new ArrayList<>());
+
+ assertThat(violations).isEmpty();
+ }
+
+ @Test
+ void maxItemsViolation() {
+ CdsEntity entity = runtime.getCdsModel().findEntity(RootTable_.CDS_NAME).orElseThrow();
+ var root = RootTable.create();
+ // Create 25 attachments (exceeds max of 20)
+ List attachments = createAttachments(25);
+ root.setAttachments(attachments);
+
+ List violations =
+ ItemsCountValidator.validate(entity, List.of(root), new ArrayList<>());
+
+ assertThat(violations).hasSize(1);
+ assertThat(violations.get(0).type()).isEqualTo(ItemsCountViolation.Type.MAX_ITEMS);
+ assertThat(violations.get(0).compositionName()).isEqualTo("attachments");
+ assertThat(violations.get(0).actualCount()).isEqualTo(25);
+ assertThat(violations.get(0).limit()).isEqualTo(20);
+ }
+
+ @Test
+ void minItemsViolation() {
+ CdsEntity entity = runtime.getCdsModel().findEntity(RootTable_.CDS_NAME).orElseThrow();
+ var root = RootTable.create();
+ // Create 1 attachment (below min of 2)
+ List attachments = createAttachments(1);
+ root.setAttachments(attachments);
+
+ List violations =
+ ItemsCountValidator.validate(entity, List.of(root), new ArrayList<>());
+
+ assertThat(violations).hasSize(1);
+ assertThat(violations.get(0).type()).isEqualTo(ItemsCountViolation.Type.MIN_ITEMS);
+ assertThat(violations.get(0).compositionName()).isEqualTo("attachments");
+ assertThat(violations.get(0).actualCount()).isEqualTo(1);
+ assertThat(violations.get(0).limit()).isEqualTo(2);
+ }
+
+ @Test
+ void emptyAttachmentsViolatesMinItems() {
+ CdsEntity entity = runtime.getCdsModel().findEntity(RootTable_.CDS_NAME).orElseThrow();
+ var root = RootTable.create();
+ root.setAttachments(Collections.emptyList());
+
+ List violations =
+ ItemsCountValidator.validate(entity, List.of(root), new ArrayList<>());
+
+ assertThat(violations).hasSize(1);
+ assertThat(violations.get(0).type()).isEqualTo(ItemsCountViolation.Type.MIN_ITEMS);
+ assertThat(violations.get(0).actualCount()).isEqualTo(0);
+ }
+
+ @Test
+ void exactMinLimitNoViolation() {
+ CdsEntity entity = runtime.getCdsModel().findEntity(RootTable_.CDS_NAME).orElseThrow();
+ var root = RootTable.create();
+ // Create exactly 2 attachments (min is 2)
+ List attachments = createAttachments(2);
+ root.setAttachments(attachments);
+
+ List violations =
+ ItemsCountValidator.validate(entity, List.of(root), new ArrayList<>());
+
+ assertThat(violations).isEmpty();
+ }
+
+ @Test
+ void exactMaxLimitNoViolation() {
+ CdsEntity entity = runtime.getCdsModel().findEntity(RootTable_.CDS_NAME).orElseThrow();
+ var root = RootTable.create();
+ // Create exactly 20 attachments (max is 20)
+ List attachments = createAttachments(20);
+ root.setAttachments(attachments);
+
+ List violations =
+ ItemsCountValidator.validate(entity, List.of(root), new ArrayList<>());
+
+ assertThat(violations).isEmpty();
+ }
+
+ @Test
+ void violationMessageKeys() {
+ var maxViolation =
+ new ItemsCountViolation(
+ ItemsCountViolation.Type.MAX_ITEMS, "attachments", "RootTable", 25, 20);
+
+ assertThat(maxViolation.getBaseMessageKey()).isEqualTo("AttachmentMaxItemsExceeded");
+ assertThat(maxViolation.getOverrideMessageKey())
+ .isEqualTo("AttachmentMaxItemsExceeded_RootTable_attachments");
+
+ var minViolation =
+ new ItemsCountViolation(
+ ItemsCountViolation.Type.MIN_ITEMS, "attachments", "RootTable", 1, 2);
+
+ assertThat(minViolation.getBaseMessageKey()).isEqualTo("AttachmentMinItemsNotReached");
+ assertThat(minViolation.getOverrideMessageKey())
+ .isEqualTo("AttachmentMinItemsNotReached_RootTable_attachments");
+ }
+
+ @Test
+ void resolveAnnotationValueWithPropertyReference() {
+ CdsEntity entity = runtime.getCdsModel().findEntity(RootTable_.CDS_NAME).orElseThrow();
+ // The @Validation.MaxItems: 20 annotation should be resolvable
+ var element = entity.findElement("attachments").orElseThrow();
+ var parentData = CdsData.create();
+ parentData.put("someField", 15);
+
+ var result =
+ ItemsCountValidator.resolveAnnotationValue(
+ element, ItemsCountValidator.ANNOTATION_MAX_ITEMS, parentData);
+
+ assertThat(result).isPresent().hasValue(20);
+ }
+
+ @Test
+ void resolveAnnotationValueReturnsEmptyWhenAnnotationNotPresent() {
+ CdsElementDefinition element = mock(CdsElementDefinition.class);
+ when(element.findAnnotation(ItemsCountValidator.ANNOTATION_MAX_ITEMS))
+ .thenReturn(Optional.empty());
+
+ var result =
+ ItemsCountValidator.resolveAnnotationValue(
+ element, ItemsCountValidator.ANNOTATION_MAX_ITEMS, Map.of());
+
+ assertThat(result).isEmpty();
+ }
+
+ @SuppressWarnings("unchecked")
+ @Test
+ void resolveAnnotationValueReturnsEmptyForNullAnnotationValue() {
+ CdsElementDefinition element = mock(CdsElementDefinition.class);
+ CdsAnnotation annotation = mock(CdsAnnotation.class);
+ when(annotation.getValue()).thenReturn(null);
+ when(element.findAnnotation(ItemsCountValidator.ANNOTATION_MAX_ITEMS))
+ .thenReturn(Optional.of(annotation));
+
+ var result =
+ ItemsCountValidator.resolveAnnotationValue(
+ element, ItemsCountValidator.ANNOTATION_MAX_ITEMS, Map.of());
+
+ assertThat(result).isEmpty();
+ }
+
+ @SuppressWarnings("unchecked")
+ @Test
+ void resolveAnnotationValueReturnsEmptyForBareAnnotation() {
+ CdsElementDefinition element = mock(CdsElementDefinition.class);
+ CdsAnnotation annotation = mock(CdsAnnotation.class);
+ when(annotation.getValue()).thenReturn(true);
+ when(element.findAnnotation(ItemsCountValidator.ANNOTATION_MAX_ITEMS))
+ .thenReturn(Optional.of(annotation));
+
+ var result =
+ ItemsCountValidator.resolveAnnotationValue(
+ element, ItemsCountValidator.ANNOTATION_MAX_ITEMS, Map.of());
+
+ assertThat(result).isEmpty();
+ }
+
+ @SuppressWarnings("unchecked")
+ @Test
+ void resolveAnnotationValueReturnsValueForNumberType() {
+ CdsElementDefinition element = mock(CdsElementDefinition.class);
+ CdsAnnotation annotation = mock(CdsAnnotation.class);
+ when(annotation.getValue()).thenReturn(42);
+ when(element.findAnnotation(ItemsCountValidator.ANNOTATION_MAX_ITEMS))
+ .thenReturn(Optional.of(annotation));
+
+ var result =
+ ItemsCountValidator.resolveAnnotationValue(
+ element, ItemsCountValidator.ANNOTATION_MAX_ITEMS, Map.of());
+
+ assertThat(result).isPresent().hasValue(42);
+ }
+
+ @SuppressWarnings("unchecked")
+ @Test
+ void resolveAnnotationValueParsesStringInteger() {
+ CdsElementDefinition element = mock(CdsElementDefinition.class);
+ CdsAnnotation annotation = mock(CdsAnnotation.class);
+ when(annotation.getValue()).thenReturn("15");
+ when(element.findAnnotation(ItemsCountValidator.ANNOTATION_MAX_ITEMS))
+ .thenReturn(Optional.of(annotation));
+
+ var result =
+ ItemsCountValidator.resolveAnnotationValue(
+ element, ItemsCountValidator.ANNOTATION_MAX_ITEMS, Map.of());
+
+ assertThat(result).isPresent().hasValue(15);
+ }
+
+ @SuppressWarnings("unchecked")
+ @Test
+ void resolveAnnotationValueResolvesPropertyReferenceFromNumber() {
+ CdsElementDefinition element = mock(CdsElementDefinition.class);
+ CdsAnnotation annotation = mock(CdsAnnotation.class);
+ when(annotation.getValue()).thenReturn("maxCount");
+ when(element.findAnnotation(ItemsCountValidator.ANNOTATION_MAX_ITEMS))
+ .thenReturn(Optional.of(annotation));
+ Map parentData = new HashMap<>();
+ parentData.put("maxCount", 30);
+
+ var result =
+ ItemsCountValidator.resolveAnnotationValue(
+ element, ItemsCountValidator.ANNOTATION_MAX_ITEMS, parentData);
+
+ assertThat(result).isPresent().hasValue(30);
+ }
+
+ @SuppressWarnings("unchecked")
+ @Test
+ void resolveAnnotationValueResolvesPropertyReferenceFromStringNumber() {
+ CdsElementDefinition element = mock(CdsElementDefinition.class);
+ CdsAnnotation annotation = mock(CdsAnnotation.class);
+ when(annotation.getValue()).thenReturn("maxCount");
+ when(element.findAnnotation(ItemsCountValidator.ANNOTATION_MAX_ITEMS))
+ .thenReturn(Optional.of(annotation));
+ Map parentData = new HashMap<>();
+ parentData.put("maxCount", "25");
+
+ var result =
+ ItemsCountValidator.resolveAnnotationValue(
+ element, ItemsCountValidator.ANNOTATION_MAX_ITEMS, parentData);
+
+ assertThat(result).isPresent().hasValue(25);
+ }
+
+ @SuppressWarnings("unchecked")
+ @Test
+ void resolveAnnotationValueReturnsEmptyForUnresolvablePropertyValue() {
+ CdsElementDefinition element = mock(CdsElementDefinition.class);
+ CdsAnnotation annotation = mock(CdsAnnotation.class);
+ when(annotation.getValue()).thenReturn("maxCount");
+ when(element.findAnnotation(ItemsCountValidator.ANNOTATION_MAX_ITEMS))
+ .thenReturn(Optional.of(annotation));
+ Map parentData = new HashMap<>();
+ parentData.put("maxCount", "not-a-number");
+
+ var result =
+ ItemsCountValidator.resolveAnnotationValue(
+ element, ItemsCountValidator.ANNOTATION_MAX_ITEMS, parentData);
+
+ assertThat(result).isEmpty();
+ }
+
+ @SuppressWarnings("unchecked")
+ @Test
+ void resolveAnnotationValueReturnsEmptyForMissingPropertyInData() {
+ CdsElementDefinition element = mock(CdsElementDefinition.class);
+ CdsAnnotation annotation = mock(CdsAnnotation.class);
+ when(annotation.getValue()).thenReturn("nonExistentProp");
+ when(element.findAnnotation(ItemsCountValidator.ANNOTATION_MAX_ITEMS))
+ .thenReturn(Optional.of(annotation));
+ Map parentData = new HashMap<>();
+
+ var result =
+ ItemsCountValidator.resolveAnnotationValue(
+ element, ItemsCountValidator.ANNOTATION_MAX_ITEMS, parentData);
+
+ assertThat(result).isEmpty();
+ }
+
+ @SuppressWarnings("unchecked")
+ @Test
+ void resolveAnnotationValueReturnsEmptyForNullParentData() {
+ CdsElementDefinition element = mock(CdsElementDefinition.class);
+ CdsAnnotation annotation = mock(CdsAnnotation.class);
+ when(annotation.getValue()).thenReturn("maxCount");
+ when(element.findAnnotation(ItemsCountValidator.ANNOTATION_MAX_ITEMS))
+ .thenReturn(Optional.of(annotation));
+
+ var result =
+ ItemsCountValidator.resolveAnnotationValue(
+ element, ItemsCountValidator.ANNOTATION_MAX_ITEMS, null);
+
+ assertThat(result).isEmpty();
+ }
+
+ @Test
+ void validateWithNullExistingDataList() {
+ CdsEntity entity = runtime.getCdsModel().findEntity(RootTable_.CDS_NAME).orElseThrow();
+ var root = RootTable.create();
+ root.setAttachments(createAttachments(25));
+
+ List violations =
+ ItemsCountValidator.validate(entity, List.of(root), null);
+
+ assertThat(violations).hasSize(1);
+ assertThat(violations.get(0).type()).isEqualTo(ItemsCountViolation.Type.MAX_ITEMS);
+ }
+
+ @Test
+ void validateWithExistingDataShorterThanNewData() {
+ CdsEntity entity = runtime.getCdsModel().findEntity(RootTable_.CDS_NAME).orElseThrow();
+ var root1 = RootTable.create();
+ root1.setAttachments(createAttachments(5));
+ var root2 = RootTable.create();
+ root2.setAttachments(createAttachments(25));
+
+ // Existing data has only one entry, but new data has two
+ var existingRoot = RootTable.create();
+ existingRoot.setTitle("existing");
+
+ List violations =
+ ItemsCountValidator.validate(entity, List.of(root1, root2), List.of(existingRoot));
+
+ assertThat(violations).hasSize(1);
+ assertThat(violations.get(0).actualCount()).isEqualTo(25);
+ }
+
+ @Test
+ void validateWithNonCollectionCompositionData() {
+ CdsEntity entity = runtime.getCdsModel().findEntity(RootTable_.CDS_NAME).orElseThrow();
+ var root = CdsData.create();
+ // Set composition as non-collection (e.g., a string)
+ root.put("attachments", "not-a-collection");
+
+ List violations =
+ ItemsCountValidator.validate(entity, List.of(root), new ArrayList<>());
+
+ // Should skip validation when composition data is not a collection
+ assertThat(violations).isEmpty();
+ }
+
+ @Test
+ void validateMergesExistingDataWithRequestData() {
+ CdsEntity entity = runtime.getCdsModel().findEntity(RootTable_.CDS_NAME).orElseThrow();
+ var root = RootTable.create();
+ root.setAttachments(createAttachments(5));
+
+ var existingRoot = RootTable.create();
+ existingRoot.setTitle("existing");
+
+ // Validate with existing data to ensure merge works
+ List violations =
+ ItemsCountValidator.validate(entity, List.of(root), List.of(existingRoot));
+
+ assertThat(violations).isEmpty();
+ }
+
+ @Test
+ void minItemsOnlyAnnotationViolation() {
+ // Items entity has only @Validation.MinItems on itemAttachments (no MaxItems)
+ CdsEntity entity = runtime.getCdsModel().findEntity(Items_.CDS_NAME).orElseThrow();
+ var item = Items.create();
+ item.setItemAttachments(Collections.emptyList());
+
+ List violations =
+ ItemsCountValidator.validate(entity, List.of(item), new ArrayList<>());
+
+ assertThat(violations).hasSize(1);
+ assertThat(violations.get(0).type()).isEqualTo(ItemsCountViolation.Type.MIN_ITEMS);
+ assertThat(violations.get(0).limit()).isEqualTo(1);
+ }
+
+ @Test
+ void minItemsOnlyAnnotationNoViolation() {
+ // Items entity has only @Validation.MinItems on itemAttachments (no MaxItems)
+ CdsEntity entity = runtime.getCdsModel().findEntity(Items_.CDS_NAME).orElseThrow();
+ var item = Items.create();
+ item.setItemAttachments(createAttachments(5));
+
+ List violations =
+ ItemsCountValidator.validate(entity, List.of(item), new ArrayList<>());
+
+ assertThat(violations).isEmpty();
+ }
+
+ private List createAttachments(int count) {
+ List attachments = new ArrayList<>();
+ for (int i = 0; i < count; i++) {
+ var attachment = Attachments.create();
+ attachment.setFileName("file" + i + ".txt");
+ attachments.add(attachment);
+ }
+ return attachments;
+ }
+}
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..04b5d798a 100644
--- a/cds-feature-attachments/src/test/resources/cds/db-model.cds
+++ b/cds-feature-attachments/src/test/resources/cds/db-model.cds
@@ -11,6 +11,8 @@ entity Roots : cuid {
title : String;
itemTable : Composition of many Items
on itemTable.rootId = $self.ID;
+ @Validation.MaxItems : 20
+ @Validation.MinItems : 2
attachments : Composition of many Attachments;
}
@@ -20,6 +22,7 @@ entity Items : cuid {
events : Composition of many Events
on events.id1 = $self.ID;
attachments : Composition of many Attachment on attachments.ID = $self.ID;
+ @Validation.MinItems : 1
itemAttachments : Composition of many Attachments;
}