From 32ac7aa48c6660afdd2db5cc26452ccd1b90a890 Mon Sep 17 00:00:00 2001 From: Lisa Julia Nebel Date: Fri, 13 Mar 2026 13:50:53 +0100 Subject: [PATCH] Add @Validation.MinItems / @Validation.MaxItems support for attachment compositions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces `ItemCountValidator` which enforces minimum and maximum attachment counts on composition fields annotated with `@Validation.MinItems` and/or `@Validation.MaxItems`. - Annotation values can be integer literals, parseable strings, or property references resolved at runtime from the entity payload (e.g. `stock`) - Active create/update events: emit an error and throw immediately - Draft patch events: emit a warning only (draft tolerates invalid state) - Draft save (activate): query the draft DB for the real attachment count, then validate as an error — skip the in-payload check in Create/Update handlers during draft activate to avoid double-validation - i18n keys follow the Fiori elements override pattern: `AttachmentMinItems[_EntityName_propertyName]` / `AttachmentMaxItems[_EntityName_propertyName]` --- .../configuration/Registration.java | 2 +- .../CreateAttachmentsHandler.java | 6 + .../UpdateAttachmentsHandler.java | 7 + .../helper/ItemCountValidator.java | 228 +++++++++++ .../DraftActiveAttachmentsHandler.java | 86 ++++- .../DraftPatchAttachmentsHandler.java | 3 + .../_i18n/i18n.properties | 4 + .../helper/ItemCountValidatorTest.java | 353 ++++++++++++++++++ .../DraftActiveAttachmentsHandlerTest.java | 154 +++++++- .../src/test/resources/cds/db-model.cds | 12 + integration-tests/db/data-model.cds | 2 + 11 files changed, 850 insertions(+), 7 deletions(-) create mode 100644 cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/ItemCountValidator.java create mode 100644 cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/ItemCountValidatorTest.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..ce1a1870a 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 @@ -145,7 +145,7 @@ public void eventHandlers(CdsRuntimeConfigurer configurer) { configurer.eventHandler( new DraftPatchAttachmentsHandler(persistenceService, eventFactory, defaultMaxSize)); configurer.eventHandler(new DraftCancelAttachmentsHandler(attachmentsReader, deleteEvent)); - configurer.eventHandler(new DraftActiveAttachmentsHandler(storage)); + configurer.eventHandler(new DraftActiveAttachmentsHandler(storage, persistenceService)); } else { logger.debug("No draft service is available. Draft event handlers will not be registered."); } diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/CreateAttachmentsHandler.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/CreateAttachmentsHandler.java index 6a10baf6c..82e5d7448 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/CreateAttachmentsHandler.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/CreateAttachmentsHandler.java @@ -7,6 +7,7 @@ import com.sap.cds.CdsData; import com.sap.cds.feature.attachments.handler.applicationservice.helper.ExtendedErrorStatuses; +import com.sap.cds.feature.attachments.handler.applicationservice.helper.ItemCountValidator; import com.sap.cds.feature.attachments.handler.applicationservice.helper.ModifyApplicationHandlerHelper; import com.sap.cds.feature.attachments.handler.applicationservice.helper.ReadonlyDataContextEnhancer; import com.sap.cds.feature.attachments.handler.applicationservice.helper.ThreadDataStorageReader; @@ -70,6 +71,11 @@ void processBefore(CdsCreateEventContext context, List data) { ModifyApplicationHandlerHelper.handleAttachmentForEntities( context.getTarget(), data, new ArrayList<>(), eventFactory, context, defaultMaxSize); } + // Skip item count validation during draft activate – DraftActiveAttachmentsHandler + // performs the authoritative check against the full DB attachment count before activation. + if (!storageReader.get()) { + ItemCountValidator.validate(context.getTarget(), data, context, false); + } } @On(event = {CqnService.EVENT_CREATE, CqnService.EVENT_UPDATE, DraftService.EVENT_DRAFT_PATCH}) diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/UpdateAttachmentsHandler.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/UpdateAttachmentsHandler.java index e7166ffc1..65b6a265d 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/UpdateAttachmentsHandler.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/UpdateAttachmentsHandler.java @@ -7,6 +7,7 @@ import com.sap.cds.CdsData; import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.Attachments; +import com.sap.cds.feature.attachments.handler.applicationservice.helper.ItemCountValidator; import com.sap.cds.feature.attachments.handler.applicationservice.helper.ModifyApplicationHandlerHelper; import com.sap.cds.feature.attachments.handler.applicationservice.helper.ReadonlyDataContextEnhancer; import com.sap.cds.feature.attachments.handler.applicationservice.helper.ThreadDataStorageReader; @@ -94,6 +95,12 @@ void processBefore(CdsUpdateEventContext context, List data) { if (!associationsAreUnchanged) { deleteRemovedAttachments(attachments, data, target, context.getUserInfo()); } + + // Skip item count validation during draft activate – DraftActiveAttachmentsHandler + // performs the authoritative check against the full DB attachment count before activation. + if (!storageReader.get()) { + ItemCountValidator.validate(target, data, context, false); + } } } diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/ItemCountValidator.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/ItemCountValidator.java new file mode 100644 index 000000000..b5c6eedee --- /dev/null +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/ItemCountValidator.java @@ -0,0 +1,228 @@ +/* + * © 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.reflect.CdsAnnotation; +import com.sap.cds.reflect.CdsElement; +import com.sap.cds.reflect.CdsEntity; +import com.sap.cds.services.EventContext; +import com.sap.cds.services.messages.Message; +import com.sap.cds.services.messages.Messages; +import java.util.List; +import java.util.Optional; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Validates {@code @Validation.MinItems} and {@code @Validation.MaxItems} constraints on attachment + * compositions. + * + *

