Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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.ItemCountValidationHandler;
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;
Expand All @@ -21,6 +22,7 @@
import com.sap.cds.feature.attachments.handler.common.AttachmentsReader;
import com.sap.cds.feature.attachments.handler.draftservice.DraftActiveAttachmentsHandler;
import com.sap.cds.feature.attachments.handler.draftservice.DraftCancelAttachmentsHandler;
import com.sap.cds.feature.attachments.handler.draftservice.DraftItemCountValidationHandler;
import com.sap.cds.feature.attachments.handler.draftservice.DraftPatchAttachmentsHandler;
import com.sap.cds.feature.attachments.service.AttachmentService;
import com.sap.cds.feature.attachments.service.AttachmentsServiceImpl;
Expand Down Expand Up @@ -133,6 +135,7 @@ public void eventHandlers(CdsRuntimeConfigurer configurer) {
configurer.eventHandler(
new ReadAttachmentsHandler(
attachmentService, new AttachmentStatusValidator(), scanRunner));
configurer.eventHandler(new ItemCountValidationHandler(attachmentsReader));
} else {
logger.debug(
"No application service is available. Application service event handlers will not be registered.");
Expand All @@ -146,6 +149,7 @@ public void eventHandlers(CdsRuntimeConfigurer configurer) {
new DraftPatchAttachmentsHandler(persistenceService, eventFactory, defaultMaxSize));
configurer.eventHandler(new DraftCancelAttachmentsHandler(attachmentsReader, deleteEvent));
configurer.eventHandler(new DraftActiveAttachmentsHandler(storage));
configurer.eventHandler(new DraftItemCountValidationHandler(persistenceService));
} else {
logger.debug("No draft service is available. Draft event handlers will not be registered.");
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,298 @@
/*
* © 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.common.AttachmentsReader;
import com.sap.cds.ql.cqn.CqnSelect;
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.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.Severity;
import com.sap.cds.services.messages.Messages;
import com.sap.cds.services.utils.model.CqnUtils;
import java.util.List;
import java.util.Optional;
import java.util.ResourceBundle;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
* The class {@link ItemCountValidationHandler} validates that the number of items in annotated
* compositions does not exceed {@code @Validation.MaxItems} or fall below
* {@code @Validation.MinItems}. For active entity operations (ApplicationService CREATE/UPDATE),
* violations produce errors that reject the request.
*/
@ServiceName(value = "*", type = ApplicationService.class)
public class ItemCountValidationHandler implements EventHandler {

private static final Logger logger = LoggerFactory.getLogger(ItemCountValidationHandler.class);

public static final String ANNOTATION_MAX_ITEMS = "Validation.MaxItems";
public static final String ANNOTATION_MIN_ITEMS = "Validation.MinItems";
public static final String MSG_KEY_MAX_ITEMS_EXCEEDED = "CompositionMaxItemsExceeded";
public static final String MSG_KEY_MIN_ITEMS_NOT_MET = "CompositionMinItemsNotMet";

private final AttachmentsReader attachmentsReader;

public ItemCountValidationHandler(AttachmentsReader attachmentsReader) {
this.attachmentsReader =
requireNonNull(attachmentsReader, "attachmentsReader must not be null");
}

@Before(event = CqnService.EVENT_CREATE)
@HandlerOrder(HandlerOrder.LATE)
void processBeforeCreate(CdsCreateEventContext context, List<CdsData> data) {
CdsEntity target = context.getTarget();
if (!hasItemCountAnnotations(target)) {
return;
}
logger.debug("Validating item count on CREATE for entity {}", target.getQualifiedName());
validateCompositionItemCounts(target, data, null, context.getMessages(), Severity.ERROR);
}

@Before(event = CqnService.EVENT_UPDATE)
@HandlerOrder(HandlerOrder.LATE)
void processBeforeUpdate(CdsUpdateEventContext context, List<CdsData> data) {
CdsEntity target = context.getTarget();
if (!hasItemCountAnnotations(target)) {
return;
}
logger.debug("Validating item count on UPDATE for entity {}", target.getQualifiedName());

// Read existing data from DB to compute the effective count
CqnSelect select = CqnUtils.toSelect(context.getCqn(), context.getTarget());
List<? extends CdsData> existingData =
attachmentsReader.readAttachments(context.getModel(), target, select);

validateCompositionItemCounts(
target, data, existingData, context.getMessages(), Severity.ERROR);
}
Comment on lines +75 to +82
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Unnecessary DB read in processBeforeUpdate — the result is never used

attachmentsReader.readAttachments(...) is called and its result is passed as existingData, but validateCompositionItemCounts ignores that parameter entirely. This triggers a real database query on every annotated UPDATE for no benefit.

Consider removing the DB read until incremental-update merging is actually implemented.

Suggested change
// Read existing data from DB to compute the effective count
CqnSelect select = CqnUtils.toSelect(context.getCqn(), context.getTarget());
List<? extends CdsData> existingData =
attachmentsReader.readAttachments(context.getModel(), target, select);
validateCompositionItemCounts(
target, data, existingData, context.getMessages(), Severity.ERROR);
}
validateCompositionItemCounts(
target, data, context.getMessages(), Severity.ERROR);

Double-check suggestion before committing. Edit this comment for amendments.


Please provide feedback on the review comment by checking the appropriate box:

  • 🌟 Awesome comment, a human might have missed that.
  • ✅ Helpful comment
  • 🤷 Neutral
  • ❌ This comment is not helpful


/**
* Validates item counts on all annotated compositions of the given entity.
*
* @param entity the entity definition
* @param requestData the request payload data
* @param existingData existing data from DB (null for CREATE operations)
* @param messages the Messages instance to add errors/warnings to
* @param severity the severity to use (ERROR for active entities, WARNING for drafts)
*/
public static void validateCompositionItemCounts(
CdsEntity entity,
List<? extends CdsData> requestData,
List<? extends CdsData> existingData,
Comment on lines +93 to +96
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: existingData parameter is accepted but never used in validateCompositionItemCounts

The method signature accepts existingData (line 96) and the UPDATE handler passes it (line 80–81), but the entire method body ignores existingData completely — computeEffectiveItemCount only examines the request payload. This means the UPDATE path reads existing data from DB (incurring an unnecessary DB query) and then silently discards it. The Javadoc on processBeforeUpdate also says "Read existing data from DB to compute the effective count", but no merging ever happens.

Should either remove the existingData parameter (and the DB read in processBeforeUpdate) until the incremental-update TODO is implemented, or actually use it to compute the merged count.

Suggested change
public static void validateCompositionItemCounts(
CdsEntity entity,
List<? extends CdsData> requestData,
List<? extends CdsData> existingData,
public static void validateCompositionItemCounts(
CdsEntity entity,
List<? extends CdsData> requestData,
Messages messages,
Severity severity) {

Double-check suggestion before committing. Edit this comment for amendments.


Please provide feedback on the review comment by checking the appropriate box:

  • 🌟 Awesome comment, a human might have missed that.
  • ✅ Helpful comment
  • 🤷 Neutral
  • ❌ This comment is not helpful

Messages messages,
Severity severity) {

if (requestData == null || requestData.isEmpty()) {
return;
}

entity
.elements()
.filter(ItemCountValidationHandler::isAnnotatedComposition)
.forEach(
element -> {
String compositionName = element.getName();
Optional<Integer> maxItems = getAnnotationIntValue(element, ANNOTATION_MAX_ITEMS);
Optional<Integer> minItems = getAnnotationIntValue(element, ANNOTATION_MIN_ITEMS);

if (maxItems.isEmpty() && minItems.isEmpty()) {
return;
}

// Count items for each row in the request data
for (CdsData row : requestData) {
int itemCount = computeEffectiveItemCount(row, compositionName);

if (itemCount < 0) {
// composition not present in payload, skip validation
continue;
}

String entitySimpleName = getSimpleName(entity.getQualifiedName());

maxItems.ifPresent(
max -> {
if (itemCount > max) {
logger.debug(
"MaxItems violation: {} has {} items, max is {}",
compositionName,
itemCount,
max);
String msgKey =
resolveMessageKey(
MSG_KEY_MAX_ITEMS_EXCEEDED, entitySimpleName, compositionName);
addMessage(messages, severity, msgKey, max, compositionName);
}
});

minItems.ifPresent(
min -> {
if (itemCount < min) {
logger.debug(
"MinItems violation: {} has {} items, min is {}",
compositionName,
itemCount,
min);
String msgKey =
resolveMessageKey(
MSG_KEY_MIN_ITEMS_NOT_MET, entitySimpleName, compositionName);
addMessage(messages, severity, msgKey, min, compositionName);
}
});
}
});
}

/**
* Computes the effective item count for a composition. For CREATE: simply counts items in the
* payload. For UPDATE: the payload list represents the new state (deep update semantics in CAP).
*
* <p>TODO: For future support of incremental updates (e.g., adding/removing individual items),
* this method may need to accept existing data from DB and the entity definition to compute the
* effective count by merging payload changes with existing data.
*
* @param requestRow a single row from the request payload
* @param compositionName the name of the composition element
* @return the effective item count, or -1 if the composition is not in the payload
*/
private static int computeEffectiveItemCount(CdsData requestRow, String compositionName) {

Object compositionValue = requestRow.get(compositionName);
if (compositionValue == null) {
// Composition not included in payload
return -1;
}

if (compositionValue instanceof List<?> payloadItems) {
// The payload for deep CREATE/UPDATE represents the full new state
return payloadItems.size();
}

return -1;
}

/** Checks if an element is a composition with item count annotations. */
public static boolean isAnnotatedComposition(CdsElement element) {
if (!element.getType().isAssociation()) {
return false;
}
CdsAssociationType assocType = element.getType().as(CdsAssociationType.class);
if (!assocType.isComposition()) {
return false;
}
return element.findAnnotation(ANNOTATION_MAX_ITEMS).isPresent()
|| element.findAnnotation(ANNOTATION_MIN_ITEMS).isPresent();
}

/**
* Reads an integer annotation value from the element. Currently supports integer literals only.
*
* <p>TODO: Support property references (e.g., {@code @Validation.MaxItems: stock}) where the
* value is read from the parent entity's property at runtime.
*
* <p>TODO: Support dynamic expressions (e.g., {@code @Validation.MaxItems: (stock > 20 ? 5 : 2)})
* where the value is computed dynamically.
*
* @param element the CDS element definition
* @param annotationName the annotation name
* @return the integer value if present and parseable, empty otherwise
*/
public static Optional<Integer> getAnnotationIntValue(CdsElement element, String annotationName) {
return element
.findAnnotation(annotationName)
.map(CdsAnnotation::getValue)
.flatMap(
value -> {
if (value instanceof Number number) {
return Optional.of(number.intValue());
}
// TODO: handle property references (String value representing a property name)
// TODO: handle dynamic expressions
try {
return Optional.of(Integer.parseInt(value.toString()));
} catch (NumberFormatException e) {
logger.warn(
"Annotation {} has non-integer value '{}', skipping validation",
annotationName,
value);
return Optional.empty();
}
});
}

/** Checks whether any composition on this entity has item count annotations. */
private static boolean hasItemCountAnnotations(CdsEntity entity) {
return entity.elements().anyMatch(ItemCountValidationHandler::isAnnotatedComposition);
}

/**
* Resolves the message key with i18n override support. Lookup order:
*
* <ol>
* <li>{baseKey}_{entitySimpleName}_{compositionName} (most specific)
* <li>{baseKey} (default fallback)
* </ol>
*
* @param baseKey the base message key
* @param entitySimpleName the simple entity name (without namespace)
* @param compositionName the composition element name
* @return the resolved message key
*/
public static String resolveMessageKey(
String baseKey, String entitySimpleName, String compositionName) {
String specificKey = baseKey + "_" + entitySimpleName + "_" + compositionName;
try {
ResourceBundle bundle = ResourceBundle.getBundle("messages");
Comment on lines +256 to +260
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Performance: ResourceBundle.getBundle("messages") is called on every single composition element validation

resolveMessageKey loads the ResourceBundle from the classpath on each invocation (once per composition per request row). ResourceBundle.getBundle has internal JVM caching, but loading and cache-lookup overhead inside a tight loop is avoidable. More importantly, this ties the library's validation logic to a hard-coded "messages" bundle name, which may conflict with the application's own bundle.

Consider caching the bundle (or its absence) in a static final field, and guarding with a null-check to avoid repeated MissingResourceException catching.

Suggested change
public static String resolveMessageKey(
String baseKey, String entitySimpleName, String compositionName) {
String specificKey = baseKey + "_" + entitySimpleName + "_" + compositionName;
try {
ResourceBundle bundle = ResourceBundle.getBundle("messages");
private static final ResourceBundle MESSAGE_BUNDLE = loadBundle();
private static ResourceBundle loadBundle() {
try {
return ResourceBundle.getBundle("messages");
} catch (java.util.MissingResourceException e) {
return null;
}
}
public static String resolveMessageKey(
String baseKey, String entitySimpleName, String compositionName) {
String specificKey = baseKey + "_" + entitySimpleName + "_" + compositionName;
if (MESSAGE_BUNDLE != null && MESSAGE_BUNDLE.containsKey(specificKey)) {
return specificKey;
}
return baseKey;
}

Double-check suggestion before committing. Edit this comment for amendments.


Please provide feedback on the review comment by checking the appropriate box:

  • 🌟 Awesome comment, a human might have missed that.
  • ✅ Helpful comment
  • 🤷 Neutral
  • ❌ This comment is not helpful

if (bundle.containsKey(specificKey)) {
return specificKey;
}
} catch (java.util.MissingResourceException e) {
logger.trace("Message bundle not found, using default key: {}", baseKey);
}
return baseKey;
}

/**
* Extracts the simple name from a fully qualified entity name.
*
* @param qualifiedName the fully qualified entity name (e.g., "my.namespace.Incidents")
* @return the simple name (e.g., "Incidents")
*/
private static String getSimpleName(String qualifiedName) {
int lastDot = qualifiedName.lastIndexOf('.');
return lastDot >= 0 ? qualifiedName.substring(lastDot + 1) : qualifiedName;
}

/**
* Adds a message with the appropriate severity and target.
*
* @param messages the Messages instance
* @param severity the message severity (ERROR or WARNING)
* @param messageKey the i18n message key
* @param limit the limit value (max or min)
* @param compositionName the composition name for targeting
*/
private static void addMessage(
Messages messages, Severity severity, String messageKey, int limit, String compositionName) {
switch (severity) {
case ERROR -> messages.error(messageKey, limit, compositionName).target(compositionName);
case WARNING -> messages.warn(messageKey, limit, compositionName).target(compositionName);
default -> messages.info(messageKey, limit, compositionName).target(compositionName);
}
}
}
Loading
Loading