-
Notifications
You must be signed in to change notification settings - Fork 7
TEST: Add @Validation.MinItems / @Validation.MaxItems support for attachmen… #753
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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. | ||
| * | ||
| * <p>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. | ||
| * | ||
| * <p>During draft operations the validator emits a <em>warning</em> message. During active-entity | ||
| * operations (create/update/save) it emits an <em>error</em> message and throws immediately via | ||
| * {@link Messages#throwIfError()}. | ||
| * | ||
| * <p>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_<EntityName>_<PropertyName>} or {@code | ||
| * AttachmentMaxItems_<EntityName>_<PropertyName>} 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<CdsData> data, EventContext eventContext, boolean isDraft) { | ||
| entity | ||
| .compositions() | ||
| .filter(ItemCountValidator::hasItemCountAnnotation) | ||
| .forEach(comp -> validateComposition(comp, entity, data, eventContext, isDraft)); | ||
lisajulia marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| } | ||
|
|
||
| // --------------------------------------------------------------------------- | ||
| // internals | ||
| // --------------------------------------------------------------------------- | ||
|
|
||
| /** | ||
| * Returns {@code true} if the given composition element carries at least one item-count | ||
| * constraint annotation ({@code @Validation.MinItems} or {@code @Validation.MaxItems}). | ||
| * | ||
| * <p>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<CdsData> 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<CdsData> 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: | ||
| * | ||
| * <ul> | ||
| * <li>Integer literal (e.g. {@code 20}) | ||
| * <li>Property reference – a string matching a numeric property name on the parent entity data | ||
| * (e.g. {@code stock}) | ||
| * <li>Any other raw string whose {@code toString()} parses as a long | ||
| * </ul> | ||
| */ | ||
| static Optional<Long> resolveIntValue( | ||
| CdsAnnotation<?> annotation, CdsEntity entity, List<CdsData> 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); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Logic Error: Property-reference resolution only looks at the first data item When multiple root rows are passed in Consider resolving the property reference per root row and validating each row's composition count against that row's limit, or document that only single-root payloads are supported. Please provide feedback on the review comment by checking the appropriate box:
|
||
| 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 <baseKey>_<SimpleName>_<propertyName>} is always used; apps without a specific | ||
| * translation entry will receive the message text from the generic {@code <baseKey>} 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; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<CdsData> 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}. | ||
| * | ||
| * <p>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<CdsData> readDraftCompositionCounts( | ||
| CdsEntity draftEntity, DraftSaveEventContext context, CdsEntity activeEntity) { | ||
| List<CdsElement> 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<CdsData> 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<CdsData> 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); | ||
| } | ||
| } |
Uh oh!
There was an error while loading. Please reload this page.