The annotation value may be an integer literal, a reference to a property on the parent entity + * (resolved at runtime from the event data), or any other raw value whose {@code toString()} + * produces a parseable integer. + * + *

During draft operations the validator emits a warning message. During active-entity + * operations (create/update/save) it emits an error message and throws immediately via + * {@link Messages#throwIfError()}. + * + *

Error messages follow the Fiori elements i18n-override pattern: the base message key is {@code + * AttachmentMinItems} / {@code AttachmentMaxItems}. To override the message for a specific + * composition property the key {@code AttachmentMinItems__} or {@code + * AttachmentMaxItems__} is tried first. Since CDS resolves the + * most-specific key it can find, only the specific key is emitted – a missing specific translation + * naturally falls back to the generic one. + */ +public final class ItemCountValidator { + + static final String ANNOTATION_MIN_ITEMS = "Validation.MinItems"; + static final String ANNOTATION_MAX_ITEMS = "Validation.MaxItems"; + + static final String MSG_MIN_ITEMS = "AttachmentMinItems"; + static final String MSG_MAX_ITEMS = "AttachmentMaxItems"; + + private static final Logger logger = LoggerFactory.getLogger(ItemCountValidator.class); + + private ItemCountValidator() {} + + /** + * Validates min/max items constraints for all attachment compositions on {@code entity} using the + * payload {@code data} as source of truth for the attachment count. + * + * @param entity the root {@link CdsEntity} whose compositions are checked + * @param data the request payload (used to count passed-in attachments) + * @param eventContext the current {@link EventContext} + * @param isDraft {@code true} when executing in draft context – issues warnings instead of errors + */ + public static void validate( + CdsEntity entity, List data, EventContext eventContext, boolean isDraft) { + entity + .compositions() + .filter(ItemCountValidator::hasItemCountAnnotation) + .forEach(comp -> validateComposition(comp, entity, data, eventContext, isDraft)); + } + + // --------------------------------------------------------------------------- + // internals + // --------------------------------------------------------------------------- + + /** + * Returns {@code true} if the given composition element carries at least one item-count + * constraint annotation ({@code @Validation.MinItems} or {@code @Validation.MaxItems}). + * + *

This method is {@code public} so it can be referenced from handler classes outside this + * package (e.g. {@code DraftActiveAttachmentsHandler}). + * + * @param element the composition element to inspect + * @return {@code true} if an item-count annotation is present + */ + public static boolean hasItemCountAnnotation(CdsElement element) { + return element.findAnnotation(ANNOTATION_MIN_ITEMS).isPresent() + || element.findAnnotation(ANNOTATION_MAX_ITEMS).isPresent(); + } + + private static void validateComposition( + CdsElement comp, + CdsEntity parentEntity, + List data, + EventContext eventContext, + boolean isDraft) { + + String compName = comp.getName(); + long attachmentCount = countInPayload(compName, data); + + Messages messages = eventContext.getMessages(); + + // --- MinItems --- + comp.findAnnotation(ANNOTATION_MIN_ITEMS) + .flatMap(ann -> resolveIntValue(ann, parentEntity, data)) + .ifPresent( + minItems -> { + if (attachmentCount < minItems) { + String msgKey = messageKey(MSG_MIN_ITEMS, parentEntity, compName); + logger.debug( + "MinItems violation on {}.{}: count={}, min={}", + parentEntity.getQualifiedName(), + compName, + attachmentCount, + minItems); + Message msg = + isDraft + ? messages.warn(msgKey, attachmentCount, minItems) + : messages.error(msgKey, attachmentCount, minItems); + msg.target(compName); + } + }); + + // --- MaxItems --- + comp.findAnnotation(ANNOTATION_MAX_ITEMS) + .flatMap(ann -> resolveIntValue(ann, parentEntity, data)) + .ifPresent( + maxItems -> { + if (attachmentCount > maxItems) { + String msgKey = messageKey(MSG_MAX_ITEMS, parentEntity, compName); + logger.debug( + "MaxItems violation on {}.{}: count={}, max={}", + parentEntity.getQualifiedName(), + compName, + attachmentCount, + maxItems); + Message msg = + isDraft + ? messages.warn(msgKey, attachmentCount, maxItems) + : messages.error(msgKey, attachmentCount, maxItems); + msg.target(compName); + } + }); + + if (!isDraft) { + messages.throwIfError(); + } + } + + /** Counts the number of items in the named composition across all data entries. */ + @SuppressWarnings("unchecked") + private static long countInPayload(String compName, List data) { + long count = 0; + for (CdsData entry : data) { + Object compValue = entry.get(compName); + if (compValue instanceof List list) { + count += list.size(); + } + } + return count; + } + + /** + * Resolves the annotation value to a long. Handles: + * + *

    + *
  • Integer literal (e.g. {@code 20}) + *
  • Property reference – a string matching a numeric property name on the parent entity data + * (e.g. {@code stock}) + *
  • Any other raw string whose {@code toString()} parses as a long + *
+ */ + static Optional resolveIntValue( + CdsAnnotation annotation, CdsEntity entity, List data) { + Object raw = annotation.getValue(); + if (raw == null) { + return Optional.empty(); + } + + // Direct numeric value + if (raw instanceof Number number) { + return Optional.of(number.longValue()); + } + + String strValue = raw.toString().trim(); + + // Try parsing directly as a number first + try { + return Optional.of(Long.parseLong(strValue)); + } catch (NumberFormatException ignored) { + // fall through + } + + // Try resolving as a property reference on the first data item + if (!data.isEmpty() && !strValue.isEmpty()) { + Object propValue = data.get(0).get(strValue); + if (propValue instanceof Number number) { + return Optional.of(number.longValue()); + } + if (propValue != null) { + try { + return Optional.of(Long.parseLong(propValue.toString().trim())); + } catch (NumberFormatException e) { + logger.warn( + "Cannot resolve @{} value '{}' as integer from property '{}'", + annotation.getName(), + raw, + strValue); + } + } + } + + logger.warn( + "Cannot resolve @{} annotation value '{}' to an integer – skipping validation", + annotation.getName(), + raw); + return Optional.empty(); + } + + /** + * Builds an i18n message key following the Fiori elements override pattern. The specific key + * {@code __} is always used; apps without a specific + * translation entry will receive the message text from the generic {@code } fallback. + */ + static String messageKey(String baseKey, CdsEntity entity, String propertyName) { + String simpleName = simpleEntityName(entity.getQualifiedName()); + return baseKey + "_" + simpleName + "_" + propertyName; + } + + private static String simpleEntityName(String qualifiedName) { + int dot = qualifiedName.lastIndexOf('.'); + return dot >= 0 ? qualifiedName.substring(dot + 1) : qualifiedName; + } +} diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/draftservice/DraftActiveAttachmentsHandler.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/draftservice/DraftActiveAttachmentsHandler.java index 2c7dd6996..49b730de7 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/draftservice/DraftActiveAttachmentsHandler.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/draftservice/DraftActiveAttachmentsHandler.java @@ -1,29 +1,111 @@ /* - * © 2024-2025 SAP SE or an SAP affiliate company and cds-feature-attachments contributors. + * © 2024-2026 SAP SE or an SAP affiliate company and cds-feature-attachments contributors. */ package com.sap.cds.feature.attachments.handler.draftservice; import static java.util.Objects.requireNonNull; +import com.sap.cds.CdsData; +import com.sap.cds.feature.attachments.handler.applicationservice.helper.ItemCountValidator; import com.sap.cds.feature.attachments.handler.applicationservice.helper.ThreadDataStorageSetter; +import com.sap.cds.ql.CQL; +import com.sap.cds.ql.Select; +import com.sap.cds.reflect.CdsElement; +import com.sap.cds.reflect.CdsEntity; import com.sap.cds.services.draft.DraftSaveEventContext; import com.sap.cds.services.draft.DraftService; 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.On; import com.sap.cds.services.handler.annotations.ServiceName; +import com.sap.cds.services.persistence.PersistenceService; +import java.util.ArrayList; +import java.util.List; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; @ServiceName(value = "*", type = DraftService.class) public class DraftActiveAttachmentsHandler implements EventHandler { + private static final Logger logger = LoggerFactory.getLogger(DraftActiveAttachmentsHandler.class); + private final ThreadDataStorageSetter threadLocalSetter; + private final PersistenceService persistence; - public DraftActiveAttachmentsHandler(ThreadDataStorageSetter threadLocalSetter) { + public DraftActiveAttachmentsHandler( + ThreadDataStorageSetter threadLocalSetter, PersistenceService persistence) { this.threadLocalSetter = requireNonNull(threadLocalSetter, "threadLocalSetter must not be null"); + this.persistence = requireNonNull(persistence, "persistence must not be null"); + } + + /** + * Before draft save: validate min/max items as errors. Draft save = activating the draft, so an + * invalid state is no longer tolerated and must result in an error. + */ + @Before + @HandlerOrder(HandlerOrder.LATE) + void validateItemCountBeforeSave(DraftSaveEventContext context) { + CdsEntity entity = context.getTarget(); + + boolean hasItemCountAnnotations = + entity.compositions().anyMatch(ItemCountValidator::hasItemCountAnnotation); + if (!hasItemCountAnnotations) { + return; + } + + logger.debug( + "Validating item count before draft save for entity {}", entity.getQualifiedName()); + + CdsEntity draftEntity = DraftUtils.getDraftEntity(entity); + List syntheticData = readDraftCompositionCounts(draftEntity, context, entity); + ItemCountValidator.validate(entity, syntheticData, context, false); } @On void processDraftSave(DraftSaveEventContext context) { threadLocalSetter.set(true, context::proceed); } + + /** + * Reads the draft composition data for all annotated compositions and builds a synthetic data + * list suitable for {@link ItemCountValidator#validate}. + * + *

A SELECT with expand for each annotated composition is executed against the draft table. The + * result is a single root {@link CdsData} entry whose composition arrays contain all found child + * rows – this allows {@code ItemCountValidator} to count them correctly. + */ + private List readDraftCompositionCounts( + CdsEntity draftEntity, DraftSaveEventContext context, CdsEntity activeEntity) { + List annotatedComps = + activeEntity.compositions().filter(ItemCountValidator::hasItemCountAnnotation).toList(); + + var expandColumns = + annotatedComps.stream().map(comp -> CQL.to(comp.getName()).expand()).toList(); + + // context.getCqn() is the CqnSelect that identifies the draft root entity (with keys/where). + // We re-build a SELECT on the draft entity adding the expand columns for compositions. + var baseCqn = context.getCqn(); + Select select = Select.from(draftEntity).columns(expandColumns); + baseCqn.where().ifPresent(select::where); + + var result = persistence.run(select); + List rows = result.listOf(CdsData.class); + + // Aggregate: collect all composition items across root rows into one synthetic entry. + // In practice draft save targets a single root entity, but we sum across all for safety. + CdsData aggregated = CdsData.create(); + for (CdsElement comp : annotatedComps) { + List allItems = new ArrayList<>(); + for (CdsData row : rows) { + Object items = row.get(comp.getName()); + if (items instanceof List list) { + list.forEach(item -> allItems.add(CdsData.create())); + } + } + aggregated.put(comp.getName(), allItems); + } + return List.of(aggregated); + } } diff --git a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/draftservice/DraftPatchAttachmentsHandler.java b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/draftservice/DraftPatchAttachmentsHandler.java index 5d66dbc96..e2ae21824 100644 --- a/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/draftservice/DraftPatchAttachmentsHandler.java +++ b/cds-feature-attachments/src/main/java/com/sap/cds/feature/attachments/handler/draftservice/DraftPatchAttachmentsHandler.java @@ -10,6 +10,7 @@ import com.sap.cds.CdsDataProcessor.Converter; import com.sap.cds.Result; import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.Attachments; +import com.sap.cds.feature.attachments.handler.applicationservice.helper.ItemCountValidator; import com.sap.cds.feature.attachments.handler.applicationservice.helper.ModifyApplicationHandlerHelper; import com.sap.cds.feature.attachments.handler.applicationservice.modifyevents.ModifyAttachmentEventFactory; import com.sap.cds.feature.attachments.handler.common.ApplicationHandlerHelper; @@ -75,5 +76,7 @@ void processBeforeDraftPatch(DraftPatchEventContext context, List) data, context, true); } } diff --git a/cds-feature-attachments/src/main/resources/cds/com.sap.cds/cds-feature-attachments/_i18n/i18n.properties b/cds-feature-attachments/src/main/resources/cds/com.sap.cds/cds-feature-attachments/_i18n/i18n.properties index 28f8695a4..cfac45d7d 100644 --- a/cds-feature-attachments/src/main/resources/cds/com.sap.cds/cds-feature-attachments/_i18n/i18n.properties +++ b/cds-feature-attachments/src/main/resources/cds/com.sap.cds/cds-feature-attachments/_i18n/i18n.properties @@ -36,3 +36,7 @@ attachment_note=Note attachment=Attachment #XTIT: Header label for Attachments attachments=Attachments +#XMSG: Error/warning message when the number of attachments is below the minimum. {0}=current count, {1}=minimum required +AttachmentMinItems=The number of attachments ({0}) is below the minimum required ({1}). +#XMSG: Error/warning message when the number of attachments exceeds the maximum. {0}=current count, {1}=maximum allowed +AttachmentMaxItems=The number of attachments ({0}) exceeds the maximum allowed ({1}). diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/ItemCountValidatorTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/ItemCountValidatorTest.java new file mode 100644 index 000000000..5bcb7c06b --- /dev/null +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/applicationservice/helper/ItemCountValidatorTest.java @@ -0,0 +1,353 @@ +/* + * © 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.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; +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.handler.helper.RuntimeHelper; +import com.sap.cds.reflect.CdsAnnotation; +import com.sap.cds.reflect.CdsEntity; +import com.sap.cds.services.EventContext; +import com.sap.cds.services.ServiceException; +import com.sap.cds.services.messages.Message; +import com.sap.cds.services.messages.Messages; +import com.sap.cds.services.runtime.CdsRuntime; +import java.util.ArrayList; +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; + +class ItemCountValidatorTest { + + private static final String EVENT_ITEMS_ENTITY = "unit.test.TestService.EventItems"; + + private static CdsRuntime runtime; + + private EventContext eventContext; + private Messages messages; + private Message messageMock; + + @BeforeAll + static void classSetup() { + runtime = RuntimeHelper.runtime; + } + + @BeforeEach + void setup() { + eventContext = mock(EventContext.class); + messages = mock(Messages.class); + messageMock = mock(Message.class); + when(eventContext.getMessages()).thenReturn(messages); + when(messageMock.target(org.mockito.ArgumentMatchers.anyString())).thenReturn(messageMock); + when(messages.warn( + org.mockito.ArgumentMatchers.anyString(), + org.mockito.ArgumentMatchers.any(Object[].class))) + .thenReturn(messageMock); + when(messages.error( + org.mockito.ArgumentMatchers.anyString(), + org.mockito.ArgumentMatchers.any(Object[].class))) + .thenReturn(messageMock); + } + + // ------------------------------------------------------------------------- + // hasItemCountAnnotation + // ------------------------------------------------------------------------- + + @Test + void hasItemCountAnnotation_minItems_returnsTrue() { + CdsEntity entity = getEventItemsEntity(); + var comp = entity.findElement("minMaxAttachments").orElseThrow(); + assertThat(ItemCountValidator.hasItemCountAnnotation(comp)).isTrue(); + } + + @Test + void hasItemCountAnnotation_maxItemsOnly_returnsTrue() { + CdsEntity entity = getEventItemsEntity(); + var comp = entity.findElement("maxAttachments").orElseThrow(); + assertThat(ItemCountValidator.hasItemCountAnnotation(comp)).isTrue(); + } + + @Test + void hasItemCountAnnotation_noAnnotation_returnsFalse() { + CdsEntity entity = getEventItemsEntity(); + var comp = entity.findElement("sizeLimitedAttachments").orElseThrow(); + assertThat(ItemCountValidator.hasItemCountAnnotation(comp)).isFalse(); + } + + // ------------------------------------------------------------------------- + // resolveIntValue + // ------------------------------------------------------------------------- + + @Test + void resolveIntValue_numericLiteral_returnsValue() { + CdsAnnotation ann = mockAnnotation("Validation.MinItems", 5); + var result = ItemCountValidator.resolveIntValue(ann, mock(CdsEntity.class), List.of()); + assertThat(result).contains(5L); + } + + @Test + void resolveIntValue_intStringLiteral_returnsValue() { + CdsAnnotation ann = mockAnnotation("Validation.MinItems", "3"); + var result = ItemCountValidator.resolveIntValue(ann, mock(CdsEntity.class), List.of()); + assertThat(result).contains(3L); + } + + @Test + void resolveIntValue_propertyRef_resolvedFromData() { + CdsAnnotation ann = mockAnnotation("Validation.MinItems", "stock"); + CdsData data = CdsData.create(); + data.put("stock", 7); + var result = ItemCountValidator.resolveIntValue(ann, mock(CdsEntity.class), List.of(data)); + assertThat(result).contains(7L); + } + + @Test + void resolveIntValue_propertyRefAsString_resolvedFromData() { + CdsAnnotation ann = mockAnnotation("Validation.MinItems", "stock"); + CdsData data = CdsData.create(); + data.put("stock", "4"); + var result = ItemCountValidator.resolveIntValue(ann, mock(CdsEntity.class), List.of(data)); + assertThat(result).contains(4L); + } + + @Test + void resolveIntValue_nullValue_returnsEmpty() { + CdsAnnotation ann = mockAnnotation("Validation.MinItems", null); + var result = ItemCountValidator.resolveIntValue(ann, mock(CdsEntity.class), List.of()); + assertThat(result).isEmpty(); + } + + @Test + void resolveIntValue_unresolvableString_returnsEmpty() { + CdsAnnotation ann = mockAnnotation("Validation.MinItems", "not_a_number"); + var result = ItemCountValidator.resolveIntValue(ann, mock(CdsEntity.class), List.of()); + assertThat(result).isEmpty(); + } + + // ------------------------------------------------------------------------- + // messageKey + // ------------------------------------------------------------------------- + + @Test + void messageKey_buildsKeyWithEntitySimpleNameAndProperty() { + CdsEntity entity = getEventItemsEntity(); + String key = + ItemCountValidator.messageKey(ItemCountValidator.MSG_MIN_ITEMS, entity, "attachments"); + assertThat(key).isEqualTo("AttachmentMinItems_EventItems_attachments"); + } + + // ------------------------------------------------------------------------- + // validate – active (error) path + // ------------------------------------------------------------------------- + + @Test + void validate_tooFewAttachments_activeModeThrowsError() { + CdsEntity entity = getEventItemsEntity(); + + // minMaxAttachments requires min 2; we pass 1 → should error + CdsData root = buildEventItemsData("minMaxAttachments", 1); + + // Make messages.throwIfError() propagate the error + org.mockito.Mockito.doThrow(new ServiceException("min items violated")) + .when(messages) + .throwIfError(); + + assertThrows( + ServiceException.class, + () -> ItemCountValidator.validate(entity, List.of(root), eventContext, false)); + } + + @Test + void validate_tooManyAttachments_activeModeThrowsError() { + CdsEntity entity = getEventItemsEntity(); + + // minMaxAttachments has max 5; we pass 6 → should error + CdsData root = buildEventItemsData("minMaxAttachments", 6); + + org.mockito.Mockito.doThrow(new ServiceException("max items violated")) + .when(messages) + .throwIfError(); + + assertThrows( + ServiceException.class, + () -> ItemCountValidator.validate(entity, List.of(root), eventContext, false)); + } + + @Test + void validate_withinBounds_activeModeNoException() { + CdsEntity entity = getEventItemsEntity(); + + // minMaxAttachments requires [2,5]; we pass 3 → OK + CdsData root = buildEventItemsData("minMaxAttachments", 3); + + assertDoesNotThrow( + () -> ItemCountValidator.validate(entity, List.of(root), eventContext, false)); + } + + // ------------------------------------------------------------------------- + // validate – draft (warning) path + // ------------------------------------------------------------------------- + + @Test + void validate_tooFewAttachments_draftModeIssuesWarningOnly() { + CdsEntity entity = getEventItemsEntity(); + + // minMaxAttachments requires min 2; we pass 1 → should warn, NOT throw + CdsData root = buildEventItemsData("minMaxAttachments", 1); + + assertDoesNotThrow( + () -> ItemCountValidator.validate(entity, List.of(root), eventContext, true)); + + org.mockito.Mockito.verify(messages) + .warn( + org.mockito.ArgumentMatchers.contains("AttachmentMinItems"), + org.mockito.ArgumentMatchers.eq(1L), + org.mockito.ArgumentMatchers.eq(2L)); + } + + @Test + void validate_tooManyAttachments_draftModeIssuesWarningOnly() { + CdsEntity entity = getEventItemsEntity(); + + // maxAttachments has max 3; we pass 5 → should warn, NOT throw + CdsData root = buildEventItemsData("maxAttachments", 5); + + assertDoesNotThrow( + () -> ItemCountValidator.validate(entity, List.of(root), eventContext, true)); + + org.mockito.Mockito.verify(messages) + .warn( + org.mockito.ArgumentMatchers.contains("AttachmentMaxItems"), + org.mockito.ArgumentMatchers.eq(5L), + org.mockito.ArgumentMatchers.eq(3L)); + } + + @Test + void validate_propertyRefAnnotation_resolvedFromData() { + CdsEntity entity = getEventItemsEntity(); + + // propertyRefAttachments has @Validation.MinItems: 'stock' + // If stock=2, we need at least 2 attachments; we pass 1 → warning in draft + CdsData root = buildEventItemsData("propertyRefAttachments", 1); + root.put("stock", 2); + + assertDoesNotThrow( + () -> ItemCountValidator.validate(entity, List.of(root), eventContext, true)); + + org.mockito.Mockito.verify(messages) + .warn( + org.mockito.ArgumentMatchers.contains("AttachmentMinItems"), + org.mockito.ArgumentMatchers.eq(1L), + org.mockito.ArgumentMatchers.eq(2L)); + } + + @Test + void validate_propertyRefResolvedToZero_noViolation() { + CdsEntity entity = getEventItemsEntity(); + + // propertyRefAttachments has @Validation.MinItems: 'stock' + // If stock=0, we need 0 attachments; we pass 0 → no violation + CdsData root = buildEventItemsData("propertyRefAttachments", 0); + root.put("stock", 0); + + assertDoesNotThrow( + () -> ItemCountValidator.validate(entity, List.of(root), eventContext, false)); + } + + @Test + void validate_exactlyAtMinimum_noViolation() { + CdsEntity entity = getEventItemsEntity(); + + // minAttachments requires min 1; we pass exactly 1 → no violation + CdsData root = buildEventItemsData("minAttachments", 1); + + assertDoesNotThrow( + () -> ItemCountValidator.validate(entity, List.of(root), eventContext, false)); + } + + @Test + void validate_exactlyAtMaximum_noViolation() { + CdsEntity entity = getEventItemsEntity(); + + // maxAttachments allows max 3; we pass exactly 3 → no violation + CdsData root = buildEventItemsData("maxAttachments", 3); + + assertDoesNotThrow( + () -> ItemCountValidator.validate(entity, List.of(root), eventContext, false)); + } + + @Test + void validate_noAnnotatedCompositions_nothingHappens() { + // Use a different entity (Roots) that has no min/max annotations + CdsEntity entity = + runtime.getCdsModel().findEntity("unit.test.TestService.RootTable").orElseThrow(); + CdsData root = CdsData.create(); + + assertDoesNotThrow( + () -> ItemCountValidator.validate(entity, List.of(root), eventContext, false)); + + org.mockito.Mockito.verifyNoInteractions(messages); + } + + // ------------------------------------------------------------------------- + // helpers + // ------------------------------------------------------------------------- + + private CdsEntity getEventItemsEntity() { + return runtime.getCdsModel().findEntity(EVENT_ITEMS_ENTITY).orElseThrow(); + } + + /** + * Builds a synthetic {@link CdsData} entry for an EventItems record. The named composition has + * {@code count} attachments. All other annotated compositions are pre-populated with data that + * satisfies their constraints so that tests can focus on a single composition at a time. + * + *

Default pre-populated counts (chosen to be within all bounds): + * + *

    + *
  • {@code minMaxAttachments}: 3 (satisfies [2,5]) + *
  • {@code minAttachments}: 1 (satisfies min=1) + *
  • {@code maxAttachments}: 0 (satisfies max=3) + *
  • {@code propertyRefAttachments}: 0 (no stock in data → constraint skipped) + *
+ */ + private static CdsData buildEventItemsData(String compositionName, int count) { + // Default valid counts for all annotated compositions in EventItems + java.util.Map defaults = new java.util.LinkedHashMap<>(); + defaults.put("minMaxAttachments", 3); + defaults.put("minAttachments", 1); + defaults.put("maxAttachments", 0); + defaults.put("propertyRefAttachments", 0); + // Override the specific composition under test + defaults.put(compositionName, count); + + CdsData root = CdsData.create(); + for (var entry : defaults.entrySet()) { + List attachments = new ArrayList<>(); + for (int i = 0; i < entry.getValue(); i++) { + Attachments att = Attachments.create(); + att.setId(UUID.randomUUID().toString()); + attachments.add(att); + } + root.put(entry.getKey(), attachments); + } + return root; + } + + @SuppressWarnings("unchecked") + private static CdsAnnotation mockAnnotation(String name, Object value) { + CdsAnnotation ann = mock(CdsAnnotation.class); + when(ann.getName()).thenReturn(name); + when(ann.getValue()).thenReturn(value); + return ann; + } +} diff --git a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/draftservice/DraftActiveAttachmentsHandlerTest.java b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/draftservice/DraftActiveAttachmentsHandlerTest.java index 3c9015b4d..cbe86ffc7 100644 --- a/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/draftservice/DraftActiveAttachmentsHandlerTest.java +++ b/cds-feature-attachments/src/test/java/com/sap/cds/feature/attachments/handler/draftservice/DraftActiveAttachmentsHandlerTest.java @@ -1,42 +1,188 @@ /* - * © 2024-2025 SAP SE or an SAP affiliate company and cds-feature-attachments contributors. + * © 2024-2026 SAP SE or an SAP affiliate company and cds-feature-attachments contributors. */ package com.sap.cds.feature.attachments.handler.draftservice; +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.ArgumentMatchers.eq; 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.CdsData; +import com.sap.cds.Result; +import com.sap.cds.feature.attachments.generated.cds4j.sap.attachments.Attachments; import com.sap.cds.feature.attachments.handler.applicationservice.helper.ThreadDataStorageSetter; +import com.sap.cds.feature.attachments.handler.helper.RuntimeHelper; +import com.sap.cds.reflect.CdsEntity; +import com.sap.cds.services.ServiceException; import com.sap.cds.services.draft.DraftSaveEventContext; +import com.sap.cds.services.messages.Message; +import com.sap.cds.services.messages.Messages; +import com.sap.cds.services.persistence.PersistenceService; +import com.sap.cds.services.runtime.CdsRuntime; +import java.util.ArrayList; +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.ArgumentCaptor; class DraftActiveAttachmentsHandlerTest { + private static final String DRAFT_ROOTS_ENTITY = "unit.test.TestService.RootTable"; + private static final String EVENT_ITEMS_ENTITY = "unit.test.TestService.EventItems"; + + private static CdsRuntime runtime; + private DraftActiveAttachmentsHandler cut; private ThreadDataStorageSetter threadLocalSetter; + private PersistenceService persistence; private ArgumentCaptor runnableCaptor; + private Messages messages; + private Message messageMock; + + @BeforeAll + static void classSetup() { + runtime = RuntimeHelper.runtime; + } @BeforeEach void setup() { threadLocalSetter = mock(ThreadDataStorageSetter.class); - cut = new DraftActiveAttachmentsHandler(threadLocalSetter); - + persistence = mock(PersistenceService.class); + cut = new DraftActiveAttachmentsHandler(threadLocalSetter, persistence); runnableCaptor = ArgumentCaptor.forClass(Runnable.class); + messages = mock(Messages.class); + messageMock = mock(Message.class); + when(messageMock.target(any(String.class))).thenReturn(messageMock); + when(messages.warn(any(String.class), any(Object[].class))).thenReturn(messageMock); + when(messages.error(any(String.class), any(Object[].class))).thenReturn(messageMock); } + // ------------------------------------------------------------------------- + // processDraftSave (@On) + // ------------------------------------------------------------------------- + @Test void setterCalled() { var context = mock(DraftSaveEventContext.class); + var entity = getRootEntity(); + when(context.getTarget()).thenReturn(entity); cut.processDraftSave(context); verify(threadLocalSetter).set(eq(true), runnableCaptor.capture()); - verifyNoInteractions(context); runnableCaptor.getValue().run(); verify(context).proceed(); } + + // ------------------------------------------------------------------------- + // validateItemCountBeforeSave (@Before) – entity without annotations + // ------------------------------------------------------------------------- + + @Test + void validateItemCount_entityWithoutAnnotations_noPersistenceQuery() { + CdsEntity entity = getRootEntity(); + var context = buildSaveContext(entity); + + assertDoesNotThrow(() -> cut.validateItemCountBeforeSave(context)); + + verifyNoInteractions(persistence); + } + + // ------------------------------------------------------------------------- + // validateItemCountBeforeSave (@Before) – entity with annotations + // ------------------------------------------------------------------------- + + @Test + void validateItemCount_withinBounds_noError() { + CdsEntity entity = getEventItemsEntity(); + var context = buildSaveContext(entity); + when(context.getMessages()).thenReturn(messages); + + // minMaxAttachments requires [2,5] → 3 items; minAttachments requires ≥1 → 1 item + // maxAttachments allows ≤3 → 0 items; propertyRefAttachments (stock ref) → no stock → skip + mockDraftDbResult(context, java.util.Map.of("minMaxAttachments", 3, "minAttachments", 1)); + + assertDoesNotThrow(() -> cut.validateItemCountBeforeSave(context)); + } + + @Test + void validateItemCount_tooFewItems_throwsError() { + CdsEntity entity = getEventItemsEntity(); + var context = buildSaveContext(entity); + when(context.getMessages()).thenReturn(messages); + + // minMaxAttachments requires min 2; return 1 item from DB; minAttachments satisfied + mockDraftDbResult(context, java.util.Map.of("minMaxAttachments", 1, "minAttachments", 1)); + org.mockito.Mockito.doThrow(new ServiceException("min items violated")) + .when(messages) + .throwIfError(); + + assertThrows(ServiceException.class, () -> cut.validateItemCountBeforeSave(context)); + verify(messages).error(any(String.class), eq(1L), eq(2L)); + } + + @Test + void validateItemCount_tooManyItems_throwsError() { + CdsEntity entity = getEventItemsEntity(); + var context = buildSaveContext(entity); + when(context.getMessages()).thenReturn(messages); + + // minMaxAttachments has max 5; return 7 items from DB; minAttachments satisfied + mockDraftDbResult(context, java.util.Map.of("minMaxAttachments", 7, "minAttachments", 1)); + org.mockito.Mockito.doThrow(new ServiceException("max items violated")) + .when(messages) + .throwIfError(); + + assertThrows(ServiceException.class, () -> cut.validateItemCountBeforeSave(context)); + verify(messages).error(any(String.class), eq(7L), eq(5L)); + } + + // ------------------------------------------------------------------------- + // helpers + // ------------------------------------------------------------------------- + + private CdsEntity getRootEntity() { + return runtime.getCdsModel().findEntity(DRAFT_ROOTS_ENTITY).orElseThrow(); + } + + private CdsEntity getEventItemsEntity() { + return runtime.getCdsModel().findEntity(EVENT_ITEMS_ENTITY).orElseThrow(); + } + + private DraftSaveEventContext buildSaveContext(CdsEntity entity) { + var context = mock(DraftSaveEventContext.class); + when(context.getTarget()).thenReturn(entity); + when(context.getCqn()).thenReturn(mock(com.sap.cds.ql.cqn.CqnSelect.class)); + when(context.getModel()).thenReturn(runtime.getCdsModel()); + return context; + } + + /** + * Mocks the persistence service to return a root row whose compositions have the given counts. + * Compositions not in the map are absent from the result (count treated as 0). + */ + private void mockDraftDbResult( + DraftSaveEventContext context, java.util.Map compCounts) { + CdsData rootRow = CdsData.create(); + for (var entry : compCounts.entrySet()) { + List attachments = new ArrayList<>(); + for (int i = 0; i < entry.getValue(); i++) { + Attachments att = Attachments.create(); + att.setId(UUID.randomUUID().toString()); + attachments.add(att); + } + rootRow.put(entry.getKey(), attachments); + } + + Result result = mock(Result.class); + when(result.listOf(CdsData.class)).thenReturn(List.of(rootRow)); + when(persistence.run(any(com.sap.cds.ql.cqn.CqnSelect.class))).thenReturn(result); + } } 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..1b7b4aab5 100644 --- a/cds-feature-attachments/src/test/resources/cds/db-model.cds +++ b/cds-feature-attachments/src/test/resources/cds/db-model.cds @@ -38,8 +38,13 @@ entity Events { entity EventItems { key id1 : UUID; note : String; + stock : Integer; sizeLimitedAttachments : Composition of many Attachments; defaultSizeLimitedAttachments : Composition of many Attachments; + minMaxAttachments : Composition of many Attachments; + minAttachments : Composition of many Attachments; + maxAttachments : Composition of many Attachments; + propertyRefAttachments : Composition of many Attachments; } annotate EventItems.sizeLimitedAttachments with { @@ -50,3 +55,10 @@ annotate EventItems.defaultSizeLimitedAttachments with { content @Validation.Maximum; }; +annotate EventItems with { + minMaxAttachments @Validation.MinItems: 2 @Validation.MaxItems: 5; + minAttachments @Validation.MinItems: 1; + maxAttachments @Validation.MaxItems: 3; + propertyRefAttachments @Validation.MinItems: 'stock'; +}; + diff --git a/integration-tests/db/data-model.cds b/integration-tests/db/data-model.cds index f6c35e191..d9977a3fe 100644 --- a/integration-tests/db/data-model.cds +++ b/integration-tests/db/data-model.cds @@ -9,11 +9,13 @@ entity AttachmentEntity : Attachments { entity Roots : cuid { title : String; + stock : Integer; attachments : Composition of many AttachmentEntity on attachments.parentKey = $self.ID; items : Composition of many Items on items.parentID = $self.ID; sizeLimitedAttachments : Composition of many Attachments; + minMaxAttachments : Composition of many Attachments; } entity Items : cuid {