From 4abec96be3c952e4f9f6e526b88a0e06492c8de5 Mon Sep 17 00:00:00 2001 From: samyuktaprabhu <33195453+samyuktaprabhu@users.noreply.github.com> Date: Fri, 13 Mar 2026 14:16:52 +0100 Subject: [PATCH 1/2] feat: add @Validation.MaxItems and @Validation.MinItems support for compositions Implements item-count validation for composition properties annotated with @Validation.MaxItems and/or @Validation.MinItems. This allows applications to constrain the number of items in a composition (e.g., attachments). Key design decisions: - Handler fires on @Before CREATE/UPDATE on ApplicationService - Draft detection via ThreadDataStorageReader (reuses existing pattern) - Draft activation (DRAFT_SAVE -> CREATE/UPDATE): warnings only - Direct active CREATE/UPDATE: errors (throwIfError) - Annotation values support integers and property references - Messages target the composition table for proper Fiori elements display - i18n keys: Validation_MaxItems, Validation_MinItems in messages.properties - Per-entity/property message override via ResourceBundle fallback pattern: Validation_MaxItems// Files changed: - New: ItemsCountValidationHandler.java (handler + annotation resolution) - New: ItemsCountValidationHandlerTest.java (comprehensive unit tests) - Modified: Registration.java (wire new handler) - Modified: messages.properties (add validation messages) - Modified: db-model.cds (test model with annotations) - Modified: data-model.cds (integration test model) - Modified: attachments.cds (sample bookshop demo) - Modified: RegistrationTest.java (updated handler count to 9) - Modified: AssociationCascaderTest.java (updated for new composition) --- .../configuration/Registration.java | 2 + .../ItemsCountValidationHandler.java | 219 +++++++ .../src/main/resources/messages.properties | 4 +- .../configuration/RegistrationTest.java | 7 +- .../ItemsCountValidationHandlerTest.java | 542 ++++++++++++++++++ .../common/AssociationCascaderTest.java | 25 +- .../src/test/resources/cds/db-model.cds | 4 + integration-tests/db/data-model.cds | 3 + samples/bookshop/srv/attachments.cds | 2 + 9 files changed, 799 insertions(+), 9 deletions(-) create mode 100644 cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/ItemsCountValidationHandler.java create mode 100644 cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/ItemsCountValidationHandlerTest.java 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..67ef0d6f8 --- /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: + * + *

+ * + *

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..460693832 --- /dev/null +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/ItemsCountValidationHandlerTest.java @@ -0,0 +1,542 @@ +/* + * © 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.verifyNoInteractions; +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; From 84aacd1a943cb2d0c44c823d9824d1f47d5a26c5 Mon Sep 17 00:00:00 2001 From: samyuktaprabhu <33195453+samyuktaprabhu@users.noreply.github.com> Date: Fri, 13 Mar 2026 14:22:18 +0100 Subject: [PATCH 2/2] style: apply spotless formatting --- .../ItemsCountValidationHandler.java | 14 +++++++------- .../ItemsCountValidationHandlerTest.java | 1 - 2 files changed, 7 insertions(+), 8 deletions(-) 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 index 67ef0d6f8..ed3e94579 100644 --- 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 @@ -33,8 +33,8 @@ * 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. + *

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: * @@ -44,9 +44,9 @@ * 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. + *

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 { @@ -163,8 +163,8 @@ void validateItemsCount(EventContext context, CdsEntity entity, List da * *

    *
  • Integer/Number literals — used directly - *
  • String values — first tried as integer literal, then as a property reference in the entity - * data + *
  • String values — first tried as integer literal, then as a property reference in the + * entity data *
* * @param annotationValue the raw annotation value 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 index 460693832..65d27b7c2 100644 --- 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 @@ -12,7 +12,6 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; import com.sap.cds.CdsData